552 lines
22 KiB
JavaScript
Executable File
552 lines
22 KiB
JavaScript
Executable File
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,
|
||
} 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';
|
||
|
||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||
|
||
// 导入高位股统计组件
|
||
import HighPositionStocks from './components/HighPositionStocks';
|
||
import { logger } from '../../utils/logger';
|
||
import { useLimitAnalyseEvents } from './hooks/useLimitAnalyseEvents';
|
||
|
||
// 主组件
|
||
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 [selectedStock, setSelectedStock] = useState(null);
|
||
const [isStockDetailOpen, setIsStockDetailOpen] = useState(false);
|
||
|
||
const toast = useToast();
|
||
|
||
// 🎯 PostHog 事件追踪
|
||
const {
|
||
trackDateSelected,
|
||
trackDailyStatsViewed,
|
||
trackSectorToggled,
|
||
trackSectorClicked,
|
||
trackLimitStockClicked,
|
||
trackSearchInitiated,
|
||
trackSearchResultClicked,
|
||
trackHighPositionStocksViewed,
|
||
trackSectorAnalysisViewed,
|
||
trackDataRefreshed,
|
||
trackStockDetailViewed,
|
||
} = useLimitAnalyseEvents();
|
||
|
||
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);
|
||
logger.debug('LimitAnalyse', '可用日期加载成功', {
|
||
count: data.events?.length || 0
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('LimitAnalyse', 'fetchAvailableDates', 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);
|
||
|
||
// 🎯 追踪每日统计数据查看
|
||
trackDailyStatsViewed(data.data, date);
|
||
|
||
// 获取词云数据
|
||
fetchWordCloudData(date);
|
||
|
||
logger.debug('LimitAnalyse', '每日分析数据加载成功', {
|
||
date,
|
||
totalStocks: data.data?.total_stocks || 0
|
||
});
|
||
// ❌ 移除数据加载成功 toast(非关键操作)
|
||
}
|
||
} catch (error) {
|
||
logger.error('LimitAnalyse', 'fetchDailyAnalysis', error, { date });
|
||
// ❌ 移除数据加载失败 toast(非关键操作)
|
||
} 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);
|
||
logger.debug('LimitAnalyse', '词云数据加载成功', {
|
||
date,
|
||
count: data.data?.length || 0
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('LimitAnalyse', 'fetchWordCloudData', error, { date });
|
||
}
|
||
};
|
||
|
||
// 格式化日期
|
||
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) => {
|
||
const previousDateStr = dateStr;
|
||
setSelectedDate(date);
|
||
const dateString = formatDateStr(date);
|
||
setDateStr(dateString);
|
||
|
||
// 🎯 追踪日期选择
|
||
trackDateSelected(dateString, previousDateStr);
|
||
|
||
fetchDailyAnalysis(dateString);
|
||
};
|
||
|
||
// 处理搜索
|
||
const handleSearch = async (searchParams) => {
|
||
// 🎯 追踪搜索开始
|
||
trackSearchInitiated(
|
||
searchParams.query,
|
||
searchParams.type || 'all',
|
||
searchParams.mode || 'hybrid'
|
||
);
|
||
|
||
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);
|
||
logger.info('LimitAnalyse', '搜索完成', {
|
||
resultCount: data.data?.total || 0,
|
||
searchParams
|
||
});
|
||
toast({
|
||
title: '搜索完成',
|
||
description: `找到 ${data.data.total} 只相关股票`,
|
||
status: 'success',
|
||
duration: 3000,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error('LimitAnalyse', 'handleSearch', error, { searchParams });
|
||
toast({
|
||
title: '搜索失败',
|
||
description: '请稍后重试',
|
||
status: 'error',
|
||
duration: 3000,
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理股票点击
|
||
const handleStockClick = (stock) => {
|
||
setSelectedStock(stock);
|
||
setIsStockDetailOpen(true);
|
||
// 🎯 追踪股票详情查看
|
||
trackStockDetailViewed(stock.scode, stock.sname, 'sector_details');
|
||
};
|
||
|
||
// 关闭股票详情弹窗
|
||
const handleCloseStockDetail = () => {
|
||
setIsStockDetailOpen(false);
|
||
setSelectedStock(null);
|
||
};
|
||
|
||
// 处理板块数据排序
|
||
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}>
|
||
{/* 导航栏已由 MainLayout 提供 */}
|
||
|
||
{/* 顶部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}
|
||
onStockClick={handleStockClick}
|
||
/>
|
||
</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={() => {}}
|
||
/>
|
||
|
||
{/* 股票详情弹窗 */}
|
||
<StockDetailModal
|
||
isOpen={isStockDetailOpen}
|
||
onClose={handleCloseStockDetail}
|
||
selectedStock={selectedStock}
|
||
/>
|
||
|
||
{/* 浮动按钮 */}
|
||
<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>
|
||
|
||
</Box>
|
||
);
|
||
} |