feat: 添加悬浮弹窗能力
This commit is contained in:
140
src/components/Citation/CitationMark.js
Normal file
140
src/components/Citation/CitationMark.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// src/components/Citation/CitationMark.js
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Popover, Typography, Space, Divider } from 'antd';
|
||||||
|
import { FileTextOutlined, UserOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引用标记组件 - 显示上标引用【1】【2】【3】
|
||||||
|
* 支持悬浮(桌面)和点击(移动)两种交互方式
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {number} props.citationId - 引用 ID(1, 2, 3...)
|
||||||
|
* @param {Object} props.citation - 引用数据对象
|
||||||
|
* @param {string} props.citation.author - 作者
|
||||||
|
* @param {string} props.citation.report_title - 报告标题
|
||||||
|
* @param {string} props.citation.declare_date - 发布日期
|
||||||
|
* @param {string} props.citation.sentences - 摘要片段
|
||||||
|
*/
|
||||||
|
const CitationMark = ({ citationId, citation }) => {
|
||||||
|
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||||
|
|
||||||
|
// 如果没有引用数据,不渲染
|
||||||
|
if (!citation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用卡片内容
|
||||||
|
const citationContent = (
|
||||||
|
<div style={{ maxWidth: 350, padding: '8px 4px' }}>
|
||||||
|
{/* 作者 */}
|
||||||
|
<Space align="start" style={{ marginBottom: 8 }}>
|
||||||
|
<UserOutlined style={{ color: '#1890ff', marginTop: 4 }} />
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>作者</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{citation.author}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
{/* 报告标题 */}
|
||||||
|
<Space align="start" style={{ marginBottom: 8 }}>
|
||||||
|
<FileTextOutlined style={{ color: '#52c41a', marginTop: 4 }} />
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>报告标题</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{citation.report_title}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
{/* 发布日期 */}
|
||||||
|
<Space align="start" style={{ marginBottom: 8 }}>
|
||||||
|
<CalendarOutlined style={{ color: '#faad14', marginTop: 4 }} />
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>发布日期</Text>
|
||||||
|
<br />
|
||||||
|
<Text style={{ fontSize: 13 }}>{citation.declare_date}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
{/* 摘要片段 */}
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||||
|
摘要片段
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
display: 'block',
|
||||||
|
color: '#595959'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{citation.sentences}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检测是否为移动设备
|
||||||
|
const isMobile = () => {
|
||||||
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移动端:仅点击触发
|
||||||
|
// 桌面端:悬浮 + 点击都触发
|
||||||
|
const triggerType = isMobile() ? 'click' : ['hover', 'click'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={citationContent}
|
||||||
|
title={`引用来源 [${citationId}]`}
|
||||||
|
trigger={triggerType}
|
||||||
|
placement="top"
|
||||||
|
overlayInnerStyle={{ maxWidth: 380 }}
|
||||||
|
open={popoverVisible}
|
||||||
|
onOpenChange={setPopoverVisible}
|
||||||
|
>
|
||||||
|
<sup
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
color: '#1890ff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 2px',
|
||||||
|
fontSize: '0.85em',
|
||||||
|
userSelect: 'none',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isMobile()) {
|
||||||
|
e.target.style.color = '#40a9ff';
|
||||||
|
e.target.style.textDecoration = 'underline';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isMobile()) {
|
||||||
|
e.target.style.color = '#1890ff';
|
||||||
|
e.target.style.textDecoration = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setPopoverVisible(!popoverVisible);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
【{citationId}】
|
||||||
|
</sup>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CitationMark;
|
||||||
104
src/components/Citation/CitedContent.js
Normal file
104
src/components/Citation/CitedContent.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// src/components/Citation/CitedContent.js
|
||||||
|
import React from 'react';
|
||||||
|
import { Typography, Space, Tag } from 'antd';
|
||||||
|
import { RobotOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||||
|
import CitationMark from './CitationMark';
|
||||||
|
import { processCitationData } from '../../utils/citationUtils';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带引用标注的内容组件
|
||||||
|
* 展示拼接的文本,每句话后显示上标引用【1】【2】【3】
|
||||||
|
* 支持鼠标悬浮和点击查看引用来源
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {Object} props.data - API 返回的原始数据 { data: [...] }
|
||||||
|
* @param {string} props.title - 标题文本,默认 "AI 分析结果"
|
||||||
|
* @param {boolean} props.showAIBadge - 是否显示 AI 生成标识,默认 true
|
||||||
|
* @param {Object} props.containerStyle - 容器额外样式(可选)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <CitedContent
|
||||||
|
* data={apiData}
|
||||||
|
* title="关联描述"
|
||||||
|
* showAIBadge={true}
|
||||||
|
* containerStyle={{ marginTop: 16 }}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
const CitedContent = ({
|
||||||
|
data,
|
||||||
|
title = 'AI 分析结果',
|
||||||
|
showAIBadge = true,
|
||||||
|
containerStyle = {}
|
||||||
|
}) => {
|
||||||
|
// 处理数据
|
||||||
|
const processed = processCitationData(data);
|
||||||
|
|
||||||
|
// 如果数据无效,不渲染
|
||||||
|
if (!processed) {
|
||||||
|
console.warn('CitedContent: Invalid data, not rendering');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 16,
|
||||||
|
...containerStyle
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<Space
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
|
||||||
|
<Text strong style={{ fontSize: 14 }}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
{showAIBadge && (
|
||||||
|
<Tag
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
color="purple"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
AI 生成
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 带引用的文本内容 */}
|
||||||
|
<div style={{ lineHeight: 1.8 }}>
|
||||||
|
{processed.segments.map((segment, index) => (
|
||||||
|
<React.Fragment key={`segment-${segment.citationId}`}>
|
||||||
|
{/* 文本片段 */}
|
||||||
|
<Text style={{ fontSize: 14 }}>
|
||||||
|
{segment.text}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 引用标记 */}
|
||||||
|
<CitationMark
|
||||||
|
citationId={segment.citationId}
|
||||||
|
citation={processed.citations[segment.citationId]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 在片段之间添加逗号分隔符(最后一个不加) */}
|
||||||
|
{index < processed.segments.length - 1 && (
|
||||||
|
<Text style={{ fontSize: 14 }}>,</Text>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CitedContent;
|
||||||
131
src/utils/citationUtils.js
Normal file
131
src/utils/citationUtils.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// src/utils/citationUtils.js
|
||||||
|
// 引用数据处理工具
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理后端返回的引用数据
|
||||||
|
*
|
||||||
|
* @param {Object} rawData - 后端返回的原始数据
|
||||||
|
* @param {Array} rawData.data - 引用数据数组
|
||||||
|
* @returns {Object|null} 处理后的数据结构,包含 segments 和 citations
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 输入格式:
|
||||||
|
* {
|
||||||
|
* data: [
|
||||||
|
* {
|
||||||
|
* author: "陈彤",
|
||||||
|
* sentences: "核心结论:...",
|
||||||
|
* query_part: "国内领先的IT解决方案提供商",
|
||||||
|
* match_score: "好",
|
||||||
|
* declare_date: "2025-04-17T00:00:00",
|
||||||
|
* report_title: "深度布局..."
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* 输出格式:
|
||||||
|
* {
|
||||||
|
* segments: [
|
||||||
|
* { text: "核心结论:...", citationId: 1 }
|
||||||
|
* ],
|
||||||
|
* citations: {
|
||||||
|
* 1: {
|
||||||
|
* author: "陈彤",
|
||||||
|
* report_title: "深度布局...",
|
||||||
|
* declare_date: "2025-04-17",
|
||||||
|
* sentences: "核心结论:..."
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const processCitationData = (rawData) => {
|
||||||
|
// 验证输入数据
|
||||||
|
if (!rawData || !rawData.data || !Array.isArray(rawData.data)) {
|
||||||
|
console.warn('citationUtils: Invalid data format, expected { data: [...] }');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawData.data.length === 0) {
|
||||||
|
console.warn('citationUtils: Empty data array');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
const citations = {};
|
||||||
|
|
||||||
|
// 处理每个引用数据项
|
||||||
|
rawData.data.forEach((item, index) => {
|
||||||
|
// 验证必需字段
|
||||||
|
if (!item.sentences) {
|
||||||
|
console.warn(`citationUtils: Missing 'sentences' field in item ${index}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const citationId = index + 1; // 引用 ID 从 1 开始
|
||||||
|
|
||||||
|
// 构建文本片段
|
||||||
|
segments.push({
|
||||||
|
text: item.sentences,
|
||||||
|
citationId: citationId
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建引用信息映射
|
||||||
|
citations[citationId] = {
|
||||||
|
author: item.author || '未知作者',
|
||||||
|
report_title: item.report_title || '未知报告',
|
||||||
|
declare_date: formatDate(item.declare_date),
|
||||||
|
sentences: item.sentences,
|
||||||
|
// 保留原始数据以备扩展
|
||||||
|
query_part: item.query_part,
|
||||||
|
match_score: item.match_score
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果没有有效的片段,返回 null
|
||||||
|
if (segments.length === 0) {
|
||||||
|
console.warn('citationUtils: No valid segments found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
citations
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
* @param {string} dateStr - ISO 格式日期字符串
|
||||||
|
* @returns {string} 格式化后的日期 YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '--';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return '--';
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('citationUtils: Date formatting error:', e);
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证引用数据格式是否有效
|
||||||
|
* @param {Object} data - 待验证的数据
|
||||||
|
* @returns {boolean} 是否有效
|
||||||
|
*/
|
||||||
|
export const isValidCitationData = (data) => {
|
||||||
|
if (!data || typeof data !== 'object') return false;
|
||||||
|
if (!data.data || !Array.isArray(data.data)) return false;
|
||||||
|
if (data.data.length === 0) return false;
|
||||||
|
|
||||||
|
// 检查至少有一个有效的 sentences 字段
|
||||||
|
return data.data.some(item => item && item.sentences);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user