Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View File

@@ -0,0 +1,967 @@
import React, { useState, useEffect } from 'react';
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();
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) {
console.error('Price API error:', res.status);
throw new Error(`Price API error: ${res.status}`);
}
return res.json();
})
.catch(err => {
console.error('Price API error:', err);
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();
console.error('News API error:', res.status, text);
return [];
}
return res.json();
})
.catch(err => {
console.error('News API error:', err);
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();
console.error('Report API error:', res.status, text);
return { results: [] };
}
return res.json();
})
.catch(err => {
console.error('Report API error:', err);
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);
} catch (error) {
console.error('获取时间轴数据失败:', error);
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) => {
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') {
setSelectedNews({
title: event.title,
content: event.content,
source: event.source,
time: event.time,
url: event.url
});
setIsNewsModalOpen(true);
} else if (event.type === 'report') {
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>
)}
</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;