feat(成就): 成就页改成「快拿到了 + 本班」加一条条成就线,不再是一墙灰卡
Deploy / deploy (push) Canceled after 0s

- 顶上「快拿到了」三个只说还差什么,旁边「本班」:最近谁拿到了什么、成就最多前五(自己不在就钉在底下)
- 38 个成就按指标收成一条条线、分五组,同一串几档排在一根进度线上,下一档标「差几」;
  悬停看说明、哪天拿到、本班几人有、全站几人有
- 还没找到的隐藏成就只露问号;本班动态里也只说「彩蛋」,不漏名字
- 题单奖章并进「题单和比赛」那组;看别人的成就只有成就线
- 去掉「仅 X% 的人获得」(分母是全站历届账号)和完成度圆环

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 00:26:37 +08:00
1 parent 85108df9d8
commit cbeb0737ef
9 files changed
+1203 -145

No files matched your search

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions"
import type { Achievement, AchievementClassBoard } from "utils/types"
import Medal from "./Medal.vue"
/**
* 「本班」:最近谁拿到了什么、成就最多的前五名(自己不在里面就钉在底下)。
* 同一个人同一刻拿到的几个并成一行(一次提交常常连着解锁两三个);
* 自己还没找到的隐藏成就只说「彩蛋」,不把名字漏出来。
*/
const props = defineProps<{
board: AchievementClassBoard
achievements: Achievement[]
me: string
compact?: boolean
}>()
const theme = useThemeVars()
const byId = computed(() => new Map(props.achievements.map((item) => [item.id, item])))
const recent = computed(() => {
const rows: { key: string; username: string; time: string; items: Achievement[] }[] = []
for (const row of props.board.recent) {
const item = byId.value.get(row.achievementId)
if (!item) continue
const last = rows.at(-1)
if (last && last.username === row.username && last.time === row.unlockTime)
last.items.push(item)
else
rows.push({
key: `${row.username}-${row.unlockTime}-${row.achievementId}`,
username: row.username,
time: row.unlockTime,
items: [item],
})
}
return rows
})
function what(items: Achievement[]) {
const known = items.filter((item) => item.metric !== null).map((item) => item.name)
const eggs = items.length - known.length
if (eggs) known.push(eggs === 1 ? "一个彩蛋" : `${eggs} 个彩蛋`)
return known.join("、")
}
</script>
<template>
<section class="card" :class="{ compact }">
<h3>本班</h3>
<div class="cols">
<div class="col">
<span class="sub">最近拿到</span>
<div v-for="row in recent" :key="row.key" class="line">
<Medal
:icon="row.items[0]!.icon"
:rarity="row.items[0]!.rarity"
:size="28"
:state="row.items[0]!.metric === null ? 'unknown' : 'on'"
/>
<span class="what">
<UserName :username="row.username" />
<span class="muted">拿到</span>
{{ what(row.items) }}
</span>
<span class="date">{{ parseTime(row.time, "M月D日") }}</span>
</div>
<span v-if="!recent.length" class="empty">这学期还没人拿到</span>
</div>
<div class="col">
<span class="sub">成就最多</span>
<div
v-for="row in board.most"
:key="row.username"
class="line rank"
:class="{ mine: row.username === me }"
>
<span class="no">{{ row.rank }}</span>
<UserName :username="row.username" />
<b>{{ row.count }}</b>
</div>
<div v-if="board.me" class="line rank mine pinned">
<span class="no">{{ board.me.rank }}</span>
<UserName :username="me" />
<b>{{ board.me.count }}</b>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.card {
padding: 14px 20px 12px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
font-size: var(--oj-fs-h2);
}
.cols {
display: grid;
grid-template-columns: minmax(0, 1fr) 200px;
gap: 28px;
margin-top: 10px;
}
.col {
display: flex;
flex-direction: column;
min-width: 0;
}
.sub {
margin-bottom: 4px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.line {
display: flex;
align-items: center;
gap: 10px;
height: 38px;
font-size: var(--oj-fs-sec);
}
.what {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.muted,
.date,
.no,
.empty {
color: v-bind("theme.textColor3");
}
.muted {
margin: 0 4px;
}
.date {
font-size: 12px;
white-space: nowrap;
}
.empty {
padding: 8px 0;
font-size: var(--oj-fs-sec);
}
.rank .no {
width: 16px;
font-variant-numeric: tabular-nums;
}
.rank b {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.mine {
margin: 0 -8px;
padding: 0 8px;
border-radius: 6px;
background: v-bind("theme.primaryColor + '1a'");
}
.pinned {
border-top: 1px dashed v-bind("theme.borderColor");
}
.compact {
padding: 12px 14px 8px;
}
.compact .cols {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import type { UserBadge } from "utils/types"
import type { Group } from "../series"
import Medal from "./Medal.vue"
import SeriesRow from "./SeriesRow.vue"
/** 一组成就线(做题 / 坚持 / …),「彩蛋」组末尾是还没找到的问号,「题单和比赛」组末尾是拿到的题单奖章 */
const props = defineProps<{ group: Group; badges?: UserBadge[]; compact?: boolean }>()
const theme = useThemeVars()
const showBadges = computed(() => props.group.name === "题单和比赛" && !!props.badges?.length)
</script>
<template>
<section class="card" :class="{ compact }">
<h3>
{{ group.name }}
<small>{{ group.got }} / {{ group.total }}</small>
</h3>
<SeriesRow v-for="item in group.series" :key="item.metric" :series="item" :compact="compact" />
<div v-if="group.masked" class="masked">
<span class="masked-title">还有 {{ group.masked }} 个没找到</span>
<div class="masked-list">
<Medal v-for="i in group.masked" :key="i" state="unknown" :size="compact ? 30 : 34" />
</div>
</div>
<div v-if="showBadges" class="badges">
<span class="masked-title">拿到的题单奖章</span>
<div class="badge-list">
<n-tooltip v-for="b in badges" :key="b.id" :delay="150">
<template #trigger>
<router-link
:to="{ name: 'problemset', params: { problemSetId: b.problemSet.id } }"
class="badge"
>
<AchievementIcon :icon="b.badge.icon" :size="32" />
<span>{{ b.badge.name }}</span>
</router-link>
</template>
{{ b.badge.description }} · 来自题单「{{ b.problemSet.title }}」
</n-tooltip>
</div>
</div>
</section>
</template>
<style scoped>
.card {
padding: 14px 20px 20px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: var(--oj-fs-h2);
}
h3 small {
font-size: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.masked,
.badges {
margin-top: 22px;
display: flex;
flex-direction: column;
gap: 8px;
}
.masked-title {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.masked-list,
.badge-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.badge {
width: 72px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 12px;
line-height: 15px;
text-align: center;
color: v-bind("theme.textColor1");
text-decoration: none;
}
.badge span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact {
padding: 12px 14px 16px;
}
</style>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_COLOR } from "utils/constants"
import type { AchievementRarity } from "utils/types"
/**
* 一枚徽章:拿到的是稀有度色的圈,没拿到的灰掉,还没找到的隐藏成就是虚线问号。
* `ring` 给了就在外面画一圈进度(「快拿到了」那几个)。
*/
const props = withDefaults(
defineProps<{
icon?: string
rarity?: AchievementRarity
size?: number
state?: "on" | "off" | "unknown"
ring?: number | null
}>(),
{ icon: "", rarity: "bronze", size: 40, state: "on", ring: null },
)
const theme = useThemeVars()
const color = computed(() => RARITY_COLOR[props.rarity])
const r = computed(() => props.size / 2 + 3)
const box = computed(() => props.size + 8)
const circumference = computed(() => 2 * Math.PI * r.value)
</script>
<template>
<span class="medal" :style="{ width: `${size}px`, height: `${size}px` }">
<svg
v-if="ring !== null && state !== 'unknown'"
class="arc"
:width="box"
:height="box"
:viewBox="`0 0 ${box} ${box}`"
aria-hidden="true"
>
<circle :cx="box / 2" :cy="box / 2" :r="r" fill="none" class="arc-track" stroke-width="3" />
<circle
:cx="box / 2"
:cy="box / 2"
:r="r"
fill="none"
class="arc-fill"
stroke-width="3"
stroke-linecap="round"
:stroke-dasharray="`${circumference * Math.min(1, ring)} ${circumference}`"
/>
</svg>
<span v-if="state === 'unknown'" class="disc unknown" :style="{ fontSize: `${size * 0.42}px` }">
?
</span>
<span
v-else
class="disc"
:class="state"
:style="state === 'on' ? { borderColor: color, background: `${color}26` } : undefined"
>
<AchievementIcon :icon="icon" :size="Math.round(size * 0.56)" />
</span>
</span>
</template>
<style scoped>
.medal {
position: relative;
flex-shrink: 0;
display: block;
}
.arc {
position: absolute;
left: -4px;
top: -4px;
transform: rotate(-90deg);
}
.arc-track {
stroke: v-bind("theme.dividerColor");
}
.arc-fill {
stroke: v-bind("theme.primaryColor");
}
.disc {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
}
.disc.off {
border-color: v-bind("theme.borderColor");
background: v-bind("theme.actionColor");
}
.disc.off :deep(svg),
.disc.off :deep(img),
.disc.off :deep(span) {
filter: grayscale(1);
opacity: 0.4;
}
.disc.unknown {
border: 1.5px dashed v-bind("theme.borderColor");
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor3");
font-weight: 700;
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { RARITY_LABEL } from "utils/constants"
import { useRarityColor } from "shared/composables/rarity"
import type { Near } from "../series"
import Medal from "./Medal.vue"
/** 「快拿到了」:每个只说还差什么,进度条底下是现在几 / 要几、本班几人有 */
defineProps<{ items: Near[]; compact?: boolean }>()
const theme = useThemeVars()
const rarityText = useRarityColor()
function now(item: Near) {
const { series, achievement } = item
return series.streak
? `现在连着 ${series.value} ${series.unit}`
: `${series.value} / ${achievement.threshold} ${series.unit}`
}
const percent = (item: Near) => Math.round((item.series.value / item.achievement.threshold!) * 100)
</script>
<template>
<section class="card" :class="{ compact }">
<h3>快拿到了</h3>
<div v-for="item in items" :key="item.achievement.id" class="item">
<Medal
:icon="item.achievement.icon"
:rarity="item.achievement.rarity"
:size="compact ? 44 : 48"
state="off"
:ring="item.series.value / item.achievement.threshold!"
/>
<div class="body">
<div class="head">
<b>{{ item.achievement.name }}</b>
<span class="tag" :style="{ color: rarityText[item.achievement.rarity] }">
{{ RARITY_LABEL[item.achievement.rarity] }}
</span>
<span class="remain">{{ item.remain }}</span>
</div>
<n-progress
type="line"
:percentage="percent(item)"
:height="6"
:show-indicator="false"
:color="theme.primaryColor"
:border-radius="3"
:fill-border-radius="3"
/>
<div class="foot">
<span>{{ now(item) }}</span>
<span v-if="item.achievement.classHolders !== null">
本班 {{ item.achievement.classHolders }} 人有
</span>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.card {
padding: 14px 20px 6px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
}
h3 {
margin: 0 0 8px;
font-size: var(--oj-fs-h2);
}
.item {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 0;
border-top: 1px solid v-bind("theme.dividerColor");
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--oj-fs-body);
}
.tag {
font-size: 12px;
}
.remain {
margin-left: auto;
font-size: var(--oj-fs-sec);
font-weight: 600;
color: v-bind("theme.primaryColorPressed");
white-space: nowrap;
}
.foot {
display: flex;
justify-content: space-between;
font-size: 12px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
/* 手机:「还差什么」换到名字下面一行 */
.compact {
padding: 12px 14px 4px;
}
.compact .head {
flex-wrap: wrap;
row-gap: 2px;
}
.compact .remain {
margin-left: 0;
flex-basis: 100%;
}
</style>
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { RARITY_LABEL } from "utils/constants"
import { parseTime } from "utils/functions"
import type { Achievement } from "utils/types"
import type { Series } from "../series"
import Medal from "./Medal.vue"
/**
* 一条成就线:左边线名和现在的数,右边几档按门槛排在一根进度线上,下一档头上标「差几」。
* 档位等距摆,不按门槛比例(1 / 10 / 50 / 100 / 200 按比例画,前两档会挤成一个点),
* 进度在相邻两档之间按比例插值。手机上线名挪到上面,线占满一行。
*/
const props = defineProps<{ series: Series; compact?: boolean }>()
const theme = useThemeVars()
// 「差几」的底色:主色压淡,明暗主题都跟着走
const gapBg = computed(() => `${theme.value.primaryColor}22`)
const size = computed(() => (props.compact ? 34 : 40))
// 少于三档也按三格排,一档、两档的线不会被拉得很长
const cols = computed(() => Math.max(props.series.items.length, 3))
const center = (i: number) => ((i + 0.5) / cols.value) * 100
const fill = computed(() => {
const { items, value } = props.series
// 连续几周给的是「现在连着几」,断过的人可能比已经拿到的那档还小 —— 线至少画到拿到的最后一档
let lastOn = -1
items.forEach((item, i) => {
if (item.unlocked) lastOn = i
})
return Math.max(lastOn < 0 ? 0 : center(lastOn), reach(items, value))
})
function reach(items: Achievement[], value: number) {
let prevT = 0
let prevX = 0
for (const [i, item] of items.entries()) {
const t = item.threshold ?? 0
const x = center(i)
if (value < t) return prevX + ((value - prevT) / (t - prevT)) * (x - prevX)
prevT = t
prevX = x
}
return prevX
}
function gap(item: Achievement) {
if (item !== props.series.next || props.series.value <= 0) return null
const r = (item.threshold ?? 0) - props.series.value
return r > 0 ? r : null
}
function when(item: Achievement) {
if (!item.unlocked) return `现在 ${props.series.value} ${props.series.unit}`
// 补发的不写日期:一次补发会给几百人盖上同一个时间戳
if (item.backfilled || !item.unlockTime) return "已拿到"
return `${parseTime(item.unlockTime, "M月D日")}拿到`
}
function holders(item: Achievement) {
const site = `全站 ${item.holders} 人有`
return item.classHolders === null ? site : `本班 ${item.classHolders} 人有 · ${site}`
}
</script>
<template>
<div class="row" :class="{ compact }">
<div class="label">
<span class="name">{{ series.label }}</span>
<span class="value">
<b>{{ series.streak ? "现在 " : "" }}{{ series.value }}</b>
<small>{{ series.unit }}</small>
</span>
</div>
<div class="track" :style="{ height: `${size + 36}px` }">
<span
class="line"
:style="{ top: `${size / 2 - 2}px`, width: `${center(series.items.length - 1)}%` }"
></span>
<span class="line done" :style="{ top: `${size / 2 - 2}px`, width: `${fill}%` }"></span>
<div
v-for="(item, i) in series.items"
:key="item.id"
class="tier"
:class="{ on: item.unlocked }"
:style="{ left: `${center(i)}%` }"
>
<n-tooltip :delay="150">
<template #trigger>
<span class="trigger">
<span v-if="gap(item)" class="gap" :style="{ bottom: `${size + 4}px` }">
差 {{ gap(item) }}
</span>
<Medal
:icon="item.icon"
:rarity="item.rarity"
:size="size"
:state="item.unlocked ? 'on' : 'off'"
/>
</span>
</template>
<div class="tip">
<b>{{ item.name }}</b> · {{ RARITY_LABEL[item.rarity] }}<br />
{{ item.description }}<br />
{{ when(item) }} · {{ holders(item) }}
</div>
</n-tooltip>
<span class="tier-name">{{ item.name }}</span>
<span class="tier-at">{{ item.threshold }} {{ series.unit }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.row {
display: grid;
grid-template-columns: 118px minmax(0, 1fr);
align-items: start;
margin-top: 30px;
}
.label {
display: flex;
flex-direction: column;
gap: 1px;
padding-right: 12px;
}
.name {
font-size: 13px;
color: v-bind("theme.textColor2");
white-space: nowrap;
}
.value b {
font-size: 18px;
line-height: 22px;
font-variant-numeric: tabular-nums;
}
.value small {
margin-left: 3px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.track {
position: relative;
}
.line {
position: absolute;
left: 0;
height: 4px;
border-radius: 2px;
background: v-bind("theme.dividerColor");
}
.line.done {
background: v-bind("theme.primaryColor");
}
.tier {
position: absolute;
top: 0;
width: 92px;
margin-left: -46px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.trigger {
position: relative;
display: block;
cursor: default;
}
.gap {
position: absolute;
left: 50%;
transform: translateX(-50%);
padding: 0 6px;
border-radius: 8px;
font-size: 11px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
color: v-bind("theme.primaryColorPressed");
background: v-bind(gapBg);
}
.tier-name {
font-size: 12px;
line-height: 15px;
text-align: center;
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.tier.on .tier-name {
color: v-bind("theme.textColor1");
font-weight: 600;
}
.tier-at {
font-size: 11px;
line-height: 14px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.tip {
max-width: 240px;
line-height: 20px;
}
/* 手机:线名挪到上面,线占满一行 */
.compact {
grid-template-columns: minmax(0, 1fr);
margin-top: 18px;
}
.compact .label {
flex-direction: row;
align-items: baseline;
gap: 8px;
margin-bottom: 24px;
}
.compact .value b {
font-size: 16px;
}
.compact .tier {
width: 72px;
margin-left: -36px;
}
.compact .tier-name {
font-size: 11px;
}
</style>