feat(排名): 个性签名挂到排名上,领奖台上每人一句、「你」卡里就地改;全服排名只列到第 100 名,展开不再卡死

- 领奖台:头像旁边一个签名气泡(手机在头像上面),只露两行,长的指上去看全文
- 「你」卡:自己的签名带「改」,就地改不用跳设置页;没写的提示「写一句个性签名」;前一名、后一名名字下面带他们的签名
- 赛道:桌面有签名的名字后面一个引号,指上去看;手机直接写在名字后面
- 个性签名以后限 30 个字(老的长签名不动,榜上只露两行)
- 老师在名单「⋯」或台上气泡的「×」能清空不合适的签名(DELETE /rankings/mood/:userId)
- 全服名单最多列到第 100 名:原来展开会一次摊开一千五百多人把页面卡死

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 09:52:06 +08:00
1 parent 540d88355c
commit 2553c6ddfe
10 files changed
+588 -69

No files matched your search

+37 -3
View File
@@ -46,6 +46,8 @@ const WINDOW_BEFORE = 5
const WINDOW_AFTER = 7 const WINDOW_AFTER = 7
/** 没有「我」(老师、没上榜)时榜面给多少 */ /** 没有「我」(老师、没上榜)时榜面给多少 */
const BOARD_HEAD_NO_ME = 30 const BOARD_HEAD_NO_ME = 30
/** 全服名单最多列到第几名,展开也到此为止(一千五百多人全摊开页面会卡死) */
const ALL_CAP = 100
/** 走势图往回看几周 */ /** 走势图往回看几周 */
const TREND_WEEKS = 4 const TREND_WEEKS = 4
/** 每周冠军列几周 */ /** 每周冠军列几周 */
@@ -58,6 +60,7 @@ function toRow(standing: Standing, change: number | null): RankRow {
user: sampleUser(entrant, null), user: sampleUser(entrant, null),
avatar: entrant.avatar, avatar: entrant.avatar,
className: entrant.className, className: entrant.className,
mood: entrant.mood,
solved: standing.solved, solved: standing.solved,
reachedAt: standing.reachedAt, reachedAt: standing.reachedAt,
change, change,
@@ -187,11 +190,13 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1 const meIndex = user ? now.findIndex((row) => row.entrant.id === user.id && row.solved > 0) : -1
const me = meIndex >= 0 ? now[meIndex] : undefined const me = meIndex >= 0 ? now[meIndex] : undefined
const cap = scope.data === "all" ? ALL_CAP : null
const listable = cap ? now.slice(0, cap) : now
const full = includeZero || c.req.query("full") === "1" const full = includeZero || c.req.query("full") === "1"
let picked = now let picked = listable
if (!full) { if (!full) {
const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME
picked = now.filter( picked = listable.filter(
(_, index) => (_, index) =>
index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER), index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER),
) )
@@ -230,7 +235,8 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
changeSince, changeSince,
total: now.length, total: now.length,
rows: picked.map((row) => rowOf(row)!), rows: picked.map((row) => rowOf(row)!),
complete: picked.length === now.length, complete: picked.length === listable.length,
cap,
me: rowOf(me), me: rowOf(me),
ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined), ahead: rowOf(meIndex > 0 ? now[meIndex - 1] : undefined),
behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined), behind: rowOf(meIndex >= 0 ? now[meIndex + 1] : undefined),
@@ -340,3 +346,31 @@ rankingRoutes.put("/rankings/hidden/:userId", requireTeacher, async (c) => {
if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生") if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生")
return success(c, null) return success(c, null)
}) })
/**
* 老师清空一个学生的个性签名。签名在排名上挂出来以后更显眼,拿同学开涮、留电话号码的
* 得有人能拿掉(用户 2026-10-10 定的)。只清这一句,他之后还能重新写。
*/
rankingRoutes.delete("/rankings/mood/:userId", requireTeacher, async (c) => {
const userId = Number(c.req.param("userId"))
if (!Number.isInteger(userId)) return failure(c, 400, "invalid-user", "用户不对")
const updated = await db
.update(schema.userProfile)
.set({ mood: null })
.where(
and(
eq(schema.userProfile.userId, userId),
inArray(
schema.userProfile.userId,
db
.select({ id: schema.user.id })
.from(schema.user)
.where(inArray(schema.user.adminType, [...STUDENT_ROLES])),
),
),
)
.returning({ id: schema.userProfile.userId })
if (!updated.length) return failure(c, 404, "user-not-found", "没有这个学生")
return success(c, null)
})
+3
View File
@@ -51,6 +51,7 @@ export interface Entrant {
username: string username: string
className: string | null className: string | null
avatar: string | null avatar: string | null
mood: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */ /** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
times: number[] times: number[]
stamps: string[] stamps: string[]
@@ -90,6 +91,7 @@ export async function loadEntrants(where: SQL, since: string | null): Promise<En
username: schema.user.username, username: schema.user.username,
className: schema.user.className, className: schema.user.className,
avatar: schema.userProfile.avatar, avatar: schema.userProfile.avatar,
mood: schema.userProfile.mood,
}) })
.from(schema.user) .from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -125,6 +127,7 @@ export async function loadEntrants(where: SQL, since: string | null): Promise<En
username: person.username, username: person.username,
className: person.className, className: person.className,
avatar: avatarOf(person.avatar), avatar: avatarOf(person.avatar),
mood: person.mood?.trim() || null,
times: pairs.map(([time]) => time), times: pairs.map(([time]) => time),
stamps: pairs.map(([, stamp]) => stamp), stamps: pairs.map(([, stamp]) => stamp),
} }
+6 -1
View File
@@ -264,6 +264,11 @@ export function setRankHidden(userId: number, hidden: boolean) {
return api.put<null>(`rankings/hidden/${userId}`, { hidden }) return api.put<null>(`rankings/hidden/${userId}`, { hidden })
} }
/** 老师清空一个学生的个性签名 */
export function clearMood(userId: number) {
return api.delete<null>(`rankings/mood/${userId}`)
}
/** /**
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global —— * 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
* 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。 * 班级榜要求调用者有班级,教师/超管拿到的是 400,所以别在没班级时切过去。
@@ -363,7 +368,7 @@ export function uploadAvatar(file: File) {
}) })
} }
export function updateProfile(data: { realName: string; mood: string }) { export function updateProfile(data: { realName?: string; mood?: string }) {
return api.put<Profile>("me/profile", data) return api.put<Profile>("me/profile", data)
} }
+218 -11
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RankRow } from "@oj2/contract" import { MOOD_MAX, type RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { updateProfile } from "oj/api"
import { useUserStore } from "shared/store/user"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import { gapText, toPass } from "../utils" import { gapText, toPass } from "../utils"
import RankAvatar from "./RankAvatar.vue" import RankAvatar from "./RankAvatar.vue"
@@ -18,6 +20,9 @@ export interface MiniRank {
* *
* 用词只说位置(前一名 / 后一名 / 进步最大),不用「追」「对手」「冲」:用户指出 * 用词只说位置(前一名 / 后一名 / 进步最大),不用「追」「对手」「冲」:用户指出
* 「要追的」「追你」会被中学生读成追求谁,「冲得最猛」也容易误会。 * 「要追的」「追你」会被中学生读成追求谁,「冲得最猛」也容易误会。
*
* 个性签名(设计稿「签名 A」):自己的签名挂在大字下面,「改」就地改,不用跳去设置页;
* 没写的给一句「写一句个性签名」。前一名、后一名的签名挂在他们名字下面。
*/ */
const props = defineProps<{ const props = defineProps<{
me: RankRow me: RankRow
@@ -31,8 +36,39 @@ const props = defineProps<{
/** 手机:前后一名的说明换到名字下面一行,别被截断 */ /** 手机:前后一名的说明换到名字下面一行,别被截断 */
compact?: boolean compact?: boolean
}>() }>()
const emit = defineEmits<{ moodSaved: [mood: string | null] }>()
const theme = useThemeVars() const theme = useThemeVars()
const message = useMessage()
const userStore = useUserStore()
const editing = ref(false)
const draft = ref("")
const saving = ref(false)
watch(editing, (open) => {
if (open) draft.value = props.me.mood ?? ""
})
async function saveMood() {
const mood = draft.value.trim()
if (mood.length > MOOD_MAX) {
message.warning(`个性签名最多 ${MOOD_MAX} 个字`)
return
}
saving.value = true
try {
await updateProfile({ mood })
if (userStore.profile) userStore.profile.mood = mood || null
emit("moodSaved", mood || null)
editing.value = false
message.success(mood ? "签名改好了" : "签名清空了")
} catch {
message.error("没保存上,再试一次")
} finally {
saving.value = false
}
}
const headline = computed(() => { const headline = computed(() => {
const { me, ahead, third } = props const { me, ahead, third } = props
@@ -77,6 +113,70 @@ function threatNote(me: RankRow, behind: RankRow) {
</div> </div>
</div> </div>
</div> </div>
<n-popover
v-model:show="editing"
trigger="click"
placement="bottom-start"
:style="{ width: '300px' }"
>
<template #trigger>
<button v-if="me.mood" class="mood" aria-label="改个性签名">
<span class="quote-mark" aria-hidden="true">“</span>
<n-ellipsis class="mood-text" :line-clamp="2" :tooltip="false">{{ me.mood }}</n-ellipsis>
<span class="mood-edit">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 20h4L19 9l-4-4L4 16z" />
</svg>
改
</span>
</button>
<button v-else class="mood empty">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 20h4L19 9l-4-4L4 16z" />
</svg>
写一句个性签名,上了领奖台全班都看得到
</button>
</template>
<form class="mood-form" @submit.prevent="saveMood">
<label class="mood-label" for="rank-mood-input">个性签名</label>
<n-input
v-model:value="draft"
:input-props="{ id: 'rank-mood-input' }"
:maxlength="MOOD_MAX"
show-count
clearable
placeholder="写点什么,让大家认识你"
autofocus
/>
<span class="mood-hint">排名、个人主页上都会显示</span>
<div class="mood-actions">
<n-button size="small" @click="editing = false">取消</n-button>
<n-button size="small" type="primary" attr-type="submit" :loading="saving">
保存
</n-button>
</div>
</form>
</n-popover>
<div v-if="headline" class="headline"> <div v-if="headline" class="headline">
<svg <svg
width="18" width="18"
@@ -97,14 +197,24 @@ function threatNote(me: RankRow, behind: RankRow) {
<div v-if="ahead" class="rival"> <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" /> <RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
<UserName :username="ahead.user.username" class="rival-name" /> <div class="rival-body">
<span class="note">{{ chaseNote(me, ahead) }}</span> <div class="rival-line">
<UserName :username="ahead.user.username" class="rival-name" />
<span class="note">{{ chaseNote(me, ahead) }}</span>
</div>
<span v-if="ahead.mood" class="rival-mood" :title="ahead.mood">“{{ ahead.mood }}”</span>
</div>
</div> </div>
<div v-if="behind" class="rival"> <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" /> <RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
<UserName :username="behind.user.username" class="rival-name" /> <div class="rival-body">
<span class="note">{{ threatNote(me, behind) }}</span> <div class="rival-line">
<UserName :username="behind.user.username" class="rival-name" />
<span class="note">{{ threatNote(me, behind) }}</span>
</div>
<span v-if="behind.mood" class="rival-mood" :title="behind.mood">“{{ behind.mood }}”</span>
</div>
</div> </div>
<div v-if="minis.length" class="minis"> <div v-if="minis.length" class="minis">
<button v-for="mini in minis" :key="mini.label" class="mini" @click="mini.go"> <button v-for="mini in minis" :key="mini.label" class="mini" @click="mini.go">
@@ -206,24 +316,121 @@ function threatNote(me: RankRow, behind: RankRow) {
.rival { .rival {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
min-width: 0; min-width: 0;
} }
.compact .rival { .rival > .tag {
display: grid; margin-top: 2px;
grid-template-columns: auto auto 1fr; }
.rival-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.rival-line {
display: flex;
align-items: center; align-items: center;
row-gap: 1px; gap: 8px;
min-width: 0;
}
.compact .rival-line {
flex-direction: column;
align-items: flex-start;
gap: 1px;
} }
.compact .note { .compact .note {
grid-column: 3;
white-space: normal; white-space: normal;
} }
.rival-mood {
font-size: 12px;
color: v-bind("theme.textColor3");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mood {
display: flex;
align-items: flex-start;
gap: 6px;
width: 100%;
padding: 8px 10px;
border: 1px solid rgba(24, 160, 88, 0.3);
border-radius: 6px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
font: inherit;
font-size: 13px;
line-height: 1.45;
text-align: left;
cursor: pointer;
}
.mood.empty {
align-items: center;
border-style: dashed;
color: #18a058;
}
.mood:hover {
border-color: #18a058;
}
.quote-mark {
color: rgba(24, 160, 88, 0.55);
font-family: Georgia, serif;
font-size: 22px;
line-height: 18px;
}
.mood-text {
flex: 1;
min-width: 0;
word-break: break-all;
}
.mood-edit {
display: inline-flex;
align-items: center;
gap: 3px;
padding-top: 1px;
font-size: 12px;
color: #18a058;
white-space: nowrap;
}
.mood-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.mood-label {
font-size: 13px;
font-weight: 600;
}
.mood-hint {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.mood-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.rival-name { .rival-name {
flex-shrink: 0; flex-shrink: 0;
max-width: 120px; max-width: 120px;
+195 -37
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RankRow } from "@oj2/contract" import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import { useRankPalette } from "../palette" import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue" import RankAvatar from "./RankAvatar.vue"
@@ -8,17 +9,22 @@ import RankAvatar from "./RankAvatar.vue"
/** /**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。 * 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
* 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。 * 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。
*
* 个性签名挂在台上(设计稿「签名 A」):桌面是头像旁边一个气泡,像在说一句话;手机台阶窄,
* 气泡放到头像上面。只露两行,长的指上去(手机点一下)看全文。老师能在气泡上清空它。
*/ */
const props = defineProps<{ const props = defineProps<{
rows: RankRow[] rows: RankRow[]
meId?: number meId?: number
chaseId?: number chaseId?: number
threatId?: number threatId?: number
teacher?: boolean
}>() }>()
defineEmits<{ open: [username: string] }>() defineEmits<{ open: [username: string]; clearMood: [row: RankRow] }>()
const theme = useThemeVars() const theme = useThemeVars()
const palette = useRankPalette() const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
/** 台阶从左到右是 2、1、3 */ /** 台阶从左到右是 2、1、3 */
const steps = computed(() => const steps = computed(() =>
@@ -27,41 +33,66 @@ const steps = computed(() =>
.filter((step): step is { place: number; row: RankRow } => !!step.row), .filter((step): step is { place: number; row: RankRow } => !!step.row),
) )
const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 } /** 台阶比原来矮一截:台上多了签名气泡,矮下来赛道才放得进机房第一屏(41 人的班也够) */
const HEIGHTS: Record<number, number> = { 1: 40, 2: 30, 3: 26 }
</script> </script>
<template> <template>
<div class="podium"> <div class="podium" :class="{ phone: !isDesktop }">
<div v-for="{ place, row } in steps" :key="place" class="step" :class="`p${place}`"> <div v-for="{ place, row } in steps" :key="place" class="step" :class="`p${place}`">
<div class="top"> <div class="who">
<svg <span class="face">
v-if="place === 1" <svg
class="crown" v-if="place === 1"
width="18" class="crown"
height="18" width="18"
viewBox="0 0 24 24" height="18"
fill="#f2c94c" viewBox="0 0 24 24"
stroke="#9a6700" fill="#f2c94c"
stroke-width="1.6" stroke="#9a6700"
stroke-linejoin="round" stroke-width="1.6"
role="img" stroke-linejoin="round"
aria-label="第一名" role="img"
> aria-label="第一名"
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" /> >
</svg> <path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
<span v-if="row.user.id === chaseId" class="tag chase">前一名</span> </svg>
<span v-else-if="row.user.id === threatId" class="tag threat">后一名</span> <RankAvatar
<span v-else-if="row.user.id === meId" class="tag me">你</span> :username="row.user.username"
:avatar="row.avatar"
:size="place === 1 ? 34 : 28"
:me="row.user.id === meId"
/>
</span>
<div class="words">
<div class="name-line">
<button class="name" @click="$emit('open', row.user.username)">
<UserName :username="row.user.username" />
</button>
<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>
<div v-if="row.mood" class="bubble">
<n-ellipsis
:line-clamp="2"
:expand-trigger="isDesktop ? undefined : 'click'"
:tooltip="isDesktop ? { contentStyle: { maxWidth: '300px' } } : false"
>
{{ row.mood }}
</n-ellipsis>
<button
v-if="teacher"
class="clear"
aria-label="清空他的签名"
title="清空他的签名"
@click="$emit('clearMood', row)"
>
×
</button>
</div>
</div>
</div> </div>
<RankAvatar
:username="row.user.username"
:avatar="row.avatar"
:size="place === 1 ? 34 : 28"
:me="row.user.id === meId"
/>
<button class="name" @click="$emit('open', row.user.username)">
<UserName :username="row.user.username" />
</button>
<div <div
class="block" class="block"
:style="{ :style="{
@@ -98,19 +129,146 @@ const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
} }
.step { .step {
width: 170px; flex: 0 1 272px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
/* 桌面:头像在左,右边名字一行、签名气泡一行,像一句聊天 */
.who {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 0 4px;
min-width: 0;
}
.face {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.crown {
position: absolute;
left: -7px;
top: -11px;
transform: rotate(-18deg);
}
.words {
flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: 3px; gap: 3px;
} }
.top { .name-line {
height: 18px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 5px;
min-width: 0;
}
.bubble {
position: relative;
padding: 3px 9px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 8px;
background: v-bind("theme.actionColor");
font-size: 12px;
line-height: 16px;
color: v-bind("theme.textColor2");
word-break: break-all;
}
.clear {
position: absolute;
right: -7px;
top: -7px;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 8px;
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor3");
font-size: 12px;
line-height: 13px;
cursor: pointer;
display: none;
}
.bubble:hover .clear,
.clear:focus-visible {
display: block;
}
.clear:hover {
color: v-bind("theme.errorColor");
border-color: v-bind("theme.errorColor");
}
/* 手机:台阶窄,气泡挪到头像上面,下面依次头像、名字 */
.phone .step {
flex: 1 1 0;
gap: 3px;
}
.phone .who {
flex-direction: column;
align-items: center;
gap: 3px;
padding: 0;
}
.phone .words {
display: contents;
}
.phone .name-line {
max-width: 100%;
flex-direction: column;
gap: 1px;
}
.phone .face {
order: -1;
margin-top: 14px;
}
.phone .crown {
left: 50%;
top: -17px;
margin-left: -9px;
transform: none;
}
.phone .bubble {
order: -2;
width: 100%;
box-sizing: border-box;
margin-bottom: 4px;
padding: 3px 6px;
font-size: 11.5px;
line-height: 15px;
}
.phone .bubble::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
width: 8px;
height: 8px;
margin-left: -4px;
background: v-bind("theme.actionColor");
border-right: 1px solid v-bind("theme.dividerColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
transform: rotate(45deg);
} }
.tag { .tag {
@@ -157,7 +315,7 @@ const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
padding-top: 5px; padding-top: 3px;
} }
.place { .place {
+61 -11
View File
@@ -14,11 +14,16 @@ import RankAvatar from "./RankAvatar.vue"
* 手机(设计稿「手机版」):窄屏上条只剩几十像素宽,看不出谁多谁少,所以条改成每一行的 * 手机(设计稿「手机版」):窄屏上条只剩几十像素宽,看不出谁多谁少,所以条改成每一行的
* 底色;默认只列前几行和你附近几行,「看全班 N 人」点开。换范围 / 时间段时父组件换 key * 底色;默认只列前几行和你附近几行,「看全班 N 人」点开。换范围 / 时间段时父组件换 key
* 重新挂载,折叠状态跟着复位。 * 重新挂载,折叠状态跟着复位。
*
* 个性签名(设计稿「签名 A」):桌面一行只有一百来像素的条,放不下一句话,所以有签名的
* 名字后面挂一个引号,指上去看;手机每行本来就有空,签名直接写在名字后面。
*/ */
const props = defineProps<{ const props = defineProps<{
rows: RankRow[] rows: RankRow[]
total: number total: number
complete: boolean complete: boolean
/** 名单最多列到第几名(全服 100),折叠处的「第 x–y 名」到此为止 */
cap?: number | null
/** 条的满格是几道(第 2 名的数,第 1 名常常一骑绝尘) */ /** 条的满格是几道(第 2 名的数,第 1 名常常一骑绝尘) */
scale: number scale: number
meId?: number meId?: number
@@ -38,6 +43,7 @@ const emit = defineEmits<{
submissions: [username: string] submissions: [username: string]
analysis: [username: string] analysis: [username: string]
hide: [row: RankRow] hide: [row: RankRow]
clearMood: [row: RankRow]
}>() }>()
const theme = useThemeVars() const theme = useThemeVars()
@@ -73,7 +79,9 @@ const items = computed<Item[]>(() => {
list.push({ kind: "row", row }) list.push({ kind: "row", row })
last = row.rank last = row.rank
} }
const end = props.complete ? (props.rows.at(-1)?.rank ?? last) : props.total const end = props.complete
? (props.rows.at(-1)?.rank ?? last)
: Math.min(props.total, props.cap ?? props.total)
if (last < end) list.push({ kind: "gap", from: last + 1, to: end }) if (last < end) list.push({ kind: "gap", from: last + 1, to: end })
return list return list
}) })
@@ -129,15 +137,19 @@ function width(row: RankRow) {
return `${Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))}%` return `${Math.max(1.5, Math.min(100, (row.solved / Math.max(1, props.scale)) * 100))}%`
} }
const menu: DropdownOption[] = [ function menuOf(row: RankRow): DropdownOption[] {
{ label: "看他的提交", key: "submissions" }, return [
{ label: "看他的智能分析", key: "analysis" }, { label: "看他的提交", key: "submissions" },
{ label: "不计入排名…", key: "hide" }, { label: "看他的智能分析", key: "analysis" },
] ...(row.mood ? [{ label: "清空他的签名…", key: "mood" }] : []),
{ label: "不计入排名…", key: "hide" },
]
}
function onMenu(key: string, row: RankRow) { function onMenu(key: string, row: RankRow) {
if (key === "submissions") emit("submissions", row.user.username) if (key === "submissions") emit("submissions", row.user.username)
else if (key === "analysis") emit("analysis", row.user.username) else if (key === "analysis") emit("analysis", row.user.username)
else if (key === "mood") emit("clearMood", row)
else emit("hide", row) else emit("hide", row)
} }
</script> </script>
@@ -168,6 +180,7 @@ function onMenu(key: string, row: RankRow) {
<button class="who" @click="emit('open', item.row.user.username)"> <button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" /> <UserName :username="item.row.user.username" />
</button> </button>
<span class="mood">{{ item.row.mood }}</span>
<span v-if="item.row.user.id === chaseId" class="tag chase">前一名</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 === threatId" class="tag threat">后一名</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">进步最大</span> <span v-else-if="item.row.user.id === hotId" class="tag hot">进步最大</span>
@@ -182,7 +195,7 @@ function onMenu(key: string, row: RankRow) {
<n-dropdown <n-dropdown
v-if="teacher" v-if="teacher"
trigger="click" trigger="click"
:options="menu" :options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)" @select="(key: string) => onMenu(key, item.row)"
> >
<button class="more" aria-label="更多">⋯</button> <button class="more" aria-label="更多">⋯</button>
@@ -196,9 +209,28 @@ function onMenu(key: string, row: RankRow) {
:size="16" :size="16"
:me="item.row.user.id === meId" :me="item.row.user.id === meId"
/> />
<button class="who" @click="emit('open', item.row.user.username)"> <n-tooltip :disabled="!item.row.mood" :style="{ maxWidth: '300px' }">
<UserName :username="item.row.user.username" /> <template #trigger>
</button> <button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
<svg
v-if="item.row.mood"
class="quote"
width="11"
height="11"
viewBox="0 0 24 24"
fill="currentColor"
role="img"
aria-label="有个性签名"
>
<path
d="M4 18v-5c0-4 2-7 6-8l1 2c-2 1-3 2.5-3 4h3v7zm9 0v-5c0-4 2-7 6-8l1 2c-2 1-3 2.5-3 4h3v7z"
/>
</svg>
</button>
</template>
{{ item.row.mood }}
</n-tooltip>
<span class="bar-box"> <span class="bar-box">
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" /> <span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
</span> </span>
@@ -218,7 +250,7 @@ function onMenu(key: string, row: RankRow) {
<n-dropdown <n-dropdown
v-if="teacher" v-if="teacher"
trigger="click" trigger="click"
:options="menu" :options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)" @select="(key: string) => onMenu(key, item.row)"
> >
<button class="more" aria-label="更多">⋯</button> <button class="more" aria-label="更多">⋯</button>
@@ -295,7 +327,18 @@ function onMenu(key: string, row: RankRow) {
.prow .who { .prow .who {
width: auto; width: auto;
max-width: 120px;
}
/* 手机:签名跟在名字后面,一行放不下就省略 */
.mood {
flex: 1; flex: 1;
min-width: 0;
font-size: 12px;
color: v-bind("theme.textColor3");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.prow .rank { .prow .rank {
@@ -351,9 +394,16 @@ function onMenu(key: string, row: RankRow) {
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center;
gap: 3px;
min-width: 0; min-width: 0;
} }
.quote {
flex-shrink: 0;
color: v-bind("theme.textColor3");
}
.bar-box { .bar-box {
flex-grow: 1; flex-grow: 1;
height: 11px; height: 11px;
+38 -4
View File
@@ -8,7 +8,7 @@ import type {
WeeklyChampion, WeeklyChampion,
} from "@oj2/contract" } from "@oj2/contract"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from "oj/api" import { clearMood, getClassBattle, getRankBoard, getWeeklyChampions, setRankHidden } from "oj/api"
import { classLabel } from "oj/submission/utils" import { classLabel } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -199,11 +199,14 @@ const subtitle = computed(() => {
const b = board.value const b = board.value
if (!b) return "" if (!b) return ""
const head = b.scope === "class" ? `${b.total} 人` : `${b.total} 人做对过` const head = b.scope === "class" ? `${b.total} 人` : `${b.total} 人做对过`
const part = b.complete const capped = !!b.cap && b.total > b.cap
? "" const part = !b.complete
: b.me ? b.me
? " · 前面一段 + 你附近一段" ? " · 前面一段 + 你附近一段"
: ` · 只列前面 ${b.rows.length} 名` : ` · 只列前面 ${b.rows.length} 名`
: capped
? ` · 只列前 ${b.cap} 名`
: ""
return `${head} · 一样多的,先做到的在前${part}` return `${head} · 一样多的,先做到的在前${part}`
}) })
/** 手机上标题旁边那句:只说从哪天起、跟什么时候比 */ /** 手机上标题旁边那句:只说从哪天起、跟什么时候比 */
@@ -342,6 +345,32 @@ function hide(row: RankRow) {
}) })
} }
/** 签名改了 / 清了:榜上这个人的每一处(赛道、领奖台、「你」卡、前后一名)就地换掉,不重取 */
function setMood(userId: number, mood: string | null) {
const b = board.value
if (!b) return
for (const row of [...b.rows, b.me, b.ahead, b.behind])
if (row?.user.id === userId) row.mood = mood
}
function onMoodSaved(mood: string | null) {
if (meId.value) setMood(meId.value, mood)
}
function confirmClearMood(row: RankRow) {
dialog.warning({
title: "清空个性签名",
content: `${row.user.username} 的签名「${row.mood}」会被清空。只清这一句,他之后还能重新写。`,
positiveText: "清空",
negativeText: "取消",
onPositiveClick: async () => {
await clearMood(row.user.id)
setMood(row.user.id, null)
message.success("签名已清空")
},
})
}
async function restore(userId: number) { async function restore(userId: number) {
await setRankHidden(userId, false) await setRankHidden(userId, false)
message.success("已恢复") message.success("已恢复")
@@ -414,7 +443,9 @@ async function restore(userId: number) {
:me-id="meId" :me-id="meId"
:chase-id="board.ahead?.user.id" :chase-id="board.ahead?.user.id"
:threat-id="board.behind?.user.id" :threat-id="board.behind?.user.id"
:teacher="teacher"
@open="open" @open="open"
@clear-mood="confirmClearMood"
/> />
<RankTrack <RankTrack
:key="`${board.scope}|${board.period}|${board.className}`" :key="`${board.scope}|${board.period}|${board.className}`"
@@ -422,6 +453,7 @@ async function restore(userId: number) {
:whole-label="board.scope === 'class' ? `看全班 ${board.total} 人` : undefined" :whole-label="board.scope === 'class' ? `看全班 ${board.total} 人` : undefined"
:total="board.total" :total="board.total"
:complete="board.complete" :complete="board.complete"
:cap="board.cap"
:scale="scale" :scale="scale"
:me-id="meId" :me-id="meId"
:chase-id="board.ahead?.user.id" :chase-id="board.ahead?.user.id"
@@ -435,6 +467,7 @@ async function restore(userId: number) {
@submissions="openSubmissions" @submissions="openSubmissions"
@analysis="openAnalysis" @analysis="openAnalysis"
@hide="hide" @hide="hide"
@clear-mood="confirmClearMood"
/> />
</template> </template>
<div v-else-if="!loading" class="empty">排名没取到,刷新一下试试</div> <div v-else-if="!loading" class="empty">排名没取到,刷新一下试试</div>
@@ -452,6 +485,7 @@ async function restore(userId: number) {
:label="`${scopeLabel(board.scope)} · ${periodLabel(board.period)}`" :label="`${scopeLabel(board.scope)} · ${periodLabel(board.period)}`"
:minis="minis" :minis="minis"
:compact="!isDesktop" :compact="!isDesktop"
@mood-saved="onMoodSaved"
/> />
<div v-else-if="board.hidden" class="note-card"> <div v-else-if="board.hidden" class="note-card">
<b>你现在不计入排名</b> <b>你现在不计入排名</b>
+13 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { MOOD_MAX } from "@oj2/contract"
import { updateProfile, uploadAvatar } from "oj/api" import { updateProfile, uploadAvatar } from "oj/api"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
@@ -25,6 +26,12 @@ async function upload({ file }: UploadCustomRequestOptions) {
} }
async function saveProfile() { async function saveProfile() {
// 以前限 256 字,老的长签名还在库里:输入框挡不住已有的字,存之前先说清楚
const length = userStore.profile?.mood?.trim().length ?? 0
if (length > MOOD_MAX) {
message.warning(`个性签名最多 ${MOOD_MAX} 个字,现在有 ${length} 个,删短一点再保存`)
return
}
try { try {
await updateProfile({ await updateProfile({
realName: userStore.profile?.realName ?? "", realName: userStore.profile?.realName ?? "",
@@ -55,7 +62,12 @@ async function saveProfile() {
<n-input v-model:value="userStore.profile.realName" /> <n-input v-model:value="userStore.profile.realName" />
</n-form-item> --> </n-form-item> -->
<n-form-item label="个性签名"> <n-form-item label="个性签名">
<n-input v-model:value="userStore.profile.mood" /> <n-input
v-model:value="userStore.profile.mood"
:maxlength="MOOD_MAX"
show-count
placeholder="会显示在排名和个人主页上"
/>
</n-form-item> </n-form-item>
<n-button @click="saveProfile">更改信息</n-button> <n-button @click="saveProfile">更改信息</n-button>
</n-form> </n-form>
+5 -1
View File
@@ -3,6 +3,9 @@ import { z } from "zod"
import { sampleUserSchema } from "./common" import { sampleUserSchema } from "./common"
import { userProfileSchema } from "./auth" import { userProfileSchema } from "./auth"
/** 个性签名最多几个字。以前是 256,排名上要露出来后改成 30(九成签名本来就不超过 26 字) */
export const MOOD_MAX = 30
export const registerRequestSchema = z.object({ export const registerRequestSchema = z.object({
username: z.string().trim().min(1).max(32), username: z.string().trim().min(1).max(32),
email: z.email().max(64), email: z.email().max(64),
@@ -12,7 +15,8 @@ export const registerRequestSchema = z.object({
export const updateProfileRequestSchema = z.object({ export const updateProfileRequestSchema = z.object({
realName: z.string().max(32).nullable().optional(), realName: z.string().max(32).nullable().optional(),
avatar: z.string().max(256).optional(), avatar: z.string().max(256).optional(),
mood: z.string().max(256).nullable().optional(), /** 排名上要挂出来(`MOOD_MAX`),太长了榜上放不下 */
mood: z.string().trim().max(MOOD_MAX, `个性签名最多 ${MOOD_MAX} 个字`).nullable().optional(),
}) })
export const metricsSchema = z.object({ export const metricsSchema = z.object({
+12
View File
@@ -19,6 +19,12 @@ export const rankRowSchema = z.object({
/** 自己上传的头像;还是默认头像时为 null,前端画名字最后一个字 */ /** 自己上传的头像;还是默认头像时为 null,前端画名字最后一个字 */
avatar: z.string().nullable(), avatar: z.string().nullable(),
className: z.string().nullable(), className: z.string().nullable(),
/**
* 个性签名,没写为 null。学生拿它展示个性、炫耀(用户 2026-10-10 定的要露出来):
* 领奖台上、「你」卡、前后一名、赛道悬停都挂着。老规矩写到 256 字,现在限 30 字,
* 老的长签名前端只露两行、指上去看全文
*/
mood: z.string().nullable(),
solved: z.number().int().nonnegative(), solved: z.number().int().nonnegative(),
/** 做到这个数的时刻(最后一道新做对的题);一道没做对时为 null */ /** 做到这个数的时刻(最后一道新做对的题);一道没做对时为 null */
reachedAt: z.string().nullable(), reachedAt: z.string().nullable(),
@@ -58,7 +64,13 @@ export const rankBoardSchema = z.object({
* 中间折起来,按名次断开的地方就是折叠处 * 中间折起来,按名次断开的地方就是折叠处
*/ */
rows: z.array(rankRowSchema), rows: z.array(rankRowSchema),
/** `rows` 已经是能列的全部(本年级 = 全部;全服 = 前 `cap` 名) */
complete: z.boolean(), complete: z.boolean(),
/**
* 名单最多列到第几名:全服 100(用户 2026-10-10 定的),本班 / 本年级 null。
* 全服一千五百多人,展开全量会把页面卡死;100 名以外不列,「你」卡上照样报名次
*/
cap: z.number().int().positive().nullable(),
/** 我在这张榜上的那一行。没登录、老师、不计入排名、或者这段时间一道没做对时为 null */ /** 我在这张榜上的那一行。没登录、老师、不计入排名、或者这段时间一道没做对时为 null */
me: rankRowSchema.nullable(), me: rankRowSchema.nullable(),
/** 名次紧挨在我前面、后面的人 */ /** 名次紧挨在我前面、后面的人 */