feat: lazy load learning map images
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
src/data/image/05-范围管理找问题.png
Normal file
BIN
src/data/image/05-范围管理找问题.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
src/data/image/06-整合与范围管理一图流.png
Normal file
BIN
src/data/image/06-整合与范围管理一图流.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/data/image/07-进度与成本管理一图流.png
Normal file
BIN
src/data/image/07-进度与成本管理一图流.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/08-质量与资源管理一图流.png
Normal file
BIN
src/data/image/08-质量与资源管理一图流.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/data/image/09-沟通与风险管理一图流.png
Normal file
BIN
src/data/image/09-沟通与风险管理一图流.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/data/image/10-采购与干系人管理一图流.png
Normal file
BIN
src/data/image/10-采购与干系人管理一图流.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user