Files
album-view/src/components/AlbumGallery.vue
2025-12-12 11:34:31 +08:00

299 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}` : ''
const getImgUrl = (path) => path ? `/t-oss${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>