merge: 成就系统前端
奖杯馆页面、成就 API 层与类型、全局解锁弹窗与待弹拉取、 WebSocket 消息分流修复。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
27
src/oj/achievement/api.ts
Normal file
27
src/oj/achievement/api.ts
Normal 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 })
|
||||||
|
}
|
||||||
166
src/oj/achievement/components/AchievementCard.vue
Normal file
166
src/oj/achievement/components/AchievementCard.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Achievement } from "utils/types"
|
||||||
|
|
||||||
|
const props = defineProps<{ achievement: Achievement }>()
|
||||||
|
|
||||||
|
const RARITY_COLOR: Record<string, string> = {
|
||||||
|
bronze: "#b87333",
|
||||||
|
silver: "#9fa6b2",
|
||||||
|
gold: "#e0a300",
|
||||||
|
platinum: "#7dd3fc",
|
||||||
|
}
|
||||||
|
|
||||||
|
const RARITY_LABEL: Record<string, string> = {
|
||||||
|
bronze: "青铜",
|
||||||
|
silver: "白银",
|
||||||
|
gold: "黄金",
|
||||||
|
platinum: "白金",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 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] }"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<div class="icon">{{ achievement.icon }}</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="title">
|
||||||
|
<span class="name">{{ achievement.name }}</span>
|
||||||
|
<n-tag
|
||||||
|
size="tiny"
|
||||||
|
:color="{
|
||||||
|
borderColor: RARITY_COLOR[achievement.rarity],
|
||||||
|
textColor: RARITY_COLOR[achievement.rarity],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ RARITY_LABEL[achievement.rarity] }}
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
<div class="desc">{{ achievement.description }}</div>
|
||||||
|
|
||||||
|
<div v-if="achievement.unlocked" class="meta">
|
||||||
|
<span>{{ unlockDate }}</span>
|
||||||
|
<span class="rate">仅 {{ achievement.unlock_rate }}% 的人获得</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="showProgressBar" class="meta">
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="percent"
|
||||||
|
:height="6"
|
||||||
|
:show-indicator="false"
|
||||||
|
/>
|
||||||
|
<span class="progress-text">
|
||||||
|
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="showBestSoFar" class="meta">
|
||||||
|
<span class="progress-text">
|
||||||
|
目标 ≤ {{ achievement.threshold }}
|
||||||
|
</span>
|
||||||
|
<span v-if="achievement.progress !== null" class="rate">
|
||||||
|
当前最好 {{ achievement.progress }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="meta">
|
||||||
|
<span class="rate">仅 {{ achievement.unlock_rate }}% 的人获得</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.meta :deep(.n-progress) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.locked {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.rare {
|
||||||
|
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
170
src/oj/achievement/index.vue
Normal file
170
src/oj/achievement/index.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
|
||||||
|
import { getUserBadges } from "oj/api"
|
||||||
|
import type { Achievement, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const name = computed(() => (route.query.name as string) || undefined)
|
||||||
|
|
||||||
|
const achievements = ref<Achievement[]>([])
|
||||||
|
const summary = ref<AchievementSummary | null>(null)
|
||||||
|
const badges = ref<UserBadge[]>([])
|
||||||
|
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">
|
||||||
|
<n-spin :show="loading">
|
||||||
|
<n-card v-if="summary" class="overview">
|
||||||
|
<div class="overview-row">
|
||||||
|
<div class="percent">
|
||||||
|
<div class="big">{{ summary.percent }}%</div>
|
||||||
|
<div class="sub">
|
||||||
|
{{ summary.unlocked }} / {{ summary.total }} 已获得
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rarity">
|
||||||
|
<div
|
||||||
|
v-for="r in summary.rarity"
|
||||||
|
:key="r.rarity"
|
||||||
|
class="rarity-item"
|
||||||
|
>
|
||||||
|
<div class="rarity-label">{{ r.label }}</div>
|
||||||
|
<div class="rarity-count">{{ r.unlocked }} / {{ r.total }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div v-if="tab !== 'badges'" class="grid">
|
||||||
|
<AchievementCard v-for="a in filtered" :key="a.id" :achievement="a" />
|
||||||
|
<n-empty v-if="!filtered.length" description="这里还什么都没有" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid">
|
||||||
|
<n-card v-for="b in badges" :key="b.id" size="small">
|
||||||
|
<div class="badge-row">
|
||||||
|
<img v-if="b.badge?.icon" :src="b.badge.icon" class="badge-icon" />
|
||||||
|
<div>
|
||||||
|
<div class="badge-name">{{ b.badge?.name }}</div>
|
||||||
|
<div class="badge-desc">{{ b.badge?.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
<n-empty v-if="!badges.length" description="还没有获得任何题单奖章" />
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hall {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.overview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.big {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.rarity {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.rarity-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.rarity-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.rarity-count {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.badge-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.badge-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,8 @@ import { ref, computed, watch, onUnmounted } from "vue"
|
|||||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
import { SubmissionStatus } from "utils/constants"
|
import { SubmissionStatus } from "utils/constants"
|
||||||
import type { Submission } from "utils/types"
|
import type { PendingAchievement, Submission } from "utils/types"
|
||||||
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
import {
|
import {
|
||||||
useSubmissionWebSocket,
|
useSubmissionWebSocket,
|
||||||
type SubmissionUpdate,
|
type SubmissionUpdate,
|
||||||
@@ -45,6 +46,20 @@ export function useSubmissionMonitor() {
|
|||||||
|
|
||||||
// ==================== WebSocket 处理 ====================
|
// ==================== WebSocket 处理 ====================
|
||||||
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||||
|
// push_to_user 复用了 submission_update 这个 channel handler,
|
||||||
|
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
|
||||||
|
const frame = data as unknown as {
|
||||||
|
type: string
|
||||||
|
achievements?: PendingAchievement[]
|
||||||
|
}
|
||||||
|
if (frame.type === "achievement_unlocked") {
|
||||||
|
useAchievementStore().enqueue(frame.achievements ?? [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type !== "submission_update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
|
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
|
||||||
|
|
||||||
if (data.submission_id !== submissionId.value) {
|
if (data.submission_id !== submissionId.value) {
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = {
|
|||||||
component: () => import("oj/user/index.vue"),
|
component: () => import("oj/user/index.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "achievement",
|
||||||
|
name: "achievement",
|
||||||
|
component: () => import("oj/achievement/index.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "setting",
|
path: "setting",
|
||||||
component: () => import("oj/user/setting.vue"),
|
component: () => import("oj/user/setting.vue"),
|
||||||
|
|||||||
102
src/shared/components/AchievementToast.vue
Normal file
102
src/shared/components/AchievementToast.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
|
|
||||||
|
const store = useAchievementStore()
|
||||||
|
const { current, queue } = storeToRefs(store)
|
||||||
|
const visible = ref(false)
|
||||||
|
|
||||||
|
const RARITY_COLOR: Record<string, string> = {
|
||||||
|
bronze: "#b87333",
|
||||||
|
silver: "#9fa6b2",
|
||||||
|
gold: "#e0a300",
|
||||||
|
platinum: "#7dd3fc",
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let gapTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// 多个同时解锁时排队依次弹出,不重叠堆积
|
||||||
|
function playNext() {
|
||||||
|
const item = store.next()
|
||||||
|
if (!item) return
|
||||||
|
visible.value = true
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
visible.value = false
|
||||||
|
await store.markRead(item.id)
|
||||||
|
gapTimer = setTimeout(playNext, 400)
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => queue.value.length,
|
||||||
|
(len) => {
|
||||||
|
if (len > 0 && !visible.value) playNext()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
if (gapTimer) clearTimeout(gapTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="slide">
|
||||||
|
<div
|
||||||
|
v-if="visible && current"
|
||||||
|
class="toast"
|
||||||
|
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
|
||||||
|
>
|
||||||
|
<div class="icon">{{ current.icon }}</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="label">成就解锁</div>
|
||||||
|
<div class="name">{{ current.name }}</div>
|
||||||
|
<div class="desc">{{ current.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid;
|
||||||
|
background: var(--n-color, rgba(24, 24, 28, 0.95));
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition: all 0.35s ease;
|
||||||
|
}
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,23 @@ import Header from "../components/Header.vue"
|
|||||||
import Login from "../components/Login.vue"
|
import Login from "../components/Login.vue"
|
||||||
import Signup from "../components/Signup.vue"
|
import Signup from "../components/Signup.vue"
|
||||||
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
||||||
|
import AchievementToast from "../components/AchievementToast.vue"
|
||||||
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
|
const achievementStore = useAchievementStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 拉取才是主通道:WebSocket 不是常驻连接(只在问题页且有提交监听时建连),
|
||||||
|
// 所以任何页面、任何时刻解锁的成就都靠这里补上
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
if (userStore.isAuthed) achievementStore.fetchPending()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,6 +36,7 @@ import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
|||||||
<Login />
|
<Login />
|
||||||
<Signup />
|
<Signup />
|
||||||
<LoginSummaryModal />
|
<LoginSummaryModal />
|
||||||
|
<AchievementToast />
|
||||||
<Beian />
|
<Beian />
|
||||||
</n-layout>
|
</n-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
55
src/shared/store/achievement.ts
Normal file
55
src/shared/store/achievement.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
getPendingAchievements,
|
||||||
|
markAchievementsRead,
|
||||||
|
} from "oj/achievement/api"
|
||||||
|
import type { PendingAchievement } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成就解锁弹窗队列。
|
||||||
|
*
|
||||||
|
* 通知走推拉结合,后端的 UserAchievement.notified 是唯一真相来源:
|
||||||
|
* - 拉(主):布局层每次路由切换拉一次 pending,覆盖全部场景,绝不丢
|
||||||
|
* - 推(增强):WebSocket 只在用户当场停留在问题页时把延迟压到几百毫秒
|
||||||
|
*
|
||||||
|
* 之所以不能只靠推:前端 WebSocket 不是常驻连接,只在问题页且有提交监听时
|
||||||
|
* 才建连,纯推会丢消息(尤其是题单奖章,那些页面根本没建连接)。
|
||||||
|
*/
|
||||||
|
export const useAchievementStore = defineStore("achievement", () => {
|
||||||
|
const queue = ref<PendingAchievement[]>([])
|
||||||
|
const current = ref<PendingAchievement | null>(null)
|
||||||
|
|
||||||
|
function enqueue(items: PendingAchievement[]) {
|
||||||
|
if (!items?.length) return
|
||||||
|
// 去重:WebSocket 推来的和 pending 拉来的可能是同一批
|
||||||
|
const known = new Set([
|
||||||
|
...queue.value.map((i) => i.id),
|
||||||
|
...(current.value ? [current.value.id] : []),
|
||||||
|
])
|
||||||
|
queue.value.push(...items.filter((i) => !known.has(i.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPending() {
|
||||||
|
try {
|
||||||
|
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
|
||||||
|
const res = await getPendingAchievements()
|
||||||
|
enqueue(res.data ?? [])
|
||||||
|
} catch {
|
||||||
|
// 拉取失败静默处理,下次路由切换会再拉
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
current.value = queue.value.shift() ?? null
|
||||||
|
return current.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(id: number) {
|
||||||
|
try {
|
||||||
|
await markAchievementsRead([id])
|
||||||
|
} catch {
|
||||||
|
// 标记失败下次会重复弹一次,可接受
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { queue, current, enqueue, fetchPending, next, markRead }
|
||||||
|
})
|
||||||
@@ -723,3 +723,49 @@ export interface DetailsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Grade = "S" | "A" | "B" | "C"
|
export type Grade = "S" | "A" | "B" | "C"
|
||||||
|
|
||||||
|
// ==================== 成就相关类型 ====================
|
||||||
|
|
||||||
|
export type AchievementRarity = "bronze" | "silver" | "gold" | "platinum"
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
rarity: AchievementRarity
|
||||||
|
hidden: boolean
|
||||||
|
// 隐藏成就未解锁时,后端已做掩码处理,以下四个字段为 null
|
||||||
|
metric: string | null
|
||||||
|
operator: "gte" | "lte" | null
|
||||||
|
threshold: number | null
|
||||||
|
unlocked: boolean
|
||||||
|
unlock_time: string | null
|
||||||
|
backfilled: boolean
|
||||||
|
progress: number | null
|
||||||
|
unlock_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementRarityStat {
|
||||||
|
rarity: AchievementRarity
|
||||||
|
label: string
|
||||||
|
total: number
|
||||||
|
unlocked: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingAchievement {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
rarity: AchievementRarity
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementSummary {
|
||||||
|
username: string
|
||||||
|
total: number
|
||||||
|
unlocked: number
|
||||||
|
percent: number
|
||||||
|
rarity: AchievementRarityStat[]
|
||||||
|
recent: PendingAchievement[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user