Files
vf_react/src/views/EventDetail/components/LimitAnalyse.js
2025-10-11 12:02:01 +08:00

832 lines
37 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 {
Box,
VStack,
HStack,
Text,
Button,
Select,
Grid,
GridItem,
Spinner,
Center,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
useToast,
useColorModeValue,
Badge,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Divider,
Flex,
Icon,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Tag,
Container,
} from '@chakra-ui/react';
import {
FiTrendingUp,
FiBarChart2,
FiPieChart,
FiActivity,
FiDownload,
FiCalendar,
FiTarget,
FiZap,
} from 'react-icons/fi';
import 'echarts-wordcloud';
// 导入现有的卡片组件
import Card from '../../../components/Card/Card';
import CardBody from '../../../components/Card/CardBody';
import CardHeader from '../../../components/Card/CardHeader';
// 导入图表组件
import ReactECharts from 'echarts-for-react';
// 导入导航栏组件
import HomeNavbar from '../../../components/Navbars/HomeNavbar';
// 板块关联TOP10数据计算
function getSectorRelationTop10(sectorData) {
// 股票代码 -> 所属板块集合
const stockSectorMap = new Map();
Object.entries(sectorData).forEach(([sector, sectorInfo]) => {
(sectorInfo.stocks || []).forEach(stock => {
const stockKey = stock.scode;
if (!stockSectorMap.has(stockKey)) {
stockSectorMap.set(stockKey, new Set());
}
(stock.core_sectors || []).forEach(s => stockSectorMap.get(stockKey).add(s));
});
});
// 统计板块对
const relations = new Map();
stockSectorMap.forEach(sectors => {
const arr = Array.from(sectors);
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
const pair = [arr[i], arr[j]].sort().join(' - ');
relations.set(pair, (relations.get(pair) || 0) + 1);
}
}
});
// Top10
const sorted = Array.from(relations.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10);
return {
labels: sorted.map(([pair]) => pair),
counts: sorted.map(([_, count]) => count)
};
}
// 板块关联关系图数据计算
function getSectorRelationGraph(sectorData) {
// 节点
const nodes = [];
const nodeMap = new Map();
const links = [];
const relationMap = new Map();
const stockInSectorMap = new Map();
let idx = 0;
Object.entries(sectorData).forEach(([sector, data]) => {
if (sector !== '其他' && sector !== '公告') {
nodes.push({
name: sector,
value: data.count,
symbolSize: Math.sqrt(data.count) * 8,
category: idx % 10,
label: {
show: true,
formatter: `{b}\n(${data.count}只)`
}
});
nodeMap.set(sector, nodes.length - 1);
idx++;
(data.stocks || []).forEach(stock => {
const stockKey = stock.scode;
if (!stockInSectorMap.has(stockKey)) {
stockInSectorMap.set(stockKey, new Set());
}
stockInSectorMap.get(stockKey).add(sector);
});
}
});
// 统计边
stockInSectorMap.forEach(sectors => {
const arr = Array.from(sectors);
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
const key = [arr[i], arr[j]].sort().join('-');
relationMap.set(key, (relationMap.get(key) || 0) + 1);
}
}
});
relationMap.forEach((value, key) => {
const [source, target] = key.split('-');
if (nodeMap.has(source) && nodeMap.has(target) && value >= 2) {
links.push({
source: nodeMap.get(source),
target: nodeMap.get(target),
value,
lineStyle: {
width: Math.log(value) * 2,
opacity: 0.6,
curveness: 0.3
}
});
}
});
return { nodes, links };
}
// 只取前10大板块其余合并为“其他”
function getTop10Sectors(chartData) {
if (!chartData || !chartData.labels || !chartData.counts) return {labels: [], counts: []};
const zipped = chartData.labels.map((label, i) => ({label, count: chartData.counts[i]}));
const sorted = zipped.sort((a, b) => b.count - a.count);
const top10 = sorted.slice(0, 10);
const rest = sorted.slice(10);
let otherCount = 0;
rest.forEach(item => { otherCount += item.count; });
const labels = top10.map(item => item.label);
const counts = top10.map(item => item.count);
if (otherCount > 0) {
labels.push('其他');
counts.push(otherCount);
}
return {labels, counts};
}
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE = isProduction ? "" : process.env.REACT_APP_API_URL;
// const API_BASE = 'http://49.232.185.254:5001'; // 改回5001端口确保和后端一致
// 涨停分析服务
const limitAnalyseService = {
async getAvailableDates() {
try {
const response = await fetch(`${API_BASE}/api/limit-analyse/available-dates`);
const text = await response.text();
try {
const data = JSON.parse(text);
return data.data || [];
} catch (e) {
throw new Error('接口返回内容不是有效的 JSON实际返回' + text.slice(0, 100));
}
} catch (error) {
console.error('Error fetching available dates:', error);
throw error;
}
},
async getAnalysisData(date) {
try {
const response = await fetch(`${API_BASE}/api/limit-analyse/data?date=${date}`);
const data = await response.json();
return data; // 修正:直接返回整个对象
} catch (error) {
console.error('Error fetching analysis data:', error);
throw error;
}
},
async getSectorData(date) {
try {
const response = await fetch(`${API_BASE}/api/limit-analyse/sector-data?date=${date}`);
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Error fetching sector data:', error);
throw error;
}
},
async getWordCloudData(date) {
try {
const response = await fetch(`${API_BASE}/api/limit-analyse/word-cloud?date=${date}`);
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Error fetching word cloud data:', error);
throw error;
}
},
async exportData(date, exportType = 'excel') {
try {
const response = await fetch(`${API_BASE}/api/limit-analyse/export-data`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ date, type: exportType }),
});
const data = await response.json();
return data.data;
} catch (error) {
console.error('Error exporting data:', error);
throw error;
}
},
};
const LimitAnalyse = () => {
const [selectedDate, setSelectedDate] = useState('');
const [availableDates, setAvailableDates] = useState([]);
const [analysisData, setAnalysisData] = useState(null);
const [sectorData, setSectorData] = useState({}); // 改为对象
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const toast = useToast();
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// 加载可用日期
useEffect(() => {
const loadAvailableDates = async () => {
try {
setIsLoading(true);
const dates = await limitAnalyseService.getAvailableDates();
setAvailableDates(dates);
if (dates.length > 0 && !selectedDate) {
setSelectedDate(dates[0]);
}
} catch (error) {
setError('加载日期列表失败');
toast({
title: '错误',
description: '加载日期列表失败',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
loadAvailableDates();
}, []);
// 加载分析数据
useEffect(() => {
const loadAnalysisData = async () => {
if (!selectedDate) return;
try {
setIsLoading(true);
setError(null);
const analysis = await limitAnalyseService.getAnalysisData(selectedDate);
setAnalysisData(analysis);
setSectorData(analysis.sector_data || {}); // sector_data为对象
// 不再用 wordCloudData
} catch (error) {
setError('加载分析数据失败');
toast({
title: '错误',
description: '加载分析数据失败',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
loadAnalysisData();
}, [selectedDate]);
// 由 sectorData 生成词云数据
const wordCloudData = analysisData?.word_freq_data || [];
// 统计卡片数据
const totalStocks = Object.values(sectorData || {}).reduce((sum, data) => sum + data.count, 0);
const sectorCount = Object.keys(sectorData || {}).length;
// 平均涨幅、最大涨幅
const avgChange = analysisData?.avg_change || 0;
const maxChange = analysisData?.max_change || 0;
// 饼图数据
const chartOption = {
title: {
text: '涨停股票分布',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'bold',
},
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
},
series: [
{
name: '板块分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
data: Object.entries(sectorData || {}).map(([name, data]) => ({
name,
value: data.count,
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
};
const wordCloudOption = {
title: {
text: '热点词汇',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'bold',
},
},
series: [
{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '70%',
height: '80%',
right: null,
bottom: null,
sizeRange: [12, 60],
rotationRange: [-90, 90],
rotationStep: 45,
gridSize: 8,
drawOutOfBound: false,
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: function () {
return 'rgb(' + [
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
].join(',') + ')';
},
},
emphasis: {
focus: 'self',
textStyle: {
shadowBlur: 10,
shadowColor: '#333',
},
},
data: wordCloudData.map((word) => ({
name: word.name,
value: Number(word.value) || 0,
})),
},
],
};
// 统计卡片组件
const StatCard = ({ icon, label, value, color, change }) => {
const iconColor = useColorModeValue(`${color}.500`, `${color}.300`);
const bgColor = useColorModeValue(`${color}.50`, `${color}.900`);
return (
<Box
p={4}
borderRadius="lg"
bg={bgColor}
border="1px solid"
borderColor={borderColor}
_hover={{
transform: 'translateY(-2px)',
shadow: 'md',
}}
transition="all 0.3s ease"
>
<VStack align="start" spacing={2}>
<HStack spacing={2}>
<Icon as={icon} color={iconColor} boxSize={5} />
<Text fontSize="sm" fontWeight="medium" color="gray.600">
{label}
</Text>
</HStack>
<Text fontSize="2xl" fontWeight="bold" color={iconColor}>
{value}
</Text>
{change && (
<HStack spacing={1}>
<StatArrow type={change > 0 ? 'increase' : 'decrease'} />
<Text fontSize="sm" color={change > 0 ? 'green.500' : 'red.500'}>
{Math.abs(change)}%
</Text>
</HStack>
)}
</VStack>
</Box>
);
};
if (error) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
<AlertTitle>错误</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
return (
<Box>
{/* 添加导航栏 */}
<HomeNavbar />
{/* 添加容器和边距 */}
<Container maxW="container.xl" px={6} py={8}>
<VStack spacing={6} align="stretch">
{/* 标题和日期选择 */}
<Card bg={cardBg} shadow="xl" borderRadius="2xl">
<CardHeader>
<HStack justify="space-between" align="center">
<VStack align="start" spacing={2}>
<HStack spacing={3}>
<Icon as={FiTrendingUp} color="blue.500" boxSize={6} />
<Text fontSize="xl" fontWeight="bold">
涨停分析
</Text>
</HStack>
<Text color="gray.600">
分析每日涨停股票的数据分布和热点词汇
</Text>
</VStack>
<HStack spacing={3}>
<Select
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
placeholder="选择日期"
maxW="200px"
size="md"
>
{availableDates.map((date) => (
<option key={date} value={date}>
{date}
</option>
))}
</Select>
<Button
leftIcon={<FiDownload />}
colorScheme="blue"
variant="outline"
onClick={() => handleExport('excel')}
size="md"
>
导出
</Button>
</HStack>
</HStack>
</CardHeader>
</Card>
{isLoading ? (
<Center py={10}>
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" />
<Text color="gray.600">加载数据中...</Text>
</VStack>
</Center>
) : (
<>
<Grid templateColumns="repeat(3, 1fr)" gap={6}>
{/* 板块分布图表 */}
<GridItem>
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiPieChart} color="blue.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
板块分布
</Text>
</HStack>
</CardHeader>
<CardBody>
<ReactECharts option={{
title: { text: '涨停股票分布', left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
legend: { orient: 'vertical', left: 'left', top: 'middle' },
series: [{
name: '板块分布',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
data: getTop10Sectors(analysisData?.chart_data).labels.map((name, i) => ({ name, value: getTop10Sectors(analysisData?.chart_data).counts[i] })),
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } },
}],
}} style={{ height: '300px' }} />
</CardBody>
</Card>
</GridItem>
{/* 板块股票数量柱状图 */}
<GridItem>
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiBarChart2} color="green.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
板块股票数量
</Text>
</HStack>
</CardHeader>
<CardBody>
<ReactECharts
option={{
title: { text: '板块股票数量分布', left: 'center', textStyle: { fontSize: 14 } },
xAxis: { type: 'category', data: getTop10Sectors(analysisData?.chart_data).labels, axisLabel: { rotate: 45 } },
yAxis: { type: 'value', name: '股票数量' },
series: [{
data: getTop10Sectors(analysisData?.chart_data).counts,
type: 'bar',
itemStyle: { color: '#5e72e4' }
}]
}}
style={{ height: '300px' }}
/>
</CardBody>
</Card>
</GridItem>
{/* 板块关联TOP10横向条形图 */}
<GridItem>
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiPieChart} color="purple.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
板块关联TOP10
</Text>
</HStack>
</CardHeader>
<CardBody>
<ReactECharts
option={{
title: { text: '板块关联度 Top 10', left: 'center', textStyle: { fontSize: 14 } },
xAxis: { type: 'value', name: '共同持有股票数量' },
yAxis: {
type: 'category',
data: getSectorRelationTop10(sectorData).labels,
axisLabel: {
fontSize: 12,
width: 120,
overflow: 'break',
formatter: function(value) {
// 自动换行
if (value.length > 10) return value.replace(/(.{10})/g, '$1\n');
return value;
}
}
},
series: [{
data: getSectorRelationTop10(sectorData).counts,
type: 'bar',
itemStyle: { color: '#45b7d1' },
label: { show: true, position: 'right' }
}],
grid: { left: 120, right: 20, top: 40, bottom: 40 }
}}
style={{ height: '300px' }}
/>
</CardBody>
</Card>
</GridItem>
</Grid>
{/* 板块关联关系图+词云 */}
<Grid templateColumns="repeat(2, 1fr)" gap={6} mt={8}>
<GridItem>
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiPieChart} color="purple.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
板块关联关系图
</Text>
</HStack>
</CardHeader>
<CardBody>
<ReactECharts
option={{
title: { text: '板块关联关系图', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {},
series: [{
type: 'graph',
layout: 'force',
data: getSectorRelationGraph(sectorData).nodes,
links: getSectorRelationGraph(sectorData).links,
roam: true,
label: { show: true },
force: { repulsion: 300, gravity: 0.1, edgeLength: 120 },
lineStyle: { width: 2, color: '#aaa', curveness: 0.2 },
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 8
}]
}}
style={{ height: '400px' }}
/>
</CardBody>
</Card>
</GridItem>
<GridItem>
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiActivity} color="orange.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
热点词云
</Text>
</HStack>
</CardHeader>
<CardBody>
<ReactECharts
option={wordCloudOption}
style={{ height: '400px' }}
/>
</CardBody>
</Card>
</GridItem>
</Grid>
{/* 数据统计 */}
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4} mt={8}>
<GridItem colSpan={2}>
<Card bg={cardBg} shadow="xl" borderRadius="2xl">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiActivity} color="purple.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
数据统计
</Text>
</HStack>
</CardHeader>
<CardBody>
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4}>
<StatCard
icon={FiTarget}
label="涨停股票总数"
value={totalStocks}
color="blue"
/>
<StatCard
icon={FiBarChart2}
label="涉及板块数"
value={sectorCount}
color="green"
/>
<StatCard
icon={FiTrendingUp}
label="平均涨幅"
value={`${avgChange}%`}
color="orange"
/>
<StatCard
icon={FiZap}
label="最大涨幅"
value={`${maxChange}%`}
color="red"
/>
</Grid>
</CardBody>
</Card>
</GridItem>
</Grid>
{/* 板块手风琴 */}
<Grid templateColumns="1fr" gap={6} mt={8}>
{Object.keys(sectorData).length > 0 && (
<GridItem>
<Card bg={cardBg} shadow="xl" borderRadius="2xl">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiBarChart2} color="teal.500" boxSize={5} />
<Text fontSize="lg" fontWeight="bold">
板块详细数据
</Text>
</HStack>
</CardHeader>
<CardBody>
<Accordion allowMultiple>
{Object.entries(sectorData).map(([sector, data], idx) => (
<AccordionItem key={sector} border="none">
<h2>
<AccordionButton _expanded={{ bg: 'gray.100' }}>
<Box flex="1" textAlign="left" fontWeight="bold">
{sector} <Tag ml={2} colorScheme="blue">{data.count}</Tag>
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<VStack align="stretch" spacing={4}>
{(data.stocks || []).map((stock, sidx) => (
<StockCard key={stock.scode} stock={stock} idx={sidx} />
))}
</VStack>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</CardBody>
</Card>
</GridItem>
)}
</Grid>
</>
)}
</VStack>
</Container>
</Box>
);
};
// 新增股票卡片组件和弹窗
const StockCard = ({ stock, idx }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<Box p={4} borderRadius="lg" bg={useColorModeValue('gray.50', 'gray.700')} border="1px solid" borderColor={useColorModeValue('gray.200', 'gray.600')}>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold" fontSize="md">{stock.sname} ({stock.scode})</Text>
{stock.continuous_days && (
<Tag colorScheme={stock.continuous_days.includes('首板') ? 'yellow' : 'red'} size="sm">
{stock.continuous_days}
</Tag>
)}
</HStack>
<Text color="gray.500" fontSize="sm">涨停时间{stock.formatted_time}</Text>
<Box mt={2} mb={2}>
<Text fontSize="sm" color="gray.700">{stock.brief}</Text>
{stock.summary && (
<Button onClick={onOpen} size="xs" colorScheme="yellow" variant="outline" ml={2} mt={2}>
查看详情
</Button>
)}
</Box>
<HStack spacing={2} mt={2} flexWrap="wrap">
{(stock.core_sectors || []).map((sector) => (
<Tag key={sector} colorScheme="blackAlpha" bg="#1b1b1b" color="#eacd76" fontWeight="500" borderRadius="md">
{sector}
</Tag>
))}
</HStack>
</VStack>
</HStack>
{/* 详细摘要弹窗美化 */}
{stock.summary && (
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
<ModalOverlay />
<ModalContent borderRadius="lg" p={0}>
<ModalHeader p={4} bg="#1b1b1b" color="#eacd76" fontWeight="bold" fontSize="lg" borderTopRadius="lg">
{stock.sname} ({stock.scode}) - 详细分析
</ModalHeader>
<ModalCloseButton color="#eacd76" top={3} right={3} />
<ModalBody p={6} bg={useColorModeValue('white', 'gray.800')}>
<Box fontSize="md" color="#222" lineHeight={1.9} whiteSpace="pre-line" dangerouslySetInnerHTML={{ __html: (stock.summary || '').replace(/<br\s*\/?>(\s*)/g, '\n').replace(/\n{2,}/g, '\n').replace(/\n/g, '<br/>') }} />
</ModalBody>
<ModalFooter bg={useColorModeValue('white', 'gray.800')} justifyContent="center">
<Button onClick={onClose} colorScheme="yellow" borderRadius="md" px={8} fontWeight="bold">关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
);
};
export default LimitAnalyse;