mixvideo-v2/apps/desktop/src/components/ReferenceFootnote.tsx

215 lines
6.2 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.

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;