- 调整圆角为更精致的 rounded 样式 - 更新背景色支持亮色/暗色主题 - 使用 ring 替代 border 实现更细腻的边框效果 - 添加精细的阴影层次感 - 优化 hover 状态的阴影过渡效果
229 lines
7.7 KiB
TypeScript
229 lines
7.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useMemo, useCallback } from 'react';
|
||
import { ExternalLink, Image as ImageIcon } from 'lucide-react';
|
||
import { ImageLightbox } from '@/components/ui/ImageLightbox';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export interface SearchImageItem {
|
||
title: string;
|
||
imageUrl: string;
|
||
width: number;
|
||
height: number;
|
||
score: string;
|
||
position: number;
|
||
sourceUrl?: string;
|
||
}
|
||
|
||
interface SearchImagesGridProps {
|
||
images: SearchImageItem[];
|
||
className?: string;
|
||
/** 最大显示图片数量,默认为 5 */
|
||
maxDisplay?: number;
|
||
}
|
||
|
||
/**
|
||
* 搜索图片瀑布流展示组件
|
||
* 实现动态回填:当图片加载失败时,自动用备用图片替换
|
||
* 确保始终显示指定数量的有效图片
|
||
*/
|
||
export function SearchImagesGrid({
|
||
images,
|
||
className,
|
||
maxDisplay = 5
|
||
}: SearchImagesGridProps) {
|
||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||
|
||
// 已加载成功的图片索引
|
||
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set());
|
||
// 加载失败的图片索引
|
||
const [errorImages, setErrorImages] = useState<Set<number>>(new Set());
|
||
|
||
/**
|
||
* 计算当前要显示的图片列表
|
||
* 策略:从所有图片中选择前 maxDisplay 张有效的图片
|
||
* 跳过已知失败的图片,自动用后续图片回填
|
||
*/
|
||
const displayImages = useMemo(() => {
|
||
const result: { image: SearchImageItem; originalIndex: number }[] = [];
|
||
|
||
for (let i = 0; i < images.length && result.length < maxDisplay; i++) {
|
||
// 跳过已知加载失败的图片
|
||
if (!errorImages.has(i)) {
|
||
result.push({ image: images[i], originalIndex: i });
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}, [images, errorImages, maxDisplay]);
|
||
|
||
// 有效图片的 URL 列表(用于 Lightbox)
|
||
const validImageUrls = useMemo(() => {
|
||
return displayImages
|
||
.filter(({ originalIndex }) => loadedImages.has(originalIndex))
|
||
.map(({ image }) => image.imageUrl);
|
||
}, [displayImages, loadedImages]);
|
||
|
||
// 图片加载成功
|
||
const handleImageLoad = useCallback((index: number) => {
|
||
setLoadedImages((prev) => new Set(prev).add(index));
|
||
}, []);
|
||
|
||
// 图片加载失败 - 标记为错误,会触发重新计算 displayImages,自动回填
|
||
const handleImageError = useCallback((index: number) => {
|
||
setErrorImages((prev) => new Set(prev).add(index));
|
||
}, []);
|
||
|
||
// 打开灯箱
|
||
const openLightbox = useCallback((displayIndex: number) => {
|
||
// 找到在有效加载图片中的索引
|
||
const loadedDisplayImages = displayImages.filter(
|
||
({ originalIndex }) => loadedImages.has(originalIndex)
|
||
);
|
||
const clickedImage = displayImages[displayIndex];
|
||
const lightboxIdx = loadedDisplayImages.findIndex(
|
||
({ originalIndex }) => originalIndex === clickedImage?.originalIndex
|
||
);
|
||
if (lightboxIdx >= 0) {
|
||
setLightboxIndex(lightboxIdx);
|
||
setLightboxOpen(true);
|
||
}
|
||
}, [displayImages, loadedImages]);
|
||
|
||
// 获取源网站域名
|
||
const getSourceDomain = (url?: string) => {
|
||
if (!url) return null;
|
||
try {
|
||
const urlObj = new URL(url);
|
||
return urlObj.hostname.replace('www.', '');
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
if (!images || images.length === 0) return null;
|
||
|
||
// 如果所有图片都加载失败,不显示任何内容
|
||
if (displayImages.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// 统计已成功加载的图片数量
|
||
const successCount = displayImages.filter(
|
||
({ originalIndex }) => loadedImages.has(originalIndex)
|
||
).length;
|
||
|
||
return (
|
||
<>
|
||
<div className={cn('mt-3', className)}>
|
||
{/* 标题 */}
|
||
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
|
||
<ImageIcon size={14} />
|
||
<span>
|
||
搜索到 {successCount > 0 ? successCount : displayImages.length} 张相关图片
|
||
</span>
|
||
</div>
|
||
|
||
{/* 瀑布流容器 */}
|
||
<div className="columns-2 sm:columns-3 gap-3 space-y-3">
|
||
{displayImages.map(({ image, originalIndex }, displayIndex) => {
|
||
const isLoaded = loadedImages.has(originalIndex);
|
||
const sourceDomain = getSourceDomain(image.sourceUrl);
|
||
|
||
return (
|
||
<div
|
||
key={`${image.imageUrl}-${originalIndex}`}
|
||
className={cn(
|
||
'break-inside-avoid mb-3 group relative',
|
||
'rounded overflow-hidden',
|
||
'bg-white dark:bg-zinc-900',
|
||
'ring-1 ring-black/5 dark:ring-white/10',
|
||
'shadow-[0_2px_8px_rgba(0,0,0,0.06)]',
|
||
'transition-all duration-200',
|
||
'hover:shadow-[0_6px_20px_rgba(0,0,0,0.1)]',
|
||
!isLoaded && 'animate-pulse'
|
||
)}
|
||
>
|
||
{/* 图片容器 */}
|
||
<div
|
||
className="relative cursor-pointer"
|
||
onClick={() => openLightbox(displayIndex)}
|
||
>
|
||
{/* 图片 */}
|
||
<img
|
||
src={image.imageUrl}
|
||
alt={image.title || `搜索图片 ${displayIndex + 1}`}
|
||
className={cn(
|
||
'w-full h-auto object-cover',
|
||
'transition-opacity duration-300',
|
||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||
)}
|
||
onLoad={() => handleImageLoad(originalIndex)}
|
||
onError={() => handleImageError(originalIndex)}
|
||
loading="lazy"
|
||
/>
|
||
|
||
{/* 加载占位 */}
|
||
{!isLoaded && (
|
||
<div
|
||
className="absolute inset-0 bg-muted/30 flex items-center justify-center"
|
||
style={{
|
||
aspectRatio: image.width && image.height ? `${image.width}/${image.height}` : '16/9',
|
||
}}
|
||
>
|
||
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 悬浮遮罩 */}
|
||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-200 pointer-events-none" />
|
||
</div>
|
||
|
||
{/* 图片信息 */}
|
||
<div className="p-2 space-y-1">
|
||
{/* 标题 */}
|
||
{image.title && (
|
||
<p
|
||
className="text-xs font-medium text-foreground/90 line-clamp-2"
|
||
title={image.title}
|
||
>
|
||
{image.title}
|
||
</p>
|
||
)}
|
||
|
||
{/* 来源链接 */}
|
||
{image.sourceUrl && sourceDomain && (
|
||
<a
|
||
href={image.sourceUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className={cn(
|
||
'flex items-center gap-1 text-xs',
|
||
'text-muted-foreground hover:text-primary',
|
||
'transition-colors duration-150'
|
||
)}
|
||
>
|
||
<ExternalLink size={10} />
|
||
<span className="truncate">{sourceDomain}</span>
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 图片灯箱 - 只包含已成功加载的图片 */}
|
||
<ImageLightbox
|
||
images={validImageUrls}
|
||
initialIndex={lightboxIndex}
|
||
isOpen={lightboxOpen}
|
||
onClose={() => setLightboxOpen(false)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|