增加多种效果
This commit is contained in:
@@ -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
33
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
81
src/App.vue
81
src/App.vue
@@ -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>
|
||||
|
||||
297
src/components/AlbumGallery.vue
Normal file
297
src/components/AlbumGallery.vue
Normal 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>
|
||||
509
src/components/CinematicSlideshow.vue
Normal file
509
src/components/CinematicSlideshow.vue
Normal 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>
|
||||
488
src/components/PhotoPlanet.vue
Normal file
488
src/components/PhotoPlanet.vue
Normal 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>
|
||||
324
src/components/PhotoWall.vue
Normal file
324
src/components/PhotoWall.vue
Normal 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>
|
||||
231
src/components/SnowEffect.vue
Normal file
231
src/components/SnowEffect.vue
Normal 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>
|
||||
283
src/components/SwipeSlideshow.vue
Normal file
283
src/components/SwipeSlideshow.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user