From 71186f0ca5adde664536146f20c2991f4eb245c5 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 4 Aug 2026 05:24:13 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(achievement):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=88=90=E5=B0=B1=20API=20=E5=B1=82=E4=B8=8E=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20WebSocket=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/oj/achievement/api.ts | 27 +++++++++++ .../composables/useSubmissionMonitor.ts | 6 +++ src/utils/types.ts | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 src/oj/achievement/api.ts 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/problem/composables/useSubmissionMonitor.ts b/src/oj/problem/composables/useSubmissionMonitor.ts index e9822f1..d76de47 100644 --- a/src/oj/problem/composables/useSubmissionMonitor.ts +++ b/src/oj/problem/composables/useSubmissionMonitor.ts @@ -45,6 +45,12 @@ export function useSubmissionMonitor() { // ==================== WebSocket 处理 ==================== const handleSubmissionUpdate = (data: SubmissionUpdate) => { + // push_to_user 复用了 submission_update 这个 channel handler, + // 其他类型的消息(如成就通知)会走同一条 WebSocket 帧进来,必须先挡掉 + if (data.type !== "submission_update") { + return + } + console.log("[SubmissionMonitor] 收到WebSocket更新:", data) if (data.submission_id !== submissionId.value) { 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[] +} From d81e00ee1400455367eb8035d70e30428e9c6d46 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 4 Aug 2026 06:42:38 -0600 Subject: [PATCH 2/4] =?UTF-8?q?feat(achievement):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=A5=96=E6=9D=AF=E9=A6=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三种视觉状态:已解锁显示日期与获得率;未解锁公开成就显示进度条; 未解锁隐藏成就只显示稀有度,不画进度条(会泄露后端刻意遮掉的门槛)。 lte 类成就(如最短代码 ≤ 50 字符)不画百分比条,改为显示当前最好成绩。 Co-Authored-By: Claude Opus 5 --- .../components/AchievementCard.vue | 166 +++++++++++++++++ src/oj/achievement/index.vue | 169 ++++++++++++++++++ src/routes.ts | 6 + 3 files changed, 341 insertions(+) create mode 100644 src/oj/achievement/components/AchievementCard.vue create mode 100644 src/oj/achievement/index.vue 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..a621a3c --- /dev/null +++ b/src/oj/achievement/index.vue @@ -0,0 +1,169 @@ + + + + + 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"), From fe7095bd746fa44820cb644401b0490e0d4cc248 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 4 Aug 2026 06:46:20 -0600 Subject: [PATCH 3/4] =?UTF-8?q?fix(achievement):=20=E5=A5=96=E6=9D=AF?= =?UTF-8?q?=E9=A6=86=E5=8F=96=20ApiResponse.data=20=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=E6=95=B4=E4=B8=AA=E5=93=8D=E5=BA=94=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http 客户端返回 ApiResponse,载荷在 .data 里。 原写法会让页面拿到 undefined,奖杯馆整片空白。 项目没有类型检查步骤,Vite 只转译不校验,构建不会报这个错。 Co-Authored-By: Claude Opus 5 --- src/oj/achievement/index.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/oj/achievement/index.vue b/src/oj/achievement/index.vue index a621a3c..6e4b1be 100644 --- a/src/oj/achievement/index.vue +++ b/src/oj/achievement/index.vue @@ -40,9 +40,10 @@ async function load() { getAchievementSummary(name.value), getUserBadges(name.value), ]) - achievements.value = list.achievements - summary.value = sum - badges.value = (badgeRes ?? []) as UserBadge[] + // http 客户端返回 ApiResponse,真实载荷在 .data 里 + achievements.value = list.data.achievements + summary.value = sum.data + badges.value = (badgeRes.data ?? []) as UserBadge[] } finally { loading.value = false } 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 4/4] =?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 } +})