Files
vf_react/src/views/Concept/ConceptTimelineModal.js
2025-11-23 13:15:41 +08:00

1091 lines
50 KiB
JavaScript
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, 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 && (
<Modal
isOpen={isOpen}
onClose={onClose}
size="full"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent maxW="1400px" m={4}>
<ModalHeader
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
position="sticky"
top={0}
zIndex={10}
>
<HStack spacing={3}>
<Icon as={FaChartLine} />
<Text>{conceptName} - 历史时间轴</Text>
<Badge colorScheme="yellow" ml={2}>
最近100天
</Badge>
<Badge colorScheme="purple" ml={2} fontSize="xs">
🔥 Max版功能
</Badge>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody
py={6}
bg="gray.50"
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#a8a8a8',
},
}}
>
{loading ? (
<Center py={20}>
<VStack spacing={4}>
<Spinner size="xl" color="purple.500" thickness="4px" />
<Text color="gray.600">正在加载时间轴数据...</Text>
</VStack>
</Center>
) : timelineData.length > 0 ? (
<Box position="relative" maxW="1200px" mx="auto" px={4}>
{/* 图例说明 */}
<Flex justify="center" mb={4} flexWrap="wrap" gap={3}>
<HStack spacing={2}>
<Box w={4} h={4} bg="#9F7AEA" borderRadius="sm" />
<Text fontSize="sm">有新闻/研报</Text>
</HStack>
<HStack spacing={2}>
<Box w={4} h={4} bg="#FC8181" borderRadius="sm" />
<Text fontSize="sm">上涨</Text>
</HStack>
<HStack spacing={2}>
<Box w={4} h={4} bg="#68D391" borderRadius="sm" />
<Text fontSize="sm">下跌</Text>
</HStack>
<HStack spacing={2}>
<Box w={4} h={4} bg="#e2e8f0" borderRadius="sm" />
<Text fontSize="sm">无数据</Text>
</HStack>
</Flex>
{/* FullCalendar 日历组件 */}
<Box
height={{ base: '600px', md: '700px' }}
bg="white"
borderRadius="xl"
boxShadow="lg"
p={4}
sx={{
// FullCalendar 样式定制
'.fc': {
height: '100%',
},
'.fc-header-toolbar': {
marginBottom: '1.5rem',
},
'.fc-toolbar-title': {
fontSize: '1.5rem',
fontWeight: 'bold',
color: 'purple.600',
},
'.fc-button': {
backgroundColor: '#9F7AEA',
borderColor: '#9F7AEA',
color: 'white',
'&:hover': {
backgroundColor: '#805AD5',
borderColor: '#805AD5',
},
'&:active, &:focus': {
backgroundColor: '#6B46C1',
borderColor: '#6B46C1',
boxShadow: 'none',
},
},
'.fc-button-active': {
backgroundColor: '#6B46C1',
borderColor: '#6B46C1',
},
'.fc-daygrid-day': {
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
backgroundColor: 'purple.50',
},
},
'.fc-daygrid-day-number': {
padding: '4px',
fontSize: '0.875rem',
},
'.fc-event': {
cursor: 'pointer',
border: 'none',
padding: '2px 4px',
fontSize: '0.75rem',
fontWeight: 'bold',
borderRadius: '4px',
transition: 'all 0.2s',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
},
},
'.fc-daygrid-event-harness': {
marginBottom: '2px',
},
}}
>
<FullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale="zh-cn"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: '',
}}
events={calendarEvents}
dateClick={handleDateClick}
eventClick={handleEventClick}
height="100%"
dayMaxEvents={3}
moreLinkText="更多"
buttonText={{
today: '今天',
month: '月',
week: '周',
}}
eventDisplay="block"
displayEventTime={false}
/>
</Box>
{/* 底部说明 */}
<Box textAlign="center" mt={6}>
<Badge
colorScheme="purple"
variant="subtle"
px={4}
py={2}
borderRadius="full"
fontSize="sm"
>
时间轴起始点
</Badge>
</Box>
</Box>
) : (
<Center py={20}>
<VStack spacing={4}>
<Icon as={FaHistory} boxSize={16} color="gray.300" />
<Text fontSize="lg" color="gray.500">
暂无历史数据
</Text>
</VStack>
</Center>
)}
{/* 风险提示 */}
<Box px={6}>
<RiskDisclaimer variant="default" />
</Box>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor="gray.200">
<Button colorScheme="purple" onClick={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 日期详情 Modal */}
{isDateDetailOpen && selectedDateData && (
<Modal
isOpen={isDateDetailOpen}
onClose={onDateDetailClose}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bgGradient="linear(to-r, purple.500, pink.500)" color="white">
<VStack align="start" spacing={2}>
<HStack>
<Icon as={CalendarIcon} />
<Text>{formatDateDisplay(selectedDate)}</Text>
</HStack>
{selectedDateData.price && (
<HStack spacing={3} fontSize="sm">
<Badge colorScheme={getPriceInfo(selectedDateData.price).color} variant="solid" px={3} py={1}>
<HStack spacing={1}>
<Icon
as={getPriceInfo(selectedDateData.price).icon}
boxSize={3}
/>
<Text>{getPriceInfo(selectedDateData.price).text}</Text>
</HStack>
</Badge>
{selectedDateData.price.stock_count && (
<Text opacity={0.9}>
📊 统计股票: {selectedDateData.price.stock_count}
</Text>
)}
</HStack>
)}
</VStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
{selectedDateData.events && selectedDateData.events.length > 0 ? (
<VStack align="stretch" spacing={4}>
{selectedDateData.events.map((event, eventIdx) => (
<Box
key={eventIdx}
p={4}
bg={event.type === 'news' ? 'blue.50' : 'green.50'}
borderRadius="lg"
borderLeft="4px solid"
borderLeftColor={event.type === 'news' ? 'blue.400' : 'green.400'}
_hover={{
transform: 'translateX(4px)',
boxShadow: 'md',
}}
transition="all 0.2s"
>
<VStack align="start" spacing={3}>
<HStack spacing={2} flexWrap="wrap">
<Badge
colorScheme={event.type === 'news' ? 'blue' : 'green'}
variant="solid"
fontSize="sm"
>
{event.type === 'news' ? '📰 新闻' : '📊 研报'}
</Badge>
{event.source && (
<Badge variant="subtle" fontSize="xs">
{event.source}
</Badge>
)}
{event.publisher && (
<Badge colorScheme="purple" variant="subtle" fontSize="xs">
{event.publisher}
</Badge>
)}
{event.rating && (
<Badge colorScheme="orange" variant="solid" fontSize="xs">
{event.rating}
</Badge>
)}
{event.security_name && (
<Badge colorScheme="cyan" variant="subtle" fontSize="xs">
{event.security_name}
</Badge>
)}
</HStack>
<Text fontWeight="bold" fontSize="md" color="gray.800">
{event.title}
</Text>
<Text fontSize="sm" color="gray.600" noOfLines={4}>
{event.content || '暂无内容'}
</Text>
{event.time && (
<Text fontSize="xs" color="gray.500">
🕐 {formatDateTime(event.time)}
</Text>
)}
<Button
size="sm"
colorScheme="blue"
variant="outline"
leftIcon={<ViewIcon />}
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);
}
}}
>
查看全文
</Button>
</VStack>
</Box>
))}
</VStack>
) : (
<Center py={12}>
<VStack spacing={4}>
<Icon as={FaHistory} boxSize={16} color="gray.300" />
<Text fontSize="lg" color="gray.500">
当日无新闻或研报
</Text>
{selectedDateData.price && (
<Text fontSize="sm" color="gray.400">
仅有涨跌幅数据
</Text>
)}
</VStack>
</Center>
)}
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor="gray.200">
<Button colorScheme="purple" onClick={onDateDetailClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 研报全文Modal */}
{isReportModalOpen && (
<Modal
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="green.500" color="white">
<VStack align="start" spacing={1}>
<Text fontSize="lg">{selectedReport?.title}</Text>
<HStack spacing={3} fontSize="sm" opacity={0.9}>
{selectedReport?.publisher && (
<Badge colorScheme="whiteAlpha" variant="solid">
{selectedReport.publisher}
</Badge>
)}
{selectedReport?.author && (
<Text>{selectedReport.author}</Text>
)}
{selectedReport?.time && (
<Text>{formatDateTime(selectedReport.time)}</Text>
)}
{selectedReport?.rating && (
<Badge colorScheme="orange" variant="solid">
{selectedReport.rating}
</Badge>
)}
{selectedReport?.security_name && (
<Badge colorScheme="cyan" variant="solid">
{selectedReport.security_name}
</Badge>
)}
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
<Box
bg="gray.50"
p={6}
borderRadius="md"
border="1px solid"
borderColor="gray.200"
>
<Text
whiteSpace="pre-wrap"
fontSize="sm"
lineHeight="tall"
color="gray.700"
>
{selectedReport?.content || '暂无内容'}
</Text>
</Box>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor="gray.200">
<HStack spacing={3}>
{selectedReport?.content_url && (
<Button
size="sm"
colorScheme="blue"
variant="outline"
leftIcon={<ExternalLinkIcon />}
onClick={() => window.open(selectedReport.content_url, '_blank')}
>
查看原文
</Button>
)}
<Button size="sm" colorScheme="green" onClick={() => setIsReportModalOpen(false)}>
关闭
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)}
{/* 新闻全文Modal */}
{isNewsModalOpen && (
<Modal
isOpen={isNewsModalOpen}
onClose={() => setIsNewsModalOpen(false)}
size="4xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="blue.500" color="white">
<VStack align="start" spacing={1}>
<Text fontSize="lg">{selectedNews?.title}</Text>
<HStack spacing={3} fontSize="sm" opacity={0.9}>
{selectedNews?.source && (
<Badge
colorScheme={selectedNews.source === 'zsxq' ? 'purple' : 'whiteAlpha'}
variant="solid"
>
{selectedNews.source === 'zsxq' ? '知识星球' : selectedNews.source}
</Badge>
)}
{selectedNews?.time && (
<Text>{formatDateTime(selectedNews.time)}</Text>
)}
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
<Box
bg="gray.50"
p={6}
borderRadius="md"
border="1px solid"
borderColor="gray.200"
>
<Text
whiteSpace="pre-wrap"
fontSize="sm"
lineHeight="tall"
color="gray.700"
>
{selectedNews?.content || '暂无内容'}
</Text>
</Box>
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor="gray.200">
<HStack spacing={3}>
{/* zsxq来源不显示查看原文按钮 */}
{selectedNews?.url && selectedNews?.source !== 'zsxq' && (
<Button
size="sm"
colorScheme="blue"
variant="outline"
leftIcon={<ExternalLinkIcon />}
onClick={() => window.open(selectedNews.url, '_blank')}
>
查看原文
</Button>
)}
<Button size="sm" colorScheme="blue" onClick={() => setIsNewsModalOpen(false)}>
关闭
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)}
</>
);
};
export default ConceptTimelineModal;