公告栏

This commit is contained in:
tianmo committed 2026-11-04 10:55:40 +08:00
1 parent 720e3e0ceb
commit 87d6561779
23 files changed
+364 -6

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
export interface AnnouncementConfig {
enabled: boolean
scrollDurationSeconds: number
messages: Record<string, string>
}
export interface AnnouncementCache {
signature: string
checkedAt: number
lastShownAt: number
config: AnnouncementConfig
}
export const ANNOUNCEMENT_REFRESH_MS = 30 * 1000
export const ANNOUNCEMENT_WINDOW_MS = 72 * 60 * 60 * 1000
export function normalizeAnnouncementConfig(value: unknown): AnnouncementConfig | null {
if (!value || typeof value !== 'object') return null
const candidate = value as Partial<AnnouncementConfig>
if (typeof candidate.enabled !== 'boolean' || !candidate.messages || typeof candidate.messages !== 'object') return null
const messages = Object.fromEntries(
Object.entries(candidate.messages)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
.map(([locale, text]) => [locale, text.replace(/\s+/g, ' ').trim()])
.filter(([, text]) => Boolean(text)),
)
if (!Object.keys(messages).length) return null
const duration = Number(candidate.scrollDurationSeconds)
return {
enabled: candidate.enabled,
scrollDurationSeconds: Number.isFinite(duration) ? Math.min(120, Math.max(8, duration)) : 24,
messages,
}
}
export function announcementContentSignature(config: AnnouncementConfig) {
return JSON.stringify(Object.entries(config.messages).sort(([left], [right]) => left.localeCompare(right)))
}
export function prepareAnnouncement(config: AnnouncementConfig, previous: AnnouncementCache | null, now: number) {
const signature = announcementContentSignature(config)
const dueForWindow = !previous?.lastShownAt || now - previous.lastShownAt >= ANNOUNCEMENT_WINDOW_MS
const contentChanged = Boolean(previous && previous.signature !== signature)
const shouldShow = config.enabled && (dueForWindow || contentChanged)
return {
shouldShow,
cache: {
signature,
checkedAt: now,
lastShownAt: shouldShow ? now : previous?.lastShownAt || 0,
config,
} satisfies AnnouncementCache,
}
}
+3
View File
@@ -39,6 +39,9 @@
"cta": "SpineThumbnails ansehen"
}
},
"announcement": {
"close": "Ankündigung schließen"
},
"common": {
"close": "Schließen",
"collapse": "Einklappen",
+3
View File
@@ -51,6 +51,9 @@
"cta": "View SpineThumbnails"
}
},
"announcement": {
"close": "Close announcement"
},
"common": {
"close": "Close",
"collapse": "Collapse",
+3
View File
@@ -39,6 +39,9 @@
"cta": "Ver SpineThumbnails"
}
},
"announcement": {
"close": "Cerrar anuncio"
},
"common": {
"close": "Cerrar",
"collapse": "Contraer",
+3
View File
@@ -39,6 +39,9 @@
"cta": "Voir SpineThumbnails"
}
},
"announcement": {
"close": "Fermer l’annonce"
},
"common": {
"close": "Fermer",
"collapse": "Réduire",
+3
View File
@@ -39,6 +39,9 @@
"cta": "SpineThumbnails を見る"
}
},
"announcement": {
"close": "お知らせを閉じる"
},
"common": {
"close": "閉じる",
"collapse": "折りたたむ",
+3
View File
@@ -39,6 +39,9 @@
"cta": "SpineThumbnails 보기"
}
},
"announcement": {
"close": "공지 닫기"
},
"common": {
"close": "닫기",
"collapse": "접기",
+3
View File
@@ -39,6 +39,9 @@
"cta": "Ver SpineThumbnails"
}
},
"announcement": {
"close": "Fechar anúncio"
},
"common": {
"close": "Fechar",
"collapse": "Recolher",
+3
View File
@@ -51,6 +51,9 @@
"cta": "Открыть SpineThumbnails"
}
},
"announcement": {
"close": "Закрыть объявление"
},
"common": {
"close": "Закрыть",
"collapse": "Свернуть",
+3
View File
@@ -51,6 +51,9 @@
"cta": "查看 SpineThumbnails"
}
},
"announcement": {
"close": "关闭公告"
},
"common": {
"close": "关闭",
"collapse": "收起",
+3
View File
@@ -39,6 +39,9 @@
"cta": "查看 SpineThumbnails"
}
},
"announcement": {
"close": "關閉公告"
},
"common": {
"close": "關閉",
"collapse": "收合",
+182
View File
@@ -0,0 +1,182 @@
<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>
+2
View File
@@ -1,6 +1,7 @@
<template>
<div class="stage-wrap">
<div ref="holder" class="stage-holder" :class="{ grid: store.settings.showGrid }" tabindex="0" @wheel.prevent="onWheelEvent" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
<AnnouncementBar />
<img
class="stage-watermark"
:class="{ 'watermark-left-panel': !store.settings.parameterPanelOnRight }"
@@ -63,6 +64,7 @@ import { useEditorShortcuts } from '../editor/useEditorShortcuts'
import { PROJECT_SAVE_EVENT } from '../editor/projectLibrary'
import { translate } from '../i18n'
import HelpDialog from './HelpDialog.vue'
import AnnouncementBar from './AnnouncementBar.vue'
import StageToolbar from './StageToolbar.vue'
import StageSettingsPanel from './StageSettingsPanel.vue'
import { onWheel, panView, type StageContext } from './stage/viewControls'