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 { 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
|
||||
}
|
||||
|
||||
|
||||
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 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>
|
||||
|
||||
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