From 4d6df4ab0cf797ff1ffddaec2cde95e4c3e24bca Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 14 Oct 2026 13:17:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=81=9A=E5=AF=B9=E4=BA=86):=20=E7=82=B9?= =?UTF-8?q?=E8=AF=84=E5=BC=B9=E7=AA=97=E7=AD=89=E7=83=9F=E8=8A=B1=E6=94=BE?= =?UTF-8?q?=E5=AE=8C=E5=86=8D=E5=87=BA=E6=9D=A5=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=94=BE=E5=88=B0=E4=B8=80=E5=8D=8A=E5=B0=B1=E8=A2=AB=E7=9B=96?= =?UTF-8?q?=E4=BD=8F=EF=BC=9B=E6=98=9F=E6=98=9F=E9=9B=A8=E3=80=81=E5=BD=A9?= =?UTF-8?q?=E8=99=B9=E5=96=B7=E6=B3=89=E6=94=B6=E7=9F=AD=E4=B8=80=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E5=8D=81=E7=A7=8D=E9=83=BD=E5=9C=A8=E4=B8=A4=E4=B8=89?= =?UTF-8?q?=E7=A7=92=E5=86=85=E6=94=BE=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../problem/components/SubmissionEffects.vue | 33 +++++--- .../oj/problem/composables/useFireworks.ts | 83 +++++++++++++------ 2 files changed, 80 insertions(+), 36 deletions(-) diff --git a/apps/web/src/oj/problem/components/SubmissionEffects.vue b/apps/web/src/oj/problem/components/SubmissionEffects.vue index eb766ce..399a5ed 100644 --- a/apps/web/src/oj/problem/components/SubmissionEffects.vue +++ b/apps/web/src/oj/problem/components/SubmissionEffects.vue @@ -35,7 +35,7 @@ const { celebrate } = useFireworks() // 只对已经能评价、且还没评过的人弹:后端要求先有 AC 才收评价,这里刚 AC 完正好; // mine 非 null 说明早就评过了,别再打扰。 // -// 点评是强制的,但从 AC 到弹窗出来有 1.5 秒(加上查一次「评过没有」),而换题会把 +// 点评是强制的,但弹窗要等烟花放完才出来(两三秒,加上查一次「评过没有」),而换题会把 // 定时器取消(见下面换题的 watch)—— 「下一题」在通过的那一刻就出现了,学生手快点 // 下去,点评就跳过了。所以这段时间里欠着一次点评(reviewOwed),离开这道题的导航 // 先拦下来:立刻弹点评,选完再去原来要去的地方。 @@ -128,11 +128,24 @@ function continueAfterReview() { closeCommentPanel() } -const { start: showCommentPanelDelayed, stop: cancelCommentPanel } = useTimeoutFn( - openReview, - 1500, - { immediate: false }, -) +/** + * 烟花放完再弹点评,两边一个时间(十种烟花长短不一,两到三秒)。 + * 每次通过记一个号,换题、被导航拦下提前弹了,就把号作废,放完也不再弹。 + */ +let reviewAfterFireworks = 0 + +function showCommentPanelAfter(fireworks: Promise) { + const run = ++reviewAfterFireworks + fireworks.then(() => { + if (run === reviewAfterFireworks) openReview() + }) +} + +function cancelCommentPanel() { + reviewAfterFireworks++ +} +// 离开题目页时烟花那边当作放完了,不能接着去查、去弹 +onScopeDispose(cancelCommentPanel) /** * 只拦「离开这道题」:换 query(左栏切页签会改 `?tab=`)不算。弹窗已经开着的时候 @@ -166,7 +179,7 @@ const { start: goToProblemSetDelayed, stop: cancelGoToProblemSet } = useTimeoutF ) /** - * 换题时收掉通过之后那两个 1.5 秒的定时器 —— 评价弹窗是按**当前**题目去查、去弹的, + * 换题时收掉通过之后等着的点评和回题单 —— 评价弹窗是按**当前**题目去查、去弹的, * 不取消的话就会给一道还没做的题弹评价。欠着的点评走不到这里:离开这道题的导航 * 已经被 holdForReview 拦下、评完才放行;还能走到这里的只有弹窗开着时按了浏览器后退。 * 结果面板、还在跟的那条提交由 submission store 自己收。 @@ -226,12 +239,12 @@ watch( if (result !== SubmissionStatus.accepted) return // 2. 放烟花 - celebrate() + const fireworks = celebrate() - // 3. 弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉 + // 3. 烟花放完弹出评价框。比赛里不打扰;题单里 1.5 秒后要跳回题单页,弹了也会被冲掉 if (ctx.value.reviewAfterAccepted) { reviewOwed.value = true - showCommentPanelDelayed() + showCommentPanelAfter(fireworks) } if (ctx.value.backToProblemSet) { diff --git a/apps/web/src/oj/problem/composables/useFireworks.ts b/apps/web/src/oj/problem/composables/useFireworks.ts index 30e73a7..c32ba04 100644 --- a/apps/web/src/oj/problem/composables/useFireworks.ts +++ b/apps/web/src/oj/problem/composables/useFireworks.ts @@ -74,9 +74,9 @@ const PALETTES = [ const RAINBOW = [COLORS.pink, COLORS.orange, COLORS.gold, COLORS.green, COLORS.blue, COLORS.purple] /** - * 盖在点评弹窗上面放:通过之后 1.5 秒点评弹窗就出来了,这十种要放 2~3 秒, - * 默认的 zIndex 100 在 Naive 弹层(2000 起)底下,后半段会被遮罩挡住。 - * 画布本身 pointer-events: none,不挡点评的点击。 + * 盖在弹层上面放。点评弹窗平时等烟花放完才出来(见 celebrate 的返回值),但学生在放的时候 + * 点「下一题」,点评会被提前叫出来 —— 默认的 zIndex 100 在 Naive 弹层(2000 起)底下, + * 剩下的半场就被遮罩挡住了。画布本身 pointer-events: none,不挡点评的点击。 */ const Z_INDEX = 3000 @@ -113,6 +113,11 @@ export function useFireworks() { // 动画期间创建的定时器,组件卸载时统一清理,避免离开页面后动画继续 const timers = new Set>() let disposed = false + // 这一场最后一次发射的 promise:canvas-confetti 同一块画布上的粒子是一场动画, + // 后发的接进去,promise 在所有粒子都落完的时候才 resolve + let lastShot: Promise | null = null + // 还没发出去的异步准备(表情包位图在解码),算「还在放」 + let preparing = 0 function runLater(fn: () => void, ms: number) { const id = setTimeout(() => { @@ -146,7 +151,22 @@ export function useFireworks() { } function fire(options: confetti.Options) { - confetti({ zIndex: Z_INDEX, ...options }) + lastShot = confetti({ zIndex: Z_INDEX, ...options }) ?? lastShot + } + + /** 定时器都走完、最后一把粒子也落完。离开页面(定时器被清掉)就当放完了 */ + function finished(): Promise { + return new Promise((resolve) => { + const check = () => { + if (disposed) return resolve() + if (timers.size > 0 || preparing > 0) { + setTimeout(check, 50) + return + } + Promise.resolve(lastShot).then(() => resolve()) + } + check() + }) } /** 没有真火箭:沿上升路线一路撒往下掉的小火星,当作尾焰。x0 → x、从屏幕底到 y */ @@ -228,15 +248,15 @@ export function useFireworks() { // 效果2: 星星雨 —— 从顶上落下一片星星 () => { - every(1200, () => { + every(1000, () => { fire({ particleCount: 2, angle: 270, spread: 20, - startVelocity: 8, + startVelocity: 10, gravity: 0.7, decay: 0.97, - ticks: 180, + ticks: 120, scalar: 1.1, shapes: ["star"], colors: [COLORS.gold, COLORS.gold, COLORS.pink, COLORS.blue, COLORS.silver], @@ -263,7 +283,7 @@ export function useFireworks() { // 效果4: 彩虹喷泉 —— 两个下角往里喷,颜色按彩虹轮换 () => { const start = performance.now() - every(1500, () => { + every(1300, () => { const color = RAINBOW[Math.floor((performance.now() - start) / 100) % RAINBOW.length] const base: confetti.Options = { particleCount: 3, @@ -271,7 +291,7 @@ export function useFireworks() { startVelocity: 55, gravity: 1, decay: 0.92, - ticks: 120, + ticks: 95, scalar: 0.8, flat: true, shapes: ["circle"], @@ -420,29 +440,40 @@ export function useFireworks() { // 效果10: 表情包烟花 —— 一左一右炸出两团表情 () => { - loadEmojiShapes().then((emojiShapes) => { - if (disposed) return - const options: confetti.Options = { - shapes: emojiShapes, - flat: true, - scalar: 5, - particleCount: 16, - spread: 360, - startVelocity: 40, - gravity: 0.7, - ticks: 150, - } - fire({ ...options, origin: { x: 0.35, y: 0.5 } }) - runLater(() => fire({ ...options, origin: { x: 0.68, y: 0.42 } }), 300) - }) + preparing++ + loadEmojiShapes() + .then((emojiShapes) => { + if (disposed) return + const options: confetti.Options = { + shapes: emojiShapes, + flat: true, + scalar: 5, + particleCount: 16, + spread: 360, + startVelocity: 40, + gravity: 0.7, + ticks: 150, + } + fire({ ...options, origin: { x: 0.35, y: 0.5 } }) + runLater(() => fire({ ...options, origin: { x: 0.68, y: 0.42 } }), 300) + }) + .catch(() => {}) + .finally(() => preparing--) }, ] + /** 最多等这么久:粒子落完的回调万一没来,别让点评弹窗一直不出来 */ + const MAX_WAIT_MS = 4000 + /** - * 触发随机烟花效果 + * 触发随机烟花效果,返回的 promise 在这一场放完时 resolve(点评弹窗等它) */ - function celebrate() { + function celebrate(): Promise { pick(fireworkTypes)() + return Promise.race([ + finished(), + new Promise((resolve) => setTimeout(resolve, MAX_WAIT_MS)), + ]) } return {