增加多种效果

This commit is contained in:
2025-12-12 10:58:07 +08:00
parent d25292ed6c
commit 967091b3d7
11 changed files with 3049 additions and 809 deletions

View File

@@ -9,8 +9,6 @@
"preview": "vite preview"
},
"dependencies": {
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -21,6 +19,9 @@
"lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",
"tailwindcss": "^4.1.4",
"three": "^0.182.0",
"vue": "^3.5.13",
"vue-router": "^4.2.5"
},
"devDependencies": {

33
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
tailwindcss:
specifier: ^4.1.4
version: 4.1.4
three:
specifier: ^0.182.0
version: 0.182.0
vue:
specifier: ^3.5.13
version: 3.5.13
@@ -299,36 +302,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -386,56 +395,67 @@ packages:
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.0':
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.0':
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.0':
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.0':
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.0':
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.0':
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.0':
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
@@ -493,24 +513,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.4':
resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.4':
resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.4':
resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.4':
resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==}
@@ -828,24 +852,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.29.2:
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.29.2:
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.29.2:
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.29.2:
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
@@ -982,6 +1010,9 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
three@0.182.0:
resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==}
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@@ -1864,6 +1895,8 @@ snapshots:
tapable@2.2.1: {}
three@0.182.0: {}
throttle-debounce@5.0.2: {}
tinyglobby@0.2.13:

View File

@@ -1,36 +1,15 @@
<template>
<div id="app">
<!-- Snowflake effect as global background -->
<div class="snowflakes" aria-hidden="true">
<div class="snowflake" v-for="i in 50" :key="i"></div>
</div>
<!-- 高性能 Canvas 雪花背景 -->
<SnowEffect />
<!-- Router view for page content -->
<!-- 路由视图 -->
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
// Create snowflake animation
const snowflakes = document.querySelectorAll('.snowflake')
snowflakes.forEach((snowflake, index) => {
const delay = Math.random() * 5
const duration = 3 + Math.random() * 15
const size = 0.5 + Math.random() * 0.8
const opacity = 0.3 + Math.random() * 0.7
const leftPosition = Math.random() * 100
snowflake.style.left = leftPosition + '%'
snowflake.style.animationDelay = delay + 's'
snowflake.style.animationDuration = duration + 's'
snowflake.style.fontSize = size + 'rem'
snowflake.style.opacity = opacity
})
})
import SnowEffect from '@/components/SnowEffect.vue'
</script>
<style>
@@ -63,6 +42,7 @@ body {
background: #0b0000; /* Deep black background */
}
/* 背景特效保持不变,营造氛围 */
body::before {
content: '';
position: fixed;
@@ -97,57 +77,14 @@ body::after {
}
@keyframes glowEffect {
0% {
opacity: 0.6;
background-position: 0% 0%;
}
33% {
opacity: 0.7;
background-position: 30% 30%;
}
66% {
opacity: 0.8;
background-position: 70% 70%;
}
100% {
opacity: 0.9;
background-position: 100% 100%;
}
0% { opacity: 0.6; background-position: 0% 0%; }
33% { opacity: 0.7; background-position: 30% 30%; }
66% { opacity: 0.8; background-position: 70% 70%; }
100% { opacity: 0.9; background-position: 100% 100%; }
}
#app {
position: relative;
min-height: 100vh;
}
/* Snowflake animation styles */
.snowflakes {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.snowflake {
position: absolute;
top: -10px;
color: rgba(255, 200, 200, 0.8);
user-select: none;
pointer-events: none;
opacity: 0.5;
text-shadow: 0 0 10px rgba(230, 57, 70, 0.5);
animation: fall linear infinite;
}
@keyframes fall {
0% {
transform: translateY(-100vh) rotate(0deg);
}
100% {
transform: translateY(100vh) rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div class="gallery-view">
<div class="gallery-header">
<h2 class="detail-title">{{ title }}</h2>
<slot name="header-actions"></slot>
</div>
<a-image-preview-group>
<div class="photo-grid">
<div
v-for="(photo, index) in photos"
:key="photo.id || index"
class="photo-card"
ref="cardRefs"
:data-src="getImgUrl(photo.compressed)"
:data-index="index"
>
<!--
优化策略
1. 列表展示使用 canvas 生成的内存缩略图 (thumbnailSrc)减少显存占用
2. 预览大图点击时使用 AntD 预览原始大图
3. 骨架屏加载完成前显示占位
-->
<a-image
v-if="thumbnails[index]"
:src="thumbnails[index]"
:preview="{
src: getImgUrl(photo.original || photo.compressed),
mask: '查看大图'
}"
class="gallery-img"
fallback="/error-img.png"
:placeholder="true"
/>
<!-- 加载占位 -->
<div v-else class="img-placeholder">
<i class="fas fa-image"></i>
</div>
</div>
</div>
</a-image-preview-group>
<!-- 触底检测 -->
<div ref="loadTrigger" class="scroll-trigger">
<div v-if="loadingMore" class="mini-loader">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
<div v-if="!hasMore && photos.length > 0" class="end-text">
<span>THE END · 美好已全部呈现</span>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
const props = defineProps({
photos: {
type: Array,
default: () => []
},
title: String,
hasMore: Boolean,
loadingMore: Boolean
})
const emit = defineEmits(['loadMore'])
const loadTrigger = ref(null)
const cardRefs = ref([])
const thumbnails = ref({}) // 存储压缩后的 Blob URL
let observer = null
let imageObserver = null
const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
// --- 核心优化:浏览器端 Canvas 压缩 ---
// 目的:虽然网络流量没变(还是下载了原图),但渲染到 DOM 上的是 resizing 后的 canvas blob
// 这极大降低了大量 <img> 标签对 GPU 显存的占用,解决滚动掉帧问题。
const compressImage = async (url) => {
return new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.src = url
img.decoding = 'async' // 异步解码,不阻塞主线程
img.onload = () => {
const canvas = document.createElement('canvas')
// 设置缩略图尺寸,例如 300px 宽,保持比例
const scale = 300 / img.width
canvas.width = 300
canvas.height = img.height * scale
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// 导出为低质量 JPEG Blob
canvas.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob))
} else {
resolve(url) // 降级策略
}
}, 'image/jpeg', 0.7)
}
img.onerror = () => resolve(url)
})
}
// 图片懒加载 + 压缩处理器
const initImageObserver = () => {
if (imageObserver) imageObserver.disconnect()
imageObserver = new IntersectionObserver((entries) => {
entries.forEach(async entry => {
if (entry.isIntersecting) {
const target = entry.target
const src = target.getAttribute('data-src')
const index = target.getAttribute('data-index')
if (src && !thumbnails.value[index]) {
// 开始处理图片
const compressedUrl = await compressImage(src)
thumbnails.value[index] = compressedUrl
imageObserver.unobserve(target) // 处理完即停止观察
}
}
})
}, { rootMargin: '200px' }) // 提前 200px 开始处理
// 观察所有卡片
if (cardRefs.value) {
cardRefs.value.forEach(el => {
if (el) imageObserver.observe(el)
})
}
}
// 触底加载
const initScrollObserver = () => {
if (observer) observer.disconnect()
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && props.hasMore && !props.loadingMore) {
emit('loadMore')
}
}, { threshold: 0.1, rootMargin: '100px' })
if (loadTrigger.value) {
observer.observe(loadTrigger.value)
}
}
watch(() => props.photos, async () => {
await nextTick()
initScrollObserver()
initImageObserver() // 新数据来了重新绑定图片观察器
}, { deep: true })
onMounted(() => {
setTimeout(() => {
initScrollObserver()
initImageObserver()
}, 200)
})
onUnmounted(() => {
if (observer) observer.disconnect()
if (imageObserver) imageObserver.disconnect()
// 释放 Blob URL 内存
Object.values(thumbnails.value).forEach(url => URL.revokeObjectURL(url))
})
</script>
<style scoped>
.gallery-view {
padding: 2rem;
max-width: 1800px;
margin: 0 auto;
}
.gallery-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
flex-wrap: wrap;
gap: 1rem;
}
.detail-title {
font-size: 2.5rem;
font-weight: 300;
color: var(--light-color);
margin: 0;
letter-spacing: 2px;
text-transform: uppercase;
text-shadow: 0 0 10px rgba(230, 57, 70, 0.2);
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
animation: fadeIn 0.6s ease-out;
}
.photo-card {
aspect-ratio: 1;
overflow: hidden;
border-radius: 4px;
position: relative;
background: #1a1a1a;
transition: all 0.4s ease;
border: 1px solid rgba(255,255,255,0.05);
}
.photo-card:hover {
transform: translateY(-5px) scale(1.02);
z-index: 2;
border-color: #e63946;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.img-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #111;
color: #333;
font-size: 2rem;
}
:deep(.ant-image) {
width: 100%;
height: 100%;
display: block;
}
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
/* 优化渲染属性 */
image-rendering: -webkit-optimize-contrast;
transform: translateZ(0); /* 开启硬件加速 */
}
/* 触底加载与结束语 */
.scroll-trigger {
min-height: 100px;
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
opacity: 0.7;
}
.mini-loader .dot {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 3px;
background: #e63946;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.mini-loader .dot:nth-child(1) { animation-delay: -0.32s; }
.mini-loader .dot:nth-child(2) { animation-delay: -0.16s; }
.end-text {
font-family: 'Montserrat', sans-serif;
font-size: 0.9rem;
letter-spacing: 3px;
color: rgba(255,255,255,0.3);
text-transform: uppercase;
position: relative;
padding: 0 20px;
}
.end-text::before, .end-text::after {
content: '';
position: absolute;
top: 50%;
width: 30px;
height: 1px;
background: rgba(255,255,255,0.2);
}
.end-text::before { left: -20px; }
.end-text::after { right: -20px; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
</style>

View File

@@ -0,0 +1,509 @@
<template>
<div class="cinematic-container" @mousemove="handleUserInteraction" @click="handleUserInteraction">
<!-- 动态模糊背景 -->
<div class="ambient-bg" :style="{ backgroundImage: `url(${currentBg})` }"></div>
<div class="ambient-overlay"></div>
<!-- 胶片颗粒质感层 -->
<div class="film-grain"></div>
<!-- 缓冲/加载指示器 -->
<transition name="fade">
<div v-if="isBuffering" class="buffering-layer">
<div class="spinner-border"></div>
</div>
</transition>
<!-- 顶部控制栏 -->
<transition name="slide-down">
<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>
</button>
<div class="meta-info">
<h3 class="slide-title">{{ title }}</h3>
<span class="slide-date" v-if="currentPhoto">{{ formatDate(currentPhoto.created_at) }}</span>
</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">
<i class="fas fa-sliders-h"></i>
</button>
<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"
>
<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>
</div>
</div>
</transition>
</div>
</div>
</div>
</transition>
<!-- 核心舞台 -->
<div class="stage" @click="togglePlay">
<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"
/>
</div>
</div>
</transition>
</div>
<!-- 底部信息 -->
<transition name="slide-up">
<div v-if="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>
</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>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
photos: { type: Array, default: () => [] },
title: String,
isFullscreen: Boolean,
initialIndex: { type: Number, default: 0 }
})
const emit = defineEmits(['exit', 'toggleFullscreen', 'update:index'])
// --- 状态管理 ---
const currentIndex = ref(props.initialIndex)
const isPlaying = ref(true)
const isBuffering = ref(true) // 初始缓冲
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': '圆形揭示'
}
let lastFrameTime = 0
let accumulatedTime = 0
let rafId = null
let controlsTimer = null
let isSwitching = false
const currentPhoto = computed(() => props.photos[currentIndex.value])
const currentBg = computed(() => currentPhoto.value ? getImgUrl(currentPhoto.value.compressed) : '')
const pptTransitions = ['ppt-fade', 'ppt-zoom-fade', 'ppt-slide-left', 'ppt-wipe', 'ppt-circle']
const currentTransitionName = computed(() => {
if (animationType.value === 'random') {
const seed = currentIndex.value % pptTransitions.length
return pptTransitions[seed]
}
return `ppt-${animationType.value}`
})
const getCurrentAnimationClass = (index) => {
const seed = index % 4
if (seed === 0) return 'anim-ken-burns-1'
if (seed === 1) return 'anim-ken-burns-2'
if (seed === 2) return 'anim-ken-burns-3'
return 'anim-ken-burns-4'
}
const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${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()
img.src = url
if (img.complete) {
resolve()
} else {
img.onload = () => resolve()
img.onerror = () => resolve()
}
})
}
const preloadQueue = (index) => {
const range = [1, 2]
range.forEach(offset => {
const targetIndex = (index + offset) % props.photos.length
if (props.photos[targetIndex]) {
const img = new Image()
img.src = getImgUrl(props.photos[targetIndex].original || props.photos[targetIndex].compressed)
}
})
}
// --- 播放控制 ---
const loop = (timestamp) => {
if (!lastFrameTime) lastFrameTime = timestamp
const deltaTime = timestamp - lastFrameTime
lastFrameTime = timestamp
if (isPlaying.value && !isBuffering.value && !isSwitching) {
accumulatedTime += deltaTime
progress.value = Math.min((accumulatedTime / duration.value) * 100, 100)
if (accumulatedTime >= duration.value) {
handleAutoSwitch()
}
}
rafId = requestAnimationFrame(loop)
}
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 }, 100)
await loadImage(nextUrl)
clearTimeout(bufferTimeout)
isBuffering.value = false
nextSlide(true)
isSwitching = false
}
const startPlay = () => {
isPlaying.value = true
lastFrameTime = 0
if (!rafId) rafId = requestAnimationFrame(loop)
}
const pausePlay = () => {
isPlaying.value = false
}
const togglePlay = () => {
if (isPlaying.value) pausePlay()
else startPlay()
}
const nextSlide = (auto = false) => {
accumulatedTime = 0
progress.value = 0
currentIndex.value = (currentIndex.value + 1) % props.photos.length
emit('update:index', currentIndex.value)
preloadQueue(currentIndex.value)
}
const prevSlide = async () => {
accumulatedTime = 0
progress.value = 0
const prevIdx = (currentIndex.value - 1 + props.photos.length) % props.photos.length
isBuffering.value = true
const prevPhoto = props.photos[prevIdx]
await loadImage(getImgUrl(prevPhoto.original || prevPhoto.compressed))
isBuffering.value = false
currentIndex.value = prevIdx
emit('update:index', currentIndex.value)
}
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)
}
}
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)
}
document.body.addEventListener('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)
})
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId)
clearTimeout(controlsTimer)
})
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:hover { cursor: default; }
.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);
}
.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;
}
@keyframes spin { to { transform: rotate(360deg); } }
.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;
}
.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: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: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; }
.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;
}
.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:hover { background: rgba(255,255,255,0.1); color: #fff; }
.pill-item.active { background: #e63946; color: #fff; }
.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; }
.image-wrapper img { max-width: 95%; max-height: 95%; object-fit: contain; box-shadow: 0 0 100px rgba(0,0,0,0.8); }
@keyframes ken-burns-1 { from { transform: scale(1) translate(0, 0); } to { transform: scale(1.15) translate(3%, 0); } }
@keyframes ken-burns-2 { from { transform: scale(1.15) translate(0, 0); } to { transform: scale(1) translate(-3%, 0); } }
@keyframes ken-burns-3 { from { transform: scale(1) translate(0, 0); } to { transform: scale(1.15) translate(0, 3%); } }
@keyframes ken-burns-4 { from { transform: scale(1.15) translate(0, 0); } to { transform: scale(1) translate(0, -3%); } }
.anim-ken-burns-1 { animation: ken-burns-1 15s ease-out forwards; }
.anim-ken-burns-2 { animation: ken-burns-2 15s ease-out forwards; }
.anim-ken-burns-3 { animation: ken-burns-3 15s ease-out forwards; }
.anim-ken-burns-4 { animation: ken-burns-4 15s ease-out forwards; }
.ppt-fade-enter-active, .ppt-fade-leave-active { transition: opacity 1.5s ease; }
.ppt-fade-enter-from, .ppt-fade-leave-to { opacity: 0; }
.ppt-zoom-fade-enter-active { transition: all 2s ease; }
.ppt-zoom-fade-leave-active { transition: all 1.5s ease; }
.ppt-zoom-fade-enter-from { opacity: 0; transform: scale(1.1); }
.ppt-zoom-fade-leave-to { opacity: 0; transform: scale(1); }
.ppt-slide-left-enter-active, .ppt-slide-left-leave-active { transition: transform 0.8s ease-in-out, opacity 0.8s; }
.ppt-slide-left-enter-from { transform: translateX(100%); opacity: 1; }
.ppt-slide-left-leave-to { transform: translateX(-20%); opacity: 0.5; }
.ppt-wipe-enter-active { animation: wipe-in 1.2s cubic-bezier(0.25, 1, 0.5, 1) forwards; }
.ppt-wipe-leave-active { animation: wipe-out 1.2s cubic-bezier(0.25, 1, 0.5, 1) forwards; }
@keyframes wipe-in { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes wipe-out { from { clip-path: inset(0 0 0 0); } to { clip-path: inset(0 0 0 100%); } }
.ppt-circle-enter-active { animation: circle-in 1.2s ease-out forwards; }
.ppt-circle-leave-active { animation: fade-out 1s ease forwards; }
@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 {
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-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;
}
.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; }
.nav-area.left { left: 0; }
.nav-area.right { right: 0; }
.nav-hint { width: 50px; height: 50px; background: rgba(0,0,0,0.5); border-radius: 50%; color: #fff; display: flex; justify-content: center; align-items: center; font-size: 1.5rem; backdrop-filter: blur(5px); }
.slide-down-enter-active, .slide-down-leave-active { transition: transform 0.4s, opacity 0.4s; }
.slide-down-enter-from, .slide-down-leave-to { transform: translateY(-100%); opacity: 0; }
.slide-up-enter-active, .slide-up-leave-active { transition: transform 0.4s, opacity 0.4s; }
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(100%); opacity: 0; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
.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); }
</style>

