299 lines
7.5 KiB
Vue
299 lines
7.5 KiB
Vue
<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>
|