mixvideo-v2/apps/desktop/src/components/similarity/SimilaritySearchCard.tsx

194 lines
7.1 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import {
ExternalLink,
Tag,
Image as ImageIcon,
TrendingUp
} from 'lucide-react';
import { SimilaritySearchCardProps } from '../../types/similaritySearch';
import SimilaritySearchService from '../../services/similaritySearchService';
/**
* 相似度检索结果卡片组件
* 遵循设计系统规范,提供统一的结果展示界面
*/
export const SimilaritySearchCard: React.FC<SimilaritySearchCardProps> = ({
result,
onSelect,
onExternalLinkClick,
showScore = true,
compact = false,
}) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
// 处理卡片点击
const handleCardClick = useCallback((e: React.MouseEvent) => {
// 如果点击的是按钮或其子元素,不触发卡片选择
const target = e.target as HTMLElement;
if (target.closest('button')) {
return;
}
if (onSelect) {
onSelect(result);
}
}, [result, onSelect]);
// 处理外部链接点击
const handleExternalClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (result.image_url) {
if (onExternalLinkClick) {
onExternalLinkClick(result.image_url);
} else {
window.open(result.image_url, '_blank', 'noopener,noreferrer');
}
}
}, [result.image_url, onExternalLinkClick]);
// 处理图片加载
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
}, []);
// 处理图片错误
const handleImageError = useCallback(() => {
setImageError(true);
setImageLoaded(true);
}, []);
// 获取相关性评分样式
const scoreColor = SimilaritySearchService.getRelevanceScoreColor(result.relevance_score);
const formattedScore = SimilaritySearchService.formatRelevanceScore(result.relevance_score);
return (
<div
className={`
card card-interactive group cursor-pointer animate-fade-in-up
relative overflow-hidden bg-gradient-to-br from-white to-gray-50/50
hover:from-white hover:to-primary-50/30 transition-all duration-500
hover:shadow-lg hover:shadow-primary-500/10 hover:-translate-y-1
border border-gray-200 hover:border-primary-300
${compact ? 'p-4' : 'p-6'}
`}
onClick={handleCardClick}
>
{/* 装饰性背景 */}
<div className="absolute top-0 right-0 w-24 h-24 bg-gradient-to-br from-primary-100 to-primary-200 rounded-full -translate-y-12 translate-x-12 opacity-40 group-hover:opacity-60 transition-all duration-500 group-hover:scale-110"></div>
{/* 相关性评分 */}
{showScore && (
<div className="absolute top-3 right-3 z-10">
<div className={`px-2 py-1 rounded-full text-xs font-medium bg-white shadow-sm border ${scoreColor}`}>
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{formattedScore}
</div>
</div>
</div>
)}
<div className="relative">
{/* 图片区域 */}
<div className={`relative ${compact ? 'h-32' : 'h-40'} bg-gray-100 rounded-lg overflow-hidden mb-4`}>
{result.image_url && !imageError ? (
<>
<img
src={result.image_url}
alt={result.style_description}
className={`w-full h-full object-cover transition-all duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
<ImageIcon className="w-8 h-8" />
</div>
)}
{/* 外部链接按钮 */}
{result.image_url && (
<button
onClick={handleExternalClick}
className="absolute top-2 left-2 p-1.5 bg-white/90 hover:bg-white rounded-lg shadow-sm transition-all duration-200 opacity-0 group-hover:opacity-100"
>
<ExternalLink className="w-3 h-3 text-gray-600" />
</button>
)}
</div>
{/* 内容区域 */}
<div className="space-y-3">
{/* 标题和描述 */}
<div>
<h3 className={`font-semibold text-high-emphasis line-clamp-2 ${
compact ? 'text-sm' : 'text-base'
}`}>
{result.style_description || '未知风格'}
</h3>
</div>
{/* 环境标签 */}
{result.environment_tags && result.environment_tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{result.environment_tags.slice(0, compact ? 2 : 3).map((tag: string, index: number) => (
<span
key={index}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-full"
>
<Tag className="w-2.5 h-2.5" />
{tag}
</span>
))}
{result.environment_tags.length > (compact ? 2 : 3) && (
<span className="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
+{result.environment_tags.length - (compact ? 2 : 3)}
</span>
)}
</div>
)}
{/* 产品信息 */}
{result.products && result.products.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-medium-emphasis font-medium"></div>
<div className="space-y-1">
{result.products.slice(0, compact ? 1 : 2).map((product: any, index: number) => (
<div key={index} className="text-xs text-gray-600">
<span className="font-medium">{product.category}</span>
{product.description && (
<span className="ml-2 text-gray-500 line-clamp-1">
{product.description}
</span>
)}
</div>
))}
{result.products.length > (compact ? 1 : 2) && (
<div className="text-xs text-gray-500">
{result.products.length - (compact ? 1 : 2)} ...
</div>
)}
</div>
</div>
)}
</div>
{/* 悬停效果 */}
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-transparent to-primary-50/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-lg"></div>
</div>
</div>
);
};
export default SimilaritySearchCard;