Files
album-view/src/components/AlbumGallery.vue

298 lines
7.4 KiB
Vue
Raw Normal View History

2025-12-12 10:58:07 +08:00
<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>