import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; import { resolveImageUrl } from "@/services/image-storage"; type CachedResourceImageProps = Omit, "src"> & { storageKey?: string; src?: string; fallback?: ReactNode; eager?: boolean; }; /** * 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。 * 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。 */ export function CachedResourceImage({ storageKey, src = "", fallback = null, eager = false, onError, ...props }: CachedResourceImageProps) { const remoteResource = Boolean(resourceIdFromStorageKey(storageKey)); const localImageResource = Boolean(storageKey && storageKey.startsWith("image:")); const targetRef = useRef(null); const [nearViewport, setNearViewport] = useState(eager || !remoteResource); const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src); const [cacheFailed, setCacheFailed] = useState(false); useEffect(() => { if (!remoteResource || eager) { setNearViewport(true); return; } const image = targetRef.current; if (!image || typeof IntersectionObserver === "undefined") { setNearViewport(true); return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNearViewport(true); observer.disconnect(); } }, { rootMargin: "240px" }, ); observer.observe(image); return () => observer.disconnect(); }, [eager, remoteResource]); useEffect(() => { let cancelled = false; setCacheFailed(false); if (remoteResource && storageKey) { if (!nearViewport) { setCachedSrc(""); return () => { cancelled = true; }; } setCachedSrc(""); const resolve = cacheResourceObjectUrl(storageKey); void resolve .then((url) => { if (!cancelled) setCachedSrc(url || src); }) .catch(() => { if (!cancelled) { setCacheFailed(true); setCachedSrc(src); } }); return () => { cancelled = true; }; } if (localImageResource && storageKey) { void resolveImageUrl(storageKey, src) .then((url) => { if (!cancelled) setCachedSrc(url || src); }) .catch(() => { if (!cancelled) setCachedSrc(src); }); return () => { cancelled = true; }; } setCachedSrc(src); return () => { cancelled = true; }; }, [localImageResource, nearViewport, remoteResource, src, storageKey]); const handleImgError = (e: React.SyntheticEvent) => { if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) { void resolveImageUrl(storageKey) .then((url) => { if (url && url !== cachedSrc) { setCachedSrc(url); return; } setCacheFailed(true); onError?.(e); }) .catch(() => { setCacheFailed(true); onError?.(e); }); return; } setCacheFailed(true); onError?.(e); }; if (!remoteResource) { if (cacheFailed && fallback) return <>{fallback}; return ; } return ( {cachedSrc && !(cacheFailed && !src) ? : fallback} ); }