feat(排名): 个性签名挂到排名上,领奖台上每人一句、「你」卡里就地改;全服排名只列到第 100 名,展开不再卡死
- 领奖台:头像旁边一个签名气泡(手机在头像上面),只露两行,长的指上去看全文 - 「你」卡:自己的签名带「改」,就地改不用跳设置页;没写的提示「写一句个性签名」;前一名、后一名名字下面带他们的签名 - 赛道:桌面有签名的名字后面一个引号,指上去看;手机直接写在名字后面 - 个性签名以后限 30 个字(老的长签名不动,榜上只露两行) - 老师在名单「⋯」或台上气泡的「×」能清空不合适的签名(DELETE /rankings/mood/:userId) - 全服名单最多列到第 100 名:原来展开会一次摊开一千五百多人把页面卡死 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
540d88355c
commit
2553c6ddfe
10 files changed
+588
-69
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user