View File

@@ -0,0 +1,488 @@
<template>
<div class="planet-container" ref="containerRef">
<!-- 3D 容器加载完成前隐藏 (opacity: 0) -->
<div
class="canvas-wrapper"
:class="{ 'visible': isReady }"
></div>
<!-- 加载进度 -->
<transition name="fade">
<div v-if="!isReady" class="loading-overlay">
<div class="loader-content">
<div class="spinner"></div>
<div class="loading-text">
正在构建影像宇宙... {{ loadingProgress }}%
<div class="loading-sub">已加载 {{ loadedCount }} / {{ totalCount }} 碎片</div>
</div>
</div>
</div>
</transition>
<!-- 顶部操作栏 -->
<div class="planet-toolbar glass-panel">
<div class="toolbar-left">
<button class="icon-btn" @click="$emit('exit')">
<i class="fas fa-arrow-left"></i> 返回
</button>
<span class="planet-title">3D 影像星球</span>
</div>
<div class="toolbar-hint">
<i :class="isMobile ? 'fas fa-hand-pointer' : 'fas fa-mouse'"></i>
{{ isMobile ? '单指旋转 · 双指缩放' : '拖拽旋转 · 滚轮缩放' }}
</div>
</div>
<!-- 隐形预览组件 -->
<div style="display: none;">
<a-image-preview-group
:preview="{
visible: previewVisible,
current: previewIndex,
onVisibleChange: (vis) => (previewVisible = vis),
onChange: (current) => (previewIndex = current)
}"
>
<a-image
v-for="(photo, index) in photos"
:key="index"
:src="getImgUrl(photo.original || photo.compressed)"
/>
</a-image-preview-group>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, computed } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
const props = defineProps({
photos: { type: Array, default: () => [] }
})
const emit = defineEmits(['exit'])
const containerRef = ref(null)
const loadingProgress = ref(0)
const loadedCount = ref(0)
const totalCount = computed(() => props.photos.length)
const isReady = ref(false)
const previewVisible = ref(false)
const previewIndex = ref(0)
const isMobile = ref(window.innerWidth < 768)
let scene, camera, renderer, controls, raycaster, mouse
let animationId
let photoMeshes = []
let particleSystem
const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
// --- 核心优化:图片压缩与纹理生成 (带 CORS 代理回退) ---
const createCompressedTexture = (originalUrl) => {
return new Promise((resolve) => {
const loadTextureImage = (url, isRetry = false) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.onload = () => {
const canvas = document.createElement('canvas')
const size = 256
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(0, 0, size, size)
const ratio = Math.max(size / img.width, size / img.height)
const centerShift_x = (size - img.width * ratio) / 2
const centerShift_y = (size - img.height * ratio) / 2
try {
ctx.drawImage(img, 0, 0, img.width, img.height, centerShift_x, centerShift_y, img.width * ratio, img.height * ratio)
ctx.strokeStyle = '#e63946'
ctx.lineWidth = 10
ctx.strokeRect(0, 0, size, size)
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
texture.minFilter = THREE.LinearFilter
resolve(texture)
} catch (e) {
if (!isRetry) tryProxyLoad()
else resolve(createFallbackTexture())
}
}
img.onerror = () => {
if (!isRetry) tryProxyLoad()
else resolve(createFallbackTexture())
}
img.src = url
}
const tryProxyLoad = () => {
const cleanUrl = originalUrl.replace(/^https?:\/\//, '')
const proxyUrl = `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}&w=256&h=256&output=jpg&q=80`
loadTextureImage(proxyUrl, true)
}
loadTextureImage(originalUrl)
})
}
const createFallbackTexture = () => {
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#111'
ctx.fillRect(0, 0, 256, 256)
ctx.strokeStyle = '#333'
ctx.lineWidth = 4
ctx.strokeRect(0,0,256,256)
ctx.fillStyle = '#e63946'
ctx.font = 'bold 24px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('NO IMAGE', 128, 128)
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
return texture
}
// --- 3D 场景初始化 ---
const initThree = async () => {
const container = containerRef.value
if (!container) return
const width = window.innerWidth
const height = window.innerHeight
scene = new THREE.Scene()
scene.background = new THREE.Color(0x050505)
scene.fog = new THREE.FogExp2(0x050505, 0.0015)
camera = new THREE.PerspectiveCamera(60, width / height, 1, 2000)
const initialZ = isMobile.value ? 900 : 600
camera.position.set(0, 0, initialZ)
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(width, height)
const wrapper = container.querySelector('.canvas-wrapper')
if (wrapper) {
wrapper.innerHTML = ''
wrapper.appendChild(renderer.domElement)
}
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = true
controls.autoRotateSpeed = 0.8
controls.minDistance = 100
controls.maxDistance = 1200
raycaster = new THREE.Raycaster()
mouse = new THREE.Vector2()
// 这里的 await 会等待所有的图片加载流程完成
await createPhotoSphere()
createParticles()
// 全部加载完成后,标记为就绪,触发淡入动画
setTimeout(() => {
isReady.value = true
}, 500) // 稍微延迟一点,让进度条显示 100% 更有满足感
window.addEventListener('click', onMouseClick)
window.addEventListener('resize', onWindowResize)
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 // 技巧:初始不可见
})
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.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
}
// --- 创建粒子系统 ---
const createParticles = () => {
const geometry = new THREE.BufferGeometry()
const vertices = []
for (let i = 0; i < 1500; i++) {
vertices.push(
THREE.MathUtils.randFloatSpread(1500),
THREE.MathUtils.randFloatSpread(1500),
THREE.MathUtils.randFloatSpread(1500)
)
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
const material = new THREE.PointsMaterial({
color: 0xe63946,
size: 2,
sizeAttenuation: true,
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
}
renderer.render(scene, camera)
}
// --- 交互事件 ---
const onWindowResize = () => {
if (!camera || !renderer) return
isMobile.value = window.innerWidth < 768
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
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) {
controls.autoRotate = false
previewIndex.value = target.userData.id
previewVisible.value = true
}
} else {
controls.autoRotate = true
}
}
onMounted(() => {
setTimeout(initThree, 100)
})
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 (renderer) renderer.dispose()
})
</script>
<style scoped>
.planet-container {
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-overlay {
position: absolute;
inset: 0;
background: #000; /* 纯黑背景遮挡 */
z-index: 20;
display: flex;
justify-content: center;
align-items: center;
}
.loader-content {
text-align: center;
color: #e63946;
}
.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;
}
.loading-text {
font-family: 'Montserrat', monospace;
font-size: 1.1rem;
letter-spacing: 1px;
color: #fff;
margin-top: 1rem;
}
.loading-sub {
font-size: 0.85rem;
color: rgba(255,255,255,0.5);
margin-top: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.8s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
@media (max-width: 768px) {
.planet-toolbar { padding: 0 1rem; }
.planet-title { font-size: 1rem; }
.toolbar-hint { font-size: 0.8rem; }
.icon-btn { padding: 6px 12px; font-size: 0.9rem; }
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div class="wall-container">
<!-- 背景装饰层 (调整为更通透的效果) -->
<div class="wall-bg-texture"></div>
<div class="wall-ambient-light"></div>
<!-- 顶部 -->
<div class="wall-header">
<h2 class="title">{{ title }}</h2>
<slot name="header-actions"></slot>
</div>
<!-- 瀑布流照片墙 -->
<div class="masonry-wrapper">
<a-image-preview-group>
<div
v-for="(photo, index) in photos"
:key="photo.id || index"
class="masonry-item"
:style="getItemStyle(index)"
>
<div class="polaroid-frame">
<div class="img-wrapper">
<a-image
:src="getImgUrl(photo.compressed)"
:preview="{
src: getImgUrl(photo.original || photo.compressed),
mask: '预览'
}"
class="wall-img"
fallback="/error-img.png"
/>
<!-- 胶带/图钉装饰 (随机出现) -->
<div v-if="index % 3 === 0" class="tape"></div>
<div v-else class="pin"></div>
</div>
<!-- 模拟手写备注区域 -->
<div class="photo-meta">
<span class="handwritten-date">{{ formatDate(photo.created_at) }}</span>
</div>
</div>
</div>
</a-image-preview-group>
</div>
<!-- 触底检测 -->
<div ref="loadTrigger" class="scroll-trigger">
<div v-if="loadingMore" class="mini-loader">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
<div v-if="!hasMore && photos.length > 0" class="end-text">
<span>THE END · 故事未完待续</span>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
photos: { type: Array, default: () => [] },
title: String,
hasMore: Boolean,
loadingMore: Boolean
})
const emit = defineEmits(['loadMore'])
const loadTrigger = ref(null)
let observer = null
const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
const formatDate = (dateStr) => {
if (!dateStr) return 'Unknown Date'
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
// --- 核心:确定性随机布局算法 ---
// 使用 index 作为种子,确保同一张图在重渲染时位置不变,避免闪烁
const seededRandom = (seed) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
const getItemStyle = (index) => {
const seed = index + 1; // 避免 0
// 旋转角度: -6deg 到 +6deg
const rotate = (seededRandom(seed) * 12 - 6).toFixed(2);
// X轴偏移: -15px 到 +15px
const translateX = (seededRandom(seed + 100) * 30 - 15).toFixed(2);
// Y轴偏移: -15px 到 +15px (制造参差感)
const translateY = (seededRandom(seed + 200) * 30 - 15).toFixed(2);
// Z-index: 鼠标悬停时会覆盖,这里仅作为默认层级
return {
transform: `translate(${translateX}px, ${translateY}px) rotate(${rotate}deg)`,
transitionDelay: `${(index % 10) * 0.05}s` // 瀑布流进场动画错落感
}
}
const initObserver = () => {
if (observer) observer.disconnect()
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && props.hasMore && !props.loadingMore) {
emit('loadMore')
}
}, { threshold: 0.1, rootMargin: '200px' })
if (loadTrigger.value) observer.observe(loadTrigger.value)
}
watch(() => props.photos.length, () => setTimeout(initObserver, 200))
onMounted(() => setTimeout(initObserver, 200))
onUnmounted(() => { if (observer) observer.disconnect() })
</script>
<style scoped>
.wall-container {
padding: 2rem;
max-width: 1800px;
margin: 0 auto;
min-height: 100vh;
position: relative;
/* 修改:背景透明,透出 App 全局的深红/黑色渐变背景 */
background-color: transparent;
overflow: hidden;
}
/* --- 背景装饰 --- */
.wall-bg-texture {
position: fixed;
inset: 0;
opacity: 0.03; /* 降低噪点透明度,更细腻 */
pointer-events: none;
z-index: 0;
/* 噪点纹理 */
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.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
.wall-ambient-light {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
/* 修改:移除之前的蓝色调,统一为暗红氛围光 */
background:
radial-gradient(circle at 50% 40%, rgba(230, 57, 70, 0.15), transparent 60%),
radial-gradient(circle at 10% 90%, rgba(20, 0, 0, 0.8), transparent 50%);
}
.wall-header {
position: relative;
z-index: 2;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4rem;
flex-wrap: wrap;
gap: 1rem;
}
.title {
font-size: 2.5rem;
font-weight: 300;
color: #fff;
margin: 0;
letter-spacing: 2px;
text-transform: uppercase;
text-shadow: 0 0 20px rgba(230, 57, 70, 0.4);
}
/* --- Masonry 核心布局 --- */
.masonry-wrapper {
position: relative;
z-index: 2;
column-count: 5;
column-gap: 3rem; /* 增加列间距,给随机偏移留空间 */
padding: 0 1rem;
}
.masonry-item {
break-inside: avoid;
margin-bottom: 4rem; /* 增加行间距 */
position: relative;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), z-index 0s;
z-index: 1;
/* 初始进场动画 */
animation: floatIn 0.8s ease-out backwards;
}
@keyframes floatIn {
from { opacity: 0; transform: translateY(50px) scale(0.9); }
to { opacity: 1; } /* transform 由行内样式接管 */
}
.masonry-item:hover {
/* 悬停时:取消随机偏移,放大,扶正,层级最高 */
transform: scale(1.1) rotate(0deg) !important;
z-index: 100 !important;
cursor: zoom-in;
}
/* 拍立得风格相框 */
.polaroid-frame {
background: #fdfdfd;
padding: 12px 12px 45px 12px; /* 经典的底部宽留白 */
/* 修改:阴影加入微弱的红色环境光,使其融合背景 */
box-shadow:
0 5px 15px rgba(0,0,0,0.5),
0 15px 40px rgba(230, 57, 70, 0.15);
border-radius: 2px;
position: relative;
/* 微弱的纸张纹理 */
background-image: linear-gradient(to bottom right, rgba(0,0,0,0.02) 0%, transparent 100%);
}
.img-wrapper {
position: relative;
overflow: hidden;
background: #eee;
}
.wall-img {
width: 100%;
display: block;
transition: filter 0.3s;
filter: sepia(0.15) contrast(1.05); /* 默认带一点复古感 */
}
.masonry-item:hover .wall-img {
filter: sepia(0) contrast(1); /* 悬停还原 */
}
/* 装饰元素:图钉 (调整为深红金属质感) */
.pin {
position: absolute;
top: -15px;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #e63946, #9b2226);
box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
z-index: 10;
}
/* 装饰元素:胶带 */
.tape {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%) rotate(-2deg);
width: 80px;
height: 25px;
background-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
backdrop-filter: blur(2px);
z-index: 10;
border-left: 1px dashed rgba(255,255,255,0.2);
border-right: 1px dashed rgba(255,255,255,0.2);
}
.photo-meta {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
text-align: center;
}
.handwritten-date {
font-family: 'Courier New', Courier, monospace; /* 模拟打印字体 */
font-size: 0.85rem;
color: #555;
letter-spacing: 1px;
opacity: 0.8;
}
/* --- 触底加载 --- */
.scroll-trigger {
position: relative;
z-index: 2;
min-height: 100px;
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
opacity: 0.7;
}
.mini-loader .dot {
display: inline-block;
width: 8px; height: 8px; margin: 0 3px;
background: #e63946; border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.mini-loader .dot:nth-child(1) { animation-delay: -0.32s; }
.mini-loader .dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
.end-text {
font-family: 'Montserrat', sans-serif;
font-size: 0.9rem;
letter-spacing: 3px;
color: rgba(255,255,255,0.3);
text-transform: uppercase;
}
/* --- 移动端适配 (Responsive) --- */
@media (max-width: 1600px) { .masonry-wrapper { column-count: 4; } }
@media (max-width: 1200px) { .masonry-wrapper { column-count: 3; } }
@media (max-width: 900px) {
.masonry-wrapper { column-count: 2; column-gap: 1.5rem; }
.wall-container { padding: 1rem; }
}
@media (max-width: 600px) {
.masonry-wrapper { column-count: 1; } /* 手机端单列,体验更好 */
.title { font-size: 1.8rem; }
.polaroid-frame { padding: 8px 8px 35px 8px; }
/* 移动端减小随机偏移幅度,防止溢出屏幕 */
.masonry-item { transform: rotate(0deg) !important; margin-bottom: 2rem; }
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<div class="snow-container">
<!-- 背景层层级低在内容下面 -->
<canvas ref="bgCanvasRef" class="snow-canvas-layer bg-layer"></canvas>
<!-- 前景层层级高在内容上面 -->
<canvas ref="fgCanvasRef" class="snow-canvas-layer fg-layer"></canvas>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const bgCanvasRef = ref(null)
const fgCanvasRef = ref(null)
let bgCtx = null
let fgCtx = null
let animationFrameId = null
let particles = []
let width = 0
let height = 0
// 风力系统配置
const windSystem = {
current: 0, // 当前风力值
target: 0, // 目标风力值(风向改变时)
time: 0 // 时间累加器,用于噪声生成
}
const config = {
count: 200, // 雪花总数
baseSize: 1.2, // 基础大小
sizeVar: 2.5, // 大小随机范围
baseSpeed: 0.5, // 基础下落速度
speedVar: 1.5, // 速度随机范围
windForce: 2.5, // 最大风力倍数
gustChance: 0.003, // 产生阵风的概率
changeSpeed: 0.01 // 风力变化的平滑度
}
class Snowflake {
constructor() {
this.reset(true)
}
reset(initial = false) {
this.x = Math.random() * width
this.y = initial ? Math.random() * height : -10
// 景深模拟size 越大,代表离镜头越近
// 0 (远) -> 1 (近)
this.depth = Math.random()
// 大小:近大远小
this.size = config.baseSize + this.depth * config.sizeVar
// 速度:近快远慢 (模拟视差)
this.vy = (config.baseSpeed + Math.random() * config.speedVar) * (0.5 + this.depth * 0.8)
// 透明度:近处可能稍微透明一点(因为虚焦),或者远处淡一点
this.opacity = 0.3 + this.depth * 0.6
// 摇摆相位
this.wobble = Math.random() * Math.PI * 2
this.wobbleSpeed = 0.01 + Math.random() * 0.02
}
update(wind) {
// 1. 基础下落
this.y += this.vy
// 2. 水平摇摆 (正弦波模拟空气阻力)
this.wobble += this.wobbleSpeed
const sway = Math.sin(this.wobble) * 0.5
// 3. 风力影响离镜头越近size越大受风影响越明显
// 加入随机扰动,让每片雪花对风的反应略有不同
const windEffect = wind * (0.8 + this.depth * 1.2) + sway
this.x += windEffect
// 4. 强风时的湍流效果:风很大时,雪花下落变慢甚至微微上浮
if (Math.abs(wind) > 1.5) {
this.y -= Math.abs(wind) * 0.05
}
// 边界循环
if (this.y > height) {
this.reset()
}
// 水平循环:如果被风吹出屏幕,从另一侧飞入
if (this.x > width + 10) {
this.x = -10
} else if (this.x < -10) {
this.x = width + 10
}
}
// 接收两个上下文,根据 depth 决定画在哪里
draw(bgCtx, fgCtx) {
// 阈值设为 0.8,意味着最靠近镜头的 20% 雪花会画在前景层,遮挡住卡片
// 剩下的 80% 画在背景层
const ctx = this.depth > 0.8 ? fgCtx : bgCtx
ctx.beginPath()
// 绘制柔和的圆形
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
// 使用径向渐变模拟雪花的晶体朦胧感(仅对大雪花开启,优化性能)
if (this.size > 2.5) {
const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, this.size)
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.opacity})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
ctx.fillStyle = gradient
} else {
ctx.fillStyle = `rgba(255, 255, 255, ${this.opacity})`
}
ctx.fill()
}
}
// 模拟自然风力系统
const updateWind = () => {
windSystem.time += 0.005
// 随机产生阵风目标
if (Math.random() < config.gustChance) {
// 产生一阵强风,方向随机但大概率向右(因为通常习惯风从左吹来)
const direction = Math.random() > 0.3 ? 1 : -0.8
windSystem.target = direction * (1 + Math.random() * config.windForce)
} else if (Math.random() < 0.01) {
// 恢复平静或微风
windSystem.target = (Math.random() - 0.5) * 0.5
}
// 平滑过渡当前风力 -> 目标风力 (Lerp)
windSystem.current += (windSystem.target - windSystem.current) * config.changeSpeed
}
const initCanvas = () => {
const bgCanvas = bgCanvasRef.value
const fgCanvas = fgCanvasRef.value
if (!bgCanvas || !fgCanvas) return
bgCtx = bgCanvas.getContext('2d', { alpha: true }) // 开启透明通道
fgCtx = fgCanvas.getContext('2d', { alpha: true })
const resize = () => {
width = window.innerWidth
height = window.innerHeight
bgCanvas.width = width
bgCanvas.height = height
fgCanvas.width = width
fgCanvas.height = height
// 重新生成粒子
particles = []
for (let i = 0; i < config.count; i++) {
particles.push(new Snowflake())
}
}
window.addEventListener('resize', resize)
resize()
const animate = () => {
// 清空两个画布
bgCtx.clearRect(0, 0, width, height)
fgCtx.clearRect(0, 0, width, height)
updateWind()
particles.forEach(p => {
p.update(windSystem.current)
// 传入两个上下文,由粒子自己决定画在哪一层
p.draw(bgCtx, fgCtx)
})
animationFrameId = requestAnimationFrame(animate)
}
animate()
return () => {
window.removeEventListener('resize', resize)
cancelAnimationFrame(animationFrameId)
}
}
onMounted(() => {
const cleanup = initCanvas()
onUnmounted(() => {
if (cleanup) cleanup()
})
})
</script>
<style scoped>
.snow-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 确保不阻挡点击 */
z-index: 0; /* 容器基准层级 */
}
.snow-canvas-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
mix-blend-mode: normal;
}
/* 背景层:位于内容之下 */
/* 假设主内容区 z-index 通常为 10+,这里设为 1 即可在背景图之上,但在内容之下 */
.bg-layer {
z-index: 1;
}
/* 前景层:位于内容之上 */
/* 模拟雪花飘在屏幕最前方 */
.fg-layer {
z-index: 9999;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<div class="swipe-container" @mousemove="handleMouseMove" @click="handleMouseMove">
<!-- 顶部工具栏 -->
<transition name="slide-down">
<div v-if="showToolbar" class="toolbar glass-panel">
<div class="toolbar-left">
<button class="icon-btn" @click="$emit('close')">
<i class="fas fa-arrow-left"></i>
</button>
<span class="counter">{{ currentIndex + 1 }} / {{ photos.length }}</span>
</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>
<div class="effect-grid">
<div
v-for="(label, key) in effectLabels"
:key="key"
class="effect-item"
:class="{active: currentEffect === key}"
@click="changeEffect(key)"
>
<div class="effect-icon">
<i :class="getEffectIcon(key)"></i>
</div>
<span>{{ label }}</span>
</div>
</div>
</div>
</transition>
</div>
</div>
</div>
</transition>
<!-- Swiper 主体 -->
<swiper
:key="swiperKey"
:modules="swiperModules"
:effect="currentEffect"
:grabCursor="true"
:centeredSlides="true"
:slidesPerView="1"
:initialSlide="initialIndex"
:navigation="{
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
}"
:keyboard="{ enabled: true }"
:zoom="true"
:speed="600"
class="fullscreen-swiper"
@slideChange="onSlideChange"
>
<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>
<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>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, EffectCube, EffectCoverflow, EffectCards, Navigation, Keyboard, Zoom } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-fade';
import 'swiper/css/effect-cube';
import 'swiper/css/effect-coverflow';
import 'swiper/css/effect-cards';
import 'swiper/css/navigation';
import 'swiper/css/zoom';
const props = defineProps({
photos: Array,
initialIndex: { type: Number, default: 0 },
isFullscreen: Boolean
})
const emit = defineEmits(['close', 'startCinematic', 'update:index', 'toggleFullscreen'])
const swiperModules = [EffectFade, EffectCube, EffectCoverflow, EffectCards, Navigation, Keyboard, Zoom]
const swiperKey = ref(0)
const currentIndex = ref(props.initialIndex)
const currentEffect = ref('slide')
// 记录每张图片的加载状态: { [index]: boolean }
const imageLoadState = reactive({})
const effectLabels = {
'slide': '经典滑动',
'fade': '淡入淡出',
'cube': '3D魔方',
'cards': '层叠卡片',
'coverflow': '空间流'
}
const getEffectIcon = (key) => {
const map = {
'slide': 'fas fa-arrows-alt-h',
'fade': 'fas fa-adjust',
'cube': 'fas fa-cube',
'cards': 'fas fa-layer-group',
'coverflow': 'fas fa-stream'
}
return map[key] || 'fas fa-circle'
}
const showToolbar = ref(true)
const showSettings = ref(false)
let toolbarTimer = null
const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
const onSlideChange = (swiper) => {
currentIndex.value = swiper.activeIndex
emit('update:index', swiper.activeIndex)
}
const onImageLoad = (index) => {
imageLoadState[index] = true
}
const changeEffect = (effect) => {
currentEffect.value = effect
swiperKey.value++
showSettings.value = false
}
const handleMouseMove = () => {
showToolbar.value = true
clearTimeout(toolbarTimer)
if (!showSettings.value) {
toolbarTimer = setTimeout(() => {
showToolbar.value = false
}, 3000)
}
}
const toggleSettings = () => {
showSettings.value = !showSettings.value
handleMouseMove()
}
const closeSettings = () => {
showSettings.value = false
}
const vClickOutside = {
mounted(el, binding) {
el.clickOutsideEvent = function(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);
}
}
onMounted(() => {
handleMouseMove()
})
onUnmounted(() => {
clearTimeout(toolbarTimer)
})
</script>
<style scoped>
.swipe-container {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #000; z-index: 100;
}
/* Loading Spinner for individual slides */
.slide-spinner {
position: absolute; inset: 0; display: flex; justify-content: center; align-items: center;
}
.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;
}
@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;
padding: 0 1.5rem; z-index: 50;
background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
}
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 1rem; }
.counter { color: rgba(255,255,255,0.7); font-family: 'Montserrat', sans-serif; font-size: 1.1rem; letter-spacing: 1px; }
.icon-btn {
width: 40px; height: 40px; border-radius: 50%;
background: rgba(255,255,255,0.1); border: none; color: #fff;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.icon-btn:hover, .icon-btn.active { background: #e63946; color: #fff; transform: scale(1.1); }
.play-btn {
background: #e63946; border: none; color: #fff;
padding: 8px 20px; border-radius: 20px;
cursor: pointer; font-size: 0.95rem; font-weight: 500;
display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 15px rgba(230,57,70,0.4);
transition: all 0.3s;
}
.play-btn:hover { background: #ff4d5a; transform: translateY(-2px); }
.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; }
: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; }
.custom-dropdown-trigger { position: relative; }
.custom-dropdown-menu {
position: absolute; top: 100%; right: 0; margin-top: 15px;
width: 220px;
background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(15px);
border: 1px solid rgba(255,255,255,0.1); border-radius: 12px;
padding: 15px; box-shadow: 0 10px 40px rgba(0,0,0,0.6);
}
.menu-header { font-size: 0.8rem; color: rgba(255,255,255,0.4); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }
.effect-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
.effect-item {
display: flex; align-items: center; gap: 12px;
padding: 10px; border-radius: 8px;
color: rgba(255,255,255,0.7); cursor: pointer; transition: all 0.2s;
}
.effect-item:hover { background: rgba(255,255,255,0.1); color: #fff; }
.effect-item.active { background: rgba(230, 57, 70, 0.2); color: #e63946; }
.effect-icon { width: 20px; text-align: center; }
.slide-down-enter-active, .slide-down-leave-active { transition: transform 0.4s, opacity 0.4s; }
.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); }
</style>

