feat(排名): 个性签名直接写在名单上,不用再指着小引号才看得到

- 桌面:写了签名的同学名字下面多一行,把签名写出来(一行二十来个字,长的指上去看全文);两栏按高度对半分
- 手机:签名从名字后面(只剩三五个字)挪到名字下面一行
- 自己还没写的,名单里自己那行有「写一句个性签名」,和「你」卡点开的是同一个框

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 22:02:15 +08:00
1 parent 6e946c99fb
commit 28a09831b5
4 files changed
+283 -177

No files matched your search

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { MOOD_MAX } from "@oj2/contract"
import { updateProfile } from "oj/api"
import { useUserStore } from "shared/store/user"
/**
* 就地改个性签名:「你」卡上的签名、赛道里自己那行的「写一句个性签名」点开的是同一个框。
* 触发按钮由调用方放进默认插槽。
*/
const props = defineProps<{ mood: string | null }>()
const emit = defineEmits<{ saved: [mood: string | null] }>()
const message = useMessage()
const userStore = useUserStore()
const inputId = useId()
const editing = ref(false)
const draft = ref("")
const saving = ref(false)
watch(editing, (open) => {
if (open) draft.value = props.mood ?? ""
})
async function save() {
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("saved", mood || null)
editing.value = false
message.success(mood ? "签名改好了" : "签名清空了")
} catch {
message.error("没保存上,再试一次")
} finally {
saving.value = false
}
}
</script>
<template>
<n-popover
v-model:show="editing"
trigger="click"
placement="bottom-start"
:style="{ width: '300px' }"
>
<template #trigger>
<slot />
</template>
<form class="mood-form" @submit.prevent="save">
<label class="mood-label" :for="inputId">个性签名</label>
<n-input
v-model:value="draft"
:input-props="{ id: inputId }"
:maxlength="MOOD_MAX"
show-count
clearable
placeholder="写点什么,让大家认识你"
autofocus
/>
<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>
</template>
<style scoped>
.mood-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.mood-label {
font-size: var(--oj-fs-sec);
font-weight: 600;
}
.mood-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
+30 -103
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { MOOD_MAX, type RankRow } from "@oj2/contract"
import 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 MoodPopover from "./MoodPopover.vue"
import RankAvatar from "./RankAvatar.vue"
export interface MiniRank {
@@ -23,7 +22,7 @@ export interface MiniRank {
* 用词只说位置(前一名 / 后一名 / 进步最大),不用「追」「对手」「冲」:用户指出
* 「要追的」「追你」会被中学生读成追求谁,「冲得最猛」也容易误会。
*
* 个性签名(设计稿「签名 A」):自己的签名挂在大字下面,「改」就地改,不用跳去设置页;
* 个性签名:自己的签名挂在大字下面,「改」就地改,不用跳去设置页;
* 没写的给一句「写一句个性签名」。前一名、后一名的签名挂在他们名字下面。
*/
const props = defineProps<{
@@ -45,36 +44,6 @@ const props = defineProps<{
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 beyond = computed(() => !!props.cap && props.me.rank > props.cap)
@@ -126,37 +95,14 @@ 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">
<MoodPopover :mood="me.mood" @saved="emit('moodSaved', $event)">
<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="13"
height="13"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -167,28 +113,26 @@ function threatNote(me: RankRow, behind: RankRow) {
>
<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
/>
<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>
改
</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>
</MoodPopover>
<div v-if="headline" class="headline">
<svg
width="18"
@@ -427,23 +371,6 @@ function threatNote(me: RankRow, behind: RankRow) {
white-space: nowrap;
}
.mood-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.mood-label {
font-size: var(--oj-fs-sec);
font-weight: 600;
}
.mood-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.rival-name {
flex-shrink: 0;
max-width: 120px;
+160 -74
View File
@@ -4,6 +4,7 @@ import { useThemeVars, type DropdownOption } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useRankPalette } from "../palette"
import MoodPopover from "./MoodPopover.vue"
import RankAvatar from "./RankAvatar.vue"
/**
@@ -19,8 +20,10 @@ import RankAvatar from "./RankAvatar.vue"
* 桌面的条只有百来像素(老师那边多一个「⋯」,七十来像素),压不下文字标签:「前一名」
* 「后一名」靠条的颜色认(和右边「你」卡里两个标签同色),「进步最大」改成涨跌那一格的橙底徽章。手机每行有空,标签照旧。
*
* 个性签名(设计稿「签名 A」):桌面一行只有一百来像素的条,放不下一句话,所以有签名的
* 名字后面挂一个引号,指上去看;手机每行本来就有空,签名直接写在名字后面。
* 个性签名(设计稿「签名直接写在赛道上」T1):写了签名的人名字下面多一行,直接写出来,
* 不再藏在引号后面指上去才看得到(用户:排名上看不出签名)。桌面这一行从名字底下一直伸到
* 本栏末尾,能露二十多个字;手机挪到名字下面一行。你还没写的话,你那行(桌面)挂一个
* 「写一句个性签名」,和「你」卡点开的是同一个框。
*/
const props = defineProps<{
rows: RankRow[]
@@ -48,6 +51,7 @@ const emit = defineEmits<{
analysis: [username: string]
hide: [row: RankRow]
clearMood: [row: RankRow]
moodSaved: [mood: string | null]
}>()
const theme = useThemeVars()
@@ -109,11 +113,31 @@ function expandGap() {
if (!props.complete) emit("expand")
}
/** 两栏:前一半、后一半。手机一栏 */
/** 桌面每一项大约多高:带签名(或「写一句」)的行多一行,两栏按高度对半分,不按人数 */
function heightOf(item: Item) {
if (item.kind === "gap") return 48
return hasSecondLine(item.row) ? 56 : 36
}
function hasSecondLine(row: RankRow) {
return !!row.mood || row.user.id === props.meId
}
/** 两栏:前一半、后一半(按高度)。手机一栏 */
const columns = computed(() => {
if (!isDesktop.value) return [items.value]
const half = Math.ceil(items.value.length / 2)
return [items.value.slice(0, half), items.value.slice(half)]
const total = items.value.reduce((sum, item) => sum + heightOf(item), 0)
let acc = 0
let cut = items.value.length
for (const [index, item] of items.value.entries()) {
const h = heightOf(item)
if (acc + h / 2 > total / 2) {
cut = index
break
}
acc += h
}
return [items.value.slice(0, cut), items.value.slice(cut)]
})
function barColor(row: RankRow) {
@@ -175,7 +199,7 @@ function onMenu(key: string, row: RankRow) {
<div
v-else-if="!isDesktop"
class="prow"
:class="{ me: item.row.user.id === meId, zero: !item.row.solved }"
:class="{ me: item.row.user.id === meId, zero: !item.row.solved, tall: !!item.row.mood }"
>
<span class="fill" :style="{ width: width(item.row), background: fillColor(item.row) }" />
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
@@ -185,13 +209,17 @@ function onMenu(key: string, row: RankRow) {
:size="20"
:me="item.row.user.id === meId"
/>
<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>
<div class="pmain">
<div class="pname">
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<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>
</div>
<span v-if="item.row.mood" class="sig">“{{ item.row.mood }}”</span>
</div>
<span class="solved">{{ item.row.solved }}</span>
<span class="change">
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
@@ -209,58 +237,73 @@ function onMenu(key: string, row: RankRow) {
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</div>
<div v-else class="lane" :class="{ me: item.row.user.id === meId, zero: !item.row.solved }">
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
<RankAvatar
:username="item.row.user.username"
:avatar="item.row.avatar"
:size="20"
:me="item.row.user.id === meId"
/>
<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" />
<div
v-else
class="entry"
:class="{ me: item.row.user.id === meId, zero: !item.row.solved }"
>
<div class="lane">
<span class="rank">{{ item.row.solved ? item.row.rank : "—" }}</span>
<RankAvatar
:username="item.row.user.username"
:avatar="item.row.avatar"
:size="20"
:me="item.row.user.id === meId"
/>
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<span class="bar-box" :style="{ background: palette.track }">
<span
class="bar"
:style="{ width: width(item.row), background: barColor(item.row) }"
/>
</span>
<span class="solved">{{ item.row.solved }}</span>
<span class="change">
<span v-if="item.row.user.id === hotId" class="up hot" title="进步最大"
>↑{{ item.row.change }}</span
>
<span v-else-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
<span v-else-if="(item.row.change ?? 0) < 0" class="down"
>↓{{ -item.row.change! }}</span
>
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<n-dropdown
v-if="teacher"
trigger="click"
:options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)"
>
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</div>
<div v-if="item.row.mood" class="sig line">
<n-ellipsis :tooltip="{ contentStyle: { maxWidth: '300px' } }"
>“{{ item.row.mood }}”</n-ellipsis
>
</div>
<div v-else-if="item.row.user.id === meId" class="line">
<MoodPopover :mood="null" @saved="emit('moodSaved', $event)">
<button class="write">
<svg
v-if="item.row.mood"
class="quote"
width="11"
height="11"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
role="img"
aria-label="有个性签名"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<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"
/>
<path d="M4 20h4L19 9l-4-4L4 16z" />
</svg>
写一句个性签名
</button>
</template>
{{ item.row.mood }}
</n-tooltip>
<span class="bar-box" :style="{ background: palette.track }">
<span class="bar" :style="{ width: width(item.row), background: barColor(item.row) }" />
</span>
<span class="solved">{{ item.row.solved }}</span>
<span class="change">
<span v-if="item.row.user.id === hotId" class="up hot" title="进步最大"
>↑{{ item.row.change }}</span
>
<span v-else-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
<span v-else-if="(item.row.change ?? 0) < 0" class="down"
>↓{{ -item.row.change! }}</span
>
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<n-dropdown
v-if="teacher"
trigger="click"
:options="menuOf(item.row)"
@select="(key: string) => onMenu(key, item.row)"
>
<button class="more" aria-label="更多">⋯</button>
</n-dropdown>
</MoodPopover>
</div>
</div>
</template>
</div>
@@ -301,7 +344,8 @@ function onMenu(key: string, row: RankRow) {
.prow {
position: relative;
height: 38px;
min-height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
@@ -331,22 +375,69 @@ function onMenu(key: string, row: RankRow) {
border-radius: 6px;
}
.prow .who {
width: auto;
max-width: 120px;
.prow.tall {
padding-top: 5px;
padding-bottom: 5px;
}
/* 手机:签名跟在名字后面,一行放不下就省略 */
.mood {
/* 手机:名字一行,签名在名字下面一行 */
.pmain {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.pname {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.prow .who {
width: auto;
min-width: 0;
}
.sig {
display: block;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
line-height: 18px;
color: v-bind("theme.textColor2");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 桌面:签名那一行从名字底下(名次 28 + 头像 20 + 两个 8 的间距)伸到本栏末尾 */
.line {
margin: -6px 0 4px 64px;
}
.line :deep(.n-ellipsis) {
max-width: 100%;
}
.write {
height: 22px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 9px;
border: 1px dashed rgba(24, 160, 88, 0.6);
border-radius: 11px;
background: v-bind("theme.cardColor");
color: #18a058;
font: inherit;
font-size: var(--oj-fs-meta);
cursor: pointer;
}
.write:hover {
border-style: solid;
}
.prow .rank {
width: 24px;
}
@@ -364,14 +455,14 @@ function onMenu(key: string, row: RankRow) {
color: #0c7a43;
}
.lane.me {
.entry.me {
margin: 0 -8px;
padding: 0 8px;
border-radius: 6px;
background: rgba(24, 160, 88, 0.12);
}
.lane.zero {
.entry.zero {
opacity: 0.55;
}
@@ -404,11 +495,6 @@ function onMenu(key: string, row: RankRow) {
min-width: 0;
}
.quote {
flex-shrink: 0;
color: v-bind("theme.textColor3");
}
.bar-box {
flex-grow: 1;
height: 16px;
+1
View File
@@ -428,6 +428,7 @@ async function restore(userId: number) {
@analysis="openAnalysis"
@hide="hide"
@clear-mood="confirmClearMood"
@mood-saved="onMoodSaved"
/>
</template>
<div v-else-if="!loading" class="empty">排名没取到,刷新一下试试</div>