feat: lazy load learning map images
This commit is contained in:
@@ -8,4 +8,6 @@ services:
|
|||||||
container_name: ittoview
|
container_name: ittoview
|
||||||
ports:
|
ports:
|
||||||
- "11.144.144.9:8035:80"
|
- "11.144.144.9:8035:80"
|
||||||
|
volumes:
|
||||||
|
- ./src/data/image:/usr/share/nginx/html/learning-images:ro
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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 {
|
location /assets {
|
||||||
expires 1y;
|
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
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageModules = import.meta.glob('../data/image/*.{png,jpg,jpeg,webp,avif,gif}', {
|
const IMAGE_DIRECTORY = '/learning-images/'
|
||||||
eager: true,
|
const IMAGE_EXTENSIONS = /\.(png|jpe?g|webp|avif|gif)$/i
|
||||||
import: 'default',
|
|
||||||
query: '?url',
|
|
||||||
}) as Record<string, string>
|
|
||||||
|
|
||||||
const learningMapImages: LearningMapImage[] = Object.entries(imageModules)
|
function imageHrefToItem(href: string): LearningMapImage | null {
|
||||||
.map(([path, src]) => {
|
const cleanHref = href.split('?')[0].split('#')[0]
|
||||||
const fileName = decodeURIComponent(path.split('/').pop() || 'learning-map')
|
const rawFileName = cleanHref.split('/').pop()
|
||||||
|
|
||||||
|
if (!rawFileName || rawFileName === '..' || !IMAGE_EXTENSIONS.test(rawFileName)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = decodeURIComponent(rawFileName)
|
||||||
const title = fileName.replace(/\.[^.]+$/, '')
|
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 }))
|
.sort((a, b) => a.fileName.localeCompare(b.fileName, 'zh-CN', { numeric: true }))
|
||||||
|
}
|
||||||
|
|
||||||
function getDistance(a: Point, b: Point) {
|
function getDistance(a: Point, b: Point) {
|
||||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
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() {
|
export function LearningMapsPage() {
|
||||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 })
|
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 })
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [learningMapImages, setLearningMapImages] = useState<LearningMapImage[]>([])
|
||||||
|
const [isLoadingImages, setIsLoadingImages] = useState(true)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const pointersRef = useRef(new Map<number, Point>())
|
const pointersRef = useRef(new Map<number, Point>())
|
||||||
const lastDragPointRef = useRef<Point | null>(null)
|
const lastDragPointRef = useRef<Point | null>(null)
|
||||||
@@ -80,6 +152,29 @@ export function LearningMapsPage() {
|
|||||||
[offset.x, offset.y, scale]
|
[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) => {
|
const showMessage = (text: string) => {
|
||||||
setMessage(text)
|
setMessage(text)
|
||||||
window.setTimeout(() => setMessage(''), 1800)
|
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>
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">用一张图看清知识结构。</p>
|
||||||
</div>
|
</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
|
<motion.div
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
@@ -256,12 +355,7 @@ export function LearningMapsPage() {
|
|||||||
onClick={() => openViewer(index)}
|
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"
|
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
|
<LazyLearningMapImage image={image} />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user