313 lines
7.8 KiB
Vue
313 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from "@iconify/vue"
|
|
import { NH2, NH3 } from "naive-ui"
|
|
import { getProfile } from "shared/api"
|
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
import { durationToDays, parseTime } from "utils/functions"
|
|
import { Profile, UserBadge as UserBadgeType } from "utils/types"
|
|
import { getMetrics, getUserBadges } from "../api"
|
|
import GroupedUserBadge from "shared/components/GroupedUserBadge.vue"
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const profile = ref<Profile | null>(null)
|
|
const problems = ref<string[]>([])
|
|
const firstSubmissionAt = ref("")
|
|
const latestSubmissionAt = ref("")
|
|
const toLatestAt = ref("")
|
|
const learnDuration = ref("")
|
|
const userBadges = ref<GroupedBadge[]>([])
|
|
const [loading, toggle] = useToggle()
|
|
const [show, toggleShow] = useToggle(false)
|
|
|
|
const { isDesktop } = useBreakpoints()
|
|
|
|
const isDefaultAvatar = computed(
|
|
() => profile.value?.avatar.endsWith("default.png") ?? true,
|
|
)
|
|
|
|
const problemsFlexRef = ref<HTMLElement | null>(null)
|
|
const itemsPerRow = ref(8)
|
|
|
|
function updateItemsPerRow() {
|
|
if (!problemsFlexRef.value) return
|
|
const buttons = problemsFlexRef.value.querySelectorAll("button")
|
|
if (!buttons.length) return
|
|
const firstTop = buttons[0].offsetTop
|
|
let count = 0
|
|
for (const btn of buttons) {
|
|
if (btn.offsetTop === firstTop) count++
|
|
else break
|
|
}
|
|
if (count > 0) itemsPerRow.value = count
|
|
}
|
|
|
|
useResizeObserver(problemsFlexRef, updateItemsPerRow)
|
|
watch(problems, async () => {
|
|
await nextTick()
|
|
updateItemsPerRow()
|
|
})
|
|
|
|
const visibleProblems = computed(() =>
|
|
show.value ? problems.value : problems.value.slice(0, itemsPerRow.value * 3),
|
|
)
|
|
|
|
const hasMoreProblems = computed(
|
|
() => problems.value.length > itemsPerRow.value * 3,
|
|
)
|
|
|
|
// 分组徽章接口
|
|
interface GroupedBadge {
|
|
icon: string
|
|
count: number
|
|
badges: UserBadgeType[]
|
|
latestEarnedTime: Date
|
|
}
|
|
|
|
// 按图标分组徽章
|
|
function groupBadgesByIcon(badges: UserBadgeType[]): GroupedBadge[] {
|
|
const grouped = new Map<string, UserBadgeType[]>()
|
|
|
|
// 按图标分组
|
|
badges.forEach((badge) => {
|
|
const icon = badge.badge.icon
|
|
if (!grouped.has(icon)) {
|
|
grouped.set(icon, [])
|
|
}
|
|
grouped.get(icon)!.push(badge)
|
|
})
|
|
|
|
// 转换为数组并排序
|
|
return Array.from(grouped.entries())
|
|
.map(([icon, badgeList]) => ({
|
|
icon,
|
|
count: badgeList.length,
|
|
badges: badgeList,
|
|
latestEarnedTime: new Date(
|
|
Math.max(...badgeList.map((b) => new Date(b.earned_time).getTime())),
|
|
),
|
|
}))
|
|
.sort((a, b) => a.icon.localeCompare(b.icon))
|
|
}
|
|
|
|
async function init() {
|
|
toggle(true)
|
|
try {
|
|
const res = await getProfile(route.query.name as string)
|
|
profile.value = res.data
|
|
const acm = res.data.acm_problems_status.problems || {}
|
|
const oi = res.data.oi_problems_status.problems || {}
|
|
const ac: string[] = []
|
|
for (let problems of [acm, oi]) {
|
|
Object.keys(problems).forEach((id) => {
|
|
if (problems[id]["status"] === 0) {
|
|
ac.push(problems[id]["_id"])
|
|
}
|
|
})
|
|
}
|
|
ac.sort()
|
|
problems.value = ac
|
|
const promises: Promise<{ data: any }>[] = []
|
|
|
|
if (profile.value.submission_number > 0) {
|
|
promises.push(getMetrics(profile.value.user.id))
|
|
}
|
|
|
|
if (route.query.name) {
|
|
promises.push(getUserBadges(route.query.name as string))
|
|
} else {
|
|
promises.push(getUserBadges())
|
|
}
|
|
|
|
const results = await Promise.all(promises)
|
|
|
|
// 处理 metrics 结果
|
|
if (profile.value.submission_number > 0) {
|
|
const metricsRes = results[0]
|
|
firstSubmissionAt.value = parseTime(metricsRes.data.first)
|
|
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
|
|
toLatestAt.value = durationToDays(
|
|
metricsRes.data.latest,
|
|
metricsRes.data.now,
|
|
)
|
|
learnDuration.value = durationToDays(
|
|
metricsRes.data.first,
|
|
metricsRes.data.latest,
|
|
)
|
|
}
|
|
|
|
// 处理 badges 结果
|
|
userBadges.value = groupBadgesByIcon(results[1].data)
|
|
} finally {
|
|
toggle(false)
|
|
}
|
|
}
|
|
|
|
const metrics = computed(() => {
|
|
if (loading.value) return []
|
|
return [
|
|
{
|
|
icon: "fluent-emoji:face-with-peeking-eye",
|
|
title: learnDuration.value,
|
|
content: "总共学习天数",
|
|
},
|
|
{
|
|
icon: "fluent-emoji:cheese-wedge",
|
|
title: toLatestAt.value,
|
|
content: "距离上次提交",
|
|
},
|
|
{
|
|
icon: "fluent-emoji:dog-face",
|
|
title: latestSubmissionAt.value,
|
|
content: "最新一次提交时间",
|
|
},
|
|
{
|
|
icon: "fluent-emoji:cat-with-wry-smile",
|
|
title: firstSubmissionAt.value,
|
|
content: "第一次提交时间",
|
|
},
|
|
{
|
|
icon: "fluent-emoji:candy",
|
|
title: profile.value?.accepted_number ?? 0,
|
|
content: "已解决的题目数量",
|
|
animate: true,
|
|
},
|
|
{
|
|
icon: "fluent-emoji:thinking-face",
|
|
title: profile.value?.submission_number ?? 0,
|
|
content: "总提交数量",
|
|
animate: true,
|
|
},
|
|
]
|
|
})
|
|
|
|
onMounted(init)
|
|
</script>
|
|
<template>
|
|
<n-flex
|
|
class="wrapper"
|
|
vertical
|
|
justify="center"
|
|
align="center"
|
|
v-if="!loading && profile"
|
|
>
|
|
<n-image
|
|
:width="140"
|
|
:height="140"
|
|
:src="profile.avatar"
|
|
:preview-disabled="isDefaultAvatar"
|
|
object-fit="cover"
|
|
:style="{
|
|
borderRadius: '50%',
|
|
overflow: 'hidden',
|
|
cursor: isDefaultAvatar ? 'default' : 'pointer',
|
|
}"
|
|
/>
|
|
<h2>{{ profile.user.username }}</h2>
|
|
<p class="desc">{{ profile.mood }}</p>
|
|
</n-flex>
|
|
|
|
<n-grid
|
|
v-if="profile && profile.submission_number > 0"
|
|
class="wrapper"
|
|
:cols="2"
|
|
:x-gap="10"
|
|
:y-gap="10"
|
|
>
|
|
<n-gi v-for="item in metrics" :key="item.content">
|
|
<n-card hoverable>
|
|
<n-flex align="center">
|
|
<Icon v-if="isDesktop" :icon="item.icon" width="50" />
|
|
<div>
|
|
<Component :is="isDesktop ? NH2 : NH3" class="number">
|
|
<n-number-animation v-if="item.animate" :to="item.title" />
|
|
<template v-else>
|
|
{{ item.title }}
|
|
</template>
|
|
</Component>
|
|
<n-h4 class="number-label">{{ item.content }}</n-h4>
|
|
</div>
|
|
</n-flex>
|
|
</n-card>
|
|
</n-gi>
|
|
</n-grid>
|
|
|
|
<!-- 徽章展示卡片 -->
|
|
<n-card
|
|
v-if="!loading && profile && userBadges.length > 0"
|
|
class="wrapper"
|
|
hoverable
|
|
>
|
|
<template #header>
|
|
<n-h4 style="margin: 0">获得的徽章</n-h4>
|
|
</template>
|
|
<n-flex wrap>
|
|
<GroupedUserBadge
|
|
v-for="groupedBadge in userBadges"
|
|
:key="groupedBadge.icon"
|
|
:grouped-badge="groupedBadge"
|
|
/>
|
|
</n-flex>
|
|
</n-card>
|
|
|
|
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
|
|
<n-descriptions-item v-if="!!problems.length">
|
|
<template #label>
|
|
<n-flex justify="space-between" align="center">
|
|
<span>已解决的题目</span>
|
|
<n-button
|
|
text
|
|
type="primary"
|
|
v-if="hasMoreProblems"
|
|
@click="toggleShow(!show)"
|
|
>
|
|
{{ show ? "隐藏全部" : "显示全部" }}
|
|
</n-button>
|
|
</n-flex>
|
|
</template>
|
|
<div ref="problemsFlexRef">
|
|
<n-flex>
|
|
<n-button
|
|
v-for="id in visibleProblems"
|
|
:key="id"
|
|
@click="router.push('/problem/' + id)"
|
|
>
|
|
{{ id }}
|
|
</n-button>
|
|
</n-flex>
|
|
</div>
|
|
</n-descriptions-item>
|
|
</n-descriptions>
|
|
<n-empty v-if="!loading && !profile" description="该用户不存在">
|
|
<template #extra>
|
|
<n-button @click="router.push('/')">返回主页</n-button>
|
|
</template>
|
|
</n-empty>
|
|
</template>
|
|
<style scoped>
|
|
.wrapper {
|
|
max-width: 610px;
|
|
margin: 16px auto 0;
|
|
}
|
|
|
|
.number {
|
|
margin-bottom: 0;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.number-label {
|
|
margin: 0;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
font-weight: normal;
|
|
}
|
|
|
|
.desc {
|
|
margin: 0 auto;
|
|
word-wrap: break-word;
|
|
max-width: 100%;
|
|
}
|
|
</style>
|