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
@@ -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)
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
/** 名次紧挨在我前面、后面的人 */
|
||||
|
||||
Reference in new issue
Block a user