增加多种效果

This commit is contained in:
2025-12-17 13:14:17 +08:00
parent 926e7b3386
commit d9cb736597
3 changed files with 240 additions and 420 deletions

View File

@@ -7,9 +7,23 @@
<!-- 胶片颗粒质感层 -->
<div class="film-grain"></div>
<!-- 高级缓冲层 (Premium Loading) -->
<!-- 1. 全局初始化加载层 (Load All First) -->
<transition name="fade">
<div v-if="isBuffering" class="buffering-layer">
<div v-if="isGlobalLoading" class="buffering-layer global">
<div class="loader-wrapper">
<div class="premium-loader"></div>
<div class="loading-text">
初始化 幻灯片...
<span class="progress-number">{{ loadingProgress }}%</span>
</div>
<div class="loading-sub">已准备 {{ loadedCount }} / {{ totalCount }}</div>
</div>
</div>
</transition>
<!-- 2. 播放时缓冲层 (如果网速慢) -->
<transition name="fade">
<div v-if="!isGlobalLoading && isBuffering" class="buffering-layer">
<div class="loader-wrapper">
<div class="premium-loader"></div>
<div class="loading-text">RENDERING</div>
@@ -17,9 +31,9 @@
</div>
</transition>
<!-- 顶部控制栏 -->
<!-- 顶部控制栏 (加载完成后显示) -->
<transition name="slide-down">
<div v-if="showControls" class="top-bar glass-panel">
<div v-if="!isGlobalLoading && showControls" class="top-bar glass-panel">
<div class="bar-left">
<button class="text-btn" @click="$emit('exit')">
<i class="fas fa-arrow-left"></i> <span class="btn-text">结束放映</span>
@@ -30,16 +44,12 @@
</div>
</div>
<div class="bar-right">
<!-- 播放/暂停大按钮 -->
<button class="icon-btn large" @click.stop="togglePlay" :title="isPlaying ? '暂停' : '播放'">
<i :class="isPlaying ? 'fas fa-pause' : 'fas fa-play'"></i>
</button>
<!-- 全屏按钮 -->
<button class="icon-btn" @click.stop="$emit('toggleFullscreen')" :title="isFullscreen ? '退出全屏' : '全屏'">
<i :class="isFullscreen ? 'fas fa-compress' : 'fas fa-expand'"></i>
</button>
<!-- 设置面板 -->
<div class="custom-dropdown-trigger" v-click-outside="closeSettings">
<button class="icon-btn" :class="{ active: showSettings }" @click.stop="toggleSettings">
@@ -49,39 +59,20 @@
<transition name="fade-scale">
<div v-if="showSettings" class="custom-dropdown-menu" @click.stop>
<div class="menu-header">放映设置</div>
<!-- 时长滑块 -->
<div class="menu-item">
<div class="label-row">
<span class="label">单张时长</span>
<span class="value highlight">{{ duration / 1000 }}</span>
</div>
<div class="custom-slider">
<input
type="range"
v-model.number="duration"
min="3000"
max="15000"
step="1000"
@input="onDurationChange"
>
<input type="range" v-model.number="duration" min="3000" max="15000" step="1000" @input="onDurationChange">
<div class="slider-fill" :style="{ width: getSliderPercent(duration, 3000, 15000) + '%' }"></div>
</div>
</div>
<!-- 动画类型 -->
<div class="menu-item">
<span class="label">PPT 转场风格</span>
<div class="pill-grid">
<div
v-for="(label, key) in animLabels"
:key="key"
class="pill-item"
:class="{ active: animationType === key }"
@click="animationType = key"
>
{{ label }}
</div>
<div v-for="(label, key) in animLabels" :key="key" class="pill-item" :class="{ active: animationType === key }" @click="animationType = key">{{ label }}</div>
</div>
</div>
</div>
@@ -92,19 +83,15 @@
</transition>
<!-- 核心舞台 -->
<div class="stage" @click="togglePlay">
<div class="stage" @click="togglePlay" v-if="!isGlobalLoading">
<transition :name="currentTransitionName" mode="in-out">
<div
v-if="currentPhoto && !isBuffering"
:key="currentIndex"
class="slide-layer"
>
<!-- 动画包裹层 -->
<div class="image-wrapper" :class="getCurrentAnimationClass(currentIndex)">
<img
:src="getImgUrl(currentPhoto.original || currentPhoto.compressed)"
alt="slide"
/>
<img :src="getImgUrl(currentPhoto.original || currentPhoto.compressed)" alt="slide" />
</div>
</div>
</transition>
@@ -112,28 +99,21 @@
<!-- 底部信息 -->
<transition name="slide-up">
<div v-if="showControls" class="bottom-bar">
<div v-if="!isGlobalLoading && showControls" class="bottom-bar">
<div class="slide-counter">
<span class="current">{{ formatNumber(currentIndex + 1) }}</span>
<span class="divider">/</span>
<span class="total">{{ formatNumber(photos.length) }}</span>
</div>
<!-- 进度条 -->
<div class="progress-track">
<div class="progress-bar" :style="{ width: progress + '%' }">
<div class="progress-glow"></div>
</div>
<div class="progress-bar" :style="{ width: progress + '%' }"><div class="progress-glow"></div></div>
</div>
</div>
</transition>
<!-- 隐形导航区 -->
<div class="nav-area left" @click.stop="prevSlide">
<div class="nav-hint"><i class="fas fa-chevron-left"></i></div>
</div>
<div class="nav-area right" @click.stop="nextSlide">
<div class="nav-hint"><i class="fas fa-chevron-right"></i></div>
</div>
<div v-if="!isGlobalLoading" class="nav-area left" @click.stop="prevSlide"><div class="nav-hint"><i class="fas fa-chevron-left"></i></div></div>
<div v-if="!isGlobalLoading" class="nav-area right" @click.stop="nextSlide"><div class="nav-hint"><i class="fas fa-chevron-right"></i></div></div>
</div>
</template>
@@ -152,21 +132,19 @@ const emit = defineEmits(['exit', 'toggleFullscreen', 'update:index'])
// --- 状态管理 ---
const currentIndex = ref(props.initialIndex)
const isPlaying = ref(true)
const isBuffering = ref(true) // 初始缓冲
const isBuffering = ref(false)
const isGlobalLoading = ref(true) // 全局加载状态
const loadingProgress = ref(0)
const loadedCount = ref(0)
const totalCount = computed(() => props.photos.length)
const duration = ref(5000)
const progress = ref(0)
const showControls = ref(true)
const showSettings = ref(false)
const animationType = ref('random')
const animLabels = {
'random': '随机特效',
'fade': '淡入淡出',
'zoom-fade': '镜头推进',
'slide-left': '左侧推入',
'wipe': '线性擦除',
'circle': '圆形揭示'
}
const animLabels = { 'random': '随机特效', 'fade': '淡入淡出', 'zoom-fade': '镜头推进', 'slide-left': '左侧推入', 'wipe': '线性擦除', 'circle': '圆形揭示' }
let lastFrameTime = 0
let accumulatedTime = 0
@@ -175,7 +153,8 @@ let controlsTimer = null
let isSwitching = false
const currentPhoto = computed(() => props.photos[currentIndex.value])
const currentBg = computed(() => currentPhoto.value ? getImgUrl(currentPhoto.value.compressed) : '')
// 加载期间给个默认背景或黑屏,避免报错
const currentBg = computed(() => (currentPhoto.value && !isGlobalLoading.value) ? getImgUrl(currentPhoto.value.compressed) : '')
const pptTransitions = ['ppt-fade', 'ppt-zoom-fade', 'ppt-slide-left', 'ppt-wipe', 'ppt-circle']
const currentTransitionName = computed(() => {
@@ -194,17 +173,16 @@ const getCurrentAnimationClass = (index) => {
return 'anim-ken-burns-4'
}
// const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
// 图片路径修正
const getImgUrl = (path) => path ? `/t-oss${path}` : ''
const formatNumber = (num) => num < 10 ? `0${num}` : num
const formatDate = (dateStr) => {
if(!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' })
}
const getSliderPercent = (val, min, max) => ((val - min) / (max - min)) * 100
// --- 核心:高级图片预加载器 ---
// --- 预加载器 ---
const loadImage = (url) => {
return new Promise((resolve) => {
const img = new Image()
@@ -222,6 +200,27 @@ const loadImage = (url) => {
})
}
// 全量预加载
const preloadAllImages = async () => {
const promises = props.photos.map(photo => {
const url = getImgUrl(photo.original || photo.compressed)
return loadImage(url).then(() => {
loadedCount.value++
loadingProgress.value = Math.floor((loadedCount.value / totalCount.value) * 100)
})
})
await Promise.all(promises)
// 全部加载完成后,稍微延迟进入
setTimeout(() => {
isGlobalLoading.value = false
startPlay() // 开始播放
handleUserInteraction()
}, 500)
}
// 动态预加载队列 (用于播放中)
const preloadQueue = (index) => {
const range = [1, 2]
range.forEach(offset => {
@@ -233,13 +232,13 @@ const preloadQueue = (index) => {
})
}
// --- 播放控制 ---
// --- 播放逻辑 ---
const loop = (timestamp) => {
if (!lastFrameTime) lastFrameTime = timestamp
const deltaTime = timestamp - lastFrameTime
lastFrameTime = timestamp
if (isPlaying.value && !isBuffering.value && !isSwitching) {
if (isPlaying.value && !isBuffering.value && !isSwitching && !isGlobalLoading.value) {
accumulatedTime += deltaTime
progress.value = Math.min((accumulatedTime / duration.value) * 100, 100)
if (accumulatedTime >= duration.value) {
@@ -252,13 +251,13 @@ const loop = (timestamp) => {
const handleAutoSwitch = async () => {
isSwitching = true
const nextIdx = (currentIndex.value + 1) % props.photos.length
// 即使预加载过,也检查一下(通常会立即返回)
const nextPhoto = props.photos[nextIdx]
const nextUrl = getImgUrl(nextPhoto.original || nextPhoto.compressed)
const bufferTimeout = setTimeout(() => { isBuffering.value = true }, 50)
const bufferTimeout = setTimeout(() => { isBuffering.value = true }, 100)
await loadImage(nextUrl)
clearTimeout(bufferTimeout)
isBuffering.value = false
@@ -272,14 +271,8 @@ const startPlay = () => {
if (!rafId) rafId = requestAnimationFrame(loop)
}
const pausePlay = () => {
isPlaying.value = false
}
const togglePlay = () => {
if (isPlaying.value) pausePlay()
else startPlay()
}
const pausePlay = () => { isPlaying.value = false }
const togglePlay = () => { isPlaying.value ? pausePlay() : startPlay() }
const nextSlide = (auto = false) => {
accumulatedTime = 0
@@ -295,12 +288,9 @@ const prevSlide = async () => {
const prevIdx = (currentIndex.value - 1 + props.photos.length) % props.photos.length
isSwitching = true
isBuffering.value = true
const prevPhoto = props.photos[prevIdx]
const bufferTimeout = setTimeout(() => { isBuffering.value = true }, 50)
await loadImage(getImgUrl(prevPhoto.original || prevPhoto.compressed))
clearTimeout(bufferTimeout)
isBuffering.value = false
isSwitching = false
@@ -308,53 +298,30 @@ const prevSlide = async () => {
emit('update:index', currentIndex.value)
}
const onDurationChange = () => {
accumulatedTime = 0
progress.value = 0
}
const onDurationChange = () => { accumulatedTime = 0; progress.value = 0 }
const handleUserInteraction = () => {
showControls.value = true
clearTimeout(controlsTimer)
if (isPlaying.value && !showSettings.value) {
controlsTimer = setTimeout(() => {
showControls.value = false
}, 3000)
controlsTimer = setTimeout(() => { showControls.value = false }, 3000)
}
}
const toggleSettings = () => {
showSettings.value = !showSettings.value
handleUserInteraction()
}
const closeSettings = () => {
showSettings.value = false
}
const toggleSettings = () => { showSettings.value = !showSettings.value; handleUserInteraction() }
const closeSettings = () => { showSettings.value = false }
const vClickOutside = {
mounted(el, binding) {
el.clickOutsideEvent = (event) => {
if (!(el === event.target || el.contains(event.target))) binding.value(event)
}
el.clickOutsideEvent = (event) => { if (!(el === event.target || el.contains(event.target))) binding.value(event) }
document.body.addEventListener('click', el.clickOutsideEvent)
},
unmounted(el) {
document.body.removeEventListener('click', el.clickOutsideEvent)
}
unmounted(el) { document.body.removeEventListener('click', el.clickOutsideEvent) }
}
onMounted(async () => {
if (props.photos.length > 0) {
const curPhoto = props.photos[currentIndex.value]
await loadImage(getImgUrl(curPhoto.original || curPhoto.compressed))
}
isBuffering.value = false
rafId = requestAnimationFrame(loop)
handleUserInteraction()
preloadQueue(currentIndex.value)
onMounted(() => {
// 不直接 startPlay而是先 preloadAll
preloadAllImages()
})
onUnmounted(() => {
@@ -362,103 +329,68 @@ onUnmounted(() => {
clearTimeout(controlsTimer)
})
watch(() => isPlaying.value, (val) => {
handleUserInteraction()
})
watch(() => isPlaying.value, (val) => { handleUserInteraction() })
</script>
<style scoped>
.cinematic-container {
position: fixed; inset: 0; background: #000; z-index: 200; overflow: hidden; cursor: none; font-family: 'Montserrat', sans-serif;
}
.cinematic-container { position: fixed; inset: 0; background: #000; z-index: 200; overflow: hidden; cursor: none; font-family: 'Montserrat', sans-serif; }
.cinematic-container:hover { cursor: default; }
/* Loading UI */
/* Loading UI (Unified) */
.buffering-layer {
position: absolute; inset: 0; z-index: 100;
display: flex; justify-content: center; align-items: center;
background: rgba(0,0,0,0.4); backdrop-filter: blur(10px);
}
.buffering-layer.global { background: #000; z-index: 200; } /* 全局加载时全黑背景 */
.loader-wrapper { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.premium-loader { width: 50px; height: 50px; position: relative; }
.premium-loader { width: 60px; height: 60px; position: relative; }
.premium-loader::before, .premium-loader::after {
content: ''; position: absolute; inset: 0; border-radius: 50%;
border: 2px solid transparent; border-top-color: #e63946;
}
.premium-loader::before { animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite; }
.premium-loader::after { animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse; inset: 6px; border-top-color: #fff; opacity: 0.8; }
.loading-text { font-size: 0.7rem; letter-spacing: 3px; color: rgba(255,255,255,0.6); font-weight: 600; animation: pulse 2s infinite; }
.premium-loader::after { animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse; inset: 8px; border-top-color: #fff; opacity: 0.8; }
.loading-text { font-size: 0.8rem; letter-spacing: 3px; color: rgba(255,255,255,0.8); font-weight: 600; animation: pulse 2s infinite; }
.progress-number { color: #e63946; margin-left: 8px; }
.loading-sub { font-size: 0.75rem; color: rgba(255,255,255,0.4); letter-spacing: 1px; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
/* Grain & Bg */
.film-grain {
position: absolute; inset: 0; z-index: 5; pointer-events: none; opacity: 0.07;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.ambient-bg {
position: absolute; top: -10%; left: -10%; width: 120%; height: 120%;
background-size: cover; background-position: center;
filter: blur(100px) brightness(0.4) saturate(1.2); z-index: 1; transition: background-image 1.5s ease;
}
.ambient-overlay {
position: absolute; inset: 0;
background: radial-gradient(circle at center, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.8) 100%); z-index: 2;
}
.film-grain { position: absolute; inset: 0; z-index: 5; pointer-events: none; opacity: 0.07; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); }
.ambient-bg { position: absolute; top: -10%; left: -10%; width: 120%; height: 120%; background-size: cover; background-position: center; filter: blur(100px) brightness(0.4) saturate(1.2); z-index: 1; transition: background-image 1.5s ease; }
.ambient-overlay { position: absolute; inset: 0; background: radial-gradient(circle at center, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.8) 100%); z-index: 2; }
/* Toolbar */
.top-bar {
position: absolute; top: 0; left: 0; right: 0; height: 90px;
display: flex; justify-content: space-between; align-items: center;
padding: 0 2.5rem; z-index: 50;
background: linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, transparent 100%);
}
.top-bar { position: absolute; top: 0; left: 0; right: 0; height: 90px; display: flex; justify-content: space-between; align-items: center; padding: 0 2.5rem; z-index: 50; background: linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, transparent 100%); }
.bar-left, .bar-right { display: flex; align-items: center; gap: 1.5rem; }
.text-btn {
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); color: #fff;
padding: 8px 16px; border-radius: 30px; cursor: pointer; transition: all 0.3s ease;
display: flex; align-items: center; gap: 8px; backdrop-filter: blur(5px);
}
.text-btn { background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); color: #fff; padding: 8px 16px; border-radius: 30px; cursor: pointer; transition: all 0.3s ease; display: flex; align-items: center; gap: 8px; backdrop-filter: blur(5px); }
.text-btn:hover { background: #e63946; border-color: #e63946; }
.meta-info { display: flex; flex-direction: column; justify-content: center; }
.slide-title { color: #fff; margin: 0; font-size: 1.1rem; font-weight: 500; letter-spacing: 1px; }
.slide-date { color: rgba(255,255,255,0.5); font-size: 0.8rem; }
.icon-btn {
width: 44px; height: 44px; border-radius: 50%;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8); font-size: 1.1rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.3s;
}
.icon-btn { width: 44px; height: 44px; border-radius: 50%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.8); font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s; }
.icon-btn:hover, .icon-btn.active { background: #fff; color: #000; box-shadow: 0 0 15px rgba(255,255,255,0.3); }
.icon-btn.large { width: 56px; height: 56px; font-size: 1.4rem; background: #e63946; border: none; color: #fff; }
.icon-btn.large:hover { transform: scale(1.1); background: #ff4d5a; }
/* Settings */
.custom-dropdown-trigger { position: relative; }
.custom-dropdown-menu {
position: absolute; top: 100%; right: 0; margin-top: 15px;
width: 280px; background: rgba(15, 15, 15, 0.95); backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px;
box-shadow: 0 20px 50px rgba(0,0,0,0.8); color: #fff; cursor: default;
}
.custom-dropdown-menu { position: absolute; top: 100%; right: 0; margin-top: 15px; width: 280px; background: rgba(15, 15, 15, 0.95); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px; box-shadow: 0 20px 50px rgba(0,0,0,0.8); color: #fff; cursor: default; }
.menu-header { font-size: 0.85rem; color: rgba(255,255,255,0.4); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 15px; }
.menu-item { margin-bottom: 20px; }
.label-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 0.9rem; }
.label { color: rgba(255,255,255,0.7); }
.highlight { color: #e63946; font-weight: bold; }
.custom-slider { position: relative; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; display: flex; align-items: center; }
.custom-slider input { position: absolute; width: 100%; height: 20px; opacity: 0; cursor: pointer; z-index: 2; margin: 0; }
.slider-fill { position: absolute; height: 100%; background: #e63946; border-radius: 2px; z-index: 1; }
.pill-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.pill-item {
text-align: center; padding: 6px 0; font-size: 0.85rem; color: rgba(255,255,255,0.6);
cursor: pointer; border-radius: 6px; background: rgba(255,255,255,0.05); transition: all 0.2s;
}
.pill-item { text-align: center; padding: 6px 0; font-size: 0.85rem; color: rgba(255,255,255,0.6); cursor: pointer; border-radius: 6px; background: rgba(255,255,255,0.05); transition: all 0.2s; }
.pill-item:hover { background: rgba(255,255,255,0.1); color: #fff; }
.pill-item.active { background: #e63946; color: #fff; }
@@ -496,26 +428,14 @@ watch(() => isPlaying.value, (val) => {
@keyframes fade-out { to { opacity: 0; } }
/* Bottom Bar */
.bottom-bar {
position: absolute; bottom: 0; left: 0; right: 0; height: 100px;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%);
display: flex; flex-direction: column; justify-content: flex-end; align-items: center;
padding-bottom: 2rem; z-index: 50;
}
.bottom-bar { position: absolute; bottom: 0; left: 0; right: 0; height: 100px; background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%); display: flex; flex-direction: column; justify-content: flex-end; align-items: center; padding-bottom: 2rem; z-index: 50; }
.slide-counter { margin-bottom: 1rem; font-size: 1.2rem; font-weight: 300; letter-spacing: 2px; }
.slide-counter .current { color: #fff; font-weight: 600; }
.slide-counter .divider { color: #e63946; margin: 0 8px; }
.slide-counter .total { color: rgba(255,255,255,0.5); }
.progress-track {
width: 60%; height: 3px; background: rgba(255,255,255,0.1); border-radius: 3px;
position: relative; overflow: hidden;
}
.progress-track { width: 60%; height: 3px; background: rgba(255,255,255,0.1); border-radius: 3px; position: relative; overflow: hidden; }
.progress-bar { height: 100%; background: #e63946; border-radius: 3px; position: relative; will-change: width; }
.progress-glow {
position: absolute; right: 0; top: 0; bottom: 0; width: 10px;
background: #fff; box-shadow: 0 0 10px #fff; opacity: 0.8;
}
.progress-glow { position: absolute; right: 0; top: 0; bottom: 0; width: 10px; background: #fff; box-shadow: 0 0 10px #fff; opacity: 0.8; }
.nav-area { position: absolute; top: 20%; bottom: 20%; width: 15%; z-index: 60; display: flex; align-items: center; justify-content: center; cursor: pointer; opacity: 0; transition: opacity 0.3s; }
.nav-area:hover { opacity: 1; }
@@ -534,56 +454,16 @@ watch(() => isPlaying.value, (val) => {
/* Mobile Adaptation */
@media (max-width: 768px) {
.top-bar {
height: 60px;
padding: 0 1rem;
}
.bar-left, .bar-right {
gap: 0.5rem;
}
.text-btn {
padding: 0;
width: 36px;
height: 36px;
border-radius: 50%;
justify-content: center;
}
.btn-text {
display: none;
}
.meta-info {
max-width: 120px;
}
.slide-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.slide-date {
display: none;
}
.icon-btn {
width: 36px;
height: 36px;
font-size: 0.9rem;
}
.icon-btn.large {
width: 44px;
height: 44px;
font-size: 1.1rem;
}
.bottom-bar {
height: 80px;
padding-bottom: 1.5rem;
}
/* Hide hover-based nav hints on mobile */
.top-bar { height: 60px; padding: 0 1rem; }
.bar-left, .bar-right { gap: 0.5rem; }
.text-btn { padding: 0; width: 36px; height: 36px; border-radius: 50%; justify-content: center; }
.btn-text { display: none; }
.meta-info { max-width: 120px; }
.slide-title { font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.slide-date { display: none; }
.icon-btn { width: 36px; height: 36px; font-size: 0.9rem; }
.icon-btn.large { width: 44px; height: 44px; font-size: 1.1rem; }
.bottom-bar { height: 80px; padding-bottom: 1.5rem; }
.nav-area:hover { opacity: 0; }
}
</style>

View File

@@ -6,15 +6,18 @@
:class="{ 'visible': isReady }"
></div>
<!-- 加载进度 -->
<!-- 加载进度 (UI 升级为 Premium Loader) -->
<transition name="fade">
<div v-if="!isReady" class="loading-overlay">
<div class="loader-content">
<div class="spinner"></div>
<!-- 双轨旋转 Loader -->
<div class="premium-loader"></div>
<div class="loading-text">
正在构建影像宇宙... {{ loadingProgress }}%
<div class="loading-sub">已加载 {{ loadedCount }} / {{ totalCount }} 碎片</div>
初始化 宇宙...
<span class="progress-number">{{ loadingProgress }}%</span>
</div>
<div class="loading-sub">已装载 {{ loadedCount }} / {{ totalCount }} 影像碎片</div>
</div>
</div>
</transition>
@@ -78,7 +81,7 @@ let animationId
let photoMeshes = []
let particleSystem
// const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
// 更新图片路径前缀
const getImgUrl = (path) => path ? `/t-oss${path}` : ''
// --- 核心优化:图片压缩与纹理生成 (带 CORS 代理回退) ---
@@ -202,7 +205,7 @@ const initThree = async () => {
// 全部加载完成后,标记为就绪,触发淡入动画
setTimeout(() => {
isReady.value = true
}, 500) // 稍微延迟一点,让进度条显示 100% 更有满足感
}, 500)
window.addEventListener('click', onMouseClick)
window.addEventListener('resize', onWindowResize)
@@ -210,63 +213,49 @@ const initThree = async () => {
animate()
}
// --- 构建照片球体 (逻辑修复:真实进度追踪) ---
// --- 构建照片球体 ---
const createPhotoSphere = async () => {
const vector = new THREE.Vector3()
const total = props.photos.length
// 纹理加载 Promise 队列
const texturePromises = []
for (let i = 0; i < total; i++) {
const photo = props.photos[i]
// 1. 创建 Mesh (先用白色占位,位置定好)
const phi = Math.acos(-1 + (2 * i) / total)
const theta = Math.sqrt(total * Math.PI) * phi
const radius = isMobile.value ? 300 : 380
const cardSize = isMobile.value ? 35 : 45
vector.setFromSphericalCoords(radius, phi, theta)
const geometry = new THREE.PlaneGeometry(cardSize, cardSize)
// 初始透明,加载好纹理再显示
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide,
map: null,
transparent: true,
opacity: 0 // 技巧:初始不可见
opacity: 0 // 初始不可见
})
const mesh = new THREE.Mesh(geometry, material)
mesh.position.copy(vector)
mesh.lookAt(new THREE.Vector3(0, 0, 0))
mesh.userData = { id: i, isPhoto: true }
scene.add(mesh)
photoMeshes.push(mesh)
// 2. 发起异步纹理加载,并追踪进度
const p = createCompressedTexture(getImgUrl(photo.compressed)).then(texture => {
mesh.material.map = texture
mesh.material.opacity = 1 // 纹理就绪,显示 Mesh
mesh.material.opacity = 1
mesh.material.needsUpdate = true
// 更新真实进度
loadedCount.value++
loadingProgress.value = Math.floor((loadedCount.value / total) * 100)
})
texturePromises.push(p)
// 分批处理 Mesh 创建,避免 UI 冻结
if (i % 10 === 0) await new Promise(r => setTimeout(r, 0))
}
// 3. 等待所有纹理加载完毕
await Promise.all(texturePromises)
loadingProgress.value = 100
}
@@ -275,7 +264,6 @@ const createPhotoSphere = async () => {
const createParticles = () => {
const geometry = new THREE.BufferGeometry()
const vertices = []
for (let i = 0; i < 1500; i++) {
vertices.push(
THREE.MathUtils.randFloatSpread(1500),
@@ -283,9 +271,7 @@ const createParticles = () => {
THREE.MathUtils.randFloatSpread(1500)
)
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
const material = new THREE.PointsMaterial({
color: 0xe63946,
size: 2,
@@ -293,24 +279,17 @@ const createParticles = () => {
transparent: true,
opacity: 0.5
})
particleSystem = new THREE.Points(geometry, material)
scene.add(particleSystem)
}
// --- 动画循环 ---
const animate = () => {
animationId = requestAnimationFrame(animate)
controls.update()
if (particleSystem) {
particleSystem.rotation.y += 0.0003
}
if (particleSystem) particleSystem.rotation.y += 0.0003
renderer.render(scene, camera)
}
// --- 交互事件 ---
const onWindowResize = () => {
if (!camera || !renderer) return
isMobile.value = window.innerWidth < 768
@@ -322,10 +301,8 @@ const onWindowResize = () => {
const onMouseClick = (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(photoMeshes)
if (intersects.length > 0) {
const target = intersects[0].object
if (target.userData.isPhoto) {
@@ -346,136 +323,66 @@ onUnmounted(() => {
window.removeEventListener('click', onMouseClick)
window.removeEventListener('resize', onWindowResize)
if (animationId) cancelAnimationFrame(animationId)
if (scene) {
scene.traverse((object) => {
if (object.geometry) object.geometry.dispose()
if (object.material) {
if (object.material.map) object.material.map.dispose()
object.material.dispose()
}
})
}
if (scene) scene.clear()
if (renderer) renderer.dispose()
})
</script>
<style scoped>
.planet-container {
position: fixed;
inset: 0;
background: #000;
z-index: 100;
overflow: hidden;
position: fixed; inset: 0; background: #000; z-index: 100; overflow: hidden;
}
.planet-toolbar {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 70px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
z-index: 10;
background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.planet-title {
color: #fff;
font-family: 'Montserrat', sans-serif;
font-size: 1.2rem;
letter-spacing: 2px;
text-transform: uppercase;
}
.toolbar-hint {
color: rgba(255,255,255,0.6);
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-btn {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn:hover {
background: #e63946;
border-color: #e63946;
}
/* 3D 容器:初始隐藏,淡入显示 */
.canvas-wrapper {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 1.5s ease-in-out; /* 优雅的淡入时间 */
}
.canvas-wrapper.visible {
opacity: 1;
}
/* Loading 遮罩 */
/* Loading 遮罩 (Premium Style) */
.loading-overlay {
position: absolute;
inset: 0;
background: #000; /* 纯黑背景遮挡 */
z-index: 20;
display: flex;
justify-content: center;
align-items: center;
position: absolute; inset: 0; z-index: 20;
display: flex; justify-content: center; align-items: center;
background: #000;
}
.loader-content {
text-align: center;
color: #e63946;
}
.loader-content { display: flex; flex-direction: column; align-items: center; gap: 1.5rem; }
.spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(230, 57, 70, 0.3);
border-top-color: #e63946;
border-radius: 50%;
animation: spin 1s infinite linear;
margin: 0 auto 1rem;
/* 双轨旋转 Loader */
.premium-loader { width: 60px; height: 60px; position: relative; }
.premium-loader::before, .premium-loader::after {
content: ''; position: absolute; inset: 0; border-radius: 50%;
border: 2px solid transparent; border-top-color: #e63946;
}
.premium-loader::before { animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite; }
.premium-loader::after { animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse; inset: 8px; border-top-color: #fff; opacity: 0.8; }
.loading-text {
font-family: 'Montserrat', monospace;
font-size: 1.1rem;
letter-spacing: 1px;
color: #fff;
margin-top: 1rem;
font-family: 'Montserrat', monospace; font-size: 0.8rem; letter-spacing: 2px;
color: #fff; font-weight: 600; text-align: center;
}
.progress-number { color: #e63946; margin-left: 8px; }
.loading-sub {
font-size: 0.85rem;
color: rgba(255,255,255,0.5);
margin-top: 0.5rem;
}
.loading-sub { font-size: 0.75rem; color: rgba(255,255,255,0.4); letter-spacing: 1px; }
@keyframes spin {
to { transform: rotate(360deg); }
@keyframes spin { to { transform: rotate(360deg); } }
/* Toolbar & Icons */
.planet-toolbar {
position: absolute; top: 0; left: 0; width: 100%; height: 70px;
display: flex; justify-content: space-between; align-items: center;
padding: 0 2rem; z-index: 10; background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
}
.toolbar-left { display: flex; align-items: center; gap: 1rem; }
.planet-title { color: #fff; font-family: 'Montserrat', sans-serif; font-size: 1.2rem; letter-spacing: 2px; text-transform: uppercase; }
.toolbar-hint { color: rgba(255,255,255,0.6); font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; }
.icon-btn {
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #fff;
padding: 8px 16px; border-radius: 20px; cursor: pointer; transition: all 0.3s;
display: flex; align-items: center; gap: 8px;
}
.icon-btn:hover { background: #e63946; border-color: #e63946; }
/* 3D 容器 */
.canvas-wrapper {
width: 100%; height: 100%; opacity: 0; transition: opacity 1.5s ease-in-out;
}
.canvas-wrapper.visible { opacity: 1; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.8s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }

View File

@@ -1,8 +1,22 @@
<template>
<div class="swipe-container" @mousemove="handleMouseMove" @click="handleMouseMove">
<!-- 顶部工具栏 -->
<!-- 全局初始化加载层 -->
<transition name="fade">
<div v-if="isGlobalLoading" class="buffering-layer global">
<div class="loader-wrapper">
<div class="premium-loader"></div>
<div class="loading-text">
初始化 画廊...
<span class="progress-number">{{ loadingProgress }}%</span>
</div>
<div class="loading-sub">已准备 {{ loadedCount }} / {{ totalCount }}</div>
</div>
</div>
</transition>
<!-- 顶部工具栏 (加载完成后显示) -->
<transition name="slide-down">
<div v-if="showToolbar" class="toolbar glass-panel">
<div v-if="!isGlobalLoading && showToolbar" class="toolbar glass-panel">
<div class="toolbar-left">
<button class="icon-btn" @click="$emit('close')">
<i class="fas fa-arrow-left"></i>
@@ -11,22 +25,16 @@
</div>
<div class="toolbar-right">
<!-- 核心入口播放幻灯片 -->
<button class="play-btn" @click="$emit('startCinematic')">
<i class="fas fa-play"></i> <span>放映</span>
</button>
<!-- 全屏按钮 -->
<button class="icon-btn" @click.stop="$emit('toggleFullscreen')" :title="isFullscreen ? '退出全屏' : '全屏'">
<i :class="isFullscreen ? 'fas fa-compress' : 'fas fa-expand'"></i>
</button>
<!-- 设置菜单 -->
<div class="custom-dropdown-trigger" v-click-outside="closeSettings">
<button class="icon-btn" :class="{active: showSettings}" @click.stop="toggleSettings">
<i class="fas fa-magic"></i>
</button>
<transition name="fade-scale">
<div v-if="showSettings" class="custom-dropdown-menu">
<div class="menu-header">切换效果</div>
@@ -51,8 +59,9 @@
</div>
</transition>
<!-- Swiper 主体 -->
<!-- Swiper 主体 (加载完成后显示) -->
<swiper
v-if="!isGlobalLoading"
:key="swiperKey"
:modules="swiperModules"
:effect="currentEffect"
@@ -60,10 +69,7 @@
:centeredSlides="true"
:slidesPerView="1"
:initialSlide="initialIndex"
:navigation="{
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
}"
:navigation="{ nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' }"
:keyboard="{ enabled: true }"
:zoom="true"
:speed="600"
@@ -72,23 +78,14 @@
>
<swiper-slide v-for="(photo, index) in photos" :key="index">
<div class="swiper-zoom-container">
<!-- 高级加载状态 -->
<transition name="fade">
<div v-if="!imageLoadState[index]" class="slide-spinner">
<div class="premium-loader-small"></div>
</div>
</transition>
<!-- 每个 Slide 的图片 -->
<img
:src="getImgUrl(photo.original || photo.compressed)"
loading="lazy"
@load="onImageLoad(index)"
:style="{ opacity: imageLoadState[index] ? 1 : 0 }"
/>
</div>
</swiper-slide>
<!-- 自定义导航按钮 -->
<div class="swiper-button-prev custom-nav"></div>
<div class="swiper-button-next custom-nav"></div>
</swiper>
@@ -96,7 +93,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, EffectCube, EffectCoverflow, EffectCards, Navigation, Keyboard, Zoom } from 'swiper/modules';
import 'swiper/css';
@@ -119,7 +116,11 @@ const swiperModules = [EffectFade, EffectCube, EffectCoverflow, EffectCards, Nav
const swiperKey = ref(0)
const currentIndex = ref(props.initialIndex)
const currentEffect = ref('slide')
const imageLoadState = reactive({})
const isGlobalLoading = ref(true)
const loadingProgress = ref(0)
const loadedCount = ref(0)
const totalCount = computed(() => props.photos.length)
const effectLabels = {
'slide': '经典滑动',
@@ -144,7 +145,6 @@ const showToolbar = ref(true)
const showSettings = ref(false)
let toolbarTimer = null
// const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
const getImgUrl = (path) => path ? `/t-oss${path}` : ''
const onSlideChange = (swiper) => {
@@ -152,10 +152,6 @@ const onSlideChange = (swiper) => {
emit('update:index', swiper.activeIndex)
}
const onImageLoad = (index) => {
imageLoadState[index] = true
}
const changeEffect = (effect) => {
currentEffect.value = effect
swiperKey.value++
@@ -182,6 +178,41 @@ const closeSettings = () => {
showSettings.value = false
}
// 预加载所有图片
const loadImage = (url) => {
return new Promise((resolve) => {
const img = new Image()
img.src = url
if (img.decode) {
img.decode().then(() => resolve()).catch(() => resolve());
} else {
if (img.complete) {
resolve()
} else {
img.onload = () => resolve()
img.onerror = () => resolve()
}
}
})
}
const preloadAllImages = async () => {
const promises = props.photos.map(photo => {
const url = getImgUrl(photo.original || photo.compressed)
return loadImage(url).then(() => {
loadedCount.value++
loadingProgress.value = Math.floor((loadedCount.value / totalCount.value) * 100)
})
})
await Promise.all(promises)
setTimeout(() => {
isGlobalLoading.value = false
handleMouseMove()
}, 500)
}
const vClickOutside = {
mounted(el, binding) {
el.clickOutsideEvent = function(event) {
@@ -197,7 +228,7 @@ const vClickOutside = {
}
onMounted(() => {
handleMouseMove()
preloadAllImages()
})
onUnmounted(() => {
@@ -211,25 +242,27 @@ onUnmounted(() => {
background: #000; z-index: 100;
}
/* 高级 Loading 样式 (小型版) */
.slide-spinner {
position: absolute; inset: 0; display: flex; justify-content: center; align-items: center;
background: transparent;
/* Loading UI (Copied from CinematicSlideshow for consistency) */
.buffering-layer {
position: absolute; inset: 0; z-index: 200;
display: flex; justify-content: center; align-items: center;
background: #000;
}
.premium-loader-small {
width: 40px; height: 40px; position: relative;
}
.premium-loader-small::before, .premium-loader-small::after {
.loader-wrapper { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.premium-loader { width: 60px; height: 60px; position: relative; }
.premium-loader::before, .premium-loader::after {
content: ''; position: absolute; inset: 0; border-radius: 50%;
border: 2px solid transparent; border-top-color: #e63946;
}
.premium-loader-small::before { animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite; }
.premium-loader-small::after { animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse; inset: 5px; border-top-color: #fff; opacity: 0.8; }
.premium-loader::before { animation: spin 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite; }
.premium-loader::after { animation: spin 1s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite reverse; inset: 8px; border-top-color: #fff; opacity: 0.8; }
.loading-text { font-size: 0.8rem; letter-spacing: 3px; color: rgba(255,255,255,0.8); font-weight: 600; animation: pulse 2s infinite; }
.progress-number { color: #e63946; margin-left: 8px; }
.loading-sub { font-size: 0.75rem; color: rgba(255,255,255,0.4); letter-spacing: 1px; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
/* 保持其他样式不变 */
/* Toolbar */
.toolbar {
position: absolute; top: 0; left: 0; width: 100%; height: 70px;
display: flex; justify-content: space-between; align-items: center;
@@ -260,7 +293,7 @@ onUnmounted(() => {
.fullscreen-swiper { width: 100%; height: 100%; }
.swiper-slide { display: flex; justify-content: center; align-items: center; background: #000; }
.swiper-slide img { max-width: 100%; max-height: 100%; object-fit: contain; transition: opacity 0.5s ease; } /* 增加图片淡入时间 */
.swiper-slide img { max-width: 100%; max-height: 100%; object-fit: contain; }
:deep(.swiper-button-next), :deep(.swiper-button-prev) { color: rgba(255,255,255,0.5); transition: color 0.3s; }
:deep(.swiper-button-next:hover), :deep(.swiper-button-prev:hover) { color: #fff; }