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

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