Files
vf_react/src/views/Concept/ConceptTimelineModal.js
zdl 35f8b5195a feat: 访问"概念中心"页面
2. 点击任意概念卡片进入概念详情
     3. 点击"历史时间轴"按钮(需要Max会员权限)
     4. 查看弹窗底部是否显示风险提示 & mock数据处理
2025-10-29 19:18:12 +08:00

996 lines
54 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 } 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 (
<>
<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">
{/* 时间轴主线 */}
<Box
position="absolute"
left="50%"
transform="translateX(-50%)"
top={0}
bottom={0}
width="2px"
bgGradient="linear(to-b, purple.200, pink.200)"
zIndex={0}
/>
<VStack spacing={0} align="stretch" position="relative">
{timelineData.map((item, index) => {
const priceInfo = getPriceInfo(item.price);
const hasEvents = item.events.length > 0;
const isExpanded = expandedDates[item.date];
return (
<Box key={`${item.date}-${index}`} position="relative">
<Flex
align="center"
py={4}
position="relative"
_hover={{ bg: 'white' }}
transition="all 0.2s"
borderRadius="lg"
px={4}
>
{/* 左侧 - 涨跌幅信息 */}
<Box
flex={1}
pr={8}
textAlign="right"
>
<VStack align="end" spacing={2}>
<Text
fontSize="sm"
fontWeight="bold"
color="gray.700"
>
{formatDateDisplay(item.date)}
</Text>
{item.price && (
<Box
bg="white"
px={4}
py={2}
borderRadius="full"
boxShadow="sm"
border="1px solid"
borderColor={`${priceInfo.color}.200`}
>
<HStack spacing={2}>
{priceInfo.icon && (
<Icon
as={priceInfo.icon}
color={`${priceInfo.color}.500`}
boxSize={4}
/>
)}
<Text
fontWeight="bold"
fontSize="md"
color={`${priceInfo.color}.600`}
>
{priceInfo.text}
</Text>
</HStack>
{item.price.stock_count && (
<Text
fontSize="xs"
color="gray.500"
mt={1}
>
统计股票: {item.price.stock_count}
</Text>
)}
</Box>
)}
{!item.price && !hasEvents && (
<Text fontSize="xs" color="gray.400" fontStyle="italic">
当日无数据
</Text>
)}
</VStack>
</Box>
{/* 中间 - 时间轴节点 */}
<Box
position="relative"
zIndex={2}
>
<Box
w={hasEvents ? 14 : (item.price ? 10 : 6)}
h={hasEvents ? 14 : (item.price ? 10 : 6)}
borderRadius="full"
bg={
hasEvents ? 'purple.500' :
item.price ? (
priceInfo.color === 'red' ? 'red.400' :
priceInfo.color === 'green' ? 'green.400' :
'gray.400'
) : 'gray.300'
}
border="4px solid"
borderColor="white"
boxShadow="lg"
position="relative"
cursor={hasEvents ? 'pointer' : 'default'}
onClick={() => hasEvents && toggleDateExpand(item.date)}
_hover={hasEvents ? {
transform: 'scale(1.2)',
bg: 'pink.500'
} : {}}
transition="all 0.2s"
>
{hasEvents && (
<>
<Badge
position="absolute"
top="-2"
right="-2"
colorScheme="red"
borderRadius="full"
minW="24px"
h="24px"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="sm"
fontWeight="bold"
boxShadow="md"
border="2px solid white"
>
{item.events.length}
</Badge>
{/* 动画点击提示 */}
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
w="100%"
h="100%"
borderRadius="full"
border="2px solid"
borderColor="purple.300"
opacity={0.5}
animation={`${pulseAnimation} 2s infinite`}
/>
</>
)}
</Box>
</Box>
{/* 右侧 - 事件信息 */}
<Box
flex={1}
pl={8}
>
{hasEvents ? (
<Box>
<Button
size="sm"
variant="ghost"
colorScheme="purple"
rightIcon={
<Icon
as={isExpanded ? ChevronDownIcon : ChevronRightIcon}
transition="transform 0.2s"
/>
}
onClick={() => toggleDateExpand(item.date)}
mb={2}
>
<HStack spacing={2}>
<Text>
{item.events.filter(e => e.type === 'news').length > 0 &&
`${item.events.filter(e => e.type === 'news').length} 条新闻`}
</Text>
{item.events.filter(e => e.type === 'news').length > 0 &&
item.events.filter(e => e.type === 'report').length > 0 &&
<Text>·</Text>
}
<Text>
{item.events.filter(e => e.type === 'report').length > 0 &&
`${item.events.filter(e => e.type === 'report').length} 份研报`}
</Text>
</HStack>
</Button>
<Collapse in={isExpanded} animateOpacity>
<VStack
align="stretch"
spacing={3}
maxH="400px"
overflowY="auto"
bg="white"
p={4}
borderRadius="lg"
boxShadow="sm"
border="1px solid"
borderColor="gray.200"
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: '#f1f1f1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: '#c1c1c1',
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: '#a8a8a8',
},
}}
>
{item.events.map((event, eventIdx) => (
<Box
key={eventIdx}
p={3}
bg={event.type === 'news' ? 'blue.50' : 'green.50'}
borderRadius="md"
borderLeft="3px solid"
borderLeftColor={event.type === 'news' ? 'blue.400' : 'green.400'}
_hover={{ transform: 'translateX(4px)' }}
transition="all 0.2s"
>
<VStack align="start" spacing={2}>
<HStack spacing={2} flexWrap="wrap">
<Badge
colorScheme={event.type === 'news' ? 'blue' : 'green'}
variant="solid"
>
{event.type === 'news' ? '新闻' : '研报'}
</Badge>
{event.source && (
<Badge variant="subtle">
{event.source}
</Badge>
)}
{event.publisher && (
<Badge colorScheme="purple" variant="subtle">
{event.publisher}
</Badge>
)}
{event.rating && (
<Badge colorScheme="orange" variant="solid">
{event.rating}
</Badge>
)}
</HStack>
<Text
fontWeight="bold"
fontSize="sm"
color="gray.800"
>
{event.title}
</Text>
<Text
fontSize="xs"
color="gray.600"
noOfLines={3}
>
{event.content || '暂无内容'}
</Text>
<Button
size="xs"
variant="link"
colorScheme="blue"
leftIcon={<ViewIcon />}
onClick={() => {
if (event.type === 'news') {
// 🎯 追踪新闻点击和详情打开
trackNewsClicked(event, date);
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, date);
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>
</Collapse>
</Box>
) : (
<Text fontSize="sm" color="gray.400" fontStyle="italic">
当日无相关资讯
</Text>
)}
</Box>
</Flex>
{/* 分隔线 */}
{index < timelineData.length - 1 && (
<Divider opacity={0.3} />
)}
</Box>
);
})}
</VStack>
{/* 时间轴结束标记 */}
<Box textAlign="center" py={8}>
<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 */}
<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 */}
<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;