feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View 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 })
}

View 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>

View 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>