Initial commit
This commit is contained in:
616
src/views/LimitAnalyse/components/DataVisualizationComponents.js
Normal file
616
src/views/LimitAnalyse/components/DataVisualizationComponents.js
Normal file
@@ -0,0 +1,616 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Center,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
SimpleGrid,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
Badge,
|
||||
Divider,
|
||||
Avatar,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import './WordCloud.css';
|
||||
import {
|
||||
BarChart, Bar,
|
||||
PieChart, Pie, Cell,
|
||||
XAxis, YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Treemap,
|
||||
Area, AreaChart,
|
||||
} from 'recharts';
|
||||
import ReactWordcloud from 'react-wordcloud';
|
||||
// 颜色配置
|
||||
const CHART_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD',
|
||||
'#D4A5A5', '#9B6B6B', '#E9967A', '#B19CD9', '#87CEEB'
|
||||
];
|
||||
|
||||
// 词云图组件
|
||||
const WordCloud = ({ data }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Center h="400px">
|
||||
<VStack>
|
||||
<Text color="gray.500">暂无词云数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const words = data.slice(0, 100).map(item => ({
|
||||
text: item.name || item.text,
|
||||
value: item.value || item.count || 1
|
||||
}));
|
||||
|
||||
const options = {
|
||||
rotations: 2,
|
||||
rotationAngles: [-90, 0],
|
||||
fontFamily: 'Microsoft YaHei, sans-serif',
|
||||
fontSizes: [16, 80],
|
||||
fontWeight: 'bold',
|
||||
padding: 3,
|
||||
scale: 'sqrt',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
getWordColor: () => {
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD'];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactWordcloud
|
||||
words={words}
|
||||
options={options}
|
||||
callbacks={callbacks}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 板块热力图组件
|
||||
const SectorHeatMap = ({ data }) => {
|
||||
if (!data) return null;
|
||||
|
||||
const sectors = Object.entries(data)
|
||||
.filter(([name]) => name !== '其他')
|
||||
.map(([name, info]) => ({
|
||||
name,
|
||||
count: info.count,
|
||||
stocks: info.stocks || []
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20);
|
||||
|
||||
const maxCount = Math.max(...sectors.map(s => s.count), 1);
|
||||
|
||||
const getColor = (count) => {
|
||||
const intensity = count / maxCount;
|
||||
if (intensity < 0.1) return '#E6F3FF';
|
||||
if (intensity < 0.2) return '#CCE7FF';
|
||||
if (intensity < 0.3) return '#99CFFF';
|
||||
if (intensity < 0.4) return '#66B7FF';
|
||||
if (intensity < 0.5) return '#339FFF';
|
||||
if (intensity < 0.6) return '#0087FF';
|
||||
if (intensity < 0.7) return '#0069CC';
|
||||
if (intensity < 0.8) return '#004C99';
|
||||
if (intensity < 0.9) return '#003066';
|
||||
return '#001433';
|
||||
};
|
||||
|
||||
const cols = 5;
|
||||
const rows = Math.ceil(sectors.length / cols);
|
||||
const cellWidth = 150;
|
||||
const cellHeight = 100;
|
||||
const padding = 10;
|
||||
|
||||
return (
|
||||
<Box overflowX="auto" p={4}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<svg
|
||||
width={cols * (cellWidth + padding) + padding}
|
||||
height={rows * (cellHeight + padding) + padding}
|
||||
>
|
||||
{sectors.map((sector, index) => {
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
const x = col * (cellWidth + padding) + padding;
|
||||
const y = row * (cellHeight + padding) + padding;
|
||||
const color = getColor(sector.count);
|
||||
|
||||
return (
|
||||
<g key={sector.name}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellWidth}
|
||||
height={cellHeight}
|
||||
fill={color}
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
rx="8"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.opacity = '0.8';
|
||||
e.target.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.opacity = '1';
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<title>{`${sector.name}: ${sector.count}只涨停`}</title>
|
||||
</rect>
|
||||
|
||||
<text
|
||||
x={x + cellWidth / 2}
|
||||
y={y + cellHeight / 2 - 10}
|
||||
textAnchor="middle"
|
||||
fill={sector.count / maxCount > 0.5 ? 'white' : '#2D3748'}
|
||||
fontSize="14"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{sector.name.length > 8 ? sector.name.slice(0, 8) + '...' : sector.name}
|
||||
</text>
|
||||
|
||||
<text
|
||||
x={x + cellWidth / 2}
|
||||
y={y + cellHeight / 2 + 10}
|
||||
textAnchor="middle"
|
||||
fill={sector.count / maxCount > 0.5 ? 'white' : '#2D3748'}
|
||||
fontSize="24"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{sector.count}
|
||||
</text>
|
||||
|
||||
<text
|
||||
x={x + cellWidth / 2}
|
||||
y={y + cellHeight / 2 + 28}
|
||||
textAnchor="middle"
|
||||
fill={sector.count / maxCount > 0.5 ? 'rgba(255,255,255,0.8)' : '#718096'}
|
||||
fontSize="12"
|
||||
>
|
||||
只涨停
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</Box>
|
||||
|
||||
<HStack justify="center" spacing={1}>
|
||||
<Text fontSize="sm" color="gray.600" mr={2}>少</Text>
|
||||
{[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1].map((intensity) => (
|
||||
<Box
|
||||
key={intensity}
|
||||
w={6}
|
||||
h={6}
|
||||
bg={getColor(intensity * maxCount)}
|
||||
borderRadius="sm"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
/>
|
||||
))}
|
||||
<Text fontSize="sm" color="gray.600" ml={2}>多</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 板块关联图组件
|
||||
const SectorRelationMap = ({ data }) => {
|
||||
if (!data || !data.length) return null;
|
||||
|
||||
const sectors = [...new Set(data.flatMap(item => item.core_sectors || []))];
|
||||
const sectorPairs = {};
|
||||
|
||||
data.forEach(stock => {
|
||||
const stockSectors = stock.core_sectors || [];
|
||||
for (let i = 0; i < stockSectors.length; i++) {
|
||||
for (let j = i + 1; j < stockSectors.length; j++) {
|
||||
const key = [stockSectors[i], stockSectors[j]].sort().join('-');
|
||||
sectorPairs[key] = (sectorPairs[key] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const topSectors = sectors
|
||||
.map(s => ({
|
||||
name: s,
|
||||
count: data.filter(d => (d.core_sectors || []).includes(s)).length
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 15)
|
||||
.map(s => s.name);
|
||||
|
||||
const matrix = [];
|
||||
const maxValue = Math.max(...Object.values(sectorPairs), 1);
|
||||
|
||||
topSectors.forEach((sector1, i) => {
|
||||
topSectors.forEach((sector2, j) => {
|
||||
if (i <= j) {
|
||||
const key = [sector1, sector2].sort().join('-');
|
||||
const value = i === j ?
|
||||
data.filter(d => (d.core_sectors || []).includes(sector1)).length :
|
||||
sectorPairs[key] || 0;
|
||||
|
||||
matrix.push({
|
||||
x: j,
|
||||
y: i,
|
||||
value,
|
||||
sector1,
|
||||
sector2,
|
||||
intensity: value / maxValue
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const getColor = (intensity) => {
|
||||
if (intensity === 0) return '#f7fafc';
|
||||
if (intensity < 0.2) return '#bee3f8';
|
||||
if (intensity < 0.4) return '#90cdf4';
|
||||
if (intensity < 0.6) return '#63b3ed';
|
||||
if (intensity < 0.8) return '#4299e1';
|
||||
return '#3182ce';
|
||||
};
|
||||
|
||||
const cellSize = 40;
|
||||
const fontSize = 10;
|
||||
|
||||
return (
|
||||
<Box overflowX="auto">
|
||||
<svg width={topSectors.length * cellSize + 150} height={topSectors.length * cellSize + 100}>
|
||||
{topSectors.map((sector, i) => (
|
||||
<text
|
||||
key={`y-${i}`}
|
||||
x={140}
|
||||
y={i * cellSize + cellSize / 2 + 55}
|
||||
textAnchor="end"
|
||||
fontSize={fontSize}
|
||||
fill="#4a5568"
|
||||
>
|
||||
{sector}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{topSectors.map((sector, i) => (
|
||||
<text
|
||||
key={`x-${i}`}
|
||||
x={150 + i * cellSize + cellSize / 2}
|
||||
y={40}
|
||||
textAnchor="middle"
|
||||
fontSize={fontSize}
|
||||
fill="#4a5568"
|
||||
transform={`rotate(-45, ${150 + i * cellSize + cellSize / 2}, 40)`}
|
||||
>
|
||||
{sector}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{matrix.map((cell, i) => (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={150 + cell.x * cellSize}
|
||||
y={50 + cell.y * cellSize}
|
||||
width={cellSize - 2}
|
||||
height={cellSize - 2}
|
||||
fill={getColor(cell.intensity)}
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="1"
|
||||
rx="2"
|
||||
>
|
||||
<title>{`${cell.sector1} - ${cell.sector2}: ${cell.value}`}</title>
|
||||
</rect>
|
||||
{cell.value > 0 && (
|
||||
<text
|
||||
x={150 + cell.x * cellSize + cellSize / 2}
|
||||
y={50 + cell.y * cellSize + cellSize / 2 + 4}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill={cell.intensity > 0.5 ? "white" : "#2d3748"}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{cell.value}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
<g transform={`translate(${topSectors.length * cellSize + 170}, 100)`}>
|
||||
<text x="0" y="-10" fontSize="12" fontWeight="bold" fill="#2d3748">强度</text>
|
||||
{[0, 0.2, 0.4, 0.6, 0.8, 1].map((intensity, i) => (
|
||||
<g key={i} transform={`translate(0, ${i * 20})`}>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="15"
|
||||
height="15"
|
||||
fill={getColor(intensity)}
|
||||
stroke="#e2e8f0"
|
||||
/>
|
||||
<text x="20" y="12" fontSize="10" fill="#4a5568">
|
||||
{intensity === 0 ? '0' : `${(intensity * maxValue).toFixed(0)}`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 数据分析主组件
|
||||
export const DataAnalysis = ({ dailyData, wordCloudData }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
const pieData = useMemo(() => {
|
||||
if (!dailyData?.chart_data) return [];
|
||||
return dailyData.chart_data.labels.slice(0, 10).map((label, index) => ({
|
||||
name: label,
|
||||
value: dailyData.chart_data.counts[index],
|
||||
}));
|
||||
}, [dailyData]);
|
||||
|
||||
const timeDistributionData = useMemo(() => {
|
||||
if (!dailyData?.summary?.zt_time_distribution) return [];
|
||||
const dist = dailyData.summary.zt_time_distribution;
|
||||
return [
|
||||
{ name: '早盘(9:30-11:30)', value: dist.morning || 0, color: '#48BB78' },
|
||||
{ name: '午盘(11:30-13:00)', value: dist.midday || 0, color: '#ED8936' },
|
||||
{ name: '尾盘(13:00-15:00)', value: dist.afternoon || 0, color: '#4299E1' }
|
||||
];
|
||||
}, [dailyData]);
|
||||
|
||||
const allStocks = useMemo(() => {
|
||||
if (!dailyData?.sector_data) return [];
|
||||
return Object.values(dailyData.sector_data).flatMap(sector => sector.stocks || []);
|
||||
}, [dailyData]);
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderRadius="xl" boxShadow="xl">
|
||||
<CardHeader>
|
||||
<Heading size="md">数据分析</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" isLazy>
|
||||
<TabList mb={4} overflowX="auto">
|
||||
<Tab>词云图</Tab>
|
||||
<Tab>板块分布</Tab>
|
||||
<Tab>板块热力图</Tab>
|
||||
<Tab>板块关联</Tab>
|
||||
<Tab>时间分布</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Box h="400px">
|
||||
{wordCloudData && wordCloudData.length > 0 ? (
|
||||
<WordCloud data={wordCloudData} />
|
||||
) : dailyData?.word_freq_data && dailyData.word_freq_data.length > 0 ? (
|
||||
<WordCloud data={dailyData.word_freq_data} />
|
||||
) : (
|
||||
<Center h="full">
|
||||
<VStack>
|
||||
<Text color="gray.500">暂无词云数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={120}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
animationBegin={0}
|
||||
animationDuration={800}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<Box h="500px" overflowY="auto">
|
||||
{dailyData?.sector_data ? (
|
||||
<SectorHeatMap data={dailyData.sector_data} />
|
||||
) : (
|
||||
<Center h="400px">
|
||||
<Text color="gray.500">暂无数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<Box h="500px" overflowY="auto">
|
||||
{allStocks.length > 0 ? (
|
||||
<SectorRelationMap data={allStocks} />
|
||||
) : (
|
||||
<Center h="400px">
|
||||
<Text color="gray.500">暂无数据</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={timeDistributionData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{timeDistributionData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<SimpleGrid columns={3} spacing={4} w="full">
|
||||
{timeDistributionData.map((item, index) => (
|
||||
<Stat key={index} textAlign="center">
|
||||
<StatLabel fontSize="sm">{item.name}</StatLabel>
|
||||
<StatNumber color={item.color}>{item.value}</StatNumber>
|
||||
<StatHelpText>
|
||||
{((item.value / (dailyData?.total_stocks || 1)) * 100).toFixed(1)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 股票详情弹窗组件
|
||||
export const StockDetailModal = ({ isOpen, onClose, selectedStock }) => {
|
||||
const accentColor = useColorModeValue('blue.500', 'blue.300');
|
||||
|
||||
if (!selectedStock) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay backdropFilter="blur(10px)" />
|
||||
<ModalContent>
|
||||
<ModalHeader bg={accentColor} color="white">
|
||||
<HStack>
|
||||
<Avatar name={selectedStock.sname} bg="white" color={accentColor} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Heading size="md">{selectedStock.sname}</Heading>
|
||||
<Text fontSize="sm">{selectedStock.scode}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
<ModalBody py={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>涨停信息</Text>
|
||||
<HStack spacing={3}>
|
||||
<Badge colorScheme="blue" fontSize="md" p={2}>
|
||||
涨停时间: {selectedStock.formatted_time || selectedStock.zt_time}
|
||||
</Badge>
|
||||
{selectedStock.continuous_days && (
|
||||
<Badge colorScheme="red" fontSize="md" p={2}>
|
||||
{selectedStock.continuous_days}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>涨停原因</Text>
|
||||
<Box p={4} bg="gray.50" borderRadius="md">
|
||||
<Text>{selectedStock.brief || '暂无涨停原因'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedStock.summary && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>详细分析</Text>
|
||||
<Box p={4} bg="gray.50" borderRadius="md">
|
||||
<Text {...getFormattedTextProps(selectedStock.summary).props}>
|
||||
{getFormattedTextProps(selectedStock.summary).children}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>所属板块</Text>
|
||||
<Wrap>
|
||||
{selectedStock.core_sectors?.map((sector, i) => (
|
||||
<WrapItem key={i}>
|
||||
<Tag size="md" colorScheme="teal">
|
||||
<TagLabel>{sector}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button colorScheme="blue" leftIcon={<ExternalLinkIcon />}>
|
||||
查看K线图
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default { DataAnalysis, StockDetailModal };
|
||||
269
src/views/LimitAnalyse/components/EnhancedCalendar.js
Normal file
269
src/views/LimitAnalyse/components/EnhancedCalendar.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
HStack,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
|
||||
const EnhancedCalendar = ({
|
||||
selectedDate,
|
||||
onDateChange,
|
||||
availableDates,
|
||||
compact = false,
|
||||
hideSelectionInfo = false,
|
||||
hideLegend = false,
|
||||
width,
|
||||
cellHeight,
|
||||
}) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
|
||||
// 当外部选择日期变化时,如果不在当前月,则切换到对应月份
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
const isSameMonth =
|
||||
currentMonth.getFullYear() === selectedDate.getFullYear() &&
|
||||
currentMonth.getMonth() === selectedDate.getMonth();
|
||||
if (!isSameMonth) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
}
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const getDaysInMonth = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
const formatDateStr = (date) => {
|
||||
if (!date) return '';
|
||||
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 getDateData = (date) => {
|
||||
if (!date) return null;
|
||||
const dateStr = formatDateStr(date);
|
||||
return availableDates.find(d => d.date === dateStr);
|
||||
};
|
||||
|
||||
const getDateColor = (count) => {
|
||||
if (!count) return 'gray.100';
|
||||
if (count <= 50) return 'green.100';
|
||||
if (count <= 80) return 'yellow.100';
|
||||
return 'red.100';
|
||||
};
|
||||
|
||||
const getDateBadgeColor = (count) => {
|
||||
if (!count) return 'gray';
|
||||
if (count <= 50) return 'green';
|
||||
if (count <= 80) return 'yellow';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const formatDisplayDate = (date) => {
|
||||
if (!date) return '';
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}年${month}月${day}日`;
|
||||
};
|
||||
|
||||
const days = getDaysInMonth(currentMonth);
|
||||
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const calendarWidth = width ? width : (compact ? '420px' : '500px');
|
||||
const dayCellHeight = cellHeight ? cellHeight : (compact ? 12 : 16); // Chakra units
|
||||
const headerSize = compact ? 'md' : 'lg';
|
||||
|
||||
return (
|
||||
<Card bg="white" boxShadow="2xl" borderRadius="xl" w={calendarWidth}>
|
||||
<CardHeader pb={compact ? 2 : 3}>
|
||||
<VStack spacing={3}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon />}
|
||||
size={compact ? 'sm' : 'md'}
|
||||
variant="ghost"
|
||||
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}
|
||||
aria-label="上个月"
|
||||
/>
|
||||
<HStack spacing={2}>
|
||||
<CalendarIcon boxSize={5} />
|
||||
<Heading size={headerSize}>
|
||||
{currentMonth.getFullYear()}年 {monthNames[currentMonth.getMonth()]}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon />}
|
||||
size={compact ? 'sm' : 'md'}
|
||||
variant="ghost"
|
||||
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}
|
||||
aria-label="下个月"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{!hideSelectionInfo && selectedDate && (
|
||||
<Alert status="info" borderRadius="md" fontSize="sm">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
当前选择:<strong>{formatDisplayDate(selectedDate)}</strong>
|
||||
{getDateData(selectedDate) &&
|
||||
` - ${getDateData(selectedDate).count}只涨停`
|
||||
}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody pt={2}>
|
||||
<SimpleGrid columns={7} spacing={compact ? 1 : 2}>
|
||||
{weekDays.map(day => (
|
||||
<Box key={day} textAlign="center" p={compact ? 2 : 3}>
|
||||
<Text fontSize={compact ? 'sm' : 'md'} fontWeight="bold" color="gray.600">
|
||||
{day}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{days.map((date, index) => {
|
||||
const dateData = getDateData(date);
|
||||
const hasData = !!dateData;
|
||||
const isSelected = date && selectedDate && date.toDateString() === selectedDate.toDateString();
|
||||
const isToday = date && date.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<Box key={index} p={1}>
|
||||
{date && (
|
||||
<Tooltip
|
||||
label={
|
||||
<VStack spacing={1} p={1}>
|
||||
<Text fontWeight="bold">{formatDisplayDate(date)}</Text>
|
||||
<Text>{hasData ? `${dateData.count}只涨停` : '暂无数据'}</Text>
|
||||
<Text fontSize="xs" color="gray.300">点击查看详情</Text>
|
||||
</VStack>
|
||||
}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
>
|
||||
<Box
|
||||
as="button"
|
||||
w="full"
|
||||
h={dayCellHeight}
|
||||
borderRadius="lg"
|
||||
position="relative"
|
||||
bg={hasData ? getDateColor(dateData.count) : 'transparent'}
|
||||
border={isSelected ? '3px solid' : isToday ? '2px solid' : '1px solid'}
|
||||
borderColor={isSelected ? 'blue.500' : isToday ? 'blue.300' : 'gray.200'}
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
zIndex: 1,
|
||||
boxShadow: 'lg',
|
||||
borderColor: 'blue.400'
|
||||
}}
|
||||
onClick={() => onDateChange(date)}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
>
|
||||
<Text
|
||||
fontSize={compact ? 'md' : 'lg'}
|
||||
fontWeight={isToday || isSelected ? 'bold' : 'normal'}
|
||||
color={isSelected ? 'blue.600' : 'gray.700'}
|
||||
>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
{hasData && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="2px"
|
||||
right="2px"
|
||||
size={compact ? 'sm' : 'md'}
|
||||
colorScheme={getDateBadgeColor(dateData.count)}
|
||||
fontSize={compact ? '10px' : '11px'}
|
||||
px={compact ? 1 : 2}
|
||||
minW={compact ? '22px' : '28px'}
|
||||
borderRadius="full"
|
||||
>
|
||||
{dateData.count}
|
||||
</Badge>
|
||||
)}
|
||||
{isToday && (
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom="2px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
fontSize={compact ? '9px' : '10px'}
|
||||
color="blue.500"
|
||||
fontWeight="bold"
|
||||
>
|
||||
今天
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{!hideLegend && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<VStack spacing={3}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="gray.600">涨停数量图例</Text>
|
||||
<HStack justify="center" spacing={4}>
|
||||
<HStack spacing={2}>
|
||||
<Box w={5} h={5} bg="green.100" borderRadius="md" border="1px solid" borderColor="green.300" />
|
||||
<Text fontSize="sm">少量 (≤50只)</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Box w={5} h={5} bg="yellow.100" borderRadius="md" border="1px solid" borderColor="yellow.400" />
|
||||
<Text fontSize="sm">中等 (51-80只)</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Box w={5} h={5} bg="red.100" borderRadius="md" border="1px solid" borderColor="red.300" />
|
||||
<Text fontSize="sm">大量 (>80只)</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedCalendar;
|
||||
245
src/views/LimitAnalyse/components/HighPositionStocks.js
Normal file
245
src/views/LimitAnalyse/components/HighPositionStocks.js
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
HStack,
|
||||
VStack,
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
Progress,
|
||||
Flex,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { StarIcon, TriangleUpIcon } from '@chakra-ui/icons';
|
||||
|
||||
const HighPositionStocks = ({ dateStr }) => {
|
||||
const [highPositionData, setHighPositionData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const accentColor = useColorModeValue('blue.500', 'blue.300');
|
||||
|
||||
useEffect(() => {
|
||||
fetchHighPositionStocks();
|
||||
}, [dateStr]);
|
||||
|
||||
const fetchHighPositionStocks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const API_URL = process.env.NODE_ENV === 'production' ? '/report-api' : 'http://111.198.58.126:5001';
|
||||
const response = await fetch(`${API_URL}/api/limit-analyse/high-position-stocks?date=${dateStr}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('High position stocks API response:', data); // 添加调试信息
|
||||
|
||||
if (data.success) {
|
||||
setHighPositionData(data.data);
|
||||
} else {
|
||||
console.error('API returned success: false', data);
|
||||
setHighPositionData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch high position stocks:', error);
|
||||
setHighPositionData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getContinuousDaysColor = (days) => {
|
||||
if (days >= 5) return 'red.500';
|
||||
if (days >= 3) return 'orange.500';
|
||||
if (days >= 2) return 'yellow.500';
|
||||
return 'green.500';
|
||||
};
|
||||
|
||||
const getContinuousDaysBadge = (days) => {
|
||||
if (days >= 5) return { color: 'red', label: '高度风险' };
|
||||
if (days >= 3) return { color: 'orange', label: '中等风险' };
|
||||
if (days >= 2) return { color: 'yellow', label: '低风险' };
|
||||
return { color: 'green', label: '正常' };
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={cardBg} borderRadius="xl" boxShadow="xl" mb={6}>
|
||||
<CardHeader>
|
||||
<Skeleton height="30px" width="200px" />
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} height="80px" borderRadius="lg" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!highPositionData) {
|
||||
return (
|
||||
<Card bg={cardBg} borderRadius="xl" boxShadow="xl" mb={6}>
|
||||
<CardBody>
|
||||
<Text textAlign="center" color="gray.500">
|
||||
暂无高位股数据
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { stocks, statistics } = highPositionData;
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderRadius="xl" boxShadow="xl" mb={6}>
|
||||
<CardHeader bg="red.500" color="white" borderTopRadius="xl">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<TriangleUpIcon boxSize={6} />
|
||||
<Heading size="md">高位股统计</Heading>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge bg="whiteAlpha.900" color="red.500" fontSize="md" px={3}>
|
||||
{statistics.total_count} 只高位股
|
||||
</Badge>
|
||||
<Badge bg="yellow.500" color="white" fontSize="md" px={3}>
|
||||
最高 {statistics.max_continuous_days} 连板
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{/* 统计卡片 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||
<Card bg="gray.50" borderRadius="lg">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">高位股总数</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="red.500">
|
||||
{statistics.total_count}
|
||||
</StatNumber>
|
||||
<StatHelpText>连续涨停股票</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg="gray.50" borderRadius="lg">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">平均连板天数</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="orange.500">
|
||||
{statistics.avg_continuous_days}
|
||||
</StatNumber>
|
||||
<StatHelpText>天</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg="gray.50" borderRadius="lg">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">最高连板</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="red.500">
|
||||
{statistics.max_continuous_days}
|
||||
</StatNumber>
|
||||
<StatHelpText>天</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 高位股列表 */}
|
||||
<Box overflowX="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>股票代码</Th>
|
||||
<Th>股票名称</Th>
|
||||
<Th isNumeric>价格</Th>
|
||||
<Th isNumeric>涨幅</Th>
|
||||
<Th isNumeric>连板天数</Th>
|
||||
<Th>风险等级</Th>
|
||||
<Th>行业</Th>
|
||||
<Th isNumeric>换手率</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{stocks.map((stock, index) => {
|
||||
const riskInfo = getContinuousDaysBadge(stock.continuous_limit_up);
|
||||
return (
|
||||
<Tr key={stock.stock_code} _hover={{ bg: 'gray.50' }}>
|
||||
<Td fontWeight="bold">{stock.stock_code}</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Text>{stock.stock_name}</Text>
|
||||
{stock.continuous_limit_up >= 5 && (
|
||||
<StarIcon color="red.500" boxSize={3} />
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric fontWeight="bold">¥{stock.price}</Td>
|
||||
<Td isNumeric color={stock.increase_rate > 0 ? 'red.500' : 'green.500'}>
|
||||
{stock.increase_rate > 0 ? '+' : ''}{stock.increase_rate}%
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge
|
||||
colorScheme={riskInfo.color}
|
||||
variant="solid"
|
||||
fontSize="sm"
|
||||
>
|
||||
{stock.continuous_limit_up}天
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Text fontSize="sm" color={getContinuousDaysColor(stock.continuous_limit_up)}>
|
||||
{riskInfo.label}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme="blue" variant="subtle" fontSize="xs">
|
||||
{stock.industry}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td isNumeric>{stock.turnover_rate}%</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box mt={4} p={3} bg="yellow.50" borderRadius="md" border="1px solid" borderColor="yellow.200">
|
||||
<HStack spacing={2}>
|
||||
<Icon as={TriangleUpIcon} color="yellow.500" />
|
||||
<Text fontSize="sm" color="yellow.700">
|
||||
<strong>风险提示:</strong>高位股通常具有较高的波动性和风险,请谨慎投资。
|
||||
连续涨停天数越多,风险相对越高。
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighPositionStocks;
|
||||
408
src/views/LimitAnalyse/components/SearchComponents.js
Normal file
408
src/views/LimitAnalyse/components/SearchComponents.js
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Select,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Badge,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tooltip,
|
||||
Divider,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { formatTooltipText, getFormattedTextProps } from '../../../utils/textUtils';
|
||||
import { SearchIcon, CalendarIcon, ViewIcon, ExternalLinkIcon, DownloadIcon } from '@chakra-ui/icons';
|
||||
|
||||
// 高级搜索组件
|
||||
export const AdvancedSearch = ({ onSearch, loading }) => {
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searchMode, setSearchMode] = useState('hybrid');
|
||||
const [searchType, setSearchType] = useState('all');
|
||||
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const toast = useToast();
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!searchKeyword.trim()) {
|
||||
toast({
|
||||
title: '请输入搜索关键词',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = {
|
||||
query: searchKeyword,
|
||||
mode: searchMode,
|
||||
type: searchType,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
};
|
||||
|
||||
// 添加日期范围
|
||||
if (dateRange.start || dateRange.end) {
|
||||
searchParams.date_range = {};
|
||||
if (dateRange.start) searchParams.date_range.start = dateRange.start.replace(/-/g, '');
|
||||
if (dateRange.end) searchParams.date_range.end = dateRange.end.replace(/-/g, '');
|
||||
}
|
||||
|
||||
onSearch(searchParams);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchKeyword('');
|
||||
setDateRange({ start: '', end: '' });
|
||||
setSearchType('all');
|
||||
setSearchMode('hybrid');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderRadius="xl" boxShadow="xl" mb={6}>
|
||||
<CardBody>
|
||||
<VStack spacing={4}>
|
||||
<HStack w="full" spacing={3}>
|
||||
<InputGroup size="lg" flex={1}>
|
||||
<InputLeftElement>
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
placeholder="搜索股票名称、代码或涨停原因..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
fontSize="md"
|
||||
/>
|
||||
</InputGroup>
|
||||
<Button
|
||||
size="lg"
|
||||
colorScheme="blue"
|
||||
onClick={handleSearch}
|
||||
isLoading={loading}
|
||||
px={8}
|
||||
leftIcon={<SearchIcon />}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={clearSearch}
|
||||
px={6}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<HStack w="full" spacing={4} align="start">
|
||||
<Box flex={1}>
|
||||
<Text fontSize="sm" mb={2} fontWeight="bold">搜索类型</Text>
|
||||
<RadioGroup value={searchType} onChange={setSearchType}>
|
||||
<HStack spacing={4}>
|
||||
<Radio value="all">全部</Radio>
|
||||
<Radio value="stock">股票</Radio>
|
||||
<Radio value="reason">涨停原因</Radio>
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Box flex={1}>
|
||||
<Text fontSize="sm" mb={2} fontWeight="bold">搜索模式</Text>
|
||||
<Select value={searchMode} onChange={(e) => setSearchMode(e.target.value)}>
|
||||
<option value="hybrid">智能搜索(推荐)</option>
|
||||
<option value="text">精确匹配</option>
|
||||
<option value="vector">语义搜索</option>
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box flex={2}>
|
||||
<Text fontSize="sm" mb={2} fontWeight="bold">日期范围(可选)</Text>
|
||||
<HStack>
|
||||
<InputGroup size="md">
|
||||
<InputLeftElement>
|
||||
<CalendarIcon color="gray.400" boxSize={4} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({...dateRange, start: e.target.value})}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Text>至</Text>
|
||||
<InputGroup size="md">
|
||||
<InputLeftElement>
|
||||
<CalendarIcon color="gray.400" boxSize={4} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({...dateRange, end: e.target.value})}
|
||||
/>
|
||||
</InputGroup>
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
<Alert status="info" borderRadius="md" fontSize="sm">
|
||||
<AlertIcon />
|
||||
<Text>
|
||||
<strong>提示:</strong>搜索结果将在新窗口中显示,不会影响当前页面的数据展示。
|
||||
您可以搜索不同日期范围内的涨停股票进行对比分析。
|
||||
</Text>
|
||||
</Alert>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 搜索结果弹窗组件
|
||||
export const SearchResultsModal = ({ isOpen, onClose, searchResults, onStockClick }) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
if (!searchResults) return null;
|
||||
|
||||
const { stocks = [], total = 0 } = searchResults;
|
||||
const totalPages = Math.ceil(total / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const currentStocks = stocks.slice(startIndex, endIndex);
|
||||
|
||||
const exportResults = () => {
|
||||
const csvContent = [
|
||||
['股票代码', '股票名称', '涨停时间', '涨停原因', '所属板块'].join(','),
|
||||
...stocks.map(stock => [
|
||||
stock.scode,
|
||||
stock.sname,
|
||||
stock.zt_time || '-',
|
||||
(stock.brief || stock.summary || '-').replace(/,/g, ','),
|
||||
(stock.core_sectors || []).join(';')
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `搜索结果_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl">
|
||||
<ModalOverlay backdropFilter="blur(5px)" />
|
||||
<ModalContent maxW="90vw" maxH="90vh">
|
||||
<ModalHeader bg="blue.500" color="white">
|
||||
<HStack justify="space-between">
|
||||
<Text>搜索结果</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge bg="whiteAlpha.900" color="blue.500" fontSize="md" px={3}>
|
||||
共找到 {total} 只股票
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<DownloadIcon />}
|
||||
onClick={exportResults}
|
||||
variant="outline"
|
||||
colorScheme="whiteAlpha"
|
||||
>
|
||||
导出CSV
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
|
||||
<ModalBody overflowY="auto" maxH="70vh" p={6}>
|
||||
{stocks.length === 0 ? (
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<AlertIcon />
|
||||
没有找到符合条件的股票,请尝试调整搜索条件。
|
||||
</Alert>
|
||||
) : (
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead position="sticky" top={0} bg="white" zIndex={1}>
|
||||
<Tr>
|
||||
<Th>序号</Th>
|
||||
<Th>股票代码</Th>
|
||||
<Th>股票名称</Th>
|
||||
<Th>涨停时间</Th>
|
||||
<Th>连板天数</Th>
|
||||
<Th>涨停原因</Th>
|
||||
<Th>所属板块</Th>
|
||||
<Th>操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{currentStocks.map((stock, index) => (
|
||||
<Tr key={`${stock.scode}-${index}`} _hover={{ bg: 'gray.50' }}>
|
||||
<Td>{startIndex + index + 1}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme="purple">{stock.scode}</Badge>
|
||||
</Td>
|
||||
<Td fontWeight="bold">{stock.sname}</Td>
|
||||
<Td fontSize="sm">{stock.zt_time || '-'}</Td>
|
||||
<Td>
|
||||
{stock.continuous_days && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
stock.continuous_days.includes('5') ? 'red' :
|
||||
stock.continuous_days.includes('3') ? 'orange' :
|
||||
stock.continuous_days.includes('2') ? 'yellow' :
|
||||
'green'
|
||||
}
|
||||
>
|
||||
{stock.continuous_days}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td maxW="300px">
|
||||
<Tooltip
|
||||
label={formatTooltipText(stock.brief || stock.summary)}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.800"
|
||||
color="white"
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
maxW="400px"
|
||||
whiteSpace="pre-line"
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
noOfLines={2}
|
||||
{...getFormattedTextProps(stock.brief || stock.summary || '-').props}
|
||||
_hover={{ cursor: 'help' }}
|
||||
>
|
||||
{getFormattedTextProps(stock.brief || stock.summary || '-').children}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td maxW="200px">
|
||||
<Wrap spacing={1}>
|
||||
{(stock.core_sectors || []).slice(0, 3).map((sector, i) => (
|
||||
<WrapItem key={i}>
|
||||
<Tag size="sm" colorScheme="teal">
|
||||
<TagLabel fontSize="xs">{sector}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
{stock.core_sectors && stock.core_sectors.length > 3 && (
|
||||
<WrapItem>
|
||||
<Tag size="sm" colorScheme="gray">
|
||||
<TagLabel fontSize="xs">+{stock.core_sectors.length - 3}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<Tooltip label="查看详情">
|
||||
<IconButton
|
||||
icon={<ViewIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => onStockClick(stock)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="查看K线">
|
||||
<IconButton
|
||||
icon={<ExternalLinkIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="green"
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<HStack justify="center" spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
isDisabled={currentPage === 1}
|
||||
>
|
||||
首页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
isDisabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Text fontSize="sm">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
isDisabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
isDisabled={currentPage === totalPages}
|
||||
>
|
||||
末页
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default { AdvancedSearch, SearchResultsModal };
|
||||
293
src/views/LimitAnalyse/components/SectorDetails.js
Normal file
293
src/views/LimitAnalyse/components/SectorDetails.js
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
HStack,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
IconButton,
|
||||
Flex,
|
||||
Circle,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { StarIcon, ViewIcon, TimeIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||
|
||||
const SectorDetails = ({ sortedSectors, totalStocks }) => {
|
||||
// 使用 useRef 来维持展开状态,避免重新渲染时重置
|
||||
const expandedSectorsRef = useRef([]);
|
||||
const [expandedSectors, setExpandedSectors] = useState([]);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
// 新增:管理每个股票涨停原因的展开状态
|
||||
const [expandedStockReasons, setExpandedStockReasons] = useState({});
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
// 只在组件首次挂载时初始化
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理展开/收起
|
||||
const handleAccordionChange = (newExpandedIndexes) => {
|
||||
expandedSectorsRef.current = newExpandedIndexes;
|
||||
setExpandedSectors(newExpandedIndexes);
|
||||
};
|
||||
|
||||
// 全部展开/收起
|
||||
const toggleAllSectors = () => {
|
||||
if (expandedSectors.length === sortedSectors.length) {
|
||||
handleAccordionChange([]);
|
||||
} else {
|
||||
handleAccordionChange(sortedSectors.map((_, index) => index));
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:切换股票涨停原因的展开状态
|
||||
const toggleStockReason = (stockCode) => {
|
||||
setExpandedStockReasons(prev => ({
|
||||
...prev,
|
||||
[stockCode]: !prev[stockCode]
|
||||
}));
|
||||
};
|
||||
|
||||
const getSectorColorScheme = (sector) => {
|
||||
if (sector === '公告') return 'orange';
|
||||
if (sector === '其他') return 'gray';
|
||||
if (sector.includes('锂电') || sector.includes('电池')) return 'green';
|
||||
if (sector.includes('AI') || sector.includes('芯片')) return 'blue';
|
||||
if (sector.includes('天然气') || sector.includes('石油')) return 'red';
|
||||
if (sector.includes('医药') || sector.includes('医疗')) return 'purple';
|
||||
if (sector.includes('新能源') || sector.includes('光伏')) return 'teal';
|
||||
if (sector.includes('金融') || sector.includes('银行')) return 'yellow';
|
||||
return 'cyan';
|
||||
};
|
||||
|
||||
const formatStockTime = (stock) => {
|
||||
if (stock.formatted_time) return stock.formatted_time;
|
||||
if (stock.zt_time) {
|
||||
const time = stock.zt_time.split(' ')[1];
|
||||
return time || stock.zt_time;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const getContinuousDaysBadgeColor = (days) => {
|
||||
if (!days) return 'gray';
|
||||
if (days.includes('5') || days.includes('6') || days.includes('7')) return 'red';
|
||||
if (days.includes('3') || days.includes('4')) return 'orange';
|
||||
if (days.includes('2')) return 'yellow';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderRadius="xl" boxShadow="xl">
|
||||
<CardHeader bg="blue.500" color="white" borderTopRadius="xl">
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack spacing={3}>
|
||||
<Heading size="md">板块详情</Heading>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge bg="whiteAlpha.900" color="blue.500" fontSize="md" px={3}>
|
||||
{sortedSectors.length} 个板块
|
||||
</Badge>
|
||||
<Badge bg="red.500" color="white" fontSize="md" px={3}>
|
||||
{totalStocks} 只涨停
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody maxH="700px" overflowY="auto" css={{
|
||||
'&::-webkit-scrollbar': { width: '8px' },
|
||||
'&::-webkit-scrollbar-track': { background: '#f1f1f1' },
|
||||
'&::-webkit-scrollbar-thumb': { background: '#888', borderRadius: '4px' },
|
||||
'&::-webkit-scrollbar-thumb:hover': { background: '#555' },
|
||||
}}>
|
||||
<Accordion
|
||||
allowMultiple
|
||||
index={expandedSectors}
|
||||
onChange={handleAccordionChange}
|
||||
>
|
||||
{sortedSectors.map(([sector, data], index) => {
|
||||
const colorScheme = getSectorColorScheme(sector);
|
||||
const isSpecial = sector === '公告' || sector === '其他';
|
||||
const isExpanded = expandedSectors.includes(index);
|
||||
|
||||
return (
|
||||
<AccordionItem key={`${sector}-${index}`} border="none" mb={3}>
|
||||
<h2>
|
||||
<AccordionButton
|
||||
bg={isExpanded ? `${colorScheme}.500` : isSpecial ? `${colorScheme}.50` : 'gray.50'}
|
||||
color={isExpanded ? 'white' : 'inherit'}
|
||||
borderRadius="lg"
|
||||
_hover={{
|
||||
bg: isExpanded ? `${colorScheme}.600` : isSpecial ? `${colorScheme}.100` : 'gray.100'
|
||||
}}
|
||||
position="relative"
|
||||
h="auto"
|
||||
py={3}
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack spacing={3}>
|
||||
<Circle
|
||||
size="35px"
|
||||
bg={isExpanded ? 'white' : `${colorScheme}.500`}
|
||||
color={isExpanded ? `${colorScheme}.500` : 'white'}
|
||||
>
|
||||
<Text fontWeight="bold" fontSize="md">{index + 1}</Text>
|
||||
</Circle>
|
||||
{sector === '公告' && <StarIcon />}
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="lg">{sector}</Text>
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
{data.count} 只涨停
|
||||
{data.stocks && data.stocks[0]?.zt_time && (
|
||||
<Text as="span" fontSize="xs" ml={2}>
|
||||
首板: {formatStockTime(data.stocks[0])}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
|
||||
<AccordionPanel pb={4} pt={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{data.stocks
|
||||
.sort((a, b) => (a.zt_time || '').localeCompare(b.zt_time || ''))
|
||||
.map((stock, idx) => (
|
||||
<Box
|
||||
key={`${stock.scode}-${idx}`}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
bg="white"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor={`${colorScheme}.400`}
|
||||
_hover={{
|
||||
transform: 'translateX(5px)',
|
||||
boxShadow: 'lg',
|
||||
borderLeftColor: `${colorScheme}.600`,
|
||||
bg: 'gray.50'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<HStack spacing={2} wrap="wrap">
|
||||
<Text fontWeight="bold" fontSize="lg">{stock.sname}</Text>
|
||||
<Badge colorScheme="purple" fontSize="sm">{stock.scode}</Badge>
|
||||
{stock.continuous_days && (
|
||||
<Badge
|
||||
colorScheme={getContinuousDaysBadgeColor(stock.continuous_days)}
|
||||
variant="solid"
|
||||
fontSize="sm"
|
||||
>
|
||||
{stock.continuous_days}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Collapse in={expandedStockReasons[stock.scode]}>
|
||||
<Box mt={2} p={3} bg="gray.50" borderRadius="md" border="1px solid" borderColor="gray.200">
|
||||
<Text fontSize="sm" color="gray.700" fontWeight="bold">
|
||||
涨停原因:
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="gray.600"
|
||||
noOfLines={3}
|
||||
{...getFormattedTextProps(stock.brief || stock.summary || '暂无涨停原因').props}
|
||||
>
|
||||
{getFormattedTextProps(stock.brief || stock.summary || '暂无涨停原因').children}
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<HStack spacing={4} fontSize="xs" color="gray.500">
|
||||
<HStack spacing={1}>
|
||||
<TimeIcon boxSize={3} />
|
||||
<Text>涨停: {formatStockTime(stock)}</Text>
|
||||
</HStack>
|
||||
{stock.first_time && (
|
||||
<Text>首板: {stock.first_time.split(' ')[0]}</Text>
|
||||
)}
|
||||
{stock.change_pct && (
|
||||
<Text color="red.500" fontWeight="bold">
|
||||
涨幅: {stock.change_pct}%
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{stock.core_sectors && stock.core_sectors.length > 0 && (
|
||||
<Wrap spacing={1} mt={1}>
|
||||
{stock.core_sectors.slice(0, 5).map((s, i) => (
|
||||
<WrapItem key={`${s}-${i}`}>
|
||||
<Tag
|
||||
size="sm"
|
||||
colorScheme={getSectorColorScheme(s)}
|
||||
variant="subtle"
|
||||
>
|
||||
<TagLabel fontSize="xs">{s}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
{stock.core_sectors.length > 5 && (
|
||||
<WrapItem>
|
||||
<Tag size="sm" colorScheme="gray">
|
||||
<TagLabel fontSize="xs">
|
||||
+{stock.core_sectors.length - 5}
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<IconButton
|
||||
icon={expandedStockReasons[stock.scode] ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme={colorScheme}
|
||||
aria-label={expandedStockReasons[stock.scode] ? "收起原因" : "展开原因"}
|
||||
onClick={() => toggleStockReason(stock.scode)}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectorDetails;
|
||||
124
src/views/LimitAnalyse/components/WordCloud.css
Normal file
124
src/views/LimitAnalyse/components/WordCloud.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/* 词云图自定义样式 */
|
||||
|
||||
/* react-wordcloud 默认tooltip样式美化 */
|
||||
.wordcloud-container .react-wordcloud-tooltip,
|
||||
.react-wordcloud-tooltip {
|
||||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.92), rgba(45, 55, 72, 0.92)) !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15) !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 12px 16px !important;
|
||||
color: #ffffff !important;
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, BlinkMacSystemFont, sans-serif !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.5 !important;
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.4),
|
||||
0 6px 16px rgba(0, 0, 0, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
-webkit-backdrop-filter: blur(12px) !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
transform: translateY(-4px) !important;
|
||||
z-index: 9999 !important;
|
||||
pointer-events: none !important;
|
||||
white-space: pre-line !important;
|
||||
min-width: 120px !important;
|
||||
max-width: 220px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* 为tooltip添加箭头指示器 */
|
||||
.react-wordcloud-tooltip::after {
|
||||
content: '' !important;
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
border: 6px solid transparent !important;
|
||||
border-top-color: rgba(0, 0, 0, 0.92) !important;
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* 词云图容器样式 */
|
||||
.wordcloud-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.wordcloud-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 80%, rgba(147, 51, 234, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 60%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 移除所有悬停效果 */
|
||||
.wordcloud-container text {
|
||||
cursor: default !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.wordcloud-container text:hover {
|
||||
/* 无任何悬停效果 */
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.wordcloud-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wordcloud-loading::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.wordcloud-tooltip {
|
||||
font-size: 12px !important;
|
||||
padding: 8px 12px !important;
|
||||
max-width: 150px !important;
|
||||
}
|
||||
|
||||
.wordcloud-tooltip .tooltip-word {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.wordcloud-container {
|
||||
height: 300px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
515
src/views/LimitAnalyse/index.js
Normal file
515
src/views/LimitAnalyse/index.js
Normal file
@@ -0,0 +1,515 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
useToast,
|
||||
Skeleton,
|
||||
IconButton,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
Card,
|
||||
CardBody,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
RepeatIcon,
|
||||
ChevronUpIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
// 导入拆分的组件
|
||||
// 注意:在实际使用中,这些组件应该被拆分到独立的文件中
|
||||
// 这里为了演示,我们假设它们已经被正确导出
|
||||
|
||||
// API配置
|
||||
const API_URL = process.env.NODE_ENV === 'production' ? '/report-api' : 'http://111.198.58.126:5001';
|
||||
|
||||
// 导入的组件(实际使用时应该从独立文件导入)
|
||||
// 恢复使用本页自带的轻量日历
|
||||
import EnhancedCalendar from './components/EnhancedCalendar';
|
||||
import SectorDetails from './components/SectorDetails';
|
||||
import { DataAnalysis, StockDetailModal } from './components/DataVisualizationComponents';
|
||||
import { AdvancedSearch, SearchResultsModal } from './components/SearchComponents';
|
||||
|
||||
// 导入导航栏组件
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
// 导入高位股统计组件
|
||||
import HighPositionStocks from './components/HighPositionStocks';
|
||||
|
||||
// 主组件
|
||||
export default function LimitAnalyse() {
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dailyData, setDailyData] = useState(null);
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [wordCloudData, setWordCloudData] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const accentColor = useColorModeValue('blue.500', 'blue.300');
|
||||
|
||||
// 获取可用日期
|
||||
useEffect(() => {
|
||||
fetchAvailableDates();
|
||||
}, []);
|
||||
|
||||
// 初始进入展示骨架屏,直到选中日期的数据加载完成
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
}, []);
|
||||
|
||||
// 根据可用日期加载最近一个有数据的日期
|
||||
useEffect(() => {
|
||||
if (availableDates && availableDates.length > 0) {
|
||||
// 选择日期字符串最大的那一天(格式为 YYYYMMDD)
|
||||
const latest = availableDates.reduce((max, cur) =>
|
||||
(!max || (cur.date && cur.date > max)) ? cur.date : max
|
||||
, null);
|
||||
|
||||
if (latest) {
|
||||
setDateStr(latest);
|
||||
const year = parseInt(latest.slice(0, 4), 10);
|
||||
const month = parseInt(latest.slice(4, 6), 10) - 1;
|
||||
const day = parseInt(latest.slice(6, 8), 10);
|
||||
setSelectedDate(new Date(year, month, day));
|
||||
fetchDailyAnalysis(latest);
|
||||
}
|
||||
} else {
|
||||
// 如果暂无可用日期,回退到今日,避免页面长时间空白
|
||||
const today = new Date();
|
||||
const dateString = formatDateStr(today);
|
||||
setDateStr(dateString);
|
||||
setSelectedDate(today);
|
||||
fetchDailyAnalysis(dateString);
|
||||
}
|
||||
}, [availableDates]);
|
||||
|
||||
// API调用函数
|
||||
const fetchAvailableDates = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/v1/dates/available`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAvailableDates(data.events);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch available dates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDailyAnalysis = async (date) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/v1/analysis/daily/${date}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setDailyData(data.data);
|
||||
|
||||
// 获取词云数据
|
||||
fetchWordCloudData(date);
|
||||
|
||||
toast({
|
||||
title: '数据加载成功',
|
||||
description: `${date} 的数据已加载`,
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch daily analysis:', error);
|
||||
toast({
|
||||
title: '网络错误',
|
||||
description: '无法加载数据,请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWordCloudData = async (date) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/v1/analysis/wordcloud/${date}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setWordCloudData(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch wordcloud data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDateStr = (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 handleDateChange = (date) => {
|
||||
setSelectedDate(date);
|
||||
const dateString = formatDateStr(date);
|
||||
setDateStr(dateString);
|
||||
fetchDailyAnalysis(dateString);
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = async (searchParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/v1/stocks/search/hybrid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(searchParams),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSearchResults(data.data);
|
||||
setIsSearchOpen(true);
|
||||
toast({
|
||||
title: '搜索完成',
|
||||
description: `找到 ${data.data.total} 只相关股票`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
toast({
|
||||
title: '搜索失败',
|
||||
description: '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理板块数据排序
|
||||
const getSortedSectorData = () => {
|
||||
if (!dailyData?.sector_data) return [];
|
||||
|
||||
const sectors = Object.entries(dailyData.sector_data);
|
||||
const announcement = sectors.find(([name]) => name === '公告');
|
||||
const others = sectors.filter(([name]) => name !== '公告' && name !== '其他');
|
||||
const other = sectors.find(([name]) => name === '其他');
|
||||
|
||||
// 按数量排序
|
||||
others.sort((a, b) => b[1].count - a[1].count);
|
||||
|
||||
// 组合:公告在最前,其他在最后
|
||||
let result = [];
|
||||
if (announcement) result.push(announcement);
|
||||
result = result.concat(others);
|
||||
if (other) result.push(other);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 渲染统计卡片
|
||||
const StatsCards = () => (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={6}>
|
||||
<Card bg={cardBg} borderRadius="lg" boxShadow="md">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">今日涨停</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="red.500">
|
||||
{dailyData?.total_stocks || 0}
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
<StatArrow type="increase" />
|
||||
较昨日 +23%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={cardBg} borderRadius="lg" boxShadow="md">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">最热板块</StatLabel>
|
||||
<StatNumber fontSize="xl" color={accentColor}>
|
||||
{dailyData?.summary?.top_sector || '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText>{dailyData?.summary?.top_sector_count || 0} 只</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={cardBg} borderRadius="lg" boxShadow="md">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">公告涨停</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="orange.500">
|
||||
{dailyData?.summary?.announcement_stocks || 0}
|
||||
</StatNumber>
|
||||
<StatHelpText>重大利好</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card bg={cardBg} borderRadius="lg" boxShadow="md">
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel fontSize="sm">早盘强势</StatLabel>
|
||||
<StatNumber fontSize="2xl" color="green.500">
|
||||
{dailyData?.summary?.zt_time_distribution?.morning || 0}
|
||||
</StatNumber>
|
||||
<StatHelpText>开盘涨停</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
|
||||
const formatDisplayDate = (date) => {
|
||||
if (!date) return '';
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}年${month}月${day}日`;
|
||||
};
|
||||
|
||||
const getSelectedDateCount = () => {
|
||||
if (!selectedDate || !availableDates?.length) return null;
|
||||
const date = formatDateStr(selectedDate);
|
||||
const found = availableDates.find(d => d.date === date);
|
||||
return found ? found.count : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 顶部Header */}
|
||||
<Box bgGradient="linear(to-br, blue.500, purple.600)" color="white" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6} alignItems="stretch">
|
||||
{/* 左侧:标题置顶,注释与图例贴底 */}
|
||||
<Flex direction="column" minH="420px" justify="space-between">
|
||||
<VStack align="start" spacing={4}>
|
||||
<HStack>
|
||||
<Badge colorScheme="whiteAlpha" fontSize="sm" px={2}>
|
||||
AI驱动
|
||||
</Badge>
|
||||
<Badge colorScheme="yellow" fontSize="sm" px={2}>
|
||||
实时更新
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Heading
|
||||
size="3xl"
|
||||
fontWeight="extrabold"
|
||||
letterSpacing="-0.5px"
|
||||
lineHeight="shorter"
|
||||
textShadow="0 6px 24px rgba(0,0,0,0.25)"
|
||||
>
|
||||
涨停板块分析平台
|
||||
</Heading>
|
||||
<Text fontSize="xl" opacity={0.98} fontWeight="semibold" textShadow="0 4px 16px rgba(0,0,0,0.2)">
|
||||
以大模型辅助整理海量信息,结合领域知识图谱与分析师复核,呈现涨停板块关键线索
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Alert
|
||||
status="info"
|
||||
borderRadius="xl"
|
||||
bg="whiteAlpha.200"
|
||||
color="whiteAlpha.900"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.300"
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
boxShadow="0 8px 32px rgba(0,0,0,0.2)"
|
||||
>
|
||||
<AlertIcon />
|
||||
<Text fontSize="md" fontWeight="medium">
|
||||
{selectedDate ? `当前选择:${formatDisplayDate(selectedDate)}` : '当前选择:--'}
|
||||
{getSelectedDateCount() != null ? ` - ${getSelectedDateCount()}只涨停` : ''}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card
|
||||
bg="whiteAlpha.200"
|
||||
color="whiteAlpha.900"
|
||||
borderRadius="xl"
|
||||
boxShadow="0 8px 32px rgba(0,0,0,0.2)"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.300"
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
w="full"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Heading size="sm" color="whiteAlpha.900">涨停数量图例</Heading>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack>
|
||||
<Box w={5} h={5} bg="green.200" borderRadius="md" border="1px solid" borderColor="whiteAlpha.400" />
|
||||
<Text fontSize="sm">少量 (≤50只)</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Box w={5} h={5} bg="yellow.200" borderRadius="md" border="1px solid" borderColor="whiteAlpha.400" />
|
||||
<Text fontSize="sm">中等 (51-80只)</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Box w={5} h={5} bg="red.200" borderRadius="md" border="1px solid" borderColor="whiteAlpha.400" />
|
||||
<Text fontSize="sm">大量 (>80只)</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 右侧:半屏日历 */}
|
||||
<Card
|
||||
bg="whiteAlpha.200"
|
||||
borderRadius="xl"
|
||||
boxShadow="0 8px 32px rgba(0,0,0,0.2)"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.300"
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
w="full"
|
||||
minH="420px"
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<EnhancedCalendar
|
||||
selectedDate={selectedDate}
|
||||
onDateChange={handleDateChange}
|
||||
availableDates={availableDates}
|
||||
compact
|
||||
hideSelectionInfo
|
||||
width="100%"
|
||||
cellHeight={10}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Container maxW="container.xl" py={8}>
|
||||
{/* 统计卡片 */}
|
||||
{loading ? (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={6}>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} height="100px" borderRadius="lg" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<StatsCards />
|
||||
)}
|
||||
|
||||
{/* 高级搜索 */}
|
||||
<AdvancedSearch onSearch={handleSearch} loading={loading} />
|
||||
|
||||
{/* 板块详情 - 核心内容 */}
|
||||
{loading ? (
|
||||
<Skeleton height="600px" borderRadius="xl" mb={6} />
|
||||
) : (
|
||||
<Box mb={6}>
|
||||
<SectorDetails
|
||||
sortedSectors={getSortedSectorData()}
|
||||
totalStocks={dailyData?.total_stocks || 0}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 高位股统计 */}
|
||||
<HighPositionStocks dateStr={dateStr} />
|
||||
|
||||
{/* 数据分析 */}
|
||||
{loading ? (
|
||||
<Skeleton height="500px" borderRadius="xl" />
|
||||
) : (
|
||||
<DataAnalysis
|
||||
dailyData={dailyData}
|
||||
wordCloudData={wordCloudData}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{/* 弹窗 */}
|
||||
<SearchResultsModal
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
searchResults={searchResults}
|
||||
onStockClick={() => {}}
|
||||
/>
|
||||
|
||||
{/* 浮动按钮 */}
|
||||
<Box position="fixed" bottom={8} right={8} zIndex={1000}>
|
||||
<VStack spacing={3}>
|
||||
<Tooltip label="刷新数据" placement="left">
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
boxShadow="2xl"
|
||||
onClick={() => fetchDailyAnalysis(dateStr)}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="回到顶部" placement="left">
|
||||
<IconButton
|
||||
icon={<ChevronUpIcon />}
|
||||
colorScheme="gray"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
boxShadow="2xl"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
/>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Footer区域 */}
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="7xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user