243 lines
9.6 KiB
Vue
243 lines
9.6 KiB
Vue
<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>
|