feat(achievement): 抬头改成 PlayStation 奖杯陈列
参照 PSN 的奖杯系统重做:六边形铭牌放完成度,四个奖杯按稀有度 倒序陈列(白金在最左),同一个杯型只换颜色,某档一个没有就置灰。 底部一条总进度分成四段,每段宽度是该档已获得占总数的比例, 拼起来正好等于总完成度——条的长度和构成都对得上数据。 移动端走全站的 useBreakpoints(tailwind md),竖排;顺手修了 卡片网格 minmax(300px) 在 320 宽机型上撑出横向滚动的问题。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,51 +93,58 @@ 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>
|
||||||
>
|
<div class="plate-label">完成度</div>
|
||||||
<div class="ring-text">
|
</div>
|
||||||
<div class="ring-percent">{{ summary.percent }}%</div>
|
|
||||||
|
<div class="trophies">
|
||||||
|
<div
|
||||||
|
v-for="r in trophies"
|
||||||
|
:key="r.rarity"
|
||||||
|
class="trophy"
|
||||||
|
:class="{ none: !r.unlocked }"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="mdi:trophy"
|
||||||
|
:width="30"
|
||||||
|
:height="30"
|
||||||
|
:style="{ color: rarityColor[r.rarity] }"
|
||||||
|
/>
|
||||||
|
<div class="trophy-count">
|
||||||
|
<b :style="{ color: rarityColor[r.rarity] }">{{
|
||||||
|
r.unlocked
|
||||||
|
}}</b>
|
||||||
|
<span>/ {{ r.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="trophy-label">{{ r.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
</n-progress>
|
|
||||||
<div class="sub">
|
|
||||||
已获得 {{ summary.unlocked }} / {{ summary.total }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rarity">
|
<div class="track">
|
||||||
<div
|
<span
|
||||||
v-for="r in summary.rarity"
|
v-for="r in trophies"
|
||||||
:key="r.rarity"
|
:key="r.rarity"
|
||||||
class="rarity-item"
|
class="seg"
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="rarity-label"
|
|
||||||
:style="{ color: rarityColor[r.rarity] }"
|
|
||||||
>
|
|
||||||
{{ r.label }}
|
|
||||||
</span>
|
|
||||||
<span class="rarity-bar">
|
|
||||||
<span
|
|
||||||
class="rarity-fill"
|
|
||||||
:style="{
|
:style="{
|
||||||
width: r.total ? (r.unlocked / r.total) * 100 + '%' : '0%',
|
width: segmentWidth(r.unlocked),
|
||||||
background: rarityColor[r.rarity],
|
background: rarityColor[r.rarity],
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
<span class="rarity-count">
|
|
||||||
<b>{{ r.unlocked }}</b> / {{ r.total }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="foot">
|
||||||
|
<span>已获得 {{ summary.unlocked }} / {{ summary.total }}</span>
|
||||||
|
<span v-if="remaining">还差 {{ remaining }} 个全收集</span>
|
||||||
|
<span v-else class="done">全部获得</span>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user