feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
27
apps/web/src/oj/achievement/api.ts
Normal file
27
apps/web/src/oj/achievement/api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import http from "utils/http"
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementSummary,
|
||||
PendingAchievement,
|
||||
} from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return http.get<{ username: string; achievements: Achievement[] }>(
|
||||
"achievements",
|
||||
{ params: name ? { name } : {} },
|
||||
)
|
||||
}
|
||||
|
||||
export function getAchievementSummary(name?: string) {
|
||||
return http.get<AchievementSummary>("achievements/summary", {
|
||||
params: name ? { name } : {},
|
||||
})
|
||||
}
|
||||
|
||||
export function getPendingAchievements() {
|
||||
return http.get<PendingAchievement[]>("achievements/pending")
|
||||
}
|
||||
|
||||
export function markAchievementsRead(ids: number[]) {
|
||||
return http.post("achievements/pending", { ids })
|
||||
}
|
||||
134
apps/web/src/oj/achievement/components/AchievementCard.vue
Normal file
134
apps/web/src/oj/achievement/components/AchievementCard.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
||||
import type { Achievement } from "utils/types"
|
||||
|
||||
const props = defineProps<{ achievement: Achievement }>()
|
||||
|
||||
// 边框用原色,tag 里的文字用跟主题走的那套
|
||||
const rarityTextColor = useRarityColor()
|
||||
|
||||
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 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] }"
|
||||
>
|
||||
<n-thing>
|
||||
<template #avatar>
|
||||
<AchievementIcon :icon="achievement.icon" :size="32" />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-text strong>{{ achievement.name }}</n-text>
|
||||
<n-tag
|
||||
size="tiny"
|
||||
:color="{
|
||||
borderColor: RARITY_COLOR[achievement.rarity],
|
||||
textColor: rarityTextColor[achievement.rarity],
|
||||
}"
|
||||
>
|
||||
{{ RARITY_LABEL[achievement.rarity] }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<n-text depth="3">{{ achievement.description }}</n-text>
|
||||
</template>
|
||||
|
||||
<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
|
||||
style="flex: 1"
|
||||
type="line"
|
||||
:percentage="percent"
|
||||
:height="6"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap">
|
||||
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showBestSoFar">
|
||||
<n-text depth="3" class="nowrap">
|
||||
目标 ≤ {{ achievement.threshold }}
|
||||
</n-text>
|
||||
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
|
||||
当前最好 {{ achievement.progress }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<n-text v-else depth="3" class="nowrap">
|
||||
仅 {{ achievement.unlock_rate }}% 的人获得
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.locked {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.rare {
|
||||
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
|
||||
}
|
||||
</style>
|
||||
227
apps/web/src/oj/achievement/index.vue
Normal file
227
apps/web/src/oj/achievement/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
|
||||
import { getUserBadges } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementRarity,
|
||||
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
|
||||
}
|
||||
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
|
||||
problemset: {
|
||||
id: number
|
||||
title: string
|
||||
} | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const name = computed(() => (route.query.name as string) || undefined)
|
||||
|
||||
// 标签和进度条同色,整行读作一个单位
|
||||
const rarityColor = useRarityColor()
|
||||
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const summary = ref<AchievementSummary | null>(null)
|
||||
|
||||
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
|
||||
const RARITY_RANK: Record<AchievementRarity, number> = {
|
||||
platinum: 0,
|
||||
gold: 1,
|
||||
silver: 2,
|
||||
bronze: 3,
|
||||
}
|
||||
|
||||
const rarities = computed(() =>
|
||||
[...(summary.value?.rarity ?? [])].sort(
|
||||
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
|
||||
),
|
||||
)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const { isDesktop } = useBreakpoints()
|
||||
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),
|
||||
])
|
||||
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
|
||||
achievements.value = list.data.achievements
|
||||
summary.value = sum.data
|
||||
badges.value = (badgeRes.data ?? []) as UserBadge[]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hall">
|
||||
<!-- delay 50ms:缓存命中时数据几乎立刻回来,不闪一下转圈 -->
|
||||
<n-spin :show="loading" :delay="50" style="min-height: 240px">
|
||||
<n-card v-if="summary">
|
||||
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
|
||||
<n-flex vertical align="center" :size="6">
|
||||
<n-progress
|
||||
type="circle"
|
||||
:percentage="summary.percent"
|
||||
:stroke-width="8"
|
||||
>
|
||||
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
|
||||
{{ summary.percent }}%
|
||||
</n-text>
|
||||
</n-progress>
|
||||
<n-text depth="3" class="nowrap">
|
||||
已获得 {{ summary.unlocked }} / {{ summary.total }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
<n-flex vertical :size="8" class="rarity">
|
||||
<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-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>
|
||||
|
||||
<template v-if="tab !== 'badges'">
|
||||
<n-grid
|
||||
v-if="filtered.length"
|
||||
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>
|
||||
|
||||
<template v-else>
|
||||
<n-grid
|
||||
v-if="badges.length"
|
||||
responsive="screen"
|
||||
cols="1 s:2 l:3"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
>
|
||||
<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-text v-if="b.problemset" depth="3" class="source">
|
||||
来自题单
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'problemset',
|
||||
params: { problemSetId: b.problemset.id },
|
||||
}"
|
||||
>
|
||||
{{ b.problemset.title }}
|
||||
</router-link>
|
||||
</n-text>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hall {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.rarity {
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.source {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.source a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user