feat(achievement): 抬头改成 PlayStation 奖杯陈列
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

参照 PSN 的奖杯系统重做:六边形铭牌放完成度,四个奖杯按稀有度
倒序陈列(白金在最左),同一个杯型只换颜色,某档一个没有就置灰。

底部一条总进度分成四段,每段宽度是该档已获得占总数的比例,
拼起来正好等于总完成度——条的长度和构成都对得上数据。

移动端走全站的 useBreakpoints(tailwind md),竖排;顺手修了
卡片网格 minmax(300px) 在 320 宽机型上撑出横向滚动的问题。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 11:34:49 -06:00
parent 0d7336bce8
commit 4072abe199

View File

@@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue"
import { getAchievements, getAchievementSummary } from "oj/achievement/api" import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api" import { getUserBadges } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useRarityColor } from "shared/composables/rarity" import { useRarityColor } from "shared/composables/rarity"
import type { Achievement, AchievementSummary } from "utils/types" import type {
Achievement,
AchievementRarity,
AchievementSummary,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue" import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge { interface UserBadge {
@@ -19,8 +25,8 @@ interface UserBadge {
const route = useRoute() const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined) const name = computed(() => (route.query.name as string) || undefined)
// 标签和进度条同色,整行读作一个单位 // 断点走全站那套tailwind md = 768px不另开一套
const rarityColor = useRarityColor() const { isMobile } = useBreakpoints()
const achievements = ref<Achievement[]>([]) const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null) const summary = ref<AchievementSummary | null>(null)
@@ -28,6 +34,35 @@ const badges = ref<UserBadge[]>([])
const tab = ref("all") const tab = ref("all")
const loading = ref(true) const loading = ref(true)
// 奖杯和数字同色,一列读作一个单位
const rarityColor = useRarityColor()
// 白金在最左,青铜在最右——奖杯陈列按稀有度倒序,接口给的顺序反过来
const RARITY_RANK: Record<AchievementRarity, number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
}
const trophies = computed(() =>
[...(summary.value?.rarity ?? [])].sort(
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
),
)
// 进度条按稀有度分段,每段宽度是该档已获得占总数的比例,
// 拼起来正好是总完成度:条形的长度和构成都是真的
function segmentWidth(unlocked: number) {
const total = summary.value?.total ?? 0
return total ? `${(unlocked / total) * 100}%` : "0%"
}
const remaining = computed(() => {
if (!summary.value) return 0
return summary.value.total - summary.value.unlocked
})
const filtered = computed(() => { const filtered = computed(() => {
if (tab.value === "unlocked") if (tab.value === "unlocked")
return achievements.value.filter((a) => a.unlocked) return achievements.value.filter((a) => a.unlocked)
@@ -58,52 +93,59 @@ watch(name, load)
</script> </script>
<template> <template>
<div class="hall"> <div class="hall" :class="{ mobile: isMobile }">
<n-spin :show="loading"> <n-spin :show="loading">
<n-card v-if="summary" class="overview"> <n-card v-if="summary" class="overview">
<div class="overview-row"> <div class="overview-row">
<div class="percent"> <div class="level">
<n-progress <div class="plate">
type="circle" <span class="plate-num">{{ summary.percent }}</span>
:percentage="summary.percent" <span class="plate-unit">%</span>
:stroke-width="8"
>
<div class="ring-text">
<div class="ring-percent">{{ summary.percent }}%</div>
</div>
</n-progress>
<div class="sub">
已获得 {{ summary.unlocked }} / {{ summary.total }}
</div> </div>
<div class="plate-label">完成度</div>
</div> </div>
<div class="rarity"> <div class="trophies">
<div <div
v-for="r in summary.rarity" v-for="r in trophies"
:key="r.rarity" :key="r.rarity"
class="rarity-item" class="trophy"
:class="{ none: !r.unlocked }"
> >
<span <Icon
class="rarity-label" icon="mdi:trophy"
:width="30"
:height="30"
:style="{ color: rarityColor[r.rarity] }" :style="{ color: rarityColor[r.rarity] }"
> />
{{ r.label }} <div class="trophy-count">
</span> <b :style="{ color: rarityColor[r.rarity] }">{{
<span class="rarity-bar"> r.unlocked
<span }}</b>
class="rarity-fill" <span>/ {{ r.total }}</span>
:style="{ </div>
width: r.total ? (r.unlocked / r.total) * 100 + '%' : '0%', <div class="trophy-label">{{ r.label }}</div>
background: rarityColor[r.rarity],
}"
/>
</span>
<span class="rarity-count">
<b>{{ r.unlocked }}</b> / {{ r.total }}
</span>
</div> </div>
</div> </div>
</div> </div>
<div class="track">
<span
v-for="r in trophies"
:key="r.rarity"
class="seg"
:style="{
width: segmentWidth(r.unlocked),
background: rarityColor[r.rarity],
}"
/>
</div>
<div class="foot">
<span>已获得 {{ summary.unlocked }} / {{ summary.total }}</span>
<span v-if="remaining">还差 {{ remaining }} 个全收集</span>
<span v-else class="done">全部获得</span>
</div>
</n-card> </n-card>
<n-tabs v-model:value="tab" type="line" class="tabs"> <n-tabs v-model:value="tab" type="line" class="tabs">
@@ -143,77 +185,163 @@ watch(name, load)
.overview-row { .overview-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 32px; gap: 28px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.percent {
/* 等级铭牌:六边形靠 clip-path老 Chrome 也支持 */
.level {
flex: none; flex: none;
width: 110px;
text-align: center; text-align: center;
} }
.percent :deep(.n-progress) { .plate {
width: 110px; width: 84px;
height: 92px;
display: flex;
align-items: baseline;
justify-content: center;
clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
background: linear-gradient(
180deg,
rgba(128, 128, 128, 0.24),
rgba(128, 128, 128, 0.1)
);
} }
.ring-percent { .plate-num {
font-size: 26px;
font-weight: 700;
line-height: 92px;
font-variant-numeric: tabular-nums;
}
.plate-unit {
font-size: 13px;
opacity: 0.6;
margin-left: 1px;
}
.plate-label {
margin-top: 6px;
font-size: 11px;
letter-spacing: 3px;
opacity: 0.55;
padding-left: 3px; /* 抵消字距在末字后面留的空 */
}
/* 奖杯陈列 */
.trophies {
flex: 1;
min-width: 260px;
display: flex;
justify-content: space-around;
gap: 8px;
}
.trophy {
text-align: center;
min-width: 56px;
}
.trophy.none {
opacity: 0.35;
filter: grayscale(1);
}
.trophy-count {
margin-top: 4px;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.trophy-count b {
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
line-height: 1;
} }
.sub { .trophy-count span {
margin-top: 8px;
font-size: 12px; font-size: 12px;
opacity: 0.65; opacity: 0.5;
white-space: nowrap; margin-left: 3px;
} }
.rarity { .trophy-label {
flex: 1; margin-top: 2px;
min-width: 240px; font-size: 11px;
display: grid; letter-spacing: 2px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); opacity: 0.55;
gap: 10px 28px; padding-left: 2px;
} }
.rarity-item {
/* 总进度条:四段拼起来正好是总完成度 */
.track {
display: flex; display: flex;
align-items: center;
gap: 10px;
}
.rarity-label {
flex: none;
width: 30px;
font-size: 13px;
font-weight: 600;
}
.rarity-bar {
flex: 1;
height: 6px; height: 6px;
min-width: 40px; margin-top: 20px;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
background: rgba(128, 128, 128, 0.2); /* 灰底在明暗两套主题下都成立 */ background: rgba(128, 128, 128, 0.2); /* 灰底在明暗两套主题下都成立 */
} }
.rarity-fill { .seg {
display: block;
height: 100%; height: 100%;
border-radius: 3px; transition: width 0.5s ease;
transition: width 0.4s ease;
} }
.rarity-count { .foot {
flex: none; display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px; font-size: 12px;
opacity: 0.7; opacity: 0.6;
white-space: nowrap;
font-variant-numeric: tabular-nums;
} }
.rarity-count b { .done {
font-size: 13px;
opacity: 0.9; opacity: 0.9;
} }
/* 移动端改成竖排:铭牌居中一行,四个奖杯等分下面一行 */
.mobile .overview-row {
flex-direction: column;
align-items: center;
gap: 14px;
}
.mobile .plate {
width: 72px;
height: 80px;
}
.mobile .plate-num {
font-size: 22px;
line-height: 80px;
}
.mobile .trophies {
width: 100%;
min-width: 0;
gap: 4px;
}
.mobile .trophy {
flex: 1 1 0;
min-width: 0;
}
.mobile .trophy svg {
width: 26px;
height: 26px;
}
.mobile .trophy-count b {
font-size: 19px;
}
.mobile .trophy-label {
letter-spacing: 1px;
padding-left: 1px;
}
.mobile .track {
margin-top: 16px;
}
.mobile .foot {
font-size: 11px;
}
/* 这个留给系统偏好,和断点无关 */
@media (prefers-reduced-motion: reduce) {
.seg {
transition: none;
}
}
.tabs { .tabs {
margin: 16px 0; margin: 16px 0;
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); /* min() 兜住窄屏:不加的话 300px 的下限会把 320 宽的手机撑出横向滚动 */
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: 12px; gap: 12px;
} }
.badge-row { .badge-row {