增加多种效果
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user