215 lines
6.2 KiB
TypeScript
215 lines
6.2 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||
import { ExternalLink, Image as ImageIcon, FileText } from 'lucide-react';
|
||
import { GroundingSource } from '../types/ragGrounding';
|
||
|
||
/**
|
||
* 引用角标属性接口
|
||
*/
|
||
interface ReferenceFootnoteProps {
|
||
/** 角标编号 */
|
||
index: number;
|
||
/** 引用来源列表 */
|
||
sources: GroundingSource[];
|
||
/** 自定义样式类名 */
|
||
className?: string;
|
||
/** 是否显示详细信息 */
|
||
showDetails?: boolean;
|
||
/** 点击回调 */
|
||
onClick?: (sources: GroundingSource[]) => void;
|
||
}
|
||
|
||
/**
|
||
* 引用来源详情弹窗属性接口
|
||
*/
|
||
interface ReferenceTooltipProps {
|
||
sources: GroundingSource[];
|
||
isVisible: boolean;
|
||
position: { top: number; left: number };
|
||
onClose: () => void;
|
||
}
|
||
|
||
/**
|
||
* 引用来源详情弹窗组件
|
||
*/
|
||
const ReferenceTooltip: React.FC<ReferenceTooltipProps> = ({
|
||
sources,
|
||
isVisible,
|
||
position,
|
||
onClose
|
||
}) => {
|
||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 点击外部关闭弹窗
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (tooltipRef.current && !tooltipRef.current.contains(event.target as Node)) {
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
if (isVisible) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [isVisible, onClose]);
|
||
|
||
if (!isVisible) return null;
|
||
|
||
return (
|
||
<div
|
||
ref={tooltipRef}
|
||
className="fixed z-50 reference-tooltip rounded-lg p-4 max-w-sm animate-fade-in"
|
||
style={{
|
||
top: position.top,
|
||
left: position.left,
|
||
transform: 'translateY(-100%)'
|
||
}}
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold text-gray-900">引用来源</h4>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||
{sources.map((source, index) => (
|
||
<div key={index} className="border-l-2 border-blue-200 pl-3 py-2">
|
||
<div className="flex items-start space-x-2">
|
||
{/* 来源类型图标 */}
|
||
<div className="flex-shrink-0 mt-0.5">
|
||
{source.content?.type === 'image' ? (
|
||
<ImageIcon className="w-4 h-4 text-blue-500" />
|
||
) : (
|
||
<FileText className="w-4 h-4 text-gray-500" />
|
||
)}
|
||
</div>
|
||
|
||
{/* 来源信息 */}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-900 truncate">
|
||
{source.title || '未知来源'}
|
||
</p>
|
||
|
||
{source.content?.description && (
|
||
<p className="text-xs text-gray-600 mt-1 line-clamp-2">
|
||
{source.content.description}
|
||
</p>
|
||
)}
|
||
|
||
{source.uri && (
|
||
<div className="flex items-center mt-1">
|
||
<ExternalLink className="w-3 h-3 text-gray-400 mr-1" />
|
||
<span className="text-xs text-gray-500 truncate">
|
||
{source.uri}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 图片预览 */}
|
||
{source.content?.type === 'image' && source.content?.image_url && (
|
||
<div className="mt-2">
|
||
<img
|
||
src={source.content.image_url}
|
||
alt={source.title || '引用图片'}
|
||
className="w-full h-20 object-cover rounded border"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 引用角标组件
|
||
* 遵循 frontend-developer 规范的设计标准
|
||
*/
|
||
export const ReferenceFootnote: React.FC<ReferenceFootnoteProps> = ({
|
||
index,
|
||
sources,
|
||
className = '',
|
||
showDetails = true,
|
||
onClick
|
||
}) => {
|
||
const [showTooltip, setShowTooltip] = useState(false);
|
||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
||
const footnoteRef = useRef<HTMLButtonElement>(null);
|
||
|
||
// 处理角标点击
|
||
const handleClick = useCallback((event: React.MouseEvent) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
if (onClick) {
|
||
onClick(sources);
|
||
}
|
||
|
||
if (showDetails && footnoteRef.current) {
|
||
const rect = footnoteRef.current.getBoundingClientRect();
|
||
setTooltipPosition({
|
||
top: rect.top + window.scrollY - 8,
|
||
left: rect.left + window.scrollX + rect.width / 2
|
||
});
|
||
setShowTooltip(!showTooltip);
|
||
}
|
||
}, [sources, onClick, showDetails, showTooltip]);
|
||
|
||
// 处理悬停
|
||
const handleMouseEnter = useCallback(() => {
|
||
if (!showTooltip && showDetails && footnoteRef.current) {
|
||
const rect = footnoteRef.current.getBoundingClientRect();
|
||
setTooltipPosition({
|
||
top: rect.top + window.scrollY - 8,
|
||
left: rect.left + window.scrollX + rect.width / 2
|
||
});
|
||
}
|
||
}, [showTooltip, showDetails]);
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={footnoteRef}
|
||
onClick={handleClick}
|
||
onMouseEnter={handleMouseEnter}
|
||
className={`
|
||
inline-flex items-center justify-center
|
||
w-5 h-5 text-xs font-medium
|
||
text-white rounded-full
|
||
reference-footnote
|
||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||
cursor-pointer
|
||
${className}
|
||
`}
|
||
title={`查看引用来源 (${sources.length} 个)`}
|
||
aria-label={`引用角标 ${index},${sources.length} 个来源`}
|
||
>
|
||
{index}
|
||
</button>
|
||
|
||
{/* 引用详情弹窗 */}
|
||
<ReferenceTooltip
|
||
sources={sources}
|
||
isVisible={showTooltip}
|
||
position={tooltipPosition}
|
||
onClose={() => setShowTooltip(false)}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ReferenceFootnote;
|