Files
OJ2/apps/web/src/oj/rank/components/RankPodium.vue
T
xuyueandClaude Opus 5.5 2bda05a978
Deploy / deploy (push) Canceled after 0s
feat(名字): 界面上不再显示 ks + 班号前缀,混着几个班的地方名字后面写班级
- 学生名字一律只显示名字(老号 ks232张三 显示成 张三),顶栏、首页问候、悬停提示、请假名单、比赛成绩表都一样
- 全服 / 年级排名、全部提交、几个班同时举手时,名字后面淡写班级;提交列表的名字列窄,只写班号(陈长 258)
- AI 班级分析点名「要多关心的同学」时不再带 ks 前缀
- 建号查重补漏:同班同名也比真名(加过数字的张三2、编号制老号);名字自己带数字(王芳1)也认得出被占;
  导入时后端再拦一次同班同名,建号页要等查重结果对上当前名单才能点
- 登录名单换成新接口 /classes/:c/members(名字按界面规则剥,换过班的老号不再露出全名),
  旧接口原样留给上线时还开着旧登录页的机房电脑
- 后台手写老格式 ks232张三 新建用户、没选班时按前缀补班级;PUT /me/profile 只带真名时不再 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:41:49 +08:00

328 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 'shared/components/RankAvatar.vue'
/**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
* 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。
*
* 个性签名挂在台上(设计稿「签名 A」):桌面是头像旁边一个气泡,像在说一句话;手机台阶窄,
* 气泡放到头像上面。只露两行,长的指上去(手机点一下)看全文。老师能在气泡上清空它。
*/
const props = defineProps<{
rows: RankRow[]
meId?: number
chaseId?: number
threatId?: number
teacher?: boolean
/** 本年级 / 全服的榜混着好几个班:名字后面淡写班级(本班榜不写) */
showClass?: boolean
}>()
defineEmits<{ open: [username: string]; clearMood: [row: RankRow] }>()
const theme = useThemeVars()
const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
/** 台阶从左到右是 2、1、3 */
const steps = computed(() =>
[2, 1, 3].map((place) => ({ place, row: props.rows[place - 1] })).filter((step): step is { place: number; row: RankRow } => !!step.row),
)
const HEIGHTS: Record<number, number> = { 1: 56, 2: 44, 3: 34 }
</script>
<template>
<div class="podium" :class="{ phone: !isDesktop }">
<div v-for="{ place, row } in steps" :key="place" class="step" :class="`p${place}`">
<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" :class-name="showClass ? row.className : null" />
</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
class="block"
:style="{
height: `${HEIGHTS[place]}px`,
background: palette.medal[place - 1]!.background,
boxShadow: row.user.id === chaseId ? `inset 0 0 0 2px ${palette.chase}` : row.user.id === meId ? `inset 0 0 0 2px ${palette.me}` : undefined,
}"
>
<span class="place" :style="{ color: palette.medal[place - 1]!.color }">{{ place }}</span>
<span class="solved"
><b>{{ row.solved }}</b
><small>道</small></span
>
</div>
</div>
<span v-if="!steps.length" class="empty">还没有人做对,现在做对 1 道就是第一名</span>
</div>
</template>
<style scoped>
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 8px;
margin: 4px 24px 0;
border-bottom: 2px solid v-bind('theme.dividerColor');
min-height: 60px;
}
.step {
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;
gap: 3px;
}
.name-line {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.bubble {
position: relative;
padding: 4px 10px;
border: 1px solid v-bind('theme.dividerColor');
border-radius: 8px;
background: v-bind('theme.actionColor');
font-size: var(--oj-fs-meta);
line-height: 18px;
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 {
font-size: 12px;
font-weight: 600;
border-radius: 3px;
padding: 0 6px;
line-height: 18px;
white-space: nowrap;
}
.tag.chase {
color: v-bind('palette.chase');
background: rgba(47, 111, 208, 0.12);
}
.tag.threat {
color: v-bind('palette.threat');
background: rgba(199, 106, 18, 0.12);
}
.tag.me {
color: v-bind('palette.me');
background: rgba(24, 160, 88, 0.12);
}
.name {
max-width: 100%;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--oj-fs-sec);
cursor: pointer;
display: flex;
min-width: 0;
}
.block {
width: 100%;
box-sizing: border-box;
border-radius: 6px 6px 0 0;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 10px;
padding-top: 5px;
}
.place {
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.solved {
font-variant-numeric: tabular-nums;
}
.solved b {
font-size: 18px;
}
.solved small {
font-size: 12px;
color: v-bind('theme.textColor3');
margin-left: 1px;
}
.empty {
padding: 24px 0;
color: v-bind('theme.textColor3');
font-size: var(--oj-fs-sec);
}
</style>