Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View File

@@ -0,0 +1,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 };

View 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">大量 (&gt;80)</Text>
</HStack>
</HStack>
</VStack>
</>
)}
</CardBody>
</Card>
);
};
export default EnhancedCalendar;

View 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;

View 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 };

View 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;

View 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;
}
}

View 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">大量 (&gt;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>
);
}