feat(achievement): 添加全局解锁弹窗与待弹拉取

推拉结合:布局层每次路由切换拉一次 pending 作为主通道,
WebSocket 只作为在线时的即时增强。纯靠推会丢消息——
前端 WebSocket 只在问题页且有提交监听时才建连。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 06:46:28 -06:00
parent fe7095bd74
commit d7d80e1c1b
4 changed files with 187 additions and 3 deletions

View File

@@ -2,7 +2,8 @@ import { ref, computed, watch, onUnmounted } from "vue"
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
import { getSubmission } from "oj/api"
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 {
useSubmissionWebSocket,
type SubmissionUpdate,
@@ -46,8 +47,16 @@ export function useSubmissionMonitor() {
// ==================== WebSocket 处理 ====================
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
// push_to_user 复用了 submission_update 这个 channel handler
// 其他类型的消息(如成就通知)会走同一条 WebSocket 帧进来,必须先挡掉
if (data.type !== "submission_update") {
// 其他类型的消息会走同一条 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
}

View 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>

View File

@@ -4,6 +4,23 @@ import Header from "../components/Header.vue"
import Login from "../components/Login.vue"
import Signup from "../components/Signup.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>
<template>
@@ -19,6 +36,7 @@ import LoginSummaryModal from "../components/LoginSummaryModal.vue"
<Login />
<Signup />
<LoginSummaryModal />
<AchievementToast />
<Beian />
</n-layout>
</template>

View 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 }
})