feat(achievement): 添加奖杯馆页面

三种视觉状态:已解锁显示日期与获得率;未解锁公开成就显示进度条;
未解锁隐藏成就只显示稀有度,不画进度条(会泄露后端刻意遮掉的门槛)。
lte 类成就(如最短代码 ≤ 50 字符)不画百分比条,改为显示当前最好成绩。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 06:42:38 -06:00
parent 71186f0ca5
commit d81e00ee14
3 changed files with 341 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
const RARITY_COLOR: Record<string, string> = {
bronze: "#b87333",
silver: "#9fa6b2",
gold: "#e0a300",
platinum: "#7dd3fc",
}
const RARITY_LABEL: Record<string, string> = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
const masked = computed(
() => props.achievement.hidden && !props.achievement.unlocked,
)
// 获得率低于 5% 的加稀有闪光边框
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
)
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
// 画成百分比毫无意义,改成直接显示当前最好成绩
const showProgressBar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "gte" &&
props.achievement.threshold !== null,
)
const showBestSoFar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "lte" &&
props.achievement.threshold !== null,
)
const percent = computed(() => {
const { progress, threshold } = props.achievement
if (threshold === null || threshold <= 0) return 100
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
})
const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlock_time) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得`
})
</script>
<template>
<n-card
size="small"
:class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<div class="row">
<div class="icon">{{ achievement.icon }}</div>
<div class="body">
<div class="title">
<span class="name">{{ achievement.name }}</span>
<n-tag
size="tiny"
:color="{
borderColor: RARITY_COLOR[achievement.rarity],
textColor: RARITY_COLOR[achievement.rarity],
}"
>
{{ RARITY_LABEL[achievement.rarity] }}
</n-tag>
</div>
<div class="desc">{{ achievement.description }}</div>
<div v-if="achievement.unlocked" class="meta">
<span>{{ unlockDate }}</span>
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span>
</div>
<div v-else-if="showProgressBar" class="meta">
<n-progress
type="line"
:percentage="percent"
:height="6"
:show-indicator="false"
/>
<span class="progress-text">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</span>
</div>
<div v-else-if="showBestSoFar" class="meta">
<span class="progress-text">
目标 {{ achievement.threshold }}
</span>
<span v-if="achievement.progress !== null" class="rate">
当前最好 {{ achievement.progress }}
</span>
</div>
<div v-else class="meta">
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span>
</div>
</div>
</div>
</n-card>
</template>
<style scoped>
.row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.icon {
font-size: 32px;
line-height: 1;
}
.body {
flex: 1;
min-width: 0;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.desc {
margin-top: 4px;
font-size: 13px;
opacity: 0.75;
}
.meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.65;
}
.meta :deep(.n-progress) {
flex: 1;
}
.progress-text {
white-space: nowrap;
}
.locked {
filter: grayscale(1);
opacity: 0.55;
}
.rare {
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api"
import type { Achievement, AchievementSummary } from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
}
const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined)
const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null)
const badges = ref<UserBadge[]>([])
const tab = ref("all")
const loading = ref(true)
const filtered = computed(() => {
if (tab.value === "unlocked")
return achievements.value.filter((a) => a.unlocked)
if (tab.value === "locked")
return achievements.value.filter((a) => !a.unlocked)
return achievements.value
})
async function load() {
loading.value = true
try {
const [list, sum, badgeRes] = await Promise.all([
getAchievements(name.value),
getAchievementSummary(name.value),
getUserBadges(name.value),
])
achievements.value = list.achievements
summary.value = sum
badges.value = (badgeRes ?? []) as UserBadge[]
} finally {
loading.value = false
}
}
onMounted(load)
watch(name, load)
</script>
<template>
<div class="hall">
<n-spin :show="loading">
<n-card v-if="summary" class="overview">
<div class="overview-row">
<div class="percent">
<div class="big">{{ summary.percent }}%</div>
<div class="sub">
{{ summary.unlocked }} / {{ summary.total }} 已获得
</div>
</div>
<div class="rarity">
<div
v-for="r in summary.rarity"
:key="r.rarity"
class="rarity-item"
>
<div class="rarity-label">{{ r.label }}</div>
<div class="rarity-count">{{ r.unlocked }} / {{ r.total }}</div>
</div>
</div>
</div>
</n-card>
<n-tabs v-model:value="tab" type="line" class="tabs">
<n-tab name="all">全部</n-tab>
<n-tab name="unlocked">已获得</n-tab>
<n-tab name="locked">未获得</n-tab>
<n-tab name="badges">题单奖章</n-tab>
</n-tabs>
<div v-if="tab !== 'badges'" class="grid">
<AchievementCard v-for="a in filtered" :key="a.id" :achievement="a" />
<n-empty v-if="!filtered.length" description="这里还什么都没有" />
</div>
<div v-else class="grid">
<n-card v-for="b in badges" :key="b.id" size="small">
<div class="badge-row">
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" />
<div>
<div class="badge-name">{{ b.badge?.name }}</div>
<div class="badge-desc">{{ b.badge?.description }}</div>
</div>
</div>
</n-card>
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" />
</div>
</n-spin>
</div>
</template>
<style scoped>
.hall {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.overview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.big {
font-size: 40px;
font-weight: 700;
line-height: 1;
}
.sub {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
}
.rarity {
display: flex;
gap: 20px;
}
.rarity-item {
text-align: center;
}
.rarity-label {
font-size: 12px;
opacity: 0.7;
}
.rarity-count {
font-weight: 600;
margin-top: 2px;
}
.tabs {
margin: 16px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.badge-row {
display: flex;
gap: 12px;
align-items: center;
}
.badge-icon {
width: 40px;
height: 40px;
}
.badge-name {
font-weight: 600;
}
.badge-desc {
font-size: 12px;
opacity: 0.7;
}
</style>

View File

@@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/user/index.vue"),
meta: { requiresAuth: true },
},
{
path: "achievement",
name: "achievement",
component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true },
},
{
path: "setting",
component: () => import("oj/user/setting.vue"),