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,832 @@
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;