Some checks failed
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
8.4 KiB
Vue
328 lines
8.4 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 type { AchievementSummary, Profile } from "utils/types"
|
||
import { getAchievementSummary } from "oj/achievement/api"
|
||
import { getMetrics } from "../api"
|
||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||
import { useUserStore } from "shared/store/user"
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const profile = ref<Profile | null>(null)
|
||
const problems = ref<string[]>([])
|
||
const firstSubmissionAt = ref("")
|
||
const latestSubmissionAt = ref("")
|
||
const toLatestAt = ref("")
|
||
const learnDuration = ref("")
|
||
const achievementSummary = ref<AchievementSummary | null>(null)
|
||
const [loading, toggle] = useToggle()
|
||
const [show, toggleShow] = useToggle(false)
|
||
|
||
const { isDesktop } = useBreakpoints()
|
||
|
||
const isDefaultAvatar = computed(
|
||
() => profile.value?.avatar.endsWith("default.png") ?? true,
|
||
)
|
||
|
||
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
|
||
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,
|
||
)
|
||
|
||
async function init() {
|
||
toggle(true)
|
||
try {
|
||
const res = await getProfile(route.query.name as string)
|
||
profile.value = res
|
||
// 用户不存在时后端返回 null,后面的统计全都无从算起
|
||
if (!res) return
|
||
const acm = res.acmProblemsStatus.problems || {}
|
||
const ac: string[] = []
|
||
Object.keys(acm).forEach((id) => {
|
||
if (acm[id]["status"] === 0) {
|
||
ac.push(acm[id]["_id"])
|
||
}
|
||
})
|
||
ac.sort()
|
||
problems.value = ac
|
||
|
||
if (res.submissionNumber > 0) {
|
||
const metricsRes = await getMetrics(res.user.id)
|
||
firstSubmissionAt.value = parseTime(metricsRes.first)
|
||
latestSubmissionAt.value = parseTime(metricsRes.latest)
|
||
toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
|
||
learnDuration.value = durationToDays(metricsRes.first, metricsRes.latest)
|
||
}
|
||
} finally {
|
||
toggle(false)
|
||
}
|
||
}
|
||
|
||
// 单独取,不塞进上面的 promises 数组:那里是按位置取 results[0]/[1] 的,
|
||
// 插一项进去会打乱既有索引。成就摘要取不到也不该影响整个个人主页
|
||
async function loadAchievementSummary() {
|
||
try {
|
||
const res = await getAchievementSummary(
|
||
(route.query.name as string) || undefined,
|
||
)
|
||
achievementSummary.value = res
|
||
} catch {
|
||
achievementSummary.value = null
|
||
}
|
||
}
|
||
|
||
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?.acceptedNumber ?? 0,
|
||
content: "已解决的题目数量",
|
||
animate: true,
|
||
},
|
||
{
|
||
icon: "fluent-emoji:thinking-face",
|
||
title: profile.value?.submissionNumber ?? 0,
|
||
content: "总提交数量",
|
||
animate: true,
|
||
},
|
||
]
|
||
})
|
||
|
||
onMounted(() => {
|
||
init()
|
||
loadAchievementSummary()
|
||
})
|
||
</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-button
|
||
v-if="userStore.isSuperAdmin"
|
||
type="info"
|
||
secondary
|
||
@click="
|
||
router.push({
|
||
name: 'ai',
|
||
query: { username: profile.user.username, duration: 'months:6' },
|
||
})
|
||
"
|
||
>
|
||
智能分析
|
||
</n-button>
|
||
</n-flex>
|
||
|
||
<n-grid
|
||
v-if="profile && profile.submissionNumber > 0"
|
||
class="wrapper"
|
||
:cols="isDesktop ? 2 : 1"
|
||
:x-gap="10"
|
||
:y-gap="10"
|
||
>
|
||
<n-gi v-for="item in metrics" :key="item.content">
|
||
<n-card hoverable>
|
||
<n-flex align="center">
|
||
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
|
||
<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 && 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-text v-if="achievementSummary.recent.length" depth="3">
|
||
最近获得
|
||
</n-text>
|
||
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
|
||
<template #trigger>
|
||
<span class="achievement-icon">
|
||
<AchievementIcon :icon="a.icon" :size="24" />
|
||
</span>
|
||
</template>
|
||
{{ a.name }}
|
||
</n-tooltip>
|
||
<n-text v-if="!achievementSummary.recent.length" depth="3">
|
||
还没有获得成就
|
||
</n-text>
|
||
</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%;
|
||
}
|
||
.achievement-title {
|
||
font-weight: 600;
|
||
}
|
||
.achievement-recent {
|
||
margin-top: 10px;
|
||
}
|
||
.achievement-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-size: 24px;
|
||
cursor: default;
|
||
}
|
||
</style>
|