增加多种效果

This commit is contained in:
2025-12-12 11:04:13 +08:00
parent 967091b3d7
commit 590046d601
2 changed files with 155 additions and 28 deletions

View File

@@ -7,10 +7,13 @@
<!-- 胶片颗粒质感层 -->
<div class="film-grain"></div>
<!-- 缓冲/加载指示器 -->
<!-- 高级缓冲层 (Premium Loading) -->
<transition name="fade">
<div v-if="isBuffering" class="buffering-layer">
<div class="spinner-border"></div>
<div class="loader-wrapper">
<div class="premium-loader"></div>
<div class="loading-text">RENDERING</div>
</div>
</div>
</transition>
@@ -19,7 +22,7 @@
<div v-if="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>
<i class="fas fa-arrow-left"></i> <span class="btn-text">结束放映</span>
</button>
<div class="meta-info">
<h3 class="slide-title">{{ title }}</h3>
@@ -32,7 +35,7 @@
<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>
@@ -200,16 +203,20 @@ const formatDate = (dateStr) => {
const getSliderPercent = (val, min, max) => ((val - min) / (max - min)) * 100
// --- 核心:预加载 ---
// --- 核心:高级图片预加载 ---
const loadImage = (url) => {
return new Promise((resolve) => {
const img = new Image()
img.src = url
if (img.complete) {
resolve()
if (img.decode) {
img.decode().then(() => resolve()).catch(() => resolve());
} else {
img.onload = () => resolve()
img.onerror = () => resolve()
if (img.complete) {
resolve()
} else {
img.onload = () => resolve()
img.onerror = () => resolve()
}
}
})
}
@@ -247,8 +254,10 @@ const handleAutoSwitch = async () => {
const nextPhoto = props.photos[nextIdx]
const nextUrl = getImgUrl(nextPhoto.original || nextPhoto.compressed)
const bufferTimeout = setTimeout(() => { isBuffering.value = true }, 100)
const bufferTimeout = setTimeout(() => { isBuffering.value = true }, 50)
await loadImage(nextUrl)
clearTimeout(bufferTimeout)
isBuffering.value = false
@@ -284,10 +293,15 @@ const prevSlide = async () => {
progress.value = 0
const prevIdx = (currentIndex.value - 1 + props.photos.length) % props.photos.length
isBuffering.value = true
isSwitching = 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
currentIndex.value = prevIdx
emit('update:index', currentIndex.value)
@@ -358,23 +372,29 @@ watch(() => isPlaying.value, (val) => {
}
.cinematic-container:hover { cursor: default; }
/* Loading UI */
.buffering-layer {
position: absolute; inset: 0; z-index: 100;
display: flex; justify-content: center; align-items: center;
background: rgba(0,0,0,0.3); backdrop-filter: blur(5px);
background: rgba(0,0,0,0.4); backdrop-filter: blur(10px);
}
.spinner-border {
width: 50px; height: 50px;
border: 4px solid rgba(255,255,255,0.1); border-top-color: #e63946; border-radius: 50%;
animation: spin 1s linear infinite;
.loader-wrapper { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.premium-loader { width: 50px; height: 50px; 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; }
@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;
@@ -385,6 +405,7 @@ watch(() => isPlaying.value, (val) => {
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;
@@ -414,6 +435,7 @@ watch(() => isPlaying.value, (val) => {
.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;
@@ -439,6 +461,7 @@ watch(() => isPlaying.value, (val) => {
.pill-item:hover { background: rgba(255,255,255,0.1); color: #fff; }
.pill-item.active { background: #e63946; color: #fff; }
/* Stage */
.stage { position: absolute; inset: 0; z-index: 10; }
.slide-layer { position: absolute; inset: 0; display: flex; justify-content: center; align-items: center; overflow: hidden; }
.image-wrapper { width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; }
@@ -471,6 +494,7 @@ watch(() => isPlaying.value, (val) => {
@keyframes circle-in { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(150% at 50% 50%); } }
@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%);
@@ -506,4 +530,59 @@ watch(() => isPlaying.value, (val) => {
.fade-enter-from, .fade-leave-to { opacity: 0; }
.fade-scale-enter-active, .fade-scale-leave-active { transition: all 0.2s; }
.fade-scale-enter-from, .fade-scale-leave-to { opacity: 0; transform: scale(0.95) translateY(-10px); }
/* 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 */
.nav-area:hover { opacity: 0; }
}
</style>

View File

@@ -72,10 +72,12 @@
>
<swiper-slide v-for="(photo, index) in photos" :key="index">
<div class="swiper-zoom-container">
<!-- 每个 Slide 独立的 Loading 状态 -->
<div v-if="!imageLoadState[index]" class="slide-spinner">
<div class="spinner-simple"></div>
</div>
<!-- 高级加载状态 -->
<transition name="fade">
<div v-if="!imageLoadState[index]" class="slide-spinner">
<div class="premium-loader-small"></div>
</div>
</transition>
<img
:src="getImgUrl(photo.original || photo.compressed)"
@@ -117,7 +119,6 @@ const swiperModules = [EffectFade, EffectCube, EffectCoverflow, EffectCards, Nav
const swiperKey = ref(0)
const currentIndex = ref(props.initialIndex)
const currentEffect = ref('slide')
// 记录每张图片的加载状态: { [index]: boolean }
const imageLoadState = reactive({})
const effectLabels = {
@@ -209,17 +210,25 @@ onUnmounted(() => {
background: #000; z-index: 100;
}
/* Loading Spinner for individual slides */
/* 高级 Loading 样式 (小型版) */
.slide-spinner {
position: absolute; inset: 0; display: flex; justify-content: center; align-items: center;
background: transparent;
}
.spinner-simple {
width: 40px; height: 40px;
border: 3px solid rgba(255,255,255,0.1); border-top-color: #e63946;
border-radius: 50%; animation: spin 0.8s linear infinite;
.premium-loader-small {
width: 40px; height: 40px; position: relative;
}
.premium-loader-small::before, .premium-loader-small::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; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 保持其他样式不变 */
.toolbar {
position: absolute; top: 0; left: 0; width: 100%; height: 70px;
display: flex; justify-content: space-between; align-items: center;
@@ -250,7 +259,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.3s ease; }
.swiper-slide img { max-width: 100%; max-height: 100%; object-fit: contain; transition: opacity 0.5s ease; } /* 增加图片淡入时间 */
: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; }
@@ -280,4 +289,43 @@ onUnmounted(() => {
.slide-down-enter-from, .slide-down-leave-to { transform: translateY(-100%); opacity: 0; }
.fade-scale-enter-active, .fade-scale-leave-active { transition: all 0.2s; }
.fade-scale-enter-from, .fade-scale-leave-to { opacity: 0; transform: scale(0.95) translateY(-10px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* 移动端适配 */
@media (max-width: 768px) {
.toolbar {
height: 60px;
padding: 0 1rem;
}
.counter {
font-size: 0.9rem;
}
.icon-btn {
width: 36px;
height: 36px;
font-size: 0.9rem;
}
.play-btn {
padding: 6px 12px;
font-size: 0.85rem;
}
/* 移动端隐藏文字,仅留播放图标 */
.play-btn span {
display: none;
}
.toolbar-left, .toolbar-right {
gap: 0.5rem;
}
/* 隐藏左右切换箭头,移动端主要靠滑动 */
:deep(.swiper-button-next), :deep(.swiper-button-prev) {
display: none;
}
}
</style>