claude-code-cchui/src/components/features/SearchImagesGrid.tsx
gaoziman 92ec88e1a3 style(组件): 优化搜索图片网格卡片样式
- 调整圆角为更精致的 rounded 样式
- 更新背景色支持亮色/暗色主题
- 使用 ring 替代 border 实现更细腻的边框效果
- 添加精细的阴影层次感
- 优化 hover 状态的阴影过渡效果
2025-12-22 14:34:07 +08:00

229 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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)}
/>
</>
);
}