832 lines
37 KiB
JavaScript
832 lines
37 KiB
JavaScript
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;
|