diff --git a/src/oj/achievement/api.ts b/src/oj/achievement/api.ts new file mode 100644 index 0000000..8d682f5 --- /dev/null +++ b/src/oj/achievement/api.ts @@ -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("achievements/summary", { + params: name ? { name } : {}, + }) +} + +export function getPendingAchievements() { + return http.get("achievements/pending") +} + +export function markAchievementsRead(ids: number[]) { + return http.post("achievements/pending", { ids }) +} diff --git a/src/oj/achievement/components/AchievementCard.vue b/src/oj/achievement/components/AchievementCard.vue new file mode 100644 index 0000000..bc4a7f6 --- /dev/null +++ b/src/oj/achievement/components/AchievementCard.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/src/oj/achievement/index.vue b/src/oj/achievement/index.vue new file mode 100644 index 0000000..6e4b1be --- /dev/null +++ b/src/oj/achievement/index.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/src/oj/problem/composables/useSubmissionMonitor.ts b/src/oj/problem/composables/useSubmissionMonitor.ts index e9822f1..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, @@ -45,6 +46,20 @@ export function useSubmissionMonitor() { // ==================== WebSocket 处理 ==================== 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) if (data.submission_id !== submissionId.value) { diff --git a/src/routes.ts b/src/routes.ts index f1ee90f..067f28e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -82,6 +82,12 @@ export const ojs: RouteRecordRaw = { component: () => import("oj/user/index.vue"), meta: { requiresAuth: true }, }, + { + path: "achievement", + name: "achievement", + component: () => import("oj/achievement/index.vue"), + meta: { requiresAuth: true }, + }, { path: "setting", component: () => import("oj/user/setting.vue"), 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 } +}) diff --git a/src/utils/types.ts b/src/utils/types.ts index db11c1f..32d87da 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -723,3 +723,49 @@ export interface DetailsData { } 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[] +}