From d7d80e1c1bb7f9e8459395f27fb6bdbdf6ce8eb7 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 4 Aug 2026 06:46:28 -0600 Subject: [PATCH] =?UTF-8?q?feat(achievement):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E8=A7=A3=E9=94=81=E5=BC=B9=E7=AA=97=E4=B8=8E?= =?UTF-8?q?=E5=BE=85=E5=BC=B9=E6=8B=89=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推拉结合:布局层每次路由切换拉一次 pending 作为主通道, WebSocket 只作为在线时的即时增强。纯靠推会丢消息—— 前端 WebSocket 只在问题页且有提交监听时才建连。 Co-Authored-By: Claude Opus 5 --- .../composables/useSubmissionMonitor.ts | 15 ++- src/shared/components/AchievementToast.vue | 102 ++++++++++++++++++ src/shared/layout/default.vue | 18 ++++ src/shared/store/achievement.ts | 55 ++++++++++ 4 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 src/shared/components/AchievementToast.vue create mode 100644 src/shared/store/achievement.ts diff --git a/src/oj/problem/composables/useSubmissionMonitor.ts b/src/oj/problem/composables/useSubmissionMonitor.ts index d76de47..832b380 100644 --- a/src/oj/problem/composables/useSubmissionMonitor.ts +++ b/src/oj/problem/composables/useSubmissionMonitor.ts @@ -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 } diff --git a/src/shared/components/AchievementToast.vue b/src/shared/components/AchievementToast.vue new file mode 100644 index 0000000..e6fd7e9 --- /dev/null +++ b/src/shared/components/AchievementToast.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/shared/layout/default.vue b/src/shared/layout/default.vue index c314529..7a01f47 100644 --- a/src/shared/layout/default.vue +++ b/src/shared/layout/default.vue @@ -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 }, +) diff --git a/src/shared/store/achievement.ts b/src/shared/store/achievement.ts new file mode 100644 index 0000000..58ffb85 --- /dev/null +++ b/src/shared/store/achievement.ts @@ -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([]) + const current = ref(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,真实载荷在 .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 } +})