Files
SpineParticlesWeb/src/views/AnnouncementBar.vue
T
2026-11-04 10:55:40 +08:00

183 lines
6.6 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
ANNOUNCEMENT_REFRESH_MS,
ANNOUNCEMENT_WINDOW_MS,
normalizeAnnouncementConfig,
prepareAnnouncement,
type AnnouncementCache,
type AnnouncementConfig,
} from '../editor/announcement'
const { t, locale } = useI18n()
const config = ref<AnnouncementConfig | null>(null)
const visible = ref(false)
const animationRun = ref(0)
const CACHE_KEY = 'SpineParticle.Announcement.v1'
let memoryCache: AnnouncementCache | null = null
let refreshTimer: ReturnType<typeof setInterval> | null = null
let staticHideTimer: ReturnType<typeof setTimeout> | null = null
let refreshing = false
const text = computed(() => {
if (!config.value?.enabled) return ''
return String(config.value.messages[locale.value]
|| config.value.messages.en
|| config.value.messages['zh-CN']
|| '')
})
const duration = computed(() => {
return config.value?.scrollDurationSeconds || 24
})
function readCache(): AnnouncementCache | null {
if (memoryCache) return memoryCache
try {
const parsed = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null') as Partial<AnnouncementCache> | null
const cachedConfig = normalizeAnnouncementConfig(parsed?.config)
if (!parsed || !cachedConfig || typeof parsed.signature !== 'string'
|| typeof parsed.checkedAt !== 'number' || typeof parsed.lastShownAt !== 'number') return null
memoryCache = {
signature: parsed.signature,
checkedAt: parsed.checkedAt!,
lastShownAt: parsed.lastShownAt!,
config: cachedConfig,
}
return memoryCache
} catch {
return null
}
}
function saveCache(cache: AnnouncementCache) {
memoryCache = cache
try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) } catch { /* Keep the in-memory cache for this session. */ }
}
function showAnnouncement(next: AnnouncementConfig) {
config.value = next
animationRun.value++
visible.value = true
if (staticHideTimer) clearTimeout(staticHideTimer)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
staticHideTimer = setTimeout(() => { visible.value = false }, 10000)
}
}
function hideAnnouncement() {
visible.value = false
}
async function refreshAnnouncement(initial = false) {
if (refreshing) return
refreshing = true
try {
const response = await fetch(`${import.meta.env.BASE_URL}announcement.json`, { cache: 'no-store' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const next = normalizeAnnouncementConfig(await response.json())
if (!next) throw new Error('Invalid announcement config')
const now = Date.now()
const decision = prepareAnnouncement(next, readCache(), now)
saveCache(decision.cache)
if (!next.enabled) {
visible.value = false
config.value = next
} else if (decision.shouldShow && next.enabled) {
showAnnouncement(next)
}
} catch {
if (!initial) return
const cached = readCache()
if (!cached?.config.enabled) return
const dueForWindow = !cached.lastShownAt || Date.now() - cached.lastShownAt >= ANNOUNCEMENT_WINDOW_MS
if (!dueForWindow) return
showAnnouncement(cached.config)
saveCache({ ...cached, lastShownAt: Date.now() })
} finally {
refreshing = false
}
}
onMounted(() => {
void refreshAnnouncement(true)
refreshTimer = setInterval(() => { void refreshAnnouncement() }, ANNOUNCEMENT_REFRESH_MS)
})
onBeforeUnmount(() => {
if (refreshTimer) clearInterval(refreshTimer)
if (staticHideTimer) clearTimeout(staticHideTimer)
})
</script>
<template>
<Transition name="announcement-fade">
<div
v-if="visible && text"
class="announcement-bar"
:aria-label="text"
:style="{ '--announcement-duration': `${duration}s` }"
tabindex="0"
role="status"
>
<div class="announcement-viewport">
<div :key="animationRun" class="announcement-track" @animationend="hideAnnouncement">
<span>{{ text }}</span>
<span aria-hidden="true">{{ text }}</span>
</div>
</div>
<button
type="button"
class="announcement-close"
:aria-label="t('announcement.close')"
:title="t('announcement.close')"
@click.stop="hideAnnouncement"
>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="m3 3 6 6M9 3 3 9" />
</svg>
</button>
</div>
</Transition>
</template>
<style scoped>
.announcement-bar {
position: absolute; top: 10px; left: 50%; z-index: 3;
width: min(60vw, 1100px); height: 27.2px; box-sizing: border-box;
overflow: hidden; border: 1px solid rgba(255, 189, 190, .34); border-radius: 999px;
background:
linear-gradient(90deg, rgba(120, 48, 55, .12), rgba(145, 57, 64, .72) 10%, rgba(145, 57, 64, .72) 90%, rgba(120, 48, 55, .12));
color: #ffe4e5; transform: translateX(-50%);
}
.announcement-viewport {
position: absolute; inset: 0 26px 0 0; overflow: hidden;
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.announcement-bar:focus-visible { outline: 2px solid #ff8d8d; outline-offset: 2px; }
.announcement-track {
display: flex; width: max-content; height: 100%; align-items: center;
white-space: nowrap; font-size: 12px; font-weight: 600;
animation: announcement-scroll var(--announcement-duration) linear 3 forwards;
}
.announcement-track span { padding-right: 96px; }
.announcement-bar:hover .announcement-track,
.announcement-bar:focus .announcement-track { animation-play-state: paused; }
.announcement-close {
position: absolute; top: 50%; right: 3px; display: grid; width: 22px; height: 22px; padding: 0;
place-items: center; border: 0; border-radius: 50%; background: transparent;
color: #ffd4d6; transform: translateY(-50%); cursor: pointer;
}
.announcement-close svg { display: block; width: 10px; height: 10px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; }
.announcement-close:hover { background: rgba(255, 255, 255, .12); color: #fff; }
.announcement-close:focus-visible { outline: 1px solid #ffb0b3; outline-offset: -2px; }
.announcement-fade-enter-active, .announcement-fade-leave-active { transition: opacity .5s ease; }
.announcement-fade-enter-from, .announcement-fade-leave-to { opacity: 0; }
@keyframes announcement-scroll {
to { transform: translateX(-50%); }
}
@media (max-width: 900px) {
.announcement-bar { width: calc(100% - 180px); }
}
@media (prefers-reduced-motion: reduce) {
.announcement-track { animation: none; justify-content: center; }
.announcement-track span:last-child { display: none; }
}
</style>