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 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'; // 绿色(下跌)
}
}
return {
id: item.date,
title: hasEvents
? `📰${newsCount} 📊${reportCount}`
: (item.price ? priceInfo.text : ''),
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 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 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)}
)}
}
onClick={() => {
if (event.type === 'news') {
trackNewsClicked(event, selectedDate);
trackNewsDetailOpened(event);
setSelectedNews({
title: event.title,
content: event.content,
source: event.source,
time: event.time,
url: event.url,
});
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
trackReportClicked(event, selectedDate);
trackReportDetailOpened(event);
setSelectedReport({
title: event.title,
content: event.content,
publisher: event.publisher,
author: event.author,
time: event.time,
rating: event.rating,
security_name: event.security_name,
content_url: event.content_url,
});
setIsReportModalOpen(true);
}
}}
>
查看全文
))}
) : (
当日无新闻或研报
{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 && (
}
onClick={() => window.open(selectedReport.content_url, '_blank')}
>
查看原文
)}
)}
{/* 新闻全文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' && (
}
onClick={() => window.open(selectedNews.url, '_blank')}
>
查看原文
)}
)}
>
);
};
export default ConceptTimelineModal;