feat(排名): 全服 100 名以外不报具体名次,写「100 名以外」并提示再做对几道进前 100;赛道每行加高,条不再被挤成一小截
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
- 全服排在 100 名以外:「你」卡和下面的小名次写「100 名以外」,不给前一名、后一名和名次走势, 改成「再做对 N 道就进前 100」;名单上没有你这一行,图例和副标题也不再提「你」 - 赛道每行从 18px 加到 28px,头像、字、条都大一号(不必刻意塞进机房一屏); 「前一名 / 后一名 / 进步最大」挂到条的末端,不再单占一列把条挤短;领奖台台阶恢复原来的高度 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2553c6ddfe
commit
45d79ed366
6 files changed
+98
-37
No files matched your search
@@ -202,8 +202,11 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 全服 100 名以外:不报具体名次,前后一名、走势都不给,只给第 100 名算还差几道
|
||||
const beyond = !!(cap && me && me.rank > cap)
|
||||
|
||||
let trend: RankBoard["trend"] = null
|
||||
if (me && period.data !== "week") {
|
||||
if (me && !beyond && period.data !== "week") {
|
||||
const thisWeek = Date.parse(weekStart())
|
||||
const points = Array.from(
|
||||
{ length: TREND_WEEKS },
|
||||
@@ -237,9 +240,10 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
|
||||
rows: picked.map((row) => rowOf(row)!),
|
||||
complete: picked.length === listable.length,
|
||||
cap,
|
||||
lastListed: beyond ? rowOf(listable.at(-1)) : null,
|
||||
me: rowOf(me),
|
||||
ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
|
||||
behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
|
||||
ahead: beyond ? null : rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
|
||||
behind: beyond ? null : rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
|
||||
trend,
|
||||
hidden: !!mine?.rankHiddenAt,
|
||||
hiddenUsers: hiddenUsers.map((row): SampleUser => sampleUser(row, null)),
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface MiniRank {
|
||||
label: string
|
||||
rank: number | null
|
||||
total: number | null
|
||||
/** 那张榜最多列到第几名(全服 100);名次在这之外就只写「100 名以外」 */
|
||||
cap?: number | null
|
||||
go: () => void
|
||||
}
|
||||
|
||||
@@ -31,6 +33,10 @@ const props = defineProps<{
|
||||
/** 领奖台第 3 名(我不在台上时用来算「再做对几道上领奖台」) */
|
||||
third: RankRow | null
|
||||
total: number
|
||||
/** 名单最多列到第几名(全服 100)。我在这之外只写「100 名以外」,不报具体名次 */
|
||||
cap?: number | null
|
||||
/** 我在 cap 名以外时,名单上最后那一位:算「再做对几道进前 100」 */
|
||||
lastListed?: RankRow | null
|
||||
label: string
|
||||
minis: MiniRank[]
|
||||
/** 手机:前后一名的说明换到名字下面一行,别被截断 */
|
||||
@@ -70,8 +76,12 @@ async function saveMood() {
|
||||
}
|
||||
}
|
||||
|
||||
const beyond = computed(() => !!props.cap && props.me.rank > props.cap)
|
||||
|
||||
const headline = computed(() => {
|
||||
const { me, ahead, third } = props
|
||||
if (beyond.value)
|
||||
return props.lastListed ? `再做对 ${toPass(me, props.lastListed)} 道就进前 ${props.cap}` : ""
|
||||
if (me.rank === 1) return "你是第一名"
|
||||
if (me.rank <= 3 && ahead)
|
||||
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
|
||||
@@ -105,9 +115,12 @@ function threatNote(me: RankRow, behind: RankRow) {
|
||||
<div class="place">
|
||||
<span class="label">{{ label }}</span>
|
||||
<div class="big">
|
||||
<span>第</span><b>{{ me.rank }}</b
|
||||
><span>名</span>
|
||||
<span class="total">/ {{ total }}</span>
|
||||
<b v-if="beyond" class="beyond">{{ cap }} 名以外</b>
|
||||
<template v-else>
|
||||
<span>第</span><b>{{ me.rank }}</b
|
||||
><span>名</span>
|
||||
<span class="total">/ {{ total }}</span>
|
||||
</template>
|
||||
<span v-if="(me.change ?? 0) > 0" class="up">↑{{ me.change }}</span>
|
||||
<span v-else-if="(me.change ?? 0) < 0" class="down">↓{{ -me.change! }}</span>
|
||||
</div>
|
||||
@@ -219,7 +232,8 @@ function threatNote(me: RankRow, behind: RankRow) {
|
||||
<div v-if="minis.length" class="minis">
|
||||
<button v-for="mini in minis" :key="mini.label" class="mini" @click="mini.go">
|
||||
<span class="mini-label">{{ mini.label }}</span>
|
||||
<b v-if="mini.rank"
|
||||
<b v-if="mini.rank && mini.cap && mini.rank > mini.cap">{{ mini.cap }} 名以外</b>
|
||||
<b v-else-if="mini.rank"
|
||||
>第 {{ mini.rank }}<small v-if="mini.total"> / {{ mini.total }}</small></b
|
||||
>
|
||||
<b v-else class="none">没上榜</b>
|
||||
@@ -285,6 +299,11 @@ function threatNote(me: RankRow, behind: RankRow) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.big b.beyond {
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
|
||||
@@ -33,8 +33,7 @@ const steps = computed(() =>
|
||||
.filter((step): step is { place: number; row: RankRow } => !!step.row),
|
||||
)
|
||||
|
||||
/** 台阶比原来矮一截:台上多了签名气泡,矮下来赛道才放得进机房第一屏(41 人的班也够) */
|
||||
const HEIGHTS: Record<number, number> = { 1: 40, 2: 30, 3: 26 }
|
||||
const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -315,7 +314,7 @@ const HEIGHTS: Record<number, number> = { 1: 40, 2: 30, 3: 26 }
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding-top: 3px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.place {
|
||||
|
||||
@@ -8,7 +8,8 @@ import RankAvatar from "./RankAvatar.vue"
|
||||
|
||||
/**
|
||||
* 赛道:每人一根条,条越长做对越多,一样长的先做到的排前面(设计稿 G1 / G2)。
|
||||
* 桌面两栏,机房 1366×768 一屏放得下一个班;本年级 / 全服只给前面一段 + 我附近一段,
|
||||
* 桌面两栏,每行 28px(原来挤成 18px 想塞进机房一屏,用户嫌太矮:不必刻意一屏);
|
||||
* 本年级 / 全服只给前面一段 + 我附近一段,
|
||||
* 名次断开的地方折起来,点了再把中间那段要回来。
|
||||
*
|
||||
* 手机(设计稿「手机版」):窄屏上条只剩几十像素宽,看不出谁多谁少,所以条改成每一行的
|
||||
@@ -132,9 +133,32 @@ function fillColor(row: RankRow) {
|
||||
return soft.bar
|
||||
}
|
||||
|
||||
function ratio(row: RankRow) {
|
||||
if (!row.solved) return 0
|
||||
return Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))
|
||||
}
|
||||
|
||||
function width(row: RankRow) {
|
||||
if (!row.solved) return "0"
|
||||
return `${Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))}%`
|
||||
return `${ratio(row)}%`
|
||||
}
|
||||
|
||||
function tagOf(row: RankRow) {
|
||||
const id = row.user.id
|
||||
if (id === props.chaseId) return { kind: "chase", label: "前一名" }
|
||||
if (id === props.threatId) return { kind: "threat", label: "后一名" }
|
||||
if (id === props.hotId) return { kind: "hot", label: "进步最大" }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面:标签挂在条的末端(原来单占一列 58px,把条挤得只剩几十像素)。条短就挂在条后面,
|
||||
* 条长了后面放不下,就收进条里靠右
|
||||
*/
|
||||
function tagPlace(row: RankRow) {
|
||||
const r = ratio(row)
|
||||
return r <= 60
|
||||
? { left: `calc(${r}% + 6px)` }
|
||||
: { right: `calc(${100 - r}% + 4px)`, backgroundColor: theme.value.cardColor }
|
||||
}
|
||||
|
||||
function menuOf(row: RankRow): DropdownOption[] {
|
||||
@@ -206,7 +230,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
<RankAvatar
|
||||
:username="item.row.user.username"
|
||||
:avatar="item.row.avatar"
|
||||
:size="16"
|
||||
:size="20"
|
||||
:me="item.row.user.id === meId"
|
||||
/>
|
||||
<n-tooltip :disabled="!item.row.mood" :style="{ maxWidth: '300px' }">
|
||||
@@ -233,6 +257,13 @@ function onMenu(key: string, row: RankRow) {
|
||||
</n-tooltip>
|
||||
<span class="bar-box">
|
||||
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
|
||||
<span
|
||||
v-if="tagOf(item.row)"
|
||||
class="tag"
|
||||
:class="tagOf(item.row)!.kind"
|
||||
:style="tagPlace(item.row)"
|
||||
>{{ tagOf(item.row)!.label }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="solved">{{ item.row.solved }}</span>
|
||||
<span class="change">
|
||||
@@ -242,11 +273,6 @@ 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>
|
||||
<n-dropdown
|
||||
v-if="teacher"
|
||||
trigger="click"
|
||||
@@ -277,16 +303,16 @@ function onMenu(key: string, row: RankRow) {
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lane {
|
||||
height: 18px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.single .column {
|
||||
@@ -361,8 +387,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
.lane.me {
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background: rgba(24, 160, 88, 0.12);
|
||||
}
|
||||
|
||||
@@ -373,7 +398,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
.rank {
|
||||
width: 28px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
@@ -385,7 +410,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 108px;
|
||||
width: 116px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -406,7 +431,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
|
||||
.bar-box {
|
||||
flex-grow: 1;
|
||||
height: 11px;
|
||||
height: 14px;
|
||||
position: relative;
|
||||
min-width: 40px;
|
||||
}
|
||||
@@ -420,7 +445,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
}
|
||||
|
||||
.solved {
|
||||
width: 26px;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -447,10 +472,10 @@ function onMenu(key: string, row: RankRow) {
|
||||
color: v-bind("theme.textColor3");
|
||||
}
|
||||
|
||||
.tag-box {
|
||||
width: 58px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
.bar-box .tag {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -488,7 +513,7 @@ function onMenu(key: string, row: RankRow) {
|
||||
}
|
||||
|
||||
.gap {
|
||||
height: 22px;
|
||||
height: 30px;
|
||||
margin: 4px 0;
|
||||
border: 0;
|
||||
border-top: 1px dashed v-bind("theme.borderColor");
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
/**
|
||||
* 排名页(设计稿「排名重设计」G1–G3,用户要「激发竞争」「图多」「一眼看出排名」)。
|
||||
*
|
||||
* 第一屏(机房 1366×768):领奖台 + 全班赛道,右边是「你」和班级对抗;往下是名次走势、
|
||||
* 上面:领奖台 + 全班赛道,右边是「你」和班级对抗(不必刻意塞进机房一屏,用户嫌行太矮);往下是名次走势、
|
||||
* 这周本班、每周冠军。老师不上榜,选班看,每行「⋯」能把怀疑抄代码的学生设成不计入排名。
|
||||
*/
|
||||
const router = useRouter()
|
||||
@@ -187,6 +187,11 @@ const scale = computed(() => rows.value[1]?.solved || rows.value[0]?.solved || 1
|
||||
const hot = computed(() => hottest(rows.value))
|
||||
|
||||
const me = computed(() => board.value?.me ?? null)
|
||||
/** 全服 100 名以外:「你」卡照样有,但名单上没有你这一行,图例和副标题别再提「你」 */
|
||||
const meListed = computed(() => {
|
||||
const b = board.value
|
||||
return !!b?.me && !(b.cap && b.me.rank > b.cap)
|
||||
})
|
||||
const meId = computed(() => (teacher.value ? undefined : userStore.user?.id))
|
||||
const mateClass = computed(() => (board.value?.scope !== "class" ? classContext.value : null))
|
||||
|
||||
@@ -201,7 +206,7 @@ const subtitle = computed(() => {
|
||||
const head = b.scope === "class" ? `${b.total} 人` : `${b.total} 人做对过`
|
||||
const capped = !!b.cap && b.total > b.cap
|
||||
const part = !b.complete
|
||||
? b.me
|
||||
? meListed.value
|
||||
? " · 前面一段 + 你附近一段"
|
||||
: ` · 只列前面 ${b.rows.length} 名`
|
||||
: capped
|
||||
@@ -247,6 +252,7 @@ const minis = computed<MiniRank[]>(() => {
|
||||
label: `${periodLabel(period.value)} · ${scopeLabel(s)}`,
|
||||
rank: other?.me?.rank ?? null,
|
||||
total: other ? other.total : null,
|
||||
cap: other?.cap,
|
||||
go: () => (scope.value = s),
|
||||
})
|
||||
}
|
||||
@@ -432,7 +438,7 @@ async function restore(userId: number) {
|
||||
<span class="muted">{{ subtitle }}</span>
|
||||
<div class="spacer" />
|
||||
<span v-if="!teacher && me" class="legend">
|
||||
<span><i :style="{ background: palette.me }" />你</span>
|
||||
<span v-if="meListed"><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="mateClass"><i :style="{ background: palette.mate }" />你们班</span>
|
||||
@@ -482,6 +488,8 @@ async function restore(userId: number) {
|
||||
:behind="board.behind"
|
||||
:third="me.rank > 3 ? (podium[2] ?? null) : null"
|
||||
:total="board.total"
|
||||
:cap="board.cap"
|
||||
:last-listed="board.lastListed"
|
||||
:label="`${scopeLabel(board.scope)} · ${periodLabel(board.period)}`"
|
||||
:minis="minis"
|
||||
:compact="!isDesktop"
|
||||
|
||||
@@ -71,6 +71,12 @@ export const rankBoardSchema = z.object({
|
||||
* 全服一千五百多人,展开全量会把页面卡死;100 名以外不列,「你」卡上照样报名次
|
||||
*/
|
||||
cap: z.number().int().positive().nullable(),
|
||||
/**
|
||||
* 我排在 `cap` 名以外时:名单上最后那一位(第 `cap` 名),给「再做对几道进前 100」用。
|
||||
* 这时 `ahead` / `behind` / `trend` 都是 null —— 100 名以外只说「100 名以外」,
|
||||
* 不报具体名次,也就没有前一名、后一名可说(用户 2026-10-10 定的)。其余情况为 null
|
||||
*/
|
||||
lastListed: rankRowSchema.nullable(),
|
||||
/** 我在这张榜上的那一行。没登录、老师、不计入排名、或者这段时间一道没做对时为 null */
|
||||
me: rankRowSchema.nullable(),
|
||||
/** 名次紧挨在我前面、后面的人 */
|
||||
|
||||
Reference in new issue
Block a user