Deploy / deploy (push) Canceled after 0s
前后端同时改,部署前开着的旧页面要刷新一次(题号列会是空的、提交筛选会失效)。 - 挂在非题目对象上的展示题号,原来叫 problem(字符串)或 displayId,统一成 problemDisplayId:提交列表、流程图列表、统计的 lastFailure / items / 今日最热题、站内信里的提交、后台题单的题目列表。别处的 problem 都是对象 或数字 id,这几处是字符串,最容易看错 - 列表筛选的 query 参数 ?problemId= 实际传的是展示题号,改成 ?problemDisplayId=(提交列表、流程图列表、三个统计接口) - 契约里 problemsetId / problemSetId、problemset / problemSet 两种拼法并存, 统一成 problemSetId / problemSet,和 ProblemSet* 类型名、problem-sets 路径、前端路由参数一致。drizzle 的列键跟表名走,没动 - aiDetail.user(用户名字符串,而契约里别的 user 都是对象)→ username。 ai_analysis.data 里存的历史快照仍是 user,没有代码读回它 - 后台题单题目表的列是按字符串键 'displayId' 取的,类型检查抓不到,一并改 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
5.9 KiB
Vue
212 lines
5.9 KiB
Vue
<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,
|
||
UserBadge,
|
||
} from "utils/types"
|
||
import AchievementCard from "./components/AchievementCard.vue"
|
||
|
||
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),
|
||
])
|
||
achievements.value = list.achievements
|
||
summary.value = sum
|
||
badges.value = badgeRes ?? []
|
||
} 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>
|