九宫格优化
This commit is contained in:
251
vite-tailwindcss/src/components/DanmakuLayer.vue
Normal file
251
vite-tailwindcss/src/components/DanmakuLayer.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div v-if="enabled && (items.length || rows.length)" class="danmaku-layer" aria-hidden="true">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="danmaku-item"
|
||||
:style="row.style"
|
||||
@animationend="onEnded(row.id)"
|
||||
>
|
||||
<span class="danmaku-name" :style="{ color: row.color }">{{ row.name }}</span>
|
||||
<span class="danmaku-sep" :style="{ color: row.color }">:</span>
|
||||
<span class="danmaku-text" :style="{ color: row.color }">{{ row.content }}</span>
|
||||
<span v-if="showTime && row.timeLabel" class="danmaku-time">{{ row.timeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { getDanmaku } from '@/api/wedding'
|
||||
import { formatRelativeTime } from '@/utils/relativeTime'
|
||||
import { resolveDanmakuColor, resolveDanmakuTint } from '@/utils/danmakuColors'
|
||||
|
||||
const props = defineProps({
|
||||
enabled: { type: Boolean, default: true },
|
||||
showTime: { type: Boolean, default: true },
|
||||
active: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const POLL_MS = 30000
|
||||
const DEDUPE_MS = 30000
|
||||
const TRACKS = 6
|
||||
|
||||
const items = ref([])
|
||||
const rows = ref([])
|
||||
let cursor = 0
|
||||
let spawnTimer = null
|
||||
let pollTimer = null
|
||||
let uid = 0
|
||||
/** @type {Map<string, number>} */
|
||||
const lastShownAt = new Map()
|
||||
|
||||
const itemKey = (item) => {
|
||||
if (item?.id != null) return String(item.id)
|
||||
return `${item?.name || ''}|${item?.content || ''}|${item?.created_at || ''}`
|
||||
}
|
||||
|
||||
const isCooling = (key) => {
|
||||
const t = lastShownAt.get(key)
|
||||
return t != null && Date.now() - t < DEDUPE_MS
|
||||
}
|
||||
|
||||
const pickNextItem = () => {
|
||||
const list = items.value
|
||||
if (!list.length) return null
|
||||
const n = list.length
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const idx = (cursor + i) % n
|
||||
const item = list[idx]
|
||||
const key = itemKey(item)
|
||||
if (!isCooling(key)) {
|
||||
cursor = (idx + 1) % n
|
||||
return item
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const buildRow = (item, { force = false } = {}) => {
|
||||
const key = itemKey(item)
|
||||
if (!force && isCooling(key)) return null
|
||||
lastShownAt.set(key, Date.now())
|
||||
|
||||
const tint = resolveDanmakuTint(item.color)
|
||||
const track = Math.floor(Math.random() * TRACKS)
|
||||
const duration = 10 + Math.random() * 8
|
||||
const delay = force ? 0 : Math.random() * 0.4
|
||||
const id = ++uid
|
||||
|
||||
return {
|
||||
id,
|
||||
name: item.name,
|
||||
content: item.content,
|
||||
color: resolveDanmakuColor(item.color),
|
||||
timeLabel: formatRelativeTime(item.created_at || Date.now()),
|
||||
style: {
|
||||
top: `${8 + track * 12}%`,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
background: tint.background,
|
||||
borderColor: tint.borderColor,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!props.enabled) {
|
||||
items.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getDanmaku()
|
||||
const next = Array.isArray(res.data) ? res.data : []
|
||||
items.value = next
|
||||
if (next.length && cursor >= next.length) cursor = cursor % next.length
|
||||
} catch {
|
||||
/* keep previous items on transient failure */
|
||||
}
|
||||
}
|
||||
|
||||
const spawnOne = () => {
|
||||
if (!props.active || !props.enabled || !items.value.length) return
|
||||
if (rows.value.length >= TRACKS * 2) return
|
||||
|
||||
const item = pickNextItem()
|
||||
if (!item) return
|
||||
const row = buildRow(item)
|
||||
if (row) rows.value.push(row)
|
||||
}
|
||||
|
||||
const pushItem = (item) => {
|
||||
if (!item || !props.enabled) return
|
||||
const id = item.id
|
||||
if (id != null && items.value.some((x) => x.id === id)) {
|
||||
// already in list — still force-spawn for sender
|
||||
} else {
|
||||
items.value = [...items.value, item]
|
||||
}
|
||||
if (!props.active) return
|
||||
const row = buildRow(item, { force: true })
|
||||
if (row) rows.value.push(row)
|
||||
}
|
||||
|
||||
const onEnded = (id) => {
|
||||
rows.value = rows.value.filter((r) => r.id !== id)
|
||||
}
|
||||
|
||||
const startSpawn = () => {
|
||||
clearInterval(spawnTimer)
|
||||
spawnTimer = null
|
||||
if (!props.enabled || !props.active || !items.value.length) return
|
||||
spawnOne()
|
||||
spawnTimer = setInterval(spawnOne, 1800)
|
||||
}
|
||||
|
||||
const stopSpawn = () => {
|
||||
clearInterval(spawnTimer)
|
||||
spawnTimer = null
|
||||
}
|
||||
|
||||
const startPoll = () => {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
if (!props.enabled) return
|
||||
pollTimer = setInterval(() => {
|
||||
fetchList()
|
||||
}, POLL_MS)
|
||||
}
|
||||
|
||||
const stopPoll = () => {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.active, items.value.length],
|
||||
() => {
|
||||
if (props.enabled && props.active && items.value.length) startSpawn()
|
||||
else {
|
||||
stopSpawn()
|
||||
if (!props.enabled) {
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.enabled,
|
||||
async (on) => {
|
||||
if (on) {
|
||||
await fetchList()
|
||||
startPoll()
|
||||
if (props.active && items.value.length) startSpawn()
|
||||
} else {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.enabled) return
|
||||
await fetchList()
|
||||
startPoll()
|
||||
if (props.active && items.value.length) startSpawn()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
})
|
||||
|
||||
defineExpose({ refresh: fetchList, pushItem })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.danmaku-item {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.28);
|
||||
box-shadow: 0 4px 14px rgba(88, 68, 42, 0.08);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
animation-name: danmaku-fly;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
backdrop-filter: blur(6px);
|
||||
max-width: min(80vw, 420px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.danmaku-name { font-weight: 600; }
|
||||
.danmaku-sep { opacity: 0.9; }
|
||||
.danmaku-text { opacity: 0.95; }
|
||||
.danmaku-time {
|
||||
margin-left: 8px;
|
||||
color: #9a9084;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@keyframes danmaku-fly {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100vw - 100%)); }
|
||||
}
|
||||
</style>
|
||||
84
vite-tailwindcss/src/components/LikeBurst.vue
Normal file
84
vite-tailwindcss/src/components/LikeBurst.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="like-burst" aria-hidden="true">
|
||||
<span
|
||||
v-for="h in hearts"
|
||||
:key="h.id"
|
||||
class="like-burst-heart"
|
||||
:style="h.style"
|
||||
@animationend="removeHeart(h.id)"
|
||||
>♥</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const hearts = ref([])
|
||||
let uid = 0
|
||||
|
||||
const burst = (count = 3) => {
|
||||
const n = Math.max(1, Math.min(3, count))
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const id = ++uid
|
||||
const left = 12 + Math.random() * 48
|
||||
const drift = (Math.random() - 0.5) * 36
|
||||
const duration = 1.2 + Math.random() * 0.6
|
||||
const delay = i * 0.08
|
||||
const scale = 0.85 + Math.random() * 0.45
|
||||
hearts.value.push({
|
||||
id,
|
||||
style: {
|
||||
left: `${left}px`,
|
||||
'--drift': `${drift}px`,
|
||||
'--scale': scale,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeHeart = (id) => {
|
||||
hearts.value = hearts.value.filter((h) => h.id !== id)
|
||||
}
|
||||
|
||||
defineExpose({ burst })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.like-burst {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 120px;
|
||||
height: 55vh;
|
||||
z-index: 45;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.like-burst-heart {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
color: #f472b6;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 8px rgba(244, 114, 182, 0.45);
|
||||
animation-name: like-rise;
|
||||
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes like-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 12px, 0) scale(calc(var(--scale, 1) * 0.6));
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--drift, 0px), -42vh, 0) scale(var(--scale, 1));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user