feat(achievement): 添加全局解锁弹窗与待弹拉取
推拉结合:布局层每次路由切换拉一次 pending 作为主通道, WebSocket 只作为在线时的即时增强。纯靠推会丢消息—— 前端 WebSocket 只在问题页且有提交监听时才建连。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
@@ -46,8 +47,16 @@ export function useSubmissionMonitor() {
|
|||||||
// ==================== WebSocket 处理 ====================
|
// ==================== WebSocket 处理 ====================
|
||||||
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||||
// push_to_user 复用了 submission_update 这个 channel handler,
|
// push_to_user 复用了 submission_update 这个 channel handler,
|
||||||
// 其他类型的消息(如成就通知)会走同一条 WebSocket 帧进来,必须先挡掉
|
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
|
||||||
if (data.type !== "submission_update") {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user