import React, { useState, useEffect } from 'react'; import { logger } from '../../utils/logger'; import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents'; import RiskDisclaimer from '../../components/RiskDisclaimer'; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalFooter, ModalBody, ModalCloseButton, Box, VStack, HStack, Text, Button, Badge, Icon, Flex, Spinner, Center, Collapse, Divider, useToast, useDisclosure, } from '@chakra-ui/react'; import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, ViewIcon } from '@chakra-ui/icons'; import { FaChartLine, FaArrowUp, FaArrowDown, FaHistory } from 'react-icons/fa'; import { keyframes } from '@emotion/react'; // 动画定义 const pulseAnimation = keyframes` 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; } 50% { transform: translate(-50%, -50%) scale(1.3); opacity: 0.2; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; } `; // API配置 - 与主文件保持一致 const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'http://111.198.58.126:16801'; const NEWS_API_URL = process.env.NODE_ENV === 'production' ? '/news-api' : 'http://111.198.58.126:21891'; const REPORT_API_URL = process.env.NODE_ENV === 'production' ? '/report-api' : 'http://111.198.58.126:8811'; const ConceptTimelineModal = ({ isOpen, onClose, conceptName, conceptId }) => { const toast = useToast(); // 🎯 PostHog 事件追踪 const { trackDateToggled, trackNewsClicked, trackNewsDetailOpened, trackReportClicked, trackReportDetailOpened, trackModalClosed, } = useConceptTimelineEvents({ conceptName, conceptId, isOpen }); const [timelineData, setTimelineData] = useState([]); const [loading, setLoading] = useState(true); const [expandedDates, setExpandedDates] = useState({}); // 研报全文Modal相关状态 const [selectedReport, setSelectedReport] = useState(null); const [isReportModalOpen, setIsReportModalOpen] = useState(false); // 新闻全文Modal相关状态 const [selectedNews, setSelectedNews] = useState(null); const [isNewsModalOpen, setIsNewsModalOpen] = useState(false); // 获取时间轴数据 const fetchTimelineData = async () => { setLoading(true); try { // 获取今天的日期(确保不是未来日期) const today = new Date(); // 重置时间到当天开始 today.setHours(0, 0, 0, 0); // 计算日期范围(最近300天,与原代码保持一致) const endDate = new Date(today); const startDate = new Date(today); startDate.setDate(startDate.getDate() - 100); // 使用100天,与原代码一致 // 确保日期格式正确 const formatDate = (date) => { 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}`; }; const startDateStr = formatDate(startDate); const endDateStr = formatDate(endDate); // 并行请求:涨跌幅数据、新闻、研报 const promises = []; // 获取涨跌幅时间序列 promises.push( fetch(`${API_BASE_URL}/concept/${conceptId}/price-timeseries?` + `start_date=${startDateStr}&` + `end_date=${endDateStr}` ).then(res => { if (!res.ok) { logger.error('ConceptTimelineModal', 'fetchTimelineData - Price API', new Error(`HTTP ${res.status}`), { conceptId, startDateStr, endDateStr }); throw new Error(`Price API error: ${res.status}`); } return res.json(); }) .catch(err => { logger.error('ConceptTimelineModal', 'fetchTimelineData - Price API', err, { conceptId }); return { timeseries: [] }; }) ); // 获取新闻(精确匹配,最近100天,最多100条) const newsParams = new URLSearchParams({ query: conceptName, exact_match: 1, start_date: startDateStr, end_date: endDateStr, top_k: 100 }); const newsUrl = `${NEWS_API_URL}/search_china_news?${newsParams}`; promises.push( fetch(newsUrl) .then(async res => { if (!res.ok) { const text = await res.text(); logger.error('ConceptTimelineModal', 'fetchTimelineData - News API', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); return []; } return res.json(); }) .catch(err => { logger.error('ConceptTimelineModal', 'fetchTimelineData - News API', err, { conceptName }); return []; }) ); // 获取研报(文本模式、精确匹配,最近100天,最多30条) const reportParams = new URLSearchParams({ query: conceptName, mode: 'text', exact_match: 1, size: 30, start_date: startDateStr }); const reportUrl = `${REPORT_API_URL}/search?${reportParams}`; promises.push( fetch(reportUrl) .then(async res => { if (!res.ok) { const text = await res.text(); logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); return { results: [] }; } return res.json(); }) .catch(err => { logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', err, { conceptName }); return { results: [] }; }) ); const [priceResult, newsResult, reportResult] = await Promise.all(promises); // 处理价格数据 const priceMap = {}; if (priceResult && priceResult.timeseries) { priceResult.timeseries.forEach(item => { const dateStr = item.trade_date; priceMap[dateStr] = { avg_change_pct: item.avg_change_pct, stock_count: item.stock_count }; }); } // 合并和分组事件数据 const events = []; // 处理新闻(按时间降序排序,最新的在前) if (newsResult && Array.isArray(newsResult)) { // 先排序 const sortedNews = newsResult.sort((a, b) => { const dateA = new Date(a.published_time || 0); const dateB = new Date(b.published_time || 0); return dateB - dateA; // 降序 }); sortedNews.forEach(news => { if (news.published_time) { // 提取日期部分(YYYY-MM-DD) let dateOnly; const fullTime = news.published_time; // 处理不同的日期格式 if (fullTime.includes('T')) { // ISO格式: 2024-12-24T09:47:30 dateOnly = fullTime.split('T')[0]; } else if (fullTime.includes(' ')) { // 空格分隔格式: 2024-12-24 09:47:30 dateOnly = fullTime.split(' ')[0]; } else { // 已经是日期格式: 2024-12-24 dateOnly = fullTime; } events.push({ type: 'news', date: dateOnly, // 只用日期部分做分组 time: fullTime, // 保留完整时间用于显示 title: news.title, content: news.detail || news.description, source: news.source, url: news.url }); } }); } // 处理研报(按时间降序排序,最新的在前),兼容 data.results 与 results if (reportResult) { const reports = (reportResult.data && Array.isArray(reportResult.data.results)) ? reportResult.data.results : (Array.isArray(reportResult.results) ? reportResult.results : []); if (reports.length > 0) { const sortedReports = reports.sort((a, b) => { const dateA = new Date((a.declare_date || '').replace(' ', 'T')); const dateB = new Date((b.declare_date || '').replace(' ', 'T')); return dateB - dateA; // 降序 }); sortedReports.forEach(report => { const rawDate = report.declare_date || ''; if (rawDate) { const dateOnly = rawDate.includes('T') ? rawDate.split('T')[0] : rawDate.includes(' ') ? rawDate.split(' ')[0] : rawDate; events.push({ type: 'report', date: dateOnly, time: rawDate, title: report.report_title, content: report.content, publisher: report.publisher, author: report.author, rating: report.rating, security_name: report.security_name, content_url: report.content_url }); } }); } } // 按日期分组 const groupedEvents = {}; events.forEach(event => { const date = event.date; if (!groupedEvents[date]) { groupedEvents[date] = []; } groupedEvents[date].push(event); }); // 创建时间轴数据 const allDates = new Set([ ...Object.keys(priceMap), ...Object.keys(groupedEvents) ]); const timeline = Array.from(allDates) .sort((a, b) => new Date(b) - new Date(a)) .map(date => ({ date, price: priceMap[date] || null, events: groupedEvents[date] || [] })); setTimelineData(timeline); logger.info('ConceptTimelineModal', '时间轴数据加载成功', { conceptId, conceptName, timelineCount: timeline.length }); } catch (error) { logger.error('ConceptTimelineModal', 'fetchTimelineData', error, { conceptId, conceptName }); // ❌ 移除获取数据失败toast // toast({ title: '获取数据失败', description: error.message, status: 'error', duration: 3000, isClosable: true }); } finally { setLoading(false); } }; // 组件挂载时获取数据 useEffect(() => { if (isOpen && conceptId) { fetchTimelineData(); setExpandedDates({}); // 重置展开状态 } }, [isOpen, conceptId]); // 切换日期展开状态 const toggleDateExpand = (date) => { const willExpand = !expandedDates[date]; // 🎯 追踪日期展开/折叠 trackDateToggled(date, willExpand); setExpandedDates(prev => ({ ...prev, [date]: !prev[date] })); }; // 格式化日期显示(包含年份) const formatDateDisplay = (dateStr) => { const date = new Date(dateStr); const today = new Date(); const diffTime = today - date; const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const formatted = `${year}-${month}-${day}`; if (diffDays === 0) return `今天 ${formatted}`; if (diffDays === 1) return `昨天 ${formatted}`; if (diffDays < 7) return `${diffDays}天前 ${formatted}`; if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前 ${formatted}`; if (diffDays < 365) return `${Math.floor(diffDays / 30)}月前 ${formatted}`; return formatted; }; // 格式化完整时间(YYYY-MM-DD HH:mm) const formatDateTime = (dateTimeStr) => { if (!dateTimeStr) return '-'; const normalized = typeof dateTimeStr === 'string' ? dateTimeStr.replace(' ', 'T') : dateTimeStr; const dt = new Date(normalized); if (isNaN(dt.getTime())) return '-'; const y = dt.getFullYear(); const m = String(dt.getMonth() + 1).padStart(2, '0'); const d = String(dt.getDate()).padStart(2, '0'); const hh = String(dt.getHours()).padStart(2, '0'); const mm = String(dt.getMinutes()).padStart(2, '0'); return `${y}-${m}-${d} ${hh}:${mm}`; }; // 获取涨跌幅颜色和图标 const getPriceInfo = (price) => { if (!price || price.avg_change_pct === null) { return { color: 'gray', icon: null, text: '无数据' }; } const value = price.avg_change_pct; if (value > 0) { return { color: 'red', icon: FaArrowUp, text: `+${value.toFixed(2)}%` }; } else if (value < 0) { return { color: 'green', icon: FaArrowDown, text: `${value.toFixed(2)}%` }; } else { return { color: 'gray', icon: null, text: '0.00%' }; } }; return ( <> {conceptName} - 历史时间轴 最近100天 🔥 Max版功能 {loading ? (
正在加载时间轴数据...
) : timelineData.length > 0 ? ( {/* 时间轴主线 */} {timelineData.map((item, index) => { const priceInfo = getPriceInfo(item.price); const hasEvents = item.events.length > 0; const isExpanded = expandedDates[item.date]; return ( {/* 左侧 - 涨跌幅信息 */} {formatDateDisplay(item.date)} {item.price && ( {priceInfo.icon && ( )} {priceInfo.text} {item.price.stock_count && ( 统计股票: {item.price.stock_count} 只 )} )} {!item.price && !hasEvents && ( 当日无数据 )} {/* 中间 - 时间轴节点 */} hasEvents && toggleDateExpand(item.date)} _hover={hasEvents ? { transform: 'scale(1.2)', bg: 'pink.500' } : {}} transition="all 0.2s" > {hasEvents && ( <> {item.events.length} {/* 动画点击提示 */} )} {/* 右侧 - 事件信息 */} {hasEvents ? ( {item.events.map((event, eventIdx) => ( {event.type === 'news' ? '新闻' : '研报'} {event.source && ( {event.source} )} {event.publisher && ( {event.publisher} )} {event.rating && ( {event.rating} )} {event.title} {event.content || '暂无内容'} ))} ) : ( 当日无相关资讯 )} {/* 分隔线 */} {index < timelineData.length - 1 && ( )} ); })} {/* 时间轴结束标记 */} 时间轴起始点 ) : (
暂无历史数据
)} {/* 风险提示 */}
{/* 研报全文Modal */} setIsReportModalOpen(false)} size="4xl" scrollBehavior="inside" > {selectedReport?.title} {selectedReport?.publisher && ( {selectedReport.publisher} )} {selectedReport?.author && ( {selectedReport.author} )} {selectedReport?.time && ( {formatDateTime(selectedReport.time)} )} {selectedReport?.rating && ( {selectedReport.rating} )} {selectedReport?.security_name && ( {selectedReport.security_name} )} {selectedReport?.content || '暂无内容'} {selectedReport?.content_url && ( )} {/* 新闻全文Modal */} setIsNewsModalOpen(false)} size="4xl" scrollBehavior="inside" > {selectedNews?.title} {selectedNews?.source && ( {selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source} )} {selectedNews?.time && ( {formatDateTime(selectedNews.time)} )} {selectedNews?.content || '暂无内容'} {/* zsxq来源不显示查看原文按钮 */} {selectedNews?.url && selectedNews?.source !== 'zsxq' && ( )} ); }; export default ConceptTimelineModal;