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