1. 播放器歌词效果
This commit is contained in:
@@ -160,6 +160,10 @@
|
||||
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间(刚刚、x分钟前等)。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="九宫格入场动画">
|
||||
<a-switch v-model:checked="cfg.nineGridAnimate" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">关闭:提前预加载、进入窗口即展示且不暂停滚动(推荐 iOS);开启:轻量淡入并短暂停滚。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
@@ -437,7 +441,7 @@
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key==='content'">
|
||||
<span class="inline-flex items-center gap-2 min-w-0">
|
||||
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuColor(record.color) }" />
|
||||
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuSwatch(record.color) }" />
|
||||
<span class="truncate">{{ record.content }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -479,7 +483,7 @@ import {
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||||
import { resolveDanmakuColor } from '@/utils/danmakuColors'
|
||||
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
||||
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
@@ -512,6 +516,7 @@ function defaultConfig() {
|
||||
musicRandomEnabled: false,
|
||||
danmakuEnabled: true,
|
||||
danmakuShowTime: true,
|
||||
nineGridAnimate: false,
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
introExitEffect: 'wind',
|
||||
freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
|
||||
@@ -869,6 +874,7 @@ onMounted(async () => {
|
||||
if (typeof cfg.musicRandomEnabled !== 'boolean') cfg.musicRandomEnabled = false
|
||||
if (typeof cfg.danmakuEnabled !== 'boolean') cfg.danmakuEnabled = true
|
||||
if (typeof cfg.danmakuShowTime !== 'boolean') cfg.danmakuShowTime = true
|
||||
if (typeof cfg.nineGridAnimate !== 'boolean') cfg.nineGridAnimate = false
|
||||
}
|
||||
} catch (e) { /* 使用默认配置 */ }
|
||||
if (adminStore.isAdmin) {
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
type="button"
|
||||
class="action-comment"
|
||||
title="送祝福"
|
||||
@click="showTrackList = false; showBlessingDrawer = true"
|
||||
@click="showTrackList = false; openBlessingDrawer()"
|
||||
>
|
||||
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="action-comment-icon" alt="" />
|
||||
<span>写一句祝福</span>
|
||||
@@ -306,6 +306,7 @@
|
||||
<PhotoGallery
|
||||
:page="page"
|
||||
:activate="page.type === 'nine-grid' ? !!nineGridActivate[pIdx] : false"
|
||||
:nine-grid-animate="!!data.nineGridAnimate"
|
||||
@preview-image="openImagePreview"
|
||||
@ready="onNineGridReady(pIdx)"
|
||||
/>
|
||||
@@ -520,13 +521,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 送祝福抽屉 -->
|
||||
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="showBlessingDrawer = false"></div>
|
||||
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="closeBlessingDrawer"></div>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col"
|
||||
:class="{ 'pointer-events-none': !showBlessingDrawer }"
|
||||
:style="{ transform: showBlessingDrawer ? 'translateY(0)' : 'translateY(100%)' }"
|
||||
>
|
||||
<div @click="showBlessingDrawer = false" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
|
||||
<div @click="closeBlessingDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
|
||||
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">送上祝福</span>
|
||||
<span class="text-center text-[10px] text-[#8A8680] mb-8 tracking-[0.4em]">即 时 展 示</span>
|
||||
<div v-if="!blessingSubmitted" class="space-y-6">
|
||||
@@ -569,13 +570,32 @@
|
||||
@click="blessingForm.color = c.key"
|
||||
/>
|
||||
</div>
|
||||
<span class="block text-[10px] text-[#a88c6b] mb-2 mt-3 tracking-widest pl-1">渐变配色</span>
|
||||
<div class="danmaku-color-row">
|
||||
<button
|
||||
v-for="c in danmakuGradientOptions"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
class="danmaku-color-dot danmaku-color-dot--grad"
|
||||
:class="{ active: blessingForm.color === c.key }"
|
||||
:style="{ background: c.gradient }"
|
||||
:title="c.label"
|
||||
@click="blessingForm.color = c.key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="handleSubmitBlessing" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">发送祝福</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-10 flex flex-col items-center">
|
||||
<span class="text-3xl text-[#a88c6b] mb-6">♥</span>
|
||||
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">祝福已送达</span>
|
||||
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">已展示在弹幕中</span>
|
||||
<div v-else class="text-center py-8 flex flex-col items-center">
|
||||
<span class="text-3xl text-[#a88c6b] mb-5">♥</span>
|
||||
<span class="text-lg text-[#2c2c2c] mb-2 tracking-[0.2em] font-light">祝福已送达</span>
|
||||
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light mb-8">已展示在弹幕中</span>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full max-w-[280px] bg-[#a88c6b] text-white py-4 rounded-full tracking-[0.3em] text-[12px] hover:bg-[#967b5c] transition-all shadow-md"
|
||||
@click="closeBlessingDrawer"
|
||||
>确认</button>
|
||||
<span class="mt-4 text-[11px] text-[#8A8680] tracking-widest">{{ blessingCloseLeft }} 秒后自动关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -595,7 +615,7 @@ import { useAudio } from '@/composables/useAudio'
|
||||
import { toast } from '@/composables/useToast'
|
||||
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||
import { getClientId } from '@/utils/clientId'
|
||||
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||||
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||||
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
|
||||
import CanvasEffects from '@/components/CanvasEffects.vue'
|
||||
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
||||
@@ -605,7 +625,7 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||
const DEFAULTS = {
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
|
||||
danmakuEnabled: true, danmakuShowTime: true,
|
||||
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
|
||||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||||
firstScreenDuration: 4500, // 首屏停留时间:首屏效果结束后再开始自动滚动
|
||||
@@ -623,6 +643,9 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
|
||||
const showOpenInBrowser = ref(false)
|
||||
const showBlessingDrawer = ref(false)
|
||||
const blessingSubmitted = ref(false)
|
||||
const blessingCloseLeft = ref(0)
|
||||
let blessingCloseTimer = null
|
||||
let blessingCloseTick = null
|
||||
const showPlayerPanel = ref(false)
|
||||
const showVolumeSlider = ref(false)
|
||||
const showPanelTrackList = ref(false)
|
||||
@@ -714,8 +737,40 @@ const nineGridHold = ref(false)
|
||||
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
||||
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
|
||||
const danmakuColorOptions = DANMAKU_COLORS
|
||||
const danmakuGradientOptions = DANMAKU_GRADIENT_COLORS
|
||||
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
|
||||
|
||||
const clearBlessingAutoClose = () => {
|
||||
clearTimeout(blessingCloseTimer)
|
||||
clearInterval(blessingCloseTick)
|
||||
blessingCloseTimer = null
|
||||
blessingCloseTick = null
|
||||
blessingCloseLeft.value = 0
|
||||
}
|
||||
|
||||
const openBlessingDrawer = () => {
|
||||
clearBlessingAutoClose()
|
||||
blessingSubmitted.value = false
|
||||
showBlessingDrawer.value = true
|
||||
}
|
||||
|
||||
const closeBlessingDrawer = () => {
|
||||
clearBlessingAutoClose()
|
||||
showBlessingDrawer.value = false
|
||||
blessingSubmitted.value = false
|
||||
}
|
||||
|
||||
const scheduleBlessingAutoClose = () => {
|
||||
clearBlessingAutoClose()
|
||||
blessingCloseLeft.value = 5
|
||||
blessingCloseTick = setInterval(() => {
|
||||
blessingCloseLeft.value = Math.max(0, blessingCloseLeft.value - 1)
|
||||
}, 1000)
|
||||
blessingCloseTimer = setTimeout(() => {
|
||||
closeBlessingDrawer()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
|
||||
let freeScrollLastTs = 0
|
||||
let nineGridObserver = null
|
||||
@@ -723,8 +778,8 @@ let nineGridRevealTimer = null
|
||||
const nineGridDone = new Set()
|
||||
const BOTTOM_RETURN_DELAY = 30000
|
||||
const FREE_SCROLL_BASE_STEP = 1.2
|
||||
/** 分批入场已占时间,收尾再略停一下即可 */
|
||||
const NINE_GRID_REVEAL_HOLD_MS = 400
|
||||
/** 开启动画时,入场结束后短暂停一下再继续自动滚 */
|
||||
const NINE_GRID_REVEAL_HOLD_ANIM_MS = 520
|
||||
const NINE_GRID_FULL_RATIO = 0.9
|
||||
|
||||
const freeInterval = computed(() => {
|
||||
@@ -938,17 +993,22 @@ const canAutoAdvance = () =>
|
||||
const beginNineGridGate = (pIdx) => {
|
||||
if (nineGridDone.has(pIdx)) return
|
||||
nineGridDone.add(pIdx)
|
||||
nineGridHold.value = true
|
||||
// 仅开启动画时暂停自动滚动;关闭动画不挡滚动
|
||||
if (data.value.nineGridAnimate) nineGridHold.value = true
|
||||
nineGridActivate[pIdx] = true
|
||||
}
|
||||
|
||||
const onNineGridReady = (pIdx) => {
|
||||
if (!nineGridDone.has(pIdx)) return
|
||||
if (!data.value.nineGridAnimate) {
|
||||
nineGridHold.value = false
|
||||
return
|
||||
}
|
||||
clearTimeout(nineGridRevealTimer)
|
||||
nineGridRevealTimer = setTimeout(() => {
|
||||
nineGridHold.value = false
|
||||
nineGridRevealTimer = null
|
||||
}, NINE_GRID_REVEAL_HOLD_MS)
|
||||
}, NINE_GRID_REVEAL_HOLD_ANIM_MS)
|
||||
}
|
||||
|
||||
const bindNineGridObserver = () => {
|
||||
@@ -957,10 +1017,13 @@ const bindNineGridObserver = () => {
|
||||
if (typeof IntersectionObserver === 'undefined') return
|
||||
const nodes = scrollContainerRef.value?.querySelectorAll('[data-nine-grid]')
|
||||
if (!nodes?.length) return
|
||||
const animate = !!data.value.nineGridAnimate
|
||||
nineGridObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.intersectionRatio < NINE_GRID_FULL_RATIO) continue
|
||||
if (!entry.isIntersecting) continue
|
||||
// 开启动画:等几乎整页入屏;关闭:进入窗口即展示
|
||||
if (animate && entry.intersectionRatio < NINE_GRID_FULL_RATIO) continue
|
||||
const raw = entry.target.getAttribute('data-nine-grid')
|
||||
if (raw == null) continue
|
||||
const pIdx = Number(raw)
|
||||
@@ -968,7 +1031,9 @@ const bindNineGridObserver = () => {
|
||||
beginNineGridGate(pIdx)
|
||||
}
|
||||
},
|
||||
{ threshold: [0, 0.85, 1] }
|
||||
animate
|
||||
? { threshold: [0, 0.85, 1] }
|
||||
: { threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
|
||||
)
|
||||
nodes.forEach((el) => nineGridObserver.observe(el))
|
||||
}
|
||||
@@ -1109,8 +1174,7 @@ const pickAiStyle = async (target, style) => {
|
||||
aiBlessingTarget.value = target
|
||||
aiBlessingLoading.value = true
|
||||
try {
|
||||
const name = target === 'rsvp' ? rsvpForm.name.trim() : blessingForm.name.trim()
|
||||
const res = await generateAiBlessing({ name, style })
|
||||
const res = await generateAiBlessing({ style })
|
||||
const text = res.data?.text || ''
|
||||
if (!text) throw new Error('未生成内容')
|
||||
if (target === 'rsvp') rsvpForm.wishes = text
|
||||
@@ -1147,6 +1211,7 @@ const handleSubmitBlessing = async () => {
|
||||
if (res.code === 200) {
|
||||
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
|
||||
blessingSubmitted.value = true
|
||||
scheduleBlessingAutoClose()
|
||||
}
|
||||
else toast.error(res.error || '提交失败')
|
||||
} catch (e) {
|
||||
@@ -1253,6 +1318,7 @@ onUnmounted(() => {
|
||||
clearTimeout(introTimer)
|
||||
clearTimeout(scrollStartTimer)
|
||||
clearBottomReturn()
|
||||
clearBlessingAutoClose()
|
||||
})
|
||||
|
||||
watch(() => data.value.scrollMode, syncPageScrollMode)
|
||||
@@ -1264,6 +1330,13 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(
|
||||
() => data.value.nineGridAnimate,
|
||||
async () => {
|
||||
await nextTick()
|
||||
bindNineGridObserver()
|
||||
}
|
||||
)
|
||||
watch(freeScrollSignature, restartAutoScroll)
|
||||
watch(
|
||||
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||
@@ -1794,6 +1867,14 @@ watch(
|
||||
box-shadow: 0 0 0 2px rgba(168,140,107,0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.danmaku-color-dot--grad {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,0.55),
|
||||
0 2px 8px rgba(88,68,42,0.18),
|
||||
0 0 12px rgba(255,140,160,0.22);
|
||||
}
|
||||
.ai-bless-wrap { position: relative; }
|
||||
.ai-bless-btn {
|
||||
font-size: 10px; letter-spacing: 0.12em; color: #a88c6b;
|
||||
|
||||
Reference in New Issue
Block a user