feat: lazy load learning map images

This commit is contained in:
ittoview
2026-05-03 04:01:50 +01:00
parent cf96a9727c
commit 667553649f
9 changed files with 123 additions and 19 deletions

View File

@@ -8,4 +8,6 @@ services:
container_name: ittoview
ports:
- "11.144.144.9:8035:80"
volumes:
- ./src/data/image:/usr/share/nginx/html/learning-images:ro
restart: always

View File

@@ -9,6 +9,14 @@ server {
try_files $uri $uri/ /index.html;
}
# 一图流图片目录,由 Docker 挂载提供,支持运行时新增图片
location /learning-images/ {
alias /usr/share/nginx/html/learning-images/;
autoindex on;
charset utf-8;
add_header Cache-Control "no-store";
}
# 静态资源缓存
location /assets {
expires 1y;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -15,19 +15,41 @@ type Point = {
y: number
}
const imageModules = import.meta.glob('../data/image/*.{png,jpg,jpeg,webp,avif,gif}', {
eager: true,
import: 'default',
query: '?url',
}) as Record<string, string>
const IMAGE_DIRECTORY = '/learning-images/'
const IMAGE_EXTENSIONS = /\.(png|jpe?g|webp|avif|gif)$/i
const learningMapImages: LearningMapImage[] = Object.entries(imageModules)
.map(([path, src]) => {
const fileName = decodeURIComponent(path.split('/').pop() || 'learning-map')
function imageHrefToItem(href: string): LearningMapImage | null {
const cleanHref = href.split('?')[0].split('#')[0]
const rawFileName = cleanHref.split('/').pop()
if (!rawFileName || rawFileName === '..' || !IMAGE_EXTENSIONS.test(rawFileName)) {
return null
}
const fileName = decodeURIComponent(rawFileName)
const title = fileName.replace(/\.[^.]+$/, '')
return { src, fileName, title }
})
return {
src: `${IMAGE_DIRECTORY}${encodeURIComponent(fileName)}`,
fileName,
title,
}
}
async function loadLearningMapImages() {
const response = await fetch(IMAGE_DIRECTORY, { cache: 'no-store' })
if (!response.ok) {
throw new Error('无法读取学习图谱')
}
const html = await response.text()
const documentHtml = new DOMParser().parseFromString(html, 'text/html')
return Array.from(documentHtml.querySelectorAll<HTMLAnchorElement>('a'))
.map((link) => imageHrefToItem(link.getAttribute('href') || ''))
.filter((item): item is LearningMapImage => Boolean(item))
.sort((a, b) => a.fileName.localeCompare(b.fileName, 'zh-CN', { numeric: true }))
}
function getDistance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y)
@@ -61,11 +83,61 @@ async function blobToPngBlob(blob: Blob) {
})
}
function LazyLearningMapImage({ image }: { image: LearningMapImage }) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [shouldLoad, setShouldLoad] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
useEffect(() => {
const container = containerRef.current
if (!container || shouldLoad) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return
setShouldLoad(true)
observer.disconnect()
},
{
rootMargin: '700px 0px',
threshold: 0.01,
}
)
observer.observe(container)
return () => observer.disconnect()
}, [shouldLoad])
return (
<div ref={containerRef} className="relative min-h-64 w-full overflow-hidden bg-gray-100 dark:bg-gray-900">
{!isLoaded && (
<div className="absolute inset-0 animate-pulse bg-gradient-to-br from-gray-100 via-gray-50 to-gray-200 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900" />
)}
{shouldLoad && (
<img
src={image.src}
alt={image.title}
loading="lazy"
decoding="async"
onLoad={() => setIsLoaded(true)}
className={clsx(
'w-full object-contain transition duration-300 group-hover:scale-[1.01]',
isLoaded ? 'opacity-100' : 'opacity-0'
)}
/>
)}
</div>
)
}
export function LearningMapsPage() {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [learningMapImages, setLearningMapImages] = useState<LearningMapImage[]>([])
const [isLoadingImages, setIsLoadingImages] = useState(true)
const [message, setMessage] = useState('')
const pointersRef = useRef(new Map<number, Point>())
const lastDragPointRef = useRef<Point | null>(null)
@@ -80,6 +152,29 @@ export function LearningMapsPage() {
[offset.x, offset.y, scale]
)
useEffect(() => {
let isMounted = true
loadLearningMapImages()
.then((images) => {
if (!isMounted) return
setLearningMapImages(images)
})
.catch(() => {
if (!isMounted) return
setLearningMapImages([])
})
.finally(() => {
if (!isMounted) return
setIsLoadingImages(false)
})
return () => {
isMounted = false
}
}, [])
const showMessage = (text: string) => {
setMessage(text)
window.setTimeout(() => setMessage(''), 1800)
@@ -241,7 +336,11 @@ export function LearningMapsPage() {
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"></p>
</div>
{learningMapImages.length > 0 ? (
{isLoadingImages ? (
<div className="rounded-2xl bg-white p-8 text-center text-sm text-gray-500 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700">
</div>
) : learningMapImages.length > 0 ? (
<motion.div
initial="hidden"
animate="visible"
@@ -256,12 +355,7 @@ export function LearningMapsPage() {
onClick={() => openViewer(index)}
className="group mb-4 block w-full break-inside-avoid overflow-hidden rounded-2xl bg-white text-left shadow-sm ring-1 ring-gray-200 transition duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-indigo-200 dark:bg-gray-800 dark:ring-gray-700 dark:hover:ring-indigo-500/50"
>
<img
src={image.src}
alt={image.title}
loading="lazy"
className="w-full bg-gray-100 object-contain transition duration-300 group-hover:scale-[1.01] dark:bg-gray-900"
/>
<LazyLearningMapImage image={image} />
</motion.button>
))}
</motion.div>