update
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

This commit is contained in:
2026-08-05 13:23:45 -06:00
parent da2334ed3a
commit a4fdd377f2
2 changed files with 130 additions and 250 deletions

View File

@@ -58,13 +58,14 @@ const unlockDate = computed(() => {
:class="{ locked: !achievement.unlocked, rare: isRare }" :class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }" :style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
> >
<div class="row"> <n-thing>
<div class="icon"> <template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" /> <AchievementIcon :icon="achievement.icon" :size="32" />
</div> </template>
<div class="body">
<div class="title"> <template #header>
<span class="name">{{ achievement.name }}</span> <n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag <n-tag
size="tiny" size="tiny"
:color="{ :color="{
@@ -74,80 +75,53 @@ const unlockDate = computed(() => {
> >
{{ RARITY_LABEL[achievement.rarity] }} {{ RARITY_LABEL[achievement.rarity] }}
</n-tag> </n-tag>
</div> </n-flex>
<div class="desc">{{ achievement.description }}</div> </template>
<div v-if="achievement.unlocked" class="meta"> <template #description>
<span>{{ unlockDate }}</span> <n-text depth="3">{{ achievement.description }}</n-text>
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span> </template>
</div>
<div v-else-if="showProgressBar" class="meta"> <n-flex align="center" :size="8" :wrap="false">
<template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</template>
<template v-else-if="showProgressBar">
<n-progress <n-progress
style="flex: 1"
type="line" type="line"
:percentage="percent" :percentage="percent"
:height="6" :height="6"
:show-indicator="false" :show-indicator="false"
/> />
<span class="progress-text"> <n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }} {{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</span> </n-text>
</div> </template>
<div v-else-if="showBestSoFar" class="meta"> <template v-else-if="showBestSoFar">
<span class="progress-text"> <n-text depth="3" class="nowrap">
目标 {{ achievement.threshold }} 目标 {{ achievement.threshold }}
</span> </n-text>
<span v-if="achievement.progress !== null" class="rate"> <n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
当前最好 {{ achievement.progress }} 当前最好 {{ achievement.progress }}
</span> </n-text>
</div> </template>
<div v-else class="meta"> <n-text v-else depth="3" class="nowrap">
<span class="rate"> {{ achievement.unlock_rate }}% 的人获得</span> {{ achievement.unlock_rate }}% 的人获得
</div> </n-text>
</div> </n-flex>
</div> </n-thing>
</n-card> </n-card>
</template> </template>
<style scoped> <style scoped>
.row { .nowrap {
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; white-space: nowrap;
} }
.locked { .locked {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
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 { import type {
Achievement, Achievement,
@@ -43,6 +44,7 @@ const rarities = computed(() =>
), ),
) )
const badges = ref<UserBadge[]>([]) const badges = ref<UserBadge[]>([])
const { isDesktop } = useBreakpoints()
const tab = ref("all") const tab = ref("all")
const loading = ref(true) const loading = ref(true)
@@ -77,47 +79,48 @@ watch(name, load)
<template> <template>
<div class="hall"> <div class="hall">
<n-spin :show="loading"> <n-card v-if="summary">
<n-card v-if="summary" class="overview"> <!-- 窄屏也不折行圆环缩小间距收窄抬头始终一行 -->
<div class="overview-row"> <n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
<div class="percent"> <n-flex vertical align="center" :size="6">
<n-progress <n-progress
type="circle" type="circle"
:percentage="summary.percent" :percentage="summary.percent"
:stroke-width="8" :stroke-width="8"
> :style="{ width: isDesktop ? '110px' : '76px' }"
<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="rarity">
<div v-for="r in rarities" :key="r.rarity" class="rarity-item">
<span
class="rarity-label"
:style="{ color: rarityColor[r.rarity] }"
>
{{ r.label }}
</span>
<span class="rarity-bar">
<span
class="rarity-fill"
:style="{
width: r.total ? (r.unlocked / r.total) * 100 + '%' : '0%',
background: rarityColor[r.rarity],
}"
/> />
</span> <n-text depth="3" class="nowrap">
<span class="rarity-count"> 已获得 {{ summary.unlocked }} / {{ summary.total }}
<b>{{ r.unlocked }}</b> / {{ r.total }} </n-text>
</span> </n-flex>
</div>
</div> <n-flex vertical :size="8" class="rarity">
</div> <n-flex
v-for="r in rarities"
:key="r.rarity"
align="center"
:wrap="false"
:size="10"
>
<n-text strong :style="{ color: rarityColor[r.rarity] }">
{{ r.label }}
</n-text>
<n-progress
style="flex: 1"
type="line"
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
:height="6"
:border-radius="3"
:fill-border-radius="3"
:color="rarityColor[r.rarity]"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ r.unlocked }} / {{ r.total }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card> </n-card>
<n-tabs v-model:value="tab" type="line" class="tabs"> <n-tabs v-model:value="tab" type="line" class="tabs">
@@ -127,24 +130,47 @@ watch(name, load)
<n-tab name="badges">题单奖章</n-tab> <n-tab name="badges">题单奖章</n-tab>
</n-tabs> </n-tabs>
<div v-if="tab !== 'badges'" class="grid"> <template v-if="tab !== 'badges'">
<AchievementCard v-for="a in filtered" :key="a.id" :achievement="a" /> <n-grid
<n-empty v-if="!filtered.length" description="这里还什么都没有" /> v-if="filtered.length"
</div> responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="a in filtered" :key="a.id">
<AchievementCard :achievement="a" />
</n-gi>
</n-grid>
<!-- 加载中不显示空态不然首屏会闪一下"什么都没有" -->
<n-empty v-else-if="!loading" description="这里还什么都没有" />
</template>
<div v-else class="grid"> <template v-else>
<n-card v-for="b in badges" :key="b.id" size="small"> <n-grid
<div class="badge-row"> v-if="badges.length"
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" /> responsive="screen"
<div> cols="1 s:2 l:3"
<div class="badge-name">{{ b.badge?.name }}</div> :x-gap="12"
<div class="badge-desc">{{ b.badge?.description }}</div> :y-gap="12"
</div> >
</div> <n-gi v-for="b in badges" :key="b.id">
<n-card size="small">
<n-thing :title="b.badge?.name" :description="b.badge?.description">
<template #avatar v-if="b.badge?.icon">
<n-avatar
:size="40"
:src="b.badge.icon"
color="transparent"
object-fit="contain"
/>
</template>
</n-thing>
</n-card> </n-card>
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" /> </n-gi>
</div> </n-grid>
</n-spin> <n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
</template>
</div> </div>
</template> </template>
@@ -154,135 +180,15 @@ watch(name, load)
margin: 0 auto; margin: 0 auto;
padding: 16px; padding: 16px;
} }
.overview-row { /* 卡片有 1100px 宽,不封顶的话 6px 高的细条会被拉到近 900px读起来很空 */
display: flex;
align-items: center;
gap: 32px;
flex-wrap: wrap;
}
.percent {
flex: none;
width: 110px;
text-align: center;
}
.percent :deep(.n-progress) {
width: 110px;
}
.ring-percent {
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.sub {
margin-top: 8px;
font-size: 12px;
opacity: 0.65;
white-space: nowrap;
}
.rarity { .rarity {
flex: 1; flex: 1;
min-width: 240px;
/* 卡片有 1100px 宽,不封顶的话 6px 高的细条会被拉到近 900px读起来很空 */
max-width: 420px; max-width: 420px;
display: grid;
/* 固定单列,四条各占一行:折成两列会让进度条短得看不出差别 */
grid-template-columns: 1fr;
gap: 8px;
} }
.rarity-item { .nowrap {
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;
min-width: 40px;
border-radius: 3px;
overflow: hidden;
background: rgba(128, 128, 128, 0.2); /* 灰底在明暗两套主题下都成立 */
}
.rarity-fill {
display: block;
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.rarity-count {
flex: none;
font-size: 12px;
opacity: 0.7;
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums;
} }
.rarity-count b {
font-size: 13px;
opacity: 0.9;
}
/* 移动端抬头挤成一行:圆环缩小、间距收窄,稀有度条不再被挤到第二行 */
@media (max-width: 768px) {
.overview-row {
flex-wrap: nowrap;
gap: 16px;
}
.percent {
/* 宽度交给底下那行"已获得 x / y",圆环缩小后不至于把文字挤出去 */
width: auto;
}
.percent :deep(.n-progress) {
width: 76px;
}
.ring-percent {
font-size: 16px;
}
.sub {
margin-top: 6px;
font-size: 11px;
}
.rarity {
min-width: 0;
}
.rarity-label {
width: auto;
font-size: 12px;
}
.rarity-count {
font-size: 11px;
}
.rarity-count b {
font-size: 12px;
}
}
.tabs { .tabs {
margin: 16px 0; margin: 16px 0;
} }
.grid {
display: grid;
/* min() 兜住窄屏:不加的话 300px 的下限会把 320 宽的手机撑出横向滚动 */
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 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> </style>