feat(achievement): 个人主页与排行榜添加成就入口

个人主页显示完成度与最近获得的成就,可跳转奖杯馆;
全服 Top100 和班级排名每行加一个跳转按钮。

成就摘要单独请求,不并进 init 里那个按位置取值的 promises 数组,
且失败时静默降级——它不该拖垮整个个人主页。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 06:57:07 -06:00
parent d7d80e1c1b
commit d31bfd8b8f
2 changed files with 136 additions and 37 deletions

View File

@@ -198,8 +198,9 @@ const columns: DataTableColumn<Rank>[] = [
"streamline-emojis:smiling-face-with-sunglasses", "streamline-emojis:smiling-face-with-sunglasses",
), ),
key: "username", key: "username",
width: 200, width: 240,
render: (row) => render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h( h(
NButton, NButton,
{ {
@@ -209,6 +210,18 @@ const columns: DataTableColumn<Rank>[] = [
}, },
() => row.user.username, () => row.user.username,
), ),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () =>
router.push("/achievement?name=" + row.user.username),
},
() => "🏆",
),
]),
}, },
{ {
title: renderTableTitle( title: renderTableTitle(
@@ -378,8 +391,9 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
{ {
title: "用户名", title: "用户名",
key: "username", key: "username",
width: 200, width: 240,
render: (row) => render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h( h(
NButton, NButton,
{ {
@@ -404,6 +418,17 @@ const myClassColumns: DataTableColumn<UserRank>[] = [
) )
: row.username, : row.username,
), ),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () => router.push("/achievement?name=" + row.username),
},
() => "🏆",
),
]),
}, },
{ {
title: "已解决", title: "已解决",

View File

@@ -4,7 +4,12 @@ import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api" import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions" import { durationToDays, parseTime } from "utils/functions"
import type { Profile, UserBadge as UserBadgeType } from "utils/types" import type {
AchievementSummary,
Profile,
UserBadge as UserBadgeType,
} from "utils/types"
import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics, getUserBadges } from "../api" import { getMetrics, getUserBadges } from "../api"
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue" import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
@@ -19,6 +24,7 @@ const latestSubmissionAt = ref("")
const toLatestAt = ref("") const toLatestAt = ref("")
const learnDuration = ref("") const learnDuration = ref("")
const userBadges = ref<GroupedBadge[]>([]) const userBadges = ref<GroupedBadge[]>([])
const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle() const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false) const [show, toggleShow] = useToggle(false)
@@ -142,6 +148,19 @@ async function init() {
} }
} }
// 单独取,不塞进上面的 promises 数组:那里是按位置取 results[0]/[1] 的,
// 插一项进去会打乱既有索引。成就摘要取不到也不该影响整个个人主页
async function loadAchievementSummary() {
try {
const res = await getAchievementSummary(
(route.query.name as string) || undefined,
)
achievementSummary.value = res.data
} catch {
achievementSummary.value = null
}
}
const metrics = computed(() => { const metrics = computed(() => {
if (loading.value) return [] if (loading.value) return []
return [ return [
@@ -180,7 +199,10 @@ const metrics = computed(() => {
] ]
}) })
onMounted(init) onMounted(() => {
init()
loadAchievementSummary()
})
</script> </script>
<template> <template>
<n-flex <n-flex
@@ -244,6 +266,48 @@ onMounted(init)
</n-gi> </n-gi>
</n-grid> </n-grid>
<!-- 成就摘要 -->
<n-card
v-if="!loading && profile && achievementSummary"
class="wrapper"
hoverable
>
<n-flex align="center" justify="space-between">
<n-flex align="center" :size="12">
<span class="achievement-title">
成就 {{ achievementSummary.unlocked }} /
{{ achievementSummary.total }}
</span>
<n-tag size="small" type="info">
{{ achievementSummary.percent }}%
</n-tag>
</n-flex>
<n-button
text
type="primary"
@click="
router.push({
path: '/achievement',
query: route.query.name ? { name: route.query.name } : {},
})
"
>
查看全部
</n-button>
</n-flex>
<n-flex align="center" :size="10" class="achievement-recent">
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger>
<span class="achievement-icon">{{ a.icon }}</span>
</template>
{{ a.name }}
</n-tooltip>
<n-text v-if="!achievementSummary.recent.length" depth="3">
还没有获得成就
</n-text>
</n-flex>
</n-card>
<!-- 徽章展示卡片 --> <!-- 徽章展示卡片 -->
<n-card <n-card
v-if="!loading && profile && userBadges.length > 0" v-if="!loading && profile && userBadges.length > 0"
@@ -321,4 +385,14 @@ h2 {
word-wrap: break-word; word-wrap: break-word;
max-width: 100%; max-width: 100%;
} }
.achievement-title {
font-weight: 600;
}
.achievement-recent {
margin-top: 10px;
}
.achievement-icon {
font-size: 24px;
cursor: default;
}
</style> </style>