Initial commit
This commit is contained in:
967
src/views/Concept/ConceptTimelineModal.js
Normal file
967
src/views/Concept/ConceptTimelineModal.js
Normal 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;
|
||||
921
src/views/Concept/components/ConceptStatsPanel.js
Normal file
921
src/views/Concept/components/ConceptStatsPanel.js
Normal file
@@ -0,0 +1,921 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Icon,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
Tooltip,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Input,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Flex,
|
||||
Avatar,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
Stat,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaArrowUp,
|
||||
FaArrowDown,
|
||||
FaFire,
|
||||
FaChartLine,
|
||||
FaNewspaper,
|
||||
FaRocket,
|
||||
FaBolt,
|
||||
FaEye,
|
||||
FaCrown,
|
||||
FaThermometerHalf,
|
||||
} from 'react-icons/fa';
|
||||
import { BsLightningFill, BsGraphUp, BsGraphDown } from 'react-icons/bs';
|
||||
|
||||
const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
// 获取正确的API基础URL
|
||||
const conceptApiBaseUrl = process.env.NODE_ENV === 'production'
|
||||
? '/concept-api'
|
||||
: 'http://111.198.58.126:16801';
|
||||
|
||||
const [statsData, setStatsData] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [timeRange, setTimeRange] = useState(7); // 默认7天
|
||||
const [customStartDate, setCustomStartDate] = useState('');
|
||||
const [customEndDate, setCustomEndDate] = useState('');
|
||||
const [useCustomRange, setUseCustomRange] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStatsData = async (days = timeRange, startDate = null, endDate = null) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 构建查询参数
|
||||
let queryParams = 'min_stock_count=3';
|
||||
if (useCustomRange && startDate && endDate) {
|
||||
queryParams += `&start_date=${startDate}&end_date=${endDate}`;
|
||||
} else {
|
||||
queryParams += `&days=${days}`;
|
||||
}
|
||||
|
||||
let response;
|
||||
let result;
|
||||
|
||||
try {
|
||||
// 优先尝试concept-api路由(通过nginx代理)
|
||||
response = await fetch(`${conceptApiBaseUrl}/statistics?${queryParams}`);
|
||||
if (response.ok) {
|
||||
result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setStatsData(result.data);
|
||||
return; // 成功获取数据,直接返回
|
||||
} else {
|
||||
throw new Error(result.note || 'Concept API返回错误');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Concept API错误: ${response.status}`);
|
||||
}
|
||||
} catch (conceptApiError) {
|
||||
console.warn('concept-api路由失败,尝试直接访问:', conceptApiError.message);
|
||||
|
||||
// 备用方案:直接访问concept_api服务(开发环境回退)
|
||||
try {
|
||||
response = await fetch(`http://111.198.58.126:16801/statistics?${queryParams}`);
|
||||
if (response.ok) {
|
||||
result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
setStatsData(result.data);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(result.note || '直接API返回错误');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`直接API错误: ${response.status}`);
|
||||
}
|
||||
} catch (directError) {
|
||||
console.error('所有API都失败:', directError);
|
||||
throw new Error('无法访问概念统计API');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
toast({
|
||||
title: '获取统计数据失败',
|
||||
description: '正在使用默认数据,请稍后刷新重试',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 使用简化的默认数据作为最后的fallback
|
||||
setStatsData({
|
||||
hot_concepts: [
|
||||
{ name: '小米大模型', change_pct: 18.76, stock_count: 12, news_count: 35 },
|
||||
{ name: '人工智能', change_pct: 15.67, stock_count: 45, news_count: 23 },
|
||||
{ name: '新能源汽车', change_pct: 12.34, stock_count: 38, news_count: 18 },
|
||||
{ name: '芯片概念', change_pct: 9.87, stock_count: 52, news_count: 31 },
|
||||
{ name: '5G通信', change_pct: 8.45, stock_count: 29, news_count: 15 },
|
||||
],
|
||||
cold_concepts: [
|
||||
{ name: '房地产', change_pct: -8.76, stock_count: 33, news_count: 12 },
|
||||
{ name: '煤炭开采', change_pct: -6.54, stock_count: 25, news_count: 8 },
|
||||
{ name: '传统零售', change_pct: -5.43, stock_count: 19, news_count: 6 },
|
||||
{ name: '钢铁冶炼', change_pct: -4.21, stock_count: 28, news_count: 9 },
|
||||
{ name: '纺织服装', change_pct: -3.98, stock_count: 15, news_count: 4 },
|
||||
],
|
||||
active_concepts: [
|
||||
{ name: '人工智能', news_count: 89, report_count: 15, total_mentions: 104 },
|
||||
{ name: '芯片概念', news_count: 76, report_count: 12, total_mentions: 88 },
|
||||
{ name: '新能源汽车', news_count: 65, report_count: 18, total_mentions: 83 },
|
||||
{ name: '生物医药', news_count: 54, report_count: 9, total_mentions: 63 },
|
||||
{ name: '量子科技', news_count: 41, report_count: 7, total_mentions: 48 },
|
||||
],
|
||||
volatile_concepts: [
|
||||
{ name: '区块链', volatility: 23.45, avg_change: 3.21, max_change: 12.34 },
|
||||
{ name: '元宇宙', volatility: 21.87, avg_change: 2.98, max_change: 11.76 },
|
||||
{ name: '虚拟现实', volatility: 19.65, avg_change: -1.23, max_change: 9.87 },
|
||||
{ name: '游戏概念', volatility: 18.32, avg_change: 4.56, max_change: 10.45 },
|
||||
{ name: '在线教育', volatility: 17.89, avg_change: -2.11, max_change: 8.76 },
|
||||
],
|
||||
momentum_concepts: [
|
||||
{ name: '数字经济', consecutive_days: 5, total_change: 18.76, avg_daily: 3.75 },
|
||||
{ name: '云计算', consecutive_days: 4, total_change: 14.32, avg_daily: 3.58 },
|
||||
{ name: '物联网', consecutive_days: 4, total_change: 12.89, avg_daily: 3.22 },
|
||||
{ name: '大数据', consecutive_days: 3, total_change: 11.45, avg_daily: 3.82 },
|
||||
{ name: '工业互联网', consecutive_days: 3, total_change: 9.87, avg_daily: 3.29 },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = (newRange) => {
|
||||
if (newRange === 'custom') {
|
||||
setUseCustomRange(true);
|
||||
// 默认设置最近7天的自定义范围
|
||||
const today = new Date();
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(today.getDate() - 6);
|
||||
|
||||
setCustomEndDate(today.toISOString().split('T')[0]);
|
||||
setCustomStartDate(weekAgo.toISOString().split('T')[0]);
|
||||
} else {
|
||||
setUseCustomRange(false);
|
||||
setTimeRange(parseInt(newRange));
|
||||
fetchStatsData(parseInt(newRange));
|
||||
}
|
||||
};
|
||||
|
||||
// 应用自定义日期范围
|
||||
const applyCustomRange = () => {
|
||||
if (customStartDate && customEndDate) {
|
||||
if (new Date(customStartDate) > new Date(customEndDate)) {
|
||||
toast({
|
||||
title: '日期选择错误',
|
||||
description: '开始日期不能晚于结束日期',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
fetchStatsData(null, customStartDate, customEndDate);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatsData();
|
||||
}, []);
|
||||
|
||||
// 当自定义范围状态改变时重新获取数据
|
||||
useEffect(() => {
|
||||
if (useCustomRange && customStartDate && customEndDate) {
|
||||
fetchStatsData(null, customStartDate, customEndDate);
|
||||
}
|
||||
}, [useCustomRange]);
|
||||
|
||||
// 格式化涨跌幅
|
||||
const formatChange = (value) => {
|
||||
if (!value) return '0.00%';
|
||||
const formatted = Math.abs(value).toFixed(2);
|
||||
return value > 0 ? `+${formatted}%` : `-${formatted}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅颜色
|
||||
const getChangeColor = (value) => {
|
||||
if (value > 0) return 'red.500';
|
||||
if (value < 0) return 'green.500';
|
||||
return 'gray.500';
|
||||
};
|
||||
|
||||
// 统计卡片组件 - 美化版
|
||||
const StatsCard = ({ title, icon, color, data, renderItem, isLoading }) => (
|
||||
<Box p={4}>
|
||||
{isLoading ? (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<HStack key={i} justify="space-between" p={3} bg="gray.50" borderRadius="lg">
|
||||
<HStack spacing={2} flex={1}>
|
||||
<Skeleton height="20px" width="20px" borderRadius="full" />
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Skeleton height="14px" width="80%" />
|
||||
<Skeleton height="12px" width="60%" />
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Skeleton height="20px" width="50px" borderRadius="md" />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack spacing={2} align="stretch" maxH="400px" overflowY="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#c1c1c1',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#a8a8a8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data?.map((item, index) => (
|
||||
<Box key={index}>
|
||||
{renderItem(item, index)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const tabsData = [
|
||||
{
|
||||
label: '涨幅榜',
|
||||
icon: FaArrowUp,
|
||||
color: 'red',
|
||||
data: statsData.hot_concepts,
|
||||
renderItem: (item, index) => (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={3}
|
||||
borderRadius="xl"
|
||||
bg={index < 3 ? 'red.50' : 'gray.50'}
|
||||
border="1px solid"
|
||||
borderColor={index < 3 ? 'red.100' : 'gray.200'}
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
cursor: 'pointer',
|
||||
bg: index < 3 ? 'red.100' : 'gray.100'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => onConceptClick?.(null, item.name)}
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Box position="relative">
|
||||
<Badge
|
||||
colorScheme={index === 0 ? 'yellow' : index === 1 ? 'orange' : index === 2 ? 'red' : 'gray'}
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
minW="24px"
|
||||
h="24px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{index === 0 && (
|
||||
<Icon
|
||||
as={FaCrown}
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
right="-8px"
|
||||
color="yellow.500"
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||||
{item.name}
|
||||
</Text>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.600">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaChartLine} boxSize={2.5} />
|
||||
<Text>{item.stock_count}股</Text>
|
||||
</HStack>
|
||||
<Text>·</Text>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaNewspaper} boxSize={2.5} />
|
||||
<Text>{item.news_count}讯</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme="red"
|
||||
variant="solid"
|
||||
borderRadius="lg"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon as={FaArrowUp} boxSize={2} mr={1} />
|
||||
{formatChange(item.change_pct)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '跌幅榜',
|
||||
icon: FaArrowDown,
|
||||
color: 'green',
|
||||
data: statsData.cold_concepts,
|
||||
renderItem: (item, index) => (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={3}
|
||||
borderRadius="xl"
|
||||
bg={index < 3 ? 'green.50' : 'gray.50'}
|
||||
border="1px solid"
|
||||
borderColor={index < 3 ? 'green.100' : 'gray.200'}
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
cursor: 'pointer',
|
||||
bg: index < 3 ? 'green.100' : 'gray.100'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => onConceptClick?.(null, item.name)}
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Badge
|
||||
colorScheme={index < 3 ? 'green' : 'gray'}
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
minW="24px"
|
||||
h="24px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||||
{item.name}
|
||||
</Text>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.600">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaChartLine} boxSize={2.5} />
|
||||
<Text>{item.stock_count}股</Text>
|
||||
</HStack>
|
||||
<Text>·</Text>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaNewspaper} boxSize={2.5} />
|
||||
<Text>{item.news_count}讯</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme="green"
|
||||
variant="solid"
|
||||
borderRadius="lg"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon as={FaArrowDown} boxSize={2} mr={1} />
|
||||
{formatChange(item.change_pct)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '活跃榜',
|
||||
icon: FaFire,
|
||||
color: 'orange',
|
||||
data: statsData.active_concepts,
|
||||
renderItem: (item, index) => (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={3}
|
||||
borderRadius="xl"
|
||||
bg={index < 3 ? 'orange.50' : 'gray.50'}
|
||||
border="1px solid"
|
||||
borderColor={index < 3 ? 'orange.100' : 'gray.200'}
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
cursor: 'pointer',
|
||||
bg: index < 3 ? 'orange.100' : 'gray.100'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => onConceptClick?.(null, item.name)}
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Box position="relative">
|
||||
<Badge
|
||||
colorScheme={index === 0 ? 'orange' : index < 3 ? 'yellow' : 'gray'}
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
minW="24px"
|
||||
h="24px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{index === 0 && (
|
||||
<Icon
|
||||
as={FaFire}
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
right="-8px"
|
||||
color="orange.500"
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||||
{item.name}
|
||||
</Text>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.600">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaNewspaper} boxSize={2.5} />
|
||||
<Text>{item.news_count}</Text>
|
||||
</HStack>
|
||||
<Text>·</Text>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaEye} boxSize={2.5} />
|
||||
<Text>{item.report_count}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme="orange"
|
||||
variant="solid"
|
||||
borderRadius="lg"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon as={FaFire} boxSize={2} mr={1} />
|
||||
{item.total_mentions}
|
||||
</Badge>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '波动榜',
|
||||
icon: BsLightningFill,
|
||||
color: 'purple',
|
||||
data: statsData.volatile_concepts,
|
||||
renderItem: (item, index) => (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={3}
|
||||
borderRadius="xl"
|
||||
bg={index < 3 ? 'purple.50' : 'gray.50'}
|
||||
border="1px solid"
|
||||
borderColor={index < 3 ? 'purple.100' : 'gray.200'}
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
cursor: 'pointer',
|
||||
bg: index < 3 ? 'purple.100' : 'gray.100'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => onConceptClick?.(null, item.name)}
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Box position="relative">
|
||||
<Badge
|
||||
colorScheme={index < 3 ? 'purple' : 'gray'}
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
minW="24px"
|
||||
h="24px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{index === 0 && (
|
||||
<Icon
|
||||
as={BsLightningFill}
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
right="-8px"
|
||||
color="purple.500"
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
均幅 {formatChange(item.avg_change)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
variant="solid"
|
||||
borderRadius="lg"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon as={BsLightningFill} boxSize={2} mr={1} />
|
||||
{item.volatility?.toFixed(1)}%
|
||||
</Badge>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '连涨榜',
|
||||
icon: FaRocket,
|
||||
color: 'cyan',
|
||||
data: statsData.momentum_concepts,
|
||||
renderItem: (item, index) => (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={3}
|
||||
borderRadius="xl"
|
||||
bg={index < 3 ? 'cyan.50' : 'gray.50'}
|
||||
border="1px solid"
|
||||
borderColor={index < 3 ? 'cyan.100' : 'gray.200'}
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
cursor: 'pointer',
|
||||
bg: index < 3 ? 'cyan.100' : 'gray.100'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={() => onConceptClick?.(null, item.name)}
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Box position="relative">
|
||||
<Badge
|
||||
colorScheme={index === 0 ? 'cyan' : index < 3 ? 'blue' : 'gray'}
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
minW="24px"
|
||||
h="24px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{index === 0 && (
|
||||
<Icon
|
||||
as={FaRocket}
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
right="-8px"
|
||||
color="cyan.500"
|
||||
boxSize={3}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="start" spacing={0} flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" noOfLines={1} color="gray.800">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
累计 {formatChange(item.total_change)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Badge
|
||||
colorScheme="cyan"
|
||||
variant="solid"
|
||||
borderRadius="lg"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon as={FaRocket} boxSize={2} mr={1} />
|
||||
{item.consecutive_days}天
|
||||
</Badge>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 顶部标题卡片 */}
|
||||
<Box
|
||||
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
mb={4}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 背景装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-20px"
|
||||
right="-20px"
|
||||
width="80px"
|
||||
height="80px"
|
||||
borderRadius="full"
|
||||
bg="whiteAlpha.200"
|
||||
filter="blur(10px)"
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-10px"
|
||||
left="-10px"
|
||||
width="60px"
|
||||
height="60px"
|
||||
borderRadius="full"
|
||||
bg="whiteAlpha.100"
|
||||
filter="blur(8px)"
|
||||
/>
|
||||
|
||||
<VStack align="start" spacing={3} position="relative" w="full">
|
||||
<Flex justify="space-between" align="center" w="full">
|
||||
<HStack spacing={2}>
|
||||
<Box p={2} bg="whiteAlpha.200" borderRadius="lg">
|
||||
<Icon as={FaChartLine} color="white" boxSize={4} />
|
||||
</Box>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Heading size="sm" color="white" fontWeight="bold">
|
||||
概念统计中心
|
||||
</Heading>
|
||||
<Text fontSize="xs" color="whiteAlpha.800">
|
||||
{statsData.summary?.date_range ?
|
||||
`统计范围: ${statsData.summary.date_range}` :
|
||||
'实时追踪热门概念动态'
|
||||
}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
bg="whiteAlpha.200"
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
onClick={() => fetchStatsData(timeRange, customStartDate, customEndDate)}
|
||||
isLoading={loading}
|
||||
loadingText="刷新中"
|
||||
borderRadius="full"
|
||||
px={3}
|
||||
>
|
||||
🔄 刷新
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 时间范围选择控件 */}
|
||||
<Flex gap={2} wrap="wrap" align="center">
|
||||
<Text fontSize="xs" color="whiteAlpha.900" fontWeight="medium">
|
||||
📅 统计周期:
|
||||
</Text>
|
||||
<ButtonGroup size="xs" isAttached variant="solid">
|
||||
<Button
|
||||
bg={!useCustomRange && timeRange === 3 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
onClick={() => handleTimeRangeChange('3')}
|
||||
borderRadius="md"
|
||||
>
|
||||
3天
|
||||
</Button>
|
||||
<Button
|
||||
bg={!useCustomRange && timeRange === 7 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
onClick={() => handleTimeRangeChange('7')}
|
||||
borderRadius="md"
|
||||
>
|
||||
7天
|
||||
</Button>
|
||||
<Button
|
||||
bg={!useCustomRange && timeRange === 14 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
onClick={() => handleTimeRangeChange('14')}
|
||||
borderRadius="md"
|
||||
>
|
||||
14天
|
||||
</Button>
|
||||
<Button
|
||||
bg={!useCustomRange && timeRange === 30 ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
onClick={() => handleTimeRangeChange('30')}
|
||||
borderRadius="md"
|
||||
>
|
||||
30天
|
||||
</Button>
|
||||
<Button
|
||||
bg={useCustomRange ? 'whiteAlpha.400' : 'whiteAlpha.200'}
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
onClick={() => handleTimeRangeChange('custom')}
|
||||
borderRadius="md"
|
||||
>
|
||||
自定义
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
{/* 自定义日期范围输入 */}
|
||||
{useCustomRange && (
|
||||
<Flex gap={2} wrap="wrap" align="center">
|
||||
<Input
|
||||
type="date"
|
||||
value={customStartDate}
|
||||
onChange={(e) => setCustomStartDate(e.target.value)}
|
||||
size="xs"
|
||||
bg="whiteAlpha.200"
|
||||
color="white"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.300"
|
||||
_hover={{ borderColor: 'whiteAlpha.400' }}
|
||||
_focus={{ borderColor: 'whiteAlpha.500', boxShadow: 'none' }}
|
||||
w="auto"
|
||||
maxW="130px"
|
||||
sx={{
|
||||
'&::-webkit-calendar-picker-indicator': {
|
||||
filter: 'invert(1)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text fontSize="xs" color="whiteAlpha.800">至</Text>
|
||||
<Input
|
||||
type="date"
|
||||
value={customEndDate}
|
||||
onChange={(e) => setCustomEndDate(e.target.value)}
|
||||
size="xs"
|
||||
bg="whiteAlpha.200"
|
||||
color="white"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.300"
|
||||
_hover={{ borderColor: 'whiteAlpha.400' }}
|
||||
_focus={{ borderColor: 'whiteAlpha.500', boxShadow: 'none' }}
|
||||
w="auto"
|
||||
maxW="130px"
|
||||
sx={{
|
||||
'&::-webkit-calendar-picker-indicator': {
|
||||
filter: 'invert(1)',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
bg="whiteAlpha.300"
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.400' }}
|
||||
onClick={applyCustomRange}
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 主内容卡片 */}
|
||||
<Box bg={bg} borderRadius="xl" border="1px" borderColor={borderColor} shadow="sm" overflow="hidden">
|
||||
<Tabs index={activeTab} onChange={setActiveTab} variant="unstyled" size="sm">
|
||||
<TabList
|
||||
bg="gray.50"
|
||||
borderBottom="1px"
|
||||
borderColor={borderColor}
|
||||
overflowX="auto"
|
||||
overflowY="hidden"
|
||||
flexWrap="nowrap"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{tabsData.map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
minW="fit-content"
|
||||
fontSize="xs"
|
||||
px={3}
|
||||
py={3}
|
||||
whiteSpace="nowrap"
|
||||
_selected={{
|
||||
bg: `${tab.color}.500`,
|
||||
color: 'white',
|
||||
borderRadius: '0',
|
||||
position: 'relative',
|
||||
_after: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: '-1px',
|
||||
left: '0',
|
||||
right: '0',
|
||||
height: '2px',
|
||||
bg: `${tab.color}.500`,
|
||||
}
|
||||
}}
|
||||
_hover={{ bg: `${tab.color}.50` }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={tab.icon} boxSize={3} />
|
||||
<Text fontWeight="medium">{tab.label}</Text>
|
||||
</HStack>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{tabsData.map((tab, index) => (
|
||||
<TabPanel key={index} px={0} py={0}>
|
||||
<StatsCard
|
||||
title={tab.label}
|
||||
icon={tab.icon}
|
||||
color={tab.color}
|
||||
data={tab.data}
|
||||
renderItem={tab.renderItem}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStatsPanel;
|
||||
1569
src/views/Concept/index.js
Normal file
1569
src/views/Concept/index.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user