import React, { useState, useEffect, useMemo } from 'react'; import { logger } from '../../utils/logger'; import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents'; import RiskDisclaimer from '../../components/RiskDisclaimer'; import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-cn'; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalFooter, ModalBody, ModalCloseButton, Box, VStack, HStack, Text, Button, Badge, Icon, Flex, Spinner, Center, Collapse, Divider, useToast, useDisclosure, SimpleGrid, Tooltip, } from '@chakra-ui/react'; import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, ViewIcon, CalendarIcon, } from '@chakra-ui/icons'; import { FaChartLine, FaArrowUp, FaArrowDown, FaHistory, FaNewspaper, FaFileAlt, } from 'react-icons/fa'; import { keyframes } from '@emotion/react'; dayjs.locale('zh-cn'); // 动画定义 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 [selectedDate, setSelectedDate] = useState(null); const [selectedDateData, setSelectedDateData] = useState(null); // 日期详情Modal const { isOpen: isDateDetailOpen, onOpen: onDateDetailOpen, onClose: onDateDetailClose } = useDisclosure(); // 研报全文Modal相关状态 const [selectedReport, setSelectedReport] = useState(null); const [isReportModalOpen, setIsReportModalOpen] = useState(false); // 新闻全文Modal相关状态 const [selectedNews, setSelectedNews] = useState(null); const [isNewsModalOpen, setIsNewsModalOpen] = useState(false); // 辅助函数:格式化日期显示(包含年份) 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%' }; } }; // 转换时间轴数据为日历事件格式 const calendarEvents = useMemo(() => { return timelineData.map(item => { const priceInfo = getPriceInfo(item.price); const hasEvents = item.events && item.events.length > 0; const newsCount = item.events.filter(e => e.type === 'news').length; const reportCount = item.events.filter(e => e.type === 'report').length; // 根据涨跌幅和事件确定颜色 let backgroundColor = '#e2e8f0'; // 默认灰色(无数据) if (hasEvents) { backgroundColor = '#9F7AEA'; // 紫色(有事件) } else if (item.price) { if (priceInfo.color === 'red') { backgroundColor = '#FC8181'; // 红色(上涨) } else if (priceInfo.color === 'green') { backgroundColor = '#68D391'; // 绿色(下跌) } } // 构建显示标题:同时显示事件和涨跌幅 let title = ''; if (hasEvents && item.price) { // 同时有事件和价格数据 title = `📰${newsCount} 📊${reportCount} ${priceInfo.text}`; } else if (hasEvents) { // 只有事件 title = `📰${newsCount} 📊${reportCount}`; } else if (item.price) { // 只有价格数据 title = priceInfo.text; } return { id: item.date, title, date: item.date, backgroundColor, borderColor: backgroundColor, extendedProps: { ...item, newsCount, reportCount, priceInfo, } }; }); }, [timelineData]); // 处理日期点击 const handleDateClick = (info) => { const clickedDate = info.dateStr; const dateData = timelineData.find(item => item.date === clickedDate); if (dateData) { setSelectedDate(clickedDate); setSelectedDateData(dateData); onDateDetailOpen(); // 追踪日期点击 trackDateToggled(clickedDate, true); } }; // 处理事件点击 const handleEventClick = (info) => { const clickedDate = info.event.id; const dateData = timelineData.find(item => item.date === clickedDate); if (dateData) { setSelectedDate(clickedDate); setSelectedDateData(dateData); onDateDetailOpen(); } }; // 获取时间轴数据 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条) // 🔄 添加回退逻辑:如果结果不足30条,去掉 exact_match 参数重新搜索 const fetchNews = async () => { try { // 第一次尝试:使用精确匹配 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}`; const res = await fetch(newsUrl); if (!res.ok) { const text = await res.text(); logger.error('ConceptTimelineModal', 'fetchTimelineData - News API (exact_match=1)', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); return []; } const newsResult = await res.json(); const newsArray = Array.isArray(newsResult) ? newsResult : []; // 检查结果数量,如果不足30条则进行回退搜索 if (newsArray.length < 30) { logger.info('ConceptTimelineModal', `新闻精确搜索结果不足30条 (${newsArray.length}),尝试模糊搜索`, { conceptName }); // 第二次尝试:去掉精确匹配参数 const fallbackParams = new URLSearchParams({ query: conceptName, start_date: startDateStr, end_date: endDateStr, top_k: 100 }); const fallbackUrl = `${NEWS_API_URL}/search_china_news?${fallbackParams}`; const fallbackRes = await fetch(fallbackUrl); if (!fallbackRes.ok) { logger.warn('ConceptTimelineModal', '新闻模糊搜索失败,使用精确搜索结果', { conceptName }); return newsArray; } const fallbackResult = await fallbackRes.json(); const fallbackArray = Array.isArray(fallbackResult) ? fallbackResult : []; logger.info('ConceptTimelineModal', `新闻模糊搜索成功,获取 ${fallbackArray.length} 条结果`, { conceptName }); return fallbackArray; } return newsArray; } catch (err) { logger.error('ConceptTimelineModal', 'fetchTimelineData - News API', err, { conceptName }); return []; } }; promises.push(fetchNews()); // 获取研报(文本模式、精确匹配,最近100天,最多30条) // 🔄 添加回退逻辑:如果结果不足10条,去掉 exact_match 参数重新搜索 const fetchReports = async () => { try { // 第一次尝试:使用精确匹配 const reportParams = new URLSearchParams({ query: conceptName, mode: 'text', exact_match: 1, size: 30, start_date: startDateStr }); const reportUrl = `${REPORT_API_URL}/search?${reportParams}`; const res = await fetch(reportUrl); if (!res.ok) { const text = await res.text(); logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API (exact_match=1)', new Error(`HTTP ${res.status}`), { conceptName, status: res.status, response: text.substring(0, 200) }); return { results: [] }; } const reportResult = await res.json(); const reports = (reportResult.data && Array.isArray(reportResult.data.results)) ? reportResult.data.results : (Array.isArray(reportResult.results) ? reportResult.results : []); // 检查结果数量,如果不足10条则进行回退搜索 if (reports.length < 10) { logger.info('ConceptTimelineModal', `研报精确搜索结果不足10条 (${reports.length}),尝试模糊搜索`, { conceptName }); // 第二次尝试:去掉精确匹配参数 const fallbackParams = new URLSearchParams({ query: conceptName, mode: 'text', size: 30, start_date: startDateStr }); const fallbackUrl = `${REPORT_API_URL}/search?${fallbackParams}`; const fallbackRes = await fetch(fallbackUrl); if (!fallbackRes.ok) { logger.warn('ConceptTimelineModal', '研报模糊搜索失败,使用精确搜索结果', { conceptName }); return { results: reports }; } const fallbackResult = await fallbackRes.json(); const fallbackReports = (fallbackResult.data && Array.isArray(fallbackResult.data.results)) ? fallbackResult.data.results : (Array.isArray(fallbackResult.results) ? fallbackResult.results : []); logger.info('ConceptTimelineModal', `研报模糊搜索成功,获取 ${fallbackReports.length} 条结果`, { conceptName }); return { results: fallbackReports }; } return { results: reports }; } catch (err) { logger.error('ConceptTimelineModal', 'fetchTimelineData - Report API', err, { conceptName }); return { results: [] }; } }; promises.push(fetchReports()); 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(); } }, [isOpen, conceptId]); return ( <> {isOpen && ( {conceptName} - 历史时间轴 最近100天 🔥 Max版功能 {loading ? (
正在加载时间轴数据...
) : timelineData.length > 0 ? ( {/* 图例说明 */} 有新闻/研报 上涨 下跌 无数据 {/* FullCalendar 日历组件 */} {/* 底部说明 */} 时间轴起始点 ) : (
暂无历史数据
)} {/* 风险提示 */}
)} {/* 日期详情 Modal */} {isDateDetailOpen && selectedDateData && ( {formatDateDisplay(selectedDate)} {selectedDateData.price && ( {getPriceInfo(selectedDateData.price).text} {selectedDateData.price.stock_count && ( 📊 统计股票: {selectedDateData.price.stock_count} 只 )} )} {selectedDateData.events && selectedDateData.events.length > 0 ? ( {selectedDateData.events.map((event, eventIdx) => ( {event.type === 'news' ? '📰 新闻' : '📊 研报'} {event.source && ( {event.source} )} {event.publisher && ( {event.publisher} )} {event.rating && ( {event.rating} )} {event.security_name && ( {event.security_name} )} {event.title} {event.content || '暂无内容'} {event.time && ( 🕐 {formatDateTime(event.time)} )} ))} ) : (
当日无新闻或研报 {selectedDateData.price && ( 仅有涨跌幅数据 )}
)}
)} {/* 研报全文Modal */} {isReportModalOpen && ( 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 */} {isNewsModalOpen && ( 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;