298 lines
7.4 KiB
Vue
298 lines
7.4 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}` : ''
|
|||
|
|
|
|||
|
|
// --- 核心优化:浏览器端 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>
|