View File

@@ -1,607 +1,291 @@
<template>
<div class="container">
<!-- Updated main title styling to match original -->
<h1 class="main-title">我们的相册</h1>
<div class="container-fluid">
<!-- Loading state -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<p>加载照片中...</p>
<!-- 加载状态 -->
<div v-if="loading" class="loading-wrapper">
<div class="loading-spinner-red"></div>
<p>正在读取美好瞬间...</p>
</div>
<!-- 相册详情模块 -->
<div v-else class="album-detail" id="albumDetail">
<button class="back-button" @click="goBack">
<i class="fas fa-arrow-left"></i> 返回相册列表
</button>
<!-- 主内容区 -->
<div v-else>
<!-- 模式 A: 网格画廊 (Grid) -->
<AlbumGallery
v-if="viewMode === 'gallery'"
:photos="visiblePhotos"
:title="albumData.title"
:has-more="hasMorePhotos"
:loading-more="loadingMore"
@loadMore="handleLoadMore"
>
<template #header-actions>
<div class="header-controls">
<button class="back-button" @click="goBack">
<i class="fas fa-arrow-left"></i> 返回
</button>
<ModeButtonGroup :current="viewMode" @change="changeViewMode" />
</div>
</template>
</AlbumGallery>
<div class="detail-header">
<h2 class="detail-title">{{ albumData.title }}</h2>
</div>
<!-- 模式 B: 照片墙 (Masonry) -->
<PhotoWall
v-if="viewMode === 'wall'"
:photos="visiblePhotos"
:title="albumData.title"
:has-more="hasMorePhotos"
:loading-more="loadingMore"
@loadMore="handleLoadMore"
>
<template #header-actions>
<div class="header-controls">
<button class="back-button" @click="goBack">
<i class="fas fa-arrow-left"></i> 返回
</button>
<ModeButtonGroup :current="viewMode" @change="changeViewMode" />
</div>
</template>
</PhotoWall>
<div class="photo-grid" id="photoGrid">
<div
v-for="(photo, index) in albumData.photos"
:key="index"
class="photo-item"
@click="showPhotoPreview(photo.compressed, index)"
>
<!-- Added loading spinner for individual photos -->
<div v-if="!photo.loaded" class="photo-loading-spinner"></div>
<img
:src="`http://i.nailaoyun.cn${photo.compressed}`"
:alt="`照片 ${index + 1}`"
class="photo-img"
:class="{ loaded: photo.loaded }"
@load="onPhotoLoad(photo)"
@error="onPhotoError(photo)"
loading="lazy"
/>
</div>
</div>
</div>
</div>
<!-- 图片预览模态框 -->
<div
v-if="previewModal.active"
class="preview-modal active"
:style="{ opacity: previewModal.opacity }"
>
<button class="close-modal" @click="closePhotoPreview">
<i class="fas fa-times"></i>
</button>
<button
class="nav-btn prev-btn"
@click="showPrevPhoto"
:disabled="currentPhotoIndex <= 0"
>
<i class="fas fa-chevron-left"></i>
</button>
<div class="modal-content">
<img
class="modal-img"
:class="{ active: previewModal.imgActive }"
:src="`http://i.nailaoyun.cn${previewModal.currentImg}`"
alt="预览图片"
:style="{ transform: `rotate(${currentRotation}deg)` }"
<!-- 模式 C: 手动轮播 (Swiper) -->
<SwipeSlideshow
v-if="viewMode === 'swiper'"
:photos="albumData.photos"
:initial-index="currentIndex"
:is-fullscreen="isFullscreen"
@close="viewMode = 'gallery'"
@startCinematic="switchToCinematic"
@toggleFullscreen="toggleFullscreen"
@update:index="(val) => currentIndex = val"
/>
<div class="modal-tools">
<button class="tool-btn" @click="rotatePhoto('left')" title="向左旋转">
<i class="fas fa-undo"></i>
</button>
<button class="tool-btn" @click="rotatePhoto('right')" title="向右旋转">
<i class="fas fa-redo"></i>
</button>
<button class="tool-btn" @click="downloadPhoto" title="下载图片">
<i class="fas fa-download"></i>
</button>
</div>
<!-- 模式 D: 电影幻灯片 (Cinematic) -->
<CinematicSlideshow
v-if="viewMode === 'cinematic'"
:photos="albumData.photos"
:title="albumData.title"
:initial-index="currentIndex"
:is-fullscreen="isFullscreen"
@exit="viewMode = 'swiper'"
@toggleFullscreen="toggleFullscreen"
@update:index="(val) => currentIndex = val"
/>
<!-- 模式 E: 3D 星球 (Planet) -->
<PhotoPlanet
v-if="viewMode === 'planet'"
:photos="albumData.photos"
@exit="viewMode = 'gallery'"
/>
</div>
<button
class="nav-btn next-btn"
@click="showNextPhoto"
:disabled="currentPhotoIndex >= albumData.photos.length - 1"
>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, h } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { albumDetailApi } from "@/api/request/api.js"
import { Tooltip } from 'ant-design-vue'
import AlbumGallery from '@/components/AlbumGallery.vue'
import PhotoWall from '@/components/PhotoWall.vue'
import SwipeSlideshow from '@/components/SwipeSlideshow.vue'
import CinematicSlideshow from '@/components/CinematicSlideshow.vue'
import PhotoPlanet from '@/components/PhotoPlanet.vue'
// --- 内部微组件:按钮组 (减少重复代码) ---
const ModeButtonGroup = (props, { emit }) => {
const modes = [
{ key: 'gallery', icon: 'fas fa-th', title: '网格画廊' },
{ key: 'wall', icon: 'fas fa-stream', title: '照片墙' },
{ key: 'planet', icon: 'fas fa-globe', title: '3D 星球' },
{ key: 'swiper', icon: 'fas fa-images', title: '手动轮播' },
{ key: 'cinematic', icon: 'fas fa-play', title: '幻灯片' }
]
return h('div', { class: 'view-mode-group' },
modes.map(mode =>
h(Tooltip, { title: mode.title, color: '#e63946' }, () =>
h('button', {
class: ['group-btn', { active: props.current === mode.key }],
onClick: () => emit('change', mode.key)
}, h('i', { class: mode.icon }))
)
)
)
}
const router = useRouter()
const route = useRoute()
// 状态
const loading = ref(true)
const albumData = reactive({
title: '',
photos: []
})
const viewMode = ref('gallery')
const albumData = reactive({ title: '', photos: [] })
const isFullscreen = ref(false)
const currentIndex = ref(0)
const previewModal = reactive({
active: false,
opacity: '0',
imgActive: false,
currentImg: ''
})
// 分页逻辑
const pageSize = 20
const visibleCount = ref(pageSize)
const loadingMore = ref(false)
const currentPhotoIndex = ref(0)
const currentRotation = ref(0)
const visiblePhotos = computed(() => albumData.photos.slice(0, visibleCount.value))
const hasMorePhotos = computed(() => visibleCount.value < albumData.photos.length)
const handleLoadMore = () => {
loadingMore.value = true
setTimeout(() => {
visibleCount.value += pageSize
loadingMore.value = false
}, 500)
}
const changeViewMode = (mode) => {
if (mode === 'swiper' || mode === 'cinematic') {
currentIndex.value = 0
}
viewMode.value = mode
}
const switchToCinematic = () => {
viewMode.value = 'cinematic'
}
const toggleFullscreen = () => {
const elem = document.documentElement
if (!document.fullscreenElement) {
elem.requestFullscreen().catch(err => console.log(err))
} else {
if (document.exitFullscreen) document.exitFullscreen()
}
}
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
const fetchAlbumDetail = async (albumId) => {
try {
loading.value = true
const res = await albumDetailApi({ id: albumId })
console.log(res)
albumData.photos = res.photos.map(photo => ({
...photo,
loaded: false
}))
albumData.photos = res.photos || []
albumData.title = res.title
} catch (error) {
console.error('获取相册详情失败:', error)
// Fallback to empty state on error
albumData.photos = []
albumData.title = '相册加载失败'
console.error('Failed:', error)
} finally {
loading.value = false
}
}
const onPhotoLoad = (photo) => {
photo.loaded = true
}
const onPhotoError = (photo) => {
photo.loaded = true
// Could add error state handling here
console.warn('Photo failed to load:', photo.compressed)
}
const goBack = () => {
router.push('/')
}
const showPhotoPreview = (photo, index) => {
previewModal.currentImg = photo
currentPhotoIndex.value = index
currentRotation.value = 0
previewModal.active = true
setTimeout(() => {
previewModal.opacity = '1'
setTimeout(() => {
previewModal.imgActive = true
}, 50)
}, 10)
}
const closePhotoPreview = () => {
previewModal.imgActive = false
previewModal.opacity = '0'
setTimeout(() => {
previewModal.active = false
}, 300)
}
const showPrevPhoto = () => {
if (currentPhotoIndex.value > 0) {
currentPhotoIndex.value--
previewModal.imgActive = false
setTimeout(() => {
previewModal.currentImg = albumData.photos[currentPhotoIndex.value].compressed
currentRotation.value = 0
setTimeout(() => {
previewModal.imgActive = true
}, 50)
}, 300)
}
}
const showNextPhoto = () => {
if (currentPhotoIndex.value < albumData.photos.length - 1) {
currentPhotoIndex.value++
previewModal.imgActive = false
setTimeout(() => {
previewModal.currentImg = albumData.photos[currentPhotoIndex.value].compressed
currentRotation.value = 0
setTimeout(() => {
previewModal.imgActive = true
}, 50)
}, 300)
}
}
const rotatePhoto = (direction) => {
if (direction === 'left') {
currentRotation.value -= 90
} else {
currentRotation.value += 90
}
}
const downloadPhoto = () => {
if (!albumData.photos.length) return
const link = document.createElement('a')
link.href = `http://i.nailaoyun.cn${albumData.photos[currentPhotoIndex.value].original || albumData.photos[currentPhotoIndex.value].compressed}`
link.download = `photo_${albumData.title}_${Date.now()}.jpg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 键盘导航
const handleKeydown = (e) => {
if (previewModal.active) {
if (e.key === 'ArrowLeft') {
showPrevPhoto()
} else if (e.key === 'ArrowRight') {
showNextPhoto()
} else if (e.key === 'Escape') {
closePhotoPreview()
}
}
}
const goBack = () => router.push('/')
onMounted(() => {
const albumId = route.params.id
fetchAlbumDetail(albumId)
document.addEventListener('keydown', handleKeydown)
window.scrollTo({ top: 0, behavior: 'smooth' })
fetchAlbumDetail(route.params.id)
document.addEventListener('fullscreenchange', onFullscreenChange)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('fullscreenchange', onFullscreenChange)
})
</script>
<style scoped>
/* Updated all styling to match original dark red theme with frosted glass effects */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 3rem;
.container-fluid {
min-height: 100vh;
position: relative;
font-family: 'Montserrat', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
z-index: 10;
}
.main-title {
color: var(--light-color);
text-align: center;
margin-bottom: 3rem;
font-weight: 300;
letter-spacing: 3px;
font-size: 2.5rem;
text-transform: uppercase;
position: relative;
padding-bottom: 1rem;
text-shadow: 0 0 10px rgba(230, 57, 70, 0.3);
}
.main-title::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 3px;
background: var(--primary-color);
box-shadow: 0 0 10px rgba(230, 57, 70, 0.5);
}
.loading-container {
.loading-wrapper {
height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
color: var(--light-color);
align-items: center;
color: #fff;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(168, 218, 220, 0.3);
border-radius: 50%;
border-top-color: var(--secondary-color);
animation: spin 1s ease-in-out infinite;
.loading-spinner-red {
width: 50px; height: 50px;
border: 3px solid rgba(230, 57, 70, 0.3); border-radius: 50%;
border-top-color: #e63946;
animation: spin 1s infinite cubic-bezier(0.55, 0.055, 0.675, 0.19);
margin-bottom: 1rem;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.album-detail {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
/* 头部控制栏 (移动端适配关键点) */
.header-controls {
display: flex;
align-items: center;
gap: 1.5rem;
max-width: 100%;
}
.back-button {
background: rgba(230, 57, 70, 0.3);
border: none;
font-size: 1rem;
color: var(--light-color);
background: rgba(230, 57, 70, 0.2);
border: 1px solid rgba(230, 57, 70, 0.3);
font-size: 0.9rem;
color: #fff;
cursor: pointer;
margin-bottom: 2rem;
padding: 0.6rem 1.2rem;
border-radius: 30px;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 0.5rem;
transition: var(--transition);
padding: 0.8rem 1.5rem;
white-space: nowrap; /* 防止换行 */
}
.back-button:hover { background: #e63946; transform: translateX(-3px); }
/* 胶囊按钮组样式 */
:deep(.view-mode-group) {
display: flex;
background: rgba(255, 255, 255, 0.1);
border-radius: 30px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.back-button:hover {
background: rgba(230, 57, 70, 0.5);
transform: translateX(-5px);
box-shadow: 0 0 10px rgba(230, 57, 70, 0.3);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
}
.detail-title {
font-size: 2.5rem;
font-weight: 300;
color: var(--light-color);
margin: 0;
letter-spacing: 2px;
text-transform: uppercase;
text-shadow: 0 0 10px rgba(230, 57, 70, 0.2);
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
}
.photo-item {
position: relative;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--card-shadow);
transition: var(--transition);
aspect-ratio: 4/3;
background: rgba(29, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transform: scale(1);
border: 1px solid rgba(230, 57, 70, 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.photo-item:hover {
transform: scale(1.03);
box-shadow: var(--card-hover-shadow);
z-index: 2;
border-color: var(--primary-color);
}
.photo-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: var(--transition);
opacity: 0;
transform: scale(0.95);
}
.photo-img.loaded {
opacity: 1;
transform: scale(1);
}
/* Added optimized loading spinner for individual photos */
.photo-loading-spinner {
width: 30px;
height: 30px;
border: 3px solid rgba(168, 218, 220, 0.3);
border-radius: 50%;
border-top-color: var(--secondary-color);
animation: spin 1s ease-in-out infinite;
position: absolute;
z-index: 2;
}
.preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
display: flex;
}
.preview-modal.active {
opacity: 1;
}
.modal-content {
position: relative;
max-width: 90%;
max-height: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.modal-img {
/* 移动端横向滚动 */
overflow-x: auto;
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: 8px;
transform: scale(0.5);
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
opacity: 0;
box-shadow: 0 20px 40px rgba(230, 57, 70, 0.2);
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox 隐藏滚动条 */
}
:deep(.view-mode-group)::-webkit-scrollbar { display: none; /* Chrome 隐藏滚动条 */ }
.modal-img.active {
transform: scale(1);
opacity: 1;
}
.modal-tools {
display: flex;
gap: 1.5rem;
margin-top: 2rem;
}
.tool-btn {
background: rgba(230, 57, 70, 0.3);
:deep(.group-btn) {
background: transparent;
border: none;
width: 50px;
height: 50px;
border-radius: 50%;
color: rgba(255, 255, 255, 0.6);
min-width: 44px; /* 增大点击区域 */
height: 36px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition);
color: white;
font-size: 1.2rem;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
font-size: 1.1rem;
}
.tool-btn:hover {
background: rgba(230, 57, 70, 0.5);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(230, 57, 70, 0.3);
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 60px;
height: 60px;
background: rgba(230, 57, 70, 0.3);
border: none;
border-radius: 50%;
color: white;
font-size: 1.5rem;
cursor: pointer;
transition: var(--transition);
z-index: 10;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.nav-btn:hover:not(:disabled) {
background: rgba(230, 57, 70, 0.5);
transform: translateY(-50%) scale(1.1);
}
.nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.prev-btn {
left: 30px;
}
.next-btn {
right: 30px;
}
.close-modal {
position: absolute;
top: 40px;
right: 40px;
background: rgba(230, 57, 70, 0.3);
border: none;
width: 50px;
height: 50px;
border-radius: 50%;
color: white;
font-size: 1.5rem;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.close-modal:hover {
background: rgba(230, 57, 70, 0.5);
transform: rotate(90deg);
}
@media (max-width: 1024px) {
.container {
padding: 2rem;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
:deep(.group-btn:hover) { color: #fff; background: rgba(255, 255, 255, 0.1); }
:deep(.group-btn.active) { background: #e63946; color: white; box-shadow: 0 2px 8px rgba(230, 57, 70, 0.4); }
/* --- 移动端全局适配媒体查询 --- */
@media (max-width: 768px) {
.container {
padding: 1.5rem;
.header-controls {
flex-direction: row;
gap: 0.8rem;
width: 100%;
justify-content: space-between;
}
.detail-title {
font-size: 2rem;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.tool-btn, .close-modal {
width: 45px;
height: 45px;
font-size: 1.1rem;
}
.nav-btn {
width: 50px;
height: 50px;
font-size: 1.3rem;
}
}
@media (max-width: 480px) {
.container {
padding: 1rem;
}
.detail-title {
font-size: 1.8rem;
}
.photo-grid {
grid-template-columns: 1fr;
.back-button {
padding: 0.5rem 1rem;
font-size: 0.85rem;
}
}
</style>

View File

@@ -1,40 +1,223 @@
<template>
<div class="album-index">
<div class="container">
<h1 class="main-title">我们的相册</h1>
<!-- 头部区域标题 + 视图切换 -->
<div class="header-section">
<h1 class="main-title">影像集</h1>
<div v-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<p>加载中...</p>
<!-- 视图切换按钮组 -->
<div class="view-mode-group glass-panel">
<a-tooltip title="网格视图" color="#e63946">
<button
class="group-btn"
:class="{ active: viewMode === 'grid' }"
@click="changeViewMode('grid')"
>
<i class="fas fa-th-large"></i>
</button>
</a-tooltip>
<a-tooltip title="列表视图" color="#e63946">
<button
class="group-btn"
:class="{ active: viewMode === 'list' }"
@click="changeViewMode('list')"
>
<i class="fas fa-list-ul"></i>
</button>
</a-tooltip>
<a-tooltip title="时光轴" color="#e63946">
<button
class="group-btn"
:class="{ active: viewMode === 'timeline' }"
@click="changeViewMode('timeline')"
>
<i class="fas fa-stream"></i>
</button>
</a-tooltip>
<a-tooltip title="卡片堆叠" color="#e63946">
<button
class="group-btn"
:class="{ active: viewMode === 'stack' }"
@click="changeViewMode('stack')"
>
<i class="fas fa-layer-group"></i>
</button>
</a-tooltip>
<a-tooltip title="3D 封面流" color="#e63946">
<button
class="group-btn"
:class="{ active: viewMode === 'coverflow' }"
@click="changeViewMode('coverflow')"
>
<i class="fas fa-box-open"></i>
</button>
</a-tooltip>
</div>
</div>
<div v-else class="album-selection">
<div
v-for="album in albums"
:key="album.id"
class="album-card"
@click="goToAlbum(album.id)"
>
<div class="album-cover">
<!-- Added optimized image loading with lazy loading and error handling -->
<img
:src="`http://i.nailaoyun.cn${album.url}`"
:alt="album.title"
loading="lazy"
@load="onImageLoad"
@error="onImageError"
class="cover-image"
/>
<div class="album-overlay">
<div class="album-info">
<h3 class="album-title">{{ album.title }}</h3>
<p class="album-date">{{ formatDate(album.created_at) }}</p>
<!-- Added photo count display -->
<p class="album-count">{{ album.photo_count }} 张照片</p>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="loading-spinner"></div>
<p>正在加载相册...</p>
</div>
<!-- 空状态 -->
<div v-else-if="albums.length === 0" class="empty-state">
<i class="fas fa-folder-open empty-icon"></i>
<p>暂无相册去创建一个吧</p>
</div>
<!-- 主内容区根据模式切换 -->
<div v-else class="content-wrapper">
<!-- 模式 A: 网格视图 (Grid) -->
<transition name="fade-up" mode="out-in">
<div v-if="viewMode === 'grid'" class="album-grid">
<div
v-for="album in albums"
:key="album.id"
class="album-card"
@click="goToAlbum(album.id)"
>
<div class="album-cover">
<img
:src="getImgUrl(album.url)"
:alt="album.title"
loading="lazy"
class="cover-image"
/>
<div class="card-overlay">
<div class="card-content">
<h3 class="card-title">{{ album.title }}</h3>
<div class="card-meta">
<span class="date">{{ formatDate(album.created_at) }}</span>
<span class="count">{{ album.photo_count }} </span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- 模式 B: 列表视图 (List) -->
<transition name="fade-up" mode="out-in">
<div v-if="viewMode === 'list'" class="album-list">
<div
v-for="album in albums"
:key="album.id"
class="list-item glass-panel"
@click="goToAlbum(album.id)"
>
<div class="list-cover">
<img :src="getImgUrl(album.url)" loading="lazy" />
</div>
<div class="list-info">
<div class="info-header">
<h3 class="list-title">{{ album.title }}</h3>
<span class="list-count badge">{{ album.photo_count }} Photos</span>
</div>
<p class="list-desc">{{ album.description || '暂无描述...' }}</p>
<div class="list-footer">
<span class="list-date"><i class="far fa-calendar-alt"></i> {{ formatDate(album.created_at) }}</span>
<button class="enter-btn">进入相册 <i class="fas fa-arrow-right"></i></button>
</div>
</div>
</div>
</div>
</transition>
<!-- 模式 C: 时光轴视图 (Timeline) -->
<transition name="fade-up" mode="out-in">
<div v-if="viewMode === 'timeline'" class="album-timeline">
<div class="timeline-line"></div>
<div
v-for="(album, index) in albums"
:key="album.id"
class="timeline-item"
:class="index % 2 === 0 ? 'left' : 'right'"
@click="goToAlbum(album.id)"
>
<div class="timeline-dot"></div>
<div class="timeline-date">{{ formatDate(album.created_at) }}</div>
<div class="timeline-content glass-panel">
<div class="timeline-cover">
<img :src="getImgUrl(album.url)" loading="lazy" />
</div>
<div class="timeline-info">
<h3>{{ album.title }}</h3>
<span class="count">{{ album.photo_count }} 张照片</span>
</div>
</div>
</div>
</div>
</transition>
<!-- 模式 D: 卡片堆叠视图 (Card Stack) -->
<transition name="fade-scale" mode="out-in">
<div v-if="viewMode === 'stack'" class="album-stack">
<swiper
:effect="'cards'"
:grabCursor="true"
:modules="swiperModules"
class="stack-swiper"
>
<swiper-slide v-for="album in albums" :key="album.id" @click="goToAlbum(album.id)">
<div class="stack-card glass-panel">
<div class="stack-cover">
<img :src="getImgUrl(album.url)" />
</div>
<div class="stack-info">
<h3>{{ album.title }}</h3>
<div class="stack-meta">
<span>{{ formatDate(album.created_at) }}</span>
<span>{{ album.photo_count }} </span>
</div>
</div>
</div>
</swiper-slide>
</swiper>
<div class="hint-text">
<i class="fas fa-layer-group"></i> 左右滑动切换卡片
</div>
</div>
</transition>
<!-- 模式 E: 3D 封面流 (Cover Flow) -->
<transition name="fade-scale" mode="out-in">
<div v-if="viewMode === 'coverflow'" class="album-coverflow">
<swiper
:effect="'coverflow'"
:grabCursor="true"
:centeredSlides="true"
:slidesPerView="'auto'"
:coverflowEffect="{
rotate: 50,
stretch: 0,
depth: 100,
modifier: 1,
slideShadows: true,
}"
:pagination="{ clickable: true }"
:modules="swiperModules"
class="coverflow-swiper"
>
<swiper-slide v-for="album in albums" :key="album.id" @click="goToAlbum(album.id)">
<div class="slide-inner">
<img :src="getImgUrl(album.url)" />
<div class="slide-info glass-panel">
<h3>{{ album.title }}</h3>
<p>{{ album.photo_count }} 张照片</p>
</div>
</div>
</swiper-slide>
</swiper>
<div class="hint-text">
<i class="fas fa-hand-pointer"></i> 左右滑动选择 · 点击进入
</div>
</div>
</transition>
</div>
</div>
</div>
@@ -45,48 +228,57 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { albumListApi } from "@/api/request/api.js"
// Swiper
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectCoverflow, EffectCards, Pagination } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-coverflow';
import 'swiper/css/effect-cards';
import 'swiper/css/pagination';
const router = useRouter()
const albums = ref([])
const loading = ref(true)
const viewMode = ref('grid') // 'grid' | 'list' | 'timeline' | 'stack' | 'coverflow'
const swiperModules = [EffectCoverflow, EffectCards, Pagination]
const getImgUrl = (path) => path ? `http://i.nailaoyun.cn${path}` : ''
const getList = async () => {
loading.value = true
try {
const res = await albumListApi()
albums.value = res
albums.value = res || []
} catch (error) {
console.error('获取相册列表失败:', error)
// Fallback to empty array on error
albums.value = []
} finally {
loading.value = false
}
}
const onImageLoad = (event) => {
event.target.classList.add('loaded')
}
const onImageError = (event) => {
// Fallback to placeholder on error
event.target.src = '/placeholder.svg?height=300&width=400&text=加载失败'
event.target.classList.add('error')
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
const goToAlbum = (albumId) => {
router.push(`/detail/${albumId}`)
}
// 切换模式并持久化
const changeViewMode = (mode) => {
viewMode.value = mode
localStorage.setItem('album_view_mode', mode)
}
onMounted(() => {
// 读取缓存的视图模式
const savedMode = localStorage.getItem('album_view_mode')
if (savedMode) {
viewMode.value = savedMode
}
getList()
})
</script>
@@ -97,209 +289,470 @@ onMounted(() => {
min-height: 100vh;
position: relative;
z-index: 2;
padding-bottom: 4rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 3rem;
padding: 2rem;
}
/* 头部布局 */
.header-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 3rem;
position: relative;
}
.main-title {
color: var(--light-color);
text-align: center;
margin-bottom: 3rem;
font-weight: 300;
letter-spacing: 3px;
letter-spacing: 4px;
font-size: 2.5rem;
text-transform: uppercase;
position: relative;
padding-bottom: 1rem;
text-shadow: 0 0 10px rgba(230, 57, 70, 0.3);
margin-bottom: 1.5rem;
text-shadow: 0 0 20px rgba(230, 57, 70, 0.4);
}
.main-title::after {
content: '';
/* 视图切换按钮组 */
.view-mode-group {
display: flex;
background: rgba(255, 255, 255, 0.05);
border-radius: 30px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
/* 移动端支持横向滑动 */
overflow-x: auto;
max-width: 100%;
}
.group-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.5);
width: 44px;
height: 36px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0; /* 防止挤压 */
}
.group-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.1); }
.group-btn.active { background: #e63946; color: white; box-shadow: 0 2px 10px rgba(230, 57, 70, 0.4); }
/* --- 模式 A: 网格视图 --- */
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.album-card {
position: relative;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s ease, box-shadow 0.3s ease;
background: #1a1a1a;
border: 1px solid rgba(255,255,255,0.05);
}
.album-card:hover {
transform: translateY(-8px);
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.4);
border-color: #e63946;
}
.album-cover {
aspect-ratio: 4/3;
position: relative;
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.album-card:hover .cover-image { transform: scale(1.1); }
.card-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.2) 50%, transparent 100%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 1.5rem;
opacity: 0.9;
}
.card-title {
color: #fff;
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.card-meta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: rgba(255,255,255,0.7);
}
/* --- 模式 B: 列表视图 --- */
.album-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1000px;
margin: 0 auto;
}
.list-item {
display: flex;
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
height: 180px;
}
.list-item:hover {
background: rgba(30, 30, 30, 0.8);
border-color: #e63946;
transform: translateX(10px);
}
.list-cover {
width: 280px;
height: 100%;
flex-shrink: 0;
}
.list-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.list-info {
flex: 1;
padding: 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.list-title {
color: #fff;
font-size: 1.4rem;
margin: 0;
}
.badge {
background: rgba(230, 57, 70, 0.2);
color: #e63946;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.list-desc {
color: rgba(255,255,255,0.6);
font-size: 0.95rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0.5rem 0;
}
.list-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.list-date {
color: rgba(255,255,255,0.4);
font-size: 0.85rem;
}
.enter-btn {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
padding: 6px 16px;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
font-size: 0.85rem;
}
.list-item:hover .enter-btn {
background: #e63946;
border-color: #e63946;
}
/* --- 模式 C: 时光轴 (Timeline) --- */
.album-timeline {
position: relative;
max-width: 1000px;
margin: 0 auto;
padding: 2rem 0;
}
/* 垂直线 */
.timeline-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: rgba(255,255,255,0.1);
transform: translateX(-50%);
width: 100px;
height: 3px;
background: var(--primary-color);
box-shadow: 0 0 10px rgba(230, 57, 70, 0.5);
}
.timeline-item {
position: relative;
margin-bottom: 4rem;
width: 50%;
padding: 0 40px;
box-sizing: border-box;
cursor: pointer;
transition: transform 0.3s ease;
}
.timeline-item:hover { transform: translateY(-5px); }
.timeline-item.left { left: 0; text-align: right; }
.timeline-item.right { left: 50%; text-align: left; }
/* 节点 */
.timeline-dot {
position: absolute;
top: 20px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #e63946;
border: 4px solid #1a1a1a;
box-shadow: 0 0 0 2px rgba(230, 57, 70, 0.5);
z-index: 2;
}
.timeline-item.left .timeline-dot { right: -8px; }
.timeline-item.right .timeline-dot { left: -8px; }
/* 日期 */
.timeline-date {
position: absolute;
top: 18px;
color: #e63946;
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 1px;
}
.timeline-item.left .timeline-date { right: -120px; text-align: left; width: 100px; }
.timeline-item.right .timeline-date { left: -120px; text-align: right; width: 100px; }
/* 内容卡片 */
.timeline-content {
background: rgba(30,30,30,0.6);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.3s;
}
.timeline-item:hover .timeline-content { border-color: #e63946; }
.timeline-cover {
width: 100%;
height: 180px;
}
.timeline-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.timeline-info {
padding: 1rem;
}
.timeline-info h3 { color: #fff; margin: 0 0 5px 0; font-size: 1.1rem; }
.timeline-info .count { color: rgba(255,255,255,0.5); font-size: 0.8rem; }
/* --- 模式 D: 卡片堆叠 (Stack) --- */
.album-stack {
display: flex;
justify-content: center;
align-items: center;
min-height: 500px;
flex-direction: column;
}
.stack-swiper {
width: 320px;
height: 460px;
}
.stack-card {
width: 100%;
height: 100%;
border-radius: 16px;
background: #222;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.stack-cover {
height: 320px;
overflow: hidden;
}
.stack-cover img { width: 100%; height: 100%; object-fit: cover; }
.stack-info {
padding: 1.5rem;
background: linear-gradient(to bottom, #2a2a2a, #222);
height: calc(100% - 320px);
display: flex;
flex-direction: column;
justify-content: center;
}
.stack-info h3 { color: #fff; font-size: 1.4rem; margin-bottom: 0.5rem; }
.stack-meta { color: #e63946; font-size: 0.9rem; display: flex; justify-content: space-between; }
/* --- 模式 E: Cover Flow --- */
.album-coverflow {
padding: 3rem 0;
text-align: center;
}
.coverflow-swiper {
width: 100%;
padding-top: 50px;
padding-bottom: 50px;
}
.coverflow-swiper .swiper-slide {
background-position: center;
background-size: cover;
width: 300px;
height: 400px;
/* 倒影效果 (CSS Reflect) */
-webkit-box-reflect: below 1px linear-gradient(transparent, transparent 70%, rgba(0,0,0,0.3));
}
.slide-inner {
width: 100%;
height: 100%;
position: relative;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
cursor: pointer;
}
.slide-inner img {
width: 100%;
height: 100%;
object-fit: cover;
}
.slide-info {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 1rem;
background: rgba(0,0,0,0.7);
color: #fff;
transform: translateY(100%);
transition: transform 0.3s;
}
.swiper-slide-active .slide-info {
transform: translateY(0);
}
.hint-text {
margin-top: 2rem;
color: rgba(255,255,255,0.5);
font-size: 0.9rem;
}
.coverflow-hint {
margin-top: 2rem;
color: rgba(255,255,255,0.5);
font-size: 0.9rem;
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
min-height: 400px;
color: var(--light-color);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(168, 218, 220, 0.3);
width: 40px; height: 40px;
border: 3px solid rgba(230, 57, 70, 0.3); border-top-color: #e63946;
border-radius: 50%;
border-top-color: var(--secondary-color);
animation: spin 1s ease-in-out infinite;
animation: spin 1s infinite linear;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin { 100% { transform: rotate(360deg); } }
.album-selection {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 3rem;
margin-top: 3rem;
}
/* Transitions */
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.4s ease; }
.fade-up-enter-from, .fade-up-leave-to { opacity: 0; transform: translateY(20px); }
.album-card {
background: rgba(29, 0, 0, 0.7);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--card-shadow);
transition: var(--transition);
cursor: pointer;
position: relative;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(230, 57, 70, 0.2);
transform: translateY(0);
}
.album-card:hover {
transform: translateY(-10px);
box-shadow: var(--card-hover-shadow);
border-color: var(--primary-color);
}
.album-cover {
height: 240px;
position: relative;
transition: var(--transition);
background-color: rgba(20, 0, 0, 0.6);
overflow: hidden;
}
/* Added optimized image styles */
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
opacity: 0;
transform: scale(1.1);
}
.cover-image.loaded {
opacity: 1;
transform: scale(1);
}
.cover-image.error {
opacity: 0.5;
filter: grayscale(100%);
}
.album-card:hover .cover-image {
transform: scale(1.05);
}
.album-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 2rem;
opacity: 1;
transition: var(--transition);
}
.album-info {
padding: 2rem;
text-align: center;
}
.album-title {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--light-color);
letter-spacing: 1px;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
.album-date {
font-size: 0.9rem;
color: var(--secondary-color);
letter-spacing: 1px;
}
/* Added photo count styling */
.album-count {
font-size: 0.8rem;
color: var(--secondary-color);
opacity: 0.8;
margin-top: 0.3rem;
}
@media (max-width: 1024px) {
.container {
padding: 2rem;
}
.album-selection {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
}
}
.fade-scale-enter-active, .fade-scale-leave-active { transition: all 0.4s ease; }
.fade-scale-enter-from, .fade-scale-leave-to { opacity: 0; transform: scale(0.9); }
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 1.5rem;
}
.header-section { margin-bottom: 2rem; }
.main-title { font-size: 1.8rem; margin-bottom: 1rem; }
.main-title {
font-size: 2rem;
margin-bottom: 2rem;
}
.list-item { height: auto; flex-direction: column; }
.list-cover { width: 100%; height: 200px; }
.list-item:hover { transform: translateY(-5px); }
.album-selection {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.5rem;
}
.coverflow-swiper .swiper-slide { width: 240px; height: 320px; }
.album-cover {
height: 200px;
}
}
@media (max-width: 480px) {
.container {
padding: 1rem;
}
.main-title {
font-size: 1.8rem;
}
.album-selection {
grid-template-columns: 1fr;
}
/* 时光轴移动端适配 */
.timeline-line { left: 20px; }
.timeline-item { width: 100%; padding-left: 50px; padding-right: 0; margin-bottom: 2rem; }
.timeline-item.left, .timeline-item.right { left: 0; text-align: left; }
.timeline-dot { left: 12px !important; right: auto !important; }
.timeline-date { position: relative; top: 0; left: 0 !important; right: auto !important; text-align: left !important; margin-bottom: 0.5rem; display: block; width: auto; }
}
</style>