Files
chat/frontend/src/components/FlashLayer.vue
T
2026-06-14 09:25:59 +08:00

243 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flash-layer">
<!-- 萤火虫粒子背景 -->
<transition name="flash-fade">
<div v-if="active" class="flash-overlay">
<span v-for="i in 18" :key="i" class="firefly" :style="fireflyStyle(i)"></span>
</div>
</transition>
<!-- 中央集气面板 -->
<transition name="flash-pop">
<div v-if="active" class="flash-panel">
<span class="flash-close" @click.stop="dismiss"></span>
<div class="flash-tap-area" @click="onTap">
<div class="flash-title"> 萤火虫降临</div>
<div class="flash-progress-ring">
<svg viewBox="0 0 100 100" class="ring-svg">
<circle cx="50" cy="50" r="44" class="ring-bg" />
<circle cx="50" cy="50" r="44" class="ring-fill"
:style="{ strokeDashoffset: ringOffset }" />
</svg>
<div class="ring-center">
<div class="ring-count">{{ progress.total }}</div>
<div class="ring-target">/ {{ active.target_clicks }}</div>
</div>
</div>
<div class="flash-hint">点击集气 · 集满全服掉限定叶 🌟</div>
<div v-if="myClicks > 0" class="my-clicks">你贡献了 {{ myClicks }} </div>
</div>
</div>
</transition>
<!-- 达标庆祝 -->
<transition name="flash-pop">
<div v-if="reward" class="reward-modal" @click.self="reward = null">
<div class="reward-card">
<div class="reward-glow"></div>
<div class="reward-leaf">
<svg viewBox="0 0 100 120" class="reward-svg">
<defs>
<radialGradient id="flash-grad">
<stop offset="0%" :stop-color="leafColor(style, 25)" />
<stop offset="100%" :stop-color="leafColor(style)" />
</radialGradient>
</defs>
<path :d="leafPath(style.shapeVariant)" fill="url(#flash-grad)" />
<g stroke="rgba(255,255,255,0.6)" stroke-width="0.8" fill="none">
<path v-for="(vp, i) in veinPaths(style)" :key="i" :d="vp" />
</g>
<!-- 萤火虫光点 -->
<circle v-for="i in 4" :key="'g'+i" :cx="30 + i*12" :cy="40 + (i%2)*30" r="2" fill="rgba(255,235,100,0.9)" />
</svg>
</div>
<h3>限定纪念叶到手</h3>
<p class="reward-variant">{{ reward.leaf_variant }}</p>
<p class="reward-desc">永不复刻 · 已收入图鉴</p>
<n-button type="primary" round @click="claimReward">收入图鉴</n-button>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { flashApi } from '@/api/flash'
import { generateLeafStyle, leafColor, leafPath, veinPaths } from '@/utils/leafGenerator'
const active = ref<any>(null)
const reward = ref<any>(null)
const progress = ref<any>({ total: 0 })
const myClicks = ref(0)
const dismissedIds = ref<Set<string>>(new Set()) // 用户已忽略的事件,不再弹回
let pollTimer: ReturnType<typeof setInterval>
let claimTimer: ReturnType<typeof setTimeout>
const style = computed(() => reward.value
? generateLeafStyle(reward.value.leaf_seed)
: generateLeafStyle('0000000000000000'))
const ringOffset = computed(() => {
const p = progress.value.progress || 0
return 276 - 276 * p // 276 = 2π*44
})
function fireflyStyle(i: number): Record<string, string> {
const left = (i * 53) % 100
const top = (i * 37) % 100
const delay = (i % 6) * 0.3
const dur = 2.5 + (i % 4)
return {
left: left + '%',
top: top + '%',
animationDelay: delay + 's',
animationDuration: dur + 's',
}
}
function onFlash(e: Event) {
const data = (e as CustomEvent).detail
if (data._phase === 'flash.spawn') {
if (dismissedIds.value.has(data.id)) return // 已忽略的不再弹
active.value = data
progress.value = { total: data.total_clicks || 0, progress: data.progress || 0, target: data.target_clicks }
} else if (data._phase === 'flash.progress') {
progress.value = data
} else if (data._phase === 'flash.result' && data.reached) {
// 达标:显示奖励并关闭集气面板
reward.value = { ...data, event_id: data.event_id }
active.value = null
}
}
async function onTap() {
if (!active.value) return
try {
const { data } = await flashApi.click(active.value.id)
progress.value = data
myClicks.value = data.my_clicks
// 本地达标也立即关闭面板,触发奖励
if (data.reached && !reward.value) {
reward.value = { event_id: active.value.id, leaf_seed: active.value.leaf_seed, leaf_variant: active.value.leaf_variant }
active.value = null
}
} catch {}
}
function dismiss() {
if (active.value) dismissedIds.value.add(active.value.id)
active.value = null
}
async function claimReward() {
if (!reward.value) return
try {
await flashApi.claim(reward.value.event_id)
reward.value = null
} catch {}
}
// 轮询检查是否有进行中的事件(弥补未在线时错过 spawn 的情况)
async function pollActive() {
// 若当前已有面板或奖励弹窗,先检查是否到期
if (active.value) {
if (new Date(active.value.end_at).getTime() <= Date.now()) {
active.value = null // 事件到期,自动关闭
return
}
}
try {
const { data } = await flashApi.getActive()
if (data.event && !reward.value && !dismissedIds.value.has(data.event.id)) {
active.value = data.event
progress.value = {
total: data.event.total_clicks,
progress: data.event.progress,
target: data.event.target_clicks,
}
}
} catch {}
}
onMounted(() => {
window.addEventListener('qingye:flash', onFlash)
pollActive()
pollTimer = setInterval(pollActive, 20000) // 每 20 秒检查一次(更轻量)
})
onUnmounted(() => {
window.removeEventListener('qingye:flash', onFlash)
clearInterval(pollTimer)
})
</script>
<style scoped>
.flash-layer { position: fixed; inset: 0; pointer-events: none; z-index: 1400; }
/* 萤火虫粒子 */
.flash-overlay { position: fixed; inset: 0; pointer-events: none; }
.firefly {
position: absolute; width: 6px; height: 6px; border-radius: 50%;
background: radial-gradient(circle, #FFEB3B 0%, rgba(255,200,0,0.4) 60%, transparent 100%);
box-shadow: 0 0 8px #FFEB3B;
animation: firefly-float ease-in-out infinite;
}
@keyframes firefly-float {
0%, 100% { transform: translate(0, 0); opacity: 0.3; }
50% { transform: translate(20px, -30px); opacity: 1; }
}
/* 中央面板 */
.flash-panel {
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(20,30,28,0.92); color: white; padding: 24px 32px; border-radius: 24px;
text-align: center; pointer-events: auto;
box-shadow: 0 0 60px rgba(255,235,59,0.4); backdrop-filter: blur(8px);
}
.flash-close {
position: absolute; top: 10px; right: 14px; cursor: pointer;
font-size: 18px; color: rgba(255,255,255,0.6); line-height: 1; z-index: 2;
}
.flash-close:hover { color: white; }
.flash-tap-area { cursor: pointer; }
.flash-title { font-size: 16px; font-weight: 700; margin-bottom: 12px; color: #FFEB3B; }
.flash-progress-ring { position: relative; width: 140px; height: 140px; margin: 0 auto 12px; }
.ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: rgba(255,255,255,0.15); stroke-width: 6; }
.ring-fill { fill: none; stroke: #FFEB3B; stroke-width: 6; stroke-linecap: round; stroke-dasharray: 276; transition: stroke-dashoffset 0.3s; filter: drop-shadow(0 0 4px #FFEB3B); }
.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring-count { font-size: 32px; font-weight: 800; color: white; }
.ring-target { font-size: 12px; color: rgba(255,255,255,0.6); }
.flash-hint { font-size: 12px; color: rgba(255,255,255,0.7); }
.my-clicks { font-size: 11px; color: #FFEB3B; margin-top: 6px; }
/* 达标奖励 */
.reward-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); pointer-events: auto;
display: flex; align-items: center; justify-content: center; z-index: 1600;
}
.reward-card {
background: var(--color-surface); padding: 32px; border-radius: 24px; text-align: center;
position: relative; overflow: hidden; max-width: 320px;
}
.reward-glow {
position: absolute; inset: 0; background: radial-gradient(circle at 50% 30%, rgba(255,235,59,0.3), transparent 60%);
animation: glow 2s ease-in-out infinite;
}
@keyframes glow { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
.reward-leaf { position: relative; margin: 0 auto 16px; width: 120px; height: 144px; filter: drop-shadow(0 0 20px rgba(255,200,0,0.6)); }
.reward-svg { width: 100%; height: 100%; animation: reward-bloom 0.8s ease; }
@keyframes reward-bloom { 0% { transform: scale(0.3) rotate(-180deg); opacity: 0; } 100% { transform: scale(1) rotate(0); opacity: 1; } }
.reward-card h3 { margin: 0 0 4px; font-size: 18px; color: var(--color-primary-darker); position: relative; }
.reward-variant { font-size: 12px; color: var(--color-text-hint); margin: 4px 0; font-family: monospace; position: relative; }
.reward-desc { font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 16px; position: relative; }
/* transitions */
.flash-fade-enter-active, .flash-fade-leave-active { transition: opacity 0.5s; }
.flash-fade-enter-from, .flash-fade-leave-to { opacity: 0; }
.flash-pop-enter-active { animation: pop-in 0.4s ease; }
.flash-pop-leave-active { transition: opacity 0.3s; }
.flash-pop-leave-to { opacity: 0; }
@keyframes pop-in { 0% { transform: translate(-50%, -50%) scale(0.7); opacity: 0; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
</style>