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 BOARD_HEAD_NO_ME = 30
/** 全服名单最多列到第几名,展开也到此为止(一千五百多人全摊开页面会卡死) */
const ALL_CAP = 100
/** 走势图往回看几周 */
const TREND_WEEKS = 4
/** 每周冠军列几周 */
@@ -58,6 +60,7 @@ function toRow(standing: Standing, change: number | null): RankRow {
user: sampleUser(entrant, null),
avatar: entrant.avatar,
className: entrant.className,
mood: entrant.mood,
solved: standing.solved,
reachedAt: standing.reachedAt,
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 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"
let picked = now
let picked = listable
if (!full) {
const head = me ? BOARD_HEAD : BOARD_HEAD_NO_ME
picked = now.filter(
picked = listable.filter(
(_, index) =>
index < head || (me && index >= meIndex - WINDOW_BEFORE && index <= meIndex + WINDOW_AFTER),
)
@@ -230,7 +235,8 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => {
changeSince,
total: now.length,
rows: picked.map((row) => rowOf(row)!),
complete: picked.length === now.length,
complete: picked.length === listable.length,
cap,
me: rowOf(me),
ahead: 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", "没有这个学生")
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
className: string | null
avatar: string | null
mood: string | null
/** 每道题第一次做对的时刻,升序;毫秒数用来比较,原文留着给出参 */
times: number[]
stamps: string[]
@@ -90,6 +91,7 @@ export async function loadEntrants(where: SQL, since: string | null): Promise<En
username: schema.user.username,
className: schema.user.className,
avatar: schema.userProfile.avatar,
mood: schema.userProfile.mood,
})
.from(schema.user)
.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,
className: person.className,
avatar: avatarOf(person.avatar),
mood: person.mood?.trim() || null,
times: pairs.map(([time]) => time),
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 })
}
/** 老师清空一个学生的个性签名 */
export function clearMood(userId: number) {
return api.delete<null>(`rankings/mood/${userId}`)
}
/**
* 本周进步榜。`scope` 只有两个取值,服务端认不出的一律当 global ——
* 班级榜要求调用者有班级,教师/超管拿到的是 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)
}
+218 -11
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { MOOD_MAX, type RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { updateProfile } from "oj/api"
import { useUserStore } from "shared/store/user"
import UserName from "shared/components/UserName.vue"
import { gapText, toPass } from "../utils"
import RankAvatar from "./RankAvatar.vue"
@@ -18,6 +20,9 @@ export interface MiniRank {
*
* 用词只说位置(前一名 / 后一名 / 进步最大),不用「追」「对手」「冲」:用户指出
* 「要追的」「追你」会被中学生读成追求谁,「冲得最猛」也容易误会。
*
* 个性签名(设计稿「签名 A」):自己的签名挂在大字下面,「改」就地改,不用跳去设置页;
* 没写的给一句「写一句个性签名」。前一名、后一名的签名挂在他们名字下面。
*/
const props = defineProps<{
me: RankRow
@@ -31,8 +36,39 @@ const props = defineProps<{
/** 手机:前后一名的说明换到名字下面一行,别被截断 */
compact?: boolean
}>()
const emit = defineEmits<{ moodSaved: [mood: string | null] }>()
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 { me, ahead, third } = props
@@ -77,6 +113,70 @@ function threatNote(me: RankRow, behind: RankRow) {
</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">
<svg
width="18"
@@ -97,14 +197,24 @@ function threatNote(me: RankRow, behind: RankRow) {
<div v-if="ahead" class="rival">
<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 class="rival-body">
<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 v-if="behind" class="rival">
<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>
<div class="rival-body">
<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 v-if="minis.length" class="minis">
<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 {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
font-size: 13px;
min-width: 0;
}
.compact .rival {
display: grid;
grid-template-columns: auto auto 1fr;
.rival > .tag {
margin-top: 2px;
}
.rival-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.rival-line {
display: flex;
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 {
grid-column: 3;
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 {
flex-shrink: 0;
max-width: 120px;
+195 -37
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { RankRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import UserName from "shared/components/UserName.vue"
import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue"
@@ -8,17 +9,22 @@ import RankAvatar from "./RankAvatar.vue"
/**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
* 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。
*
* 个性签名挂在台上(设计稿「签名 A」):桌面是头像旁边一个气泡,像在说一句话;手机台阶窄,
* 气泡放到头像上面。只露两行,长的指上去(手机点一下)看全文。老师能在气泡上清空它。
*/
const props = defineProps<{
rows: RankRow[]
meId?: number
chaseId?: number
threatId?: number
teacher?: boolean
}>()
defineEmits<{ open: [username: string] }>()
defineEmits<{ open: [username: string]; clearMood: [row: RankRow] }>()
const theme = useThemeVars()
const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
/** 台阶从左到右是 2、1、3 */
const steps = computed(() =>
@@ -27,41 +33,66 @@ const steps = computed(() =>
.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>
<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 class="top">
<svg
v-if="place === 1"
class="crown"
width="18"
height="18"
viewBox="0 0 24 24"
fill="#f2c94c"
stroke="#9a6700"
stroke-width="1.6"
stroke-linejoin="round"
role="img"
aria-label="第一名"
>
<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-else-if="row.user.id === meId" class="tag me">你</span>
<div class="who">
<span class="face">
<svg
v-if="place === 1"
class="crown"
width="18"
height="18"
viewBox="0 0 24 24"
fill="#f2c94c"
stroke="#9a6700"
stroke-width="1.6"
stroke-linejoin="round"
role="img"
aria-label="第一名"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<RankAvatar
: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>
<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
class="block"
:style="{
@@ -98,19 +129,146 @@ const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
}
.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;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.top {
height: 18px;
.name-line {
display: flex;
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 {
@@ -157,7 +315,7 @@ const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
align-items: flex-start;
justify-content: center;
gap: 10px;
padding-top: 5px;
padding-top: 3px;
}
.place {
+61 -11
View File
@@ -14,11 +14,16 @@ import RankAvatar from "./RankAvatar.vue"
* 手机(设计稿「手机版」):窄屏上条只剩几十像素宽,看不出谁多谁少,所以条改成每一行的
* 底色;默认只列前几行和你附近几行,「看全班 N 人」点开。换范围 / 时间段时父组件换 key
* 重新挂载,折叠状态跟着复位。
*
* 个性签名(设计稿「签名 A」):桌面一行只有一百来像素的条,放不下一句话,所以有签名的
* 名字后面挂一个引号,指上去看;手机每行本来就有空,签名直接写在名字后面。
*/
const props = defineProps<{
rows: RankRow[]
total: number
complete: boolean
/** 名单最多列到第几名(全服 100),折叠处的「第 x–y 名」到此为止 */
cap?: number | null
/** 条的满格是几道(第 2 名的数,第 1 名常常一骑绝尘) */
scale: number
meId?: number
@@ -38,6 +43,7 @@ const emit = defineEmits<{
submissions: [username: string]
analysis: [username: string]
hide: [row: RankRow]
clearMood: [row: RankRow]
}>()
const theme = useThemeVars()
@@ -73,7 +79,9 @@ const items = computed<Item[]>(() => {
list.push({ kind: "row", row })
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 })
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))}%`
}
const menu: DropdownOption[] = [
{ label: "看他的提交", key: "submissions" },
{ label: "看他的智能分析", key: "analysis" },
{ label: "不计入排名…", key: "hide" },
]
function menuOf(row: RankRow): DropdownOption[] {
return [
{ label: "看他的提交", key: "submissions" },
{ label: "看他的智能分析", key: "analysis" },
...(row.mood ? [{ label: "清空他的签名…", key: "mood" }] : []),
{ 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 if (key === "mood") emit("clearMood", row)
else emit("hide", row)
}
</script>
@@ -168,6 +180,7 @@ function onMenu(key: string, row: RankRow) {
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<span class="mood">{{ item.row.mood }}</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>
@@ -182,7 +195,7 @@ function onMenu(key: string, row: RankRow) {
<n-dropdown
v-if="teacher"
trigger="click"
:options="menu"
:options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)"
>
<button class="more" aria-label="更多">⋯</button>
@@ -196,9 +209,28 @@ function onMenu(key: string, row: RankRow) {
:size="16"
:me="item.row.user.id === meId"
/>
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<n-tooltip :disabled="!item.row.mood" :style="{ maxWidth: '300px' }">
<template #trigger>
<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" :style="{ width: width(item.row), background: barColor(item.row) }" />
</span>
@@ -218,7 +250,7 @@ function onMenu(key: string, row: RankRow) {
<n-dropdown
v-if="teacher"
trigger="click"
:options="menu"
:options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)"
>
<button class="more" aria-label="更多">⋯</button>
@@ -295,7 +327,18 @@ function onMenu(key: string, row: RankRow) {
.prow .who {
width: auto;
max-width: 120px;
}
/* 手机:签名跟在名字后面,一行放不下就省略 */
.mood {
flex: 1;
min-width: 0;
font-size: 12px;
color: v-bind("theme.textColor3");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prow .rank {
@@ -351,9 +394,16 @@ function onMenu(key: string, row: RankRow) {
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
gap: 3px;
min-width: 0;
}
.quote {
flex-shrink: 0;
color: v-bind("theme.textColor3");
}
.bar-box {
flex-grow: 1;
height: 11px;
+38 -4
View File
@@ -8,7 +8,7 @@ import type {
WeeklyChampion,
} from "@oj2/contract"
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 UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -199,11 +199,14 @@ const subtitle = computed(() => {
const b = board.value
if (!b) return ""
const head = b.scope === "class" ? `${b.total} 人` : `${b.total} 人做对过`
const part = b.complete
? ""
: b.me
const capped = !!b.cap && b.total > b.cap
const part = !b.complete
? b.me
? " · 前面一段 + 你附近一段"
: ` · 只列前面 ${b.rows.length} 名`
: capped
? ` · 只列前 ${b.cap} 名`
: ""
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) {
await setRankHidden(userId, false)
message.success("已恢复")
@@ -414,7 +443,9 @@ async function restore(userId: number) {
:me-id="meId"
:chase-id="board.ahead?.user.id"
:threat-id="board.behind?.user.id"
:teacher="teacher"
@open="open"
@clear-mood="confirmClearMood"
/>
<RankTrack
:key="`${board.scope}|${board.period}|${board.className}`"
@@ -422,6 +453,7 @@ async function restore(userId: number) {
:whole-label="board.scope === 'class' ? `看全班 ${board.total} 人` : undefined"
:total="board.total"
:complete="board.complete"
:cap="board.cap"
:scale="scale"
:me-id="meId"
:chase-id="board.ahead?.user.id"
@@ -435,6 +467,7 @@ async function restore(userId: number) {
@submissions="openSubmissions"
@analysis="openAnalysis"
@hide="hide"
@clear-mood="confirmClearMood"
/>
</template>
<div v-else-if="!loading" class="empty">排名没取到,刷新一下试试</div>
@@ -452,6 +485,7 @@ async function restore(userId: number) {
:label="`${scopeLabel(board.scope)} · ${periodLabel(board.period)}`"
:minis="minis"
:compact="!isDesktop"
@mood-saved="onMoodSaved"
/>
<div v-else-if="board.hidden" class="note-card">
<b>你现在不计入排名</b>
+13 -1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { MOOD_MAX } from "@oj2/contract"
import { updateProfile, uploadAvatar } from "oj/api"
import { useUserStore } from "shared/store/user"
@@ -25,6 +26,12 @@ async function upload({ file }: UploadCustomRequestOptions) {
}
async function saveProfile() {
// 以前限 256 字,老的长签名还在库里:输入框挡不住已有的字,存之前先说清楚
const length = userStore.profile?.mood?.trim().length ?? 0
if (length > MOOD_MAX) {
message.warning(`个性签名最多 ${MOOD_MAX} 个字,现在有 ${length} 个,删短一点再保存`)
return
}
try {
await updateProfile({
realName: userStore.profile?.realName ?? "",
@@ -55,7 +62,12 @@ async function saveProfile() {
<n-input v-model:value="userStore.profile.realName" />
</n-form-item> -->
<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-button @click="saveProfile">更改信息</n-button>
</n-form>
+5 -1
View File
@@ -3,6 +3,9 @@ import { z } from "zod"
import { sampleUserSchema } from "./common"
import { userProfileSchema } from "./auth"
/** 个性签名最多几个字。以前是 256,排名上要露出来后改成 30(九成签名本来就不超过 26 字) */
export const MOOD_MAX = 30
export const registerRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
email: z.email().max(64),
@@ -12,7 +15,8 @@ export const registerRequestSchema = z.object({
export const updateProfileRequestSchema = z.object({
realName: z.string().max(32).nullable().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({
+12
View File
@@ -19,6 +19,12 @@ export const rankRowSchema = z.object({
/** 自己上传的头像;还是默认头像时为 null,前端画名字最后一个字 */
avatar: z.string().nullable(),
className: z.string().nullable(),
/**
* 个性签名,没写为 null。学生拿它展示个性、炫耀(用户 2026-10-10 定的要露出来):
* 领奖台上、「你」卡、前后一名、赛道悬停都挂着。老规矩写到 256 字,现在限 30 字,
* 老的长签名前端只露两行、指上去看全文
*/
mood: z.string().nullable(),
solved: z.number().int().nonnegative(),
/** 做到这个数的时刻(最后一道新做对的题);一道没做对时为 null */
reachedAt: z.string().nullable(),
@@ -58,7 +64,13 @@ export const rankBoardSchema = z.object({
* 中间折起来,按名次断开的地方就是折叠处
*/
rows: z.array(rankRowSchema),
/** `rows` 已经是能列的全部(本年级 = 全部;全服 = 前 `cap` 名) */
complete: z.boolean(),
/**
* 名单最多列到第几名:全服 100(用户 2026-10-10 定的),本班 / 本年级 null。
* 全服一千五百多人,展开全量会把页面卡死;100 名以外不列,「你」卡上照样报名次
*/
cap: z.number().int().positive().nullable(),
/** 我在这张榜上的那一行。没登录、老师、不计入排名、或者这段时间一道没做对时为 null */
me: rankRowSchema.nullable(),
/** 名次紧挨在我前面、后面的人 */