feat(HotspotOverview): 异动卡片布局优化与股票列表展开功能
- CompactAlertCard 布局重构: - 未选中:时间 + 概念名称/标签 + 评分(左)/α(右) - 选中:增加板块均涨/涨跌家数 + V2指标(确认率/Z-Score/成交额/动量) - 新增 StockListPanel 组件,选中卡片后展开显示相关股票列表 - 修复卡片点击高度闪烁问题(固定 minH + flexShrink) - 股票列表支持点击跳转到公司详情页 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,375 +14,168 @@ import {
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Collapse,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
Portal,
|
||||
} from '@chakra-ui/react';
|
||||
import { keyframes, css } from '@emotion/react';
|
||||
import { css } from '@emotion/react';
|
||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||
import {
|
||||
Clock,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
BarChart3,
|
||||
Flame,
|
||||
Target,
|
||||
Activity,
|
||||
Rocket,
|
||||
Waves,
|
||||
Gauge,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import { colors, glassEffect } from '../../../theme/glassTheme';
|
||||
import { colors } from '../../../theme/glassTheme';
|
||||
import {
|
||||
ALERT_TYPE_CONFIG,
|
||||
getAlertTypeLabel,
|
||||
getAlertTypeDescription,
|
||||
getScoreColor,
|
||||
formatScore,
|
||||
} from '../utils/chartHelpers';
|
||||
import MiniTimelineChart from '@components/Charts/Stock/MiniTimelineChart';
|
||||
|
||||
// 动画
|
||||
const pulseGlow = keyframes`
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* 获取异动类型图标
|
||||
*/
|
||||
const getAlertIcon = (alertType) => {
|
||||
const iconMap = {
|
||||
surge_up: TrendingUp,
|
||||
surge: Zap,
|
||||
surge_down: TrendingDown,
|
||||
volume_surge_up: Activity,
|
||||
shrink_surge_up: Rocket,
|
||||
volume_oscillation: Waves,
|
||||
limit_up: Flame,
|
||||
rank_jump: Target,
|
||||
volume_spike: BarChart3,
|
||||
};
|
||||
return iconMap[alertType] || Zap;
|
||||
};
|
||||
|
||||
/**
|
||||
* 单个异动详情卡片
|
||||
* 单个异动详情卡片(精简版 - 只显示统计和股票列表)
|
||||
*/
|
||||
const AlertDetailCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||
const navigate = useNavigate();
|
||||
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||
const isUp = alert.alert_type !== 'surge_down';
|
||||
const AlertIcon = getAlertIcon(alert.alert_type);
|
||||
|
||||
const handleStockClick = (e, stockCode) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/company?scode=${stockCode}`);
|
||||
};
|
||||
|
||||
const handleConceptClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (alert.concept_id) {
|
||||
navigate(`/concept/${alert.concept_id}`);
|
||||
}
|
||||
// 计算统计信息
|
||||
const getStockStats = () => {
|
||||
if (!stocks || stocks.length === 0) return null;
|
||||
const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct));
|
||||
if (validStocks.length === 0) return null;
|
||||
const avgChange = validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length;
|
||||
const upCount = validStocks.filter(s => s.change_pct > 0).length;
|
||||
const downCount = validStocks.filter(s => s.change_pct < 0).length;
|
||||
return { avgChange, upCount, downCount };
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="16px"
|
||||
border={isExpanded ? `1px solid ${alertConfig.color}50` : '1px solid rgba(255, 255, 255, 0.08)'}
|
||||
overflow="hidden"
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
_hover={{
|
||||
border: `1px solid ${alertConfig.color}40`,
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{/* 顶部渐变条 */}
|
||||
/* 展开内容 - 只显示统计和股票列表(去除重复字段) */
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box
|
||||
h="2px"
|
||||
bgGradient={`linear(to-r, ${alertConfig.gradient[0]}, ${alertConfig.gradient[1]})`}
|
||||
opacity={isExpanded ? 1 : 0.6}
|
||||
/>
|
||||
|
||||
{/* 主内容区 - 可点击展开 */}
|
||||
<Box
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
bg="rgba(0, 0, 0, 0.15)"
|
||||
borderRadius="12px"
|
||||
mx={2}
|
||||
mb={2}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 第一行:展开箭头 + 概念名称 + 评分 */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Icon
|
||||
as={isExpanded ? ChevronDown : ChevronRight}
|
||||
color={colors.text.secondary}
|
||||
boxSize={4}
|
||||
transition="transform 0.2s"
|
||||
/>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="10px"
|
||||
bg={`${alertConfig.color}20`}
|
||||
>
|
||||
<Icon
|
||||
as={AlertIcon}
|
||||
boxSize={4}
|
||||
color={alertConfig.color}
|
||||
css={css`filter: drop-shadow(0 0 4px ${alertConfig.color}80);`}
|
||||
/>
|
||||
</Box>
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<HStack>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
color={colors.text.primary}
|
||||
css={css`text-shadow: 0 0 20px ${alertConfig.color}30;`}
|
||||
{loadingStocks ? (
|
||||
<HStack justify="center" py={4}>
|
||||
<Spinner size="sm" color={alertConfig.color} />
|
||||
<Text fontSize="sm" color={colors.text.secondary}>加载相关股票...</Text>
|
||||
</HStack>
|
||||
) : stocks && stocks.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{/* 统计信息栏 */}
|
||||
{(() => {
|
||||
const stats = getStockStats();
|
||||
if (!stats) return null;
|
||||
const { avgChange, upCount, downCount } = stats;
|
||||
return (
|
||||
<HStack
|
||||
spacing={6}
|
||||
px={4}
|
||||
py={3}
|
||||
bg="rgba(255, 255, 255, 0.02)"
|
||||
borderBottom="1px solid rgba(255, 255, 255, 0.05)"
|
||||
fontSize="xs"
|
||||
>
|
||||
{alert.concept_name}
|
||||
</Text>
|
||||
<Tooltip label="查看概念详情" hasArrow>
|
||||
<Box
|
||||
as="span"
|
||||
cursor="pointer"
|
||||
onClick={handleConceptClick}
|
||||
_hover={{ color: alertConfig.color }}
|
||||
>
|
||||
<Icon as={ExternalLink} boxSize={3} color={colors.text.muted} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
{alert.time}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 评分 */}
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
bg={`${getScoreColor(alert.final_score)}15`}
|
||||
border={`1px solid ${getScoreColor(alert.final_score)}30`}
|
||||
>
|
||||
<Icon as={Gauge} boxSize={3.5} color={getScoreColor(alert.final_score)} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getScoreColor(alert.final_score)}
|
||||
css={css`text-shadow: 0 0 10px ${getScoreColor(alert.final_score)}50;`}
|
||||
>
|
||||
{formatScore(alert.final_score)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>分</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:类型标签 + Alpha + 其他指标 */}
|
||||
<Flex gap={2} flexWrap="wrap" align="center">
|
||||
<Tooltip label={getAlertTypeDescription(alert.alert_type)} hasArrow>
|
||||
<Badge
|
||||
bg={`${alertConfig.color}20`}
|
||||
color={alertConfig.color}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
cursor="help"
|
||||
>
|
||||
{getAlertTypeLabel(alert.alert_type)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
{alert.alpha != null && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color={colors.text.muted}>Alpha</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={alert.alpha >= 0 ? colors.market.up : colors.market.down}
|
||||
>
|
||||
{alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{(alert.limit_up_ratio || 0) > 0.03 && (
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Flame} boxSize={3} color="#fa541c" />
|
||||
<Text fontSize="xs" fontWeight="bold" color="#fa541c">
|
||||
{Math.round(alert.limit_up_ratio * 100)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{alert.is_v2 && alert.confirm_ratio != null && (
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" color={colors.text.muted}>确认</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="medium"
|
||||
color={alert.confirm_ratio >= 0.8 ? '#52c41a' : alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'}
|
||||
>
|
||||
{Math.round(alert.confirm_ratio * 100)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 展开内容 - 相关股票 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box
|
||||
borderTop="1px solid rgba(255, 255, 255, 0.08)"
|
||||
p={4}
|
||||
bg="rgba(0, 0, 0, 0.2)"
|
||||
>
|
||||
{loadingStocks ? (
|
||||
<HStack justify="center" py={4}>
|
||||
<Spinner size="sm" color={alertConfig.color} />
|
||||
<Text fontSize="sm" color={colors.text.secondary}>加载相关股票...</Text>
|
||||
</HStack>
|
||||
) : stocks && stocks.length > 0 ? (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 统计信息 */}
|
||||
{(() => {
|
||||
const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct));
|
||||
if (validStocks.length === 0) return null;
|
||||
const avgChange = validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length;
|
||||
const upCount = validStocks.filter(s => s.change_pct > 0).length;
|
||||
const downCount = validStocks.filter(s => s.change_pct < 0).length;
|
||||
return (
|
||||
<HStack
|
||||
spacing={4}
|
||||
p={3}
|
||||
bg="rgba(255, 255, 255, 0.02)"
|
||||
borderRadius="10px"
|
||||
fontSize="xs"
|
||||
>
|
||||
<HStack>
|
||||
<Text color={colors.text.muted}>均涨:</Text>
|
||||
<Text fontWeight="bold" color={avgChange >= 0 ? colors.market.up : colors.market.down}>
|
||||
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
|
||||
<HStack spacing={2}>
|
||||
<Text color={colors.text.muted}>均涨:</Text>
|
||||
<Text fontWeight="bold" color={avgChange >= 0 ? colors.market.up : colors.market.down}>
|
||||
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.market.up} fontWeight="medium">{upCount}涨</Text>
|
||||
<Text color={colors.text.muted}>/</Text>
|
||||
<Text color={colors.market.down} fontWeight="medium">{downCount}跌</Text>
|
||||
</HStack>
|
||||
{(alert.limit_up_ratio || 0) > 0.03 && (
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Flame} boxSize={3} color="#fa541c" />
|
||||
<Text color={colors.text.muted}>涨停比</Text>
|
||||
<Text fontWeight="bold" color="#fa541c">
|
||||
{Math.round(alert.limit_up_ratio * 100)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.market.up} fontWeight="medium">{upCount}涨</Text>
|
||||
<Text color={colors.text.muted}>/</Text>
|
||||
<Text color={colors.market.down} fontWeight="medium">{downCount}跌</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 股票列表 */}
|
||||
<Box maxH="250px" overflowY="auto" pr={1}>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{stocks.slice(0, 15).map((stock, idx) => {
|
||||
const changePct = stock.change_pct;
|
||||
const hasChange = changePct != null && !isNaN(changePct);
|
||||
const stockCode = stock.code || stock.stock_code;
|
||||
const stockName = stock.name || stock.stock_name || '-';
|
||||
{/* 股票列表 */}
|
||||
<Box maxH="250px" overflowY="auto" px={3} py={2}>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{stocks.slice(0, 15).map((stock, idx) => {
|
||||
const changePct = stock.change_pct;
|
||||
const hasChange = changePct != null && !isNaN(changePct);
|
||||
const stockCode = stock.code || stock.stock_code;
|
||||
const stockName = stock.name || stock.stock_name || '-';
|
||||
|
||||
return (
|
||||
<HStack
|
||||
key={idx}
|
||||
p={2}
|
||||
borderRadius="8px"
|
||||
cursor="pointer"
|
||||
onClick={(e) => handleStockClick(e, stockCode)}
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
transition="background 0.15s"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
{/* 股票名称 - 带迷你分时图悬停 */}
|
||||
<Popover trigger="hover" placement="left" isLazy>
|
||||
<PopoverTrigger>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="#60a5fa"
|
||||
fontWeight="medium"
|
||||
_hover={{ color: '#93c5fd', textDecoration: 'underline' }}
|
||||
>
|
||||
{stockName}
|
||||
</Text>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent
|
||||
w="200px"
|
||||
h="100px"
|
||||
bg="rgba(15, 15, 25, 0.95)"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.5)"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverBody p={2}>
|
||||
<Text fontSize="xs" color={colors.text.secondary} mb={1}>
|
||||
{stockName} 分时走势
|
||||
</Text>
|
||||
<Box h="70px">
|
||||
<MiniTimelineChart stockCode={stockCode} />
|
||||
</Box>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
{stockCode}
|
||||
</Text>
|
||||
</HStack>
|
||||
return (
|
||||
<HStack
|
||||
key={idx}
|
||||
px={2}
|
||||
py={1.5}
|
||||
borderRadius="8px"
|
||||
cursor="pointer"
|
||||
onClick={(e) => handleStockClick(e, stockCode)}
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
transition="background 0.15s"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
hasChange && changePct > 0 ? colors.market.up :
|
||||
hasChange && changePct < 0 ? colors.market.down :
|
||||
colors.text.muted
|
||||
}
|
||||
color="#60a5fa"
|
||||
fontWeight="medium"
|
||||
_hover={{ color: '#93c5fd', textDecoration: 'underline' }}
|
||||
>
|
||||
{hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
|
||||
{stockName}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
{stockCode}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
hasChange && changePct > 0 ? colors.market.up :
|
||||
hasChange && changePct < 0 ? colors.market.down :
|
||||
colors.text.muted
|
||||
}
|
||||
>
|
||||
{hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{stocks.length > 15 && (
|
||||
<Text fontSize="xs" color={colors.text.muted} textAlign="center">
|
||||
共 {stocks.length} 只相关股票,显示前 15 只
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
|
||||
暂无相关股票数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
{stocks.length > 15 && (
|
||||
<Text fontSize="xs" color={colors.text.muted} textAlign="center" pb={2}>
|
||||
共 {stocks.length} 只相关股票,显示前 15 只
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
|
||||
暂无相关股票数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -551,3 +344,6 @@ const AlertDetailDrawer = ({ isOpen, onClose, alertData }) => {
|
||||
};
|
||||
|
||||
export default AlertDetailDrawer;
|
||||
|
||||
// 导出 AlertDetailCard 以便在其他地方复用
|
||||
export { AlertDetailCard };
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Flex, HStack, Text, Icon } from '@chakra-ui/react';
|
||||
import { css } from '@emotion/react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { Zap, X } from 'lucide-react';
|
||||
|
||||
import { ALERT_TYPE_CONFIG } from '../utils/chartHelpers';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
@@ -74,7 +74,7 @@ const AlertCountBadge = memo(({ totalCount, filteredCount, selectedAlertType })
|
||||
color={colors.accent.purple}
|
||||
css={css`text-shadow: 0 0 8px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
{selectedAlertType ? `${filteredCount}/${totalCount}` : totalCount}
|
||||
{filteredCount}/{totalCount}
|
||||
</Text>
|
||||
</HStack>
|
||||
));
|
||||
@@ -95,6 +95,10 @@ const AlertFilterSection = ({
|
||||
onDateChange,
|
||||
minDate,
|
||||
maxDate,
|
||||
// 分时图点击的时间段
|
||||
chartClickedTimeRange,
|
||||
// 清除时间段筛选
|
||||
onClearTimeRange,
|
||||
}) => {
|
||||
return (
|
||||
<Flex
|
||||
@@ -102,6 +106,65 @@ const AlertFilterSection = ({
|
||||
gap={2}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 时间段标签 - 点击分时图后显示 */}
|
||||
{chartClickedTimeRange && (
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
bg="rgba(249, 115, 22, 0.2)"
|
||||
border="1px solid rgba(249, 115, 22, 0.4)"
|
||||
>
|
||||
<Text fontSize="xs" color="#f97316" fontWeight="bold">
|
||||
{chartClickedTimeRange}
|
||||
</Text>
|
||||
<Icon
|
||||
as={X}
|
||||
boxSize={3}
|
||||
color="#f97316"
|
||||
cursor="pointer"
|
||||
_hover={{ color: '#ea580c' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClearTimeRange?.();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 全部按钮 - 放在最前面 */}
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
bg={!selectedAlertType ? 'rgba(139, 92, 246, 0.35)' : 'rgba(139, 92, 246, 0.15)'}
|
||||
border={!selectedAlertType ? '2px solid #8b5cf6' : '1px solid rgba(139, 92, 246, 0.25)'}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
transform={!selectedAlertType ? 'scale(1.05)' : 'scale(1)'}
|
||||
boxShadow={!selectedAlertType ? '0 0 15px rgba(139, 92, 246, 0.4)' : 'none'}
|
||||
onClick={onClearFilter}
|
||||
_hover={{
|
||||
bg: 'rgba(139, 92, 246, 0.25)',
|
||||
boxShadow: '0 0 10px rgba(139, 92, 246, 0.3)',
|
||||
transform: 'scale(1.02)',
|
||||
}}
|
||||
>
|
||||
<Text fontSize="xs" color="#8b5cf6" fontWeight={!selectedAlertType ? 'bold' : 'medium'}>
|
||||
全部
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="#8b5cf6"
|
||||
css={css`text-shadow: 0 0 8px rgba(139, 92, 246, 0.5);`}
|
||||
>
|
||||
{totalCount}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 筛选标签 */}
|
||||
{Object.entries(alertSummary || {})
|
||||
.filter(([_, count]) => count > 0)
|
||||
@@ -121,19 +184,6 @@ const AlertFilterSection = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 清除筛选按钮 */}
|
||||
{selectedAlertType && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={colors.accent.purple}
|
||||
cursor="pointer"
|
||||
onClick={onClearFilter}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
清除筛选
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 异动总数徽章 */}
|
||||
<AlertCountBadge
|
||||
totalCount={totalCount}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||
export { default as AlertSummary } from './AlertSummary';
|
||||
export { default as AlertDetailDrawer } from './AlertDetailDrawer';
|
||||
export { default as AlertDetailDrawer, AlertDetailCard } from './AlertDetailDrawer';
|
||||
export { default as AlertFilterSection } from './AlertFilterSection';
|
||||
|
||||
@@ -11,31 +11,32 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
Heading,
|
||||
HStack,
|
||||
VStack,
|
||||
Spinner,
|
||||
Center,
|
||||
Spacer,
|
||||
Icon,
|
||||
Flex,
|
||||
Tooltip,
|
||||
useDisclosure,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import { keyframes, css } from '@emotion/react';
|
||||
import {
|
||||
Flame,
|
||||
List,
|
||||
LineChart,
|
||||
Info,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useHotspotData } from './hooks';
|
||||
import { IndexMinuteChart, AlertDetailDrawer, AlertFilterSection } from './components';
|
||||
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
||||
@@ -69,11 +70,23 @@ const shimmer = keyframes`
|
||||
|
||||
/**
|
||||
* 紧凑型异动卡片(用于横向滚动)- Glassmorphism 风格
|
||||
* 选中时拉长并显示统计信息
|
||||
*/
|
||||
const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
||||
const CompactAlertCard = ({ alert, onClick, isSelected, stocks, loadingStocks }) => {
|
||||
const config = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||
const isUp = alert.alert_type !== 'surge_down';
|
||||
|
||||
// 计算统计信息(选中时)
|
||||
const stats = React.useMemo(() => {
|
||||
if (!isSelected || !stocks || stocks.length === 0) return null;
|
||||
const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct));
|
||||
if (validStocks.length === 0) return null;
|
||||
const avgChange = validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length;
|
||||
const upCount = validStocks.filter(s => s.change_pct > 0).length;
|
||||
const downCount = validStocks.filter(s => s.change_pct < 0).length;
|
||||
return { avgChange, upCount, downCount };
|
||||
}, [isSelected, stocks]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={glassEffect.light.bg}
|
||||
@@ -81,13 +94,15 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
||||
borderRadius="16px"
|
||||
border={isSelected ? `1px solid ${config.color}60` : glassEffect.light.border}
|
||||
p={3}
|
||||
minW="180px"
|
||||
maxW="200px"
|
||||
minW={isSelected ? "340px" : "180px"}
|
||||
maxW={isSelected ? "420px" : "200px"}
|
||||
minH="80px"
|
||||
cursor="pointer"
|
||||
onClick={() => onClick?.(alert)}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
flexShrink={0}
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
border: `1px solid ${config.color}50`,
|
||||
@@ -123,45 +138,65 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 时间 + 类型 */}
|
||||
<HStack justify="space-between" mb={1.5}>
|
||||
<Text fontSize="xs" color={colors.text.muted} fontFamily="mono">
|
||||
{alert.time}
|
||||
</Text>
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
bg={`${config.color}20`}
|
||||
border={`1px solid ${config.color}30`}
|
||||
>
|
||||
<Icon
|
||||
as={isUp ? TrendingUp : TrendingDown}
|
||||
boxSize={3}
|
||||
color={config.color}
|
||||
css={css`filter: drop-shadow(0 0 4px ${config.color}80);`}
|
||||
/>
|
||||
<Text fontSize="10px" fontWeight="bold" color={config.color}>
|
||||
{getAlertTypeLabel(alert.alert_type)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 概念名称 */}
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
color={colors.text.primary}
|
||||
noOfLines={1}
|
||||
mb={1.5}
|
||||
css={isSelected ? css`text-shadow: 0 0 20px ${config.color}40;` : undefined}
|
||||
>
|
||||
{alert.concept_name}
|
||||
{/* 第一行:时间 */}
|
||||
<Text fontSize="xs" color={colors.text.muted} fontFamily="mono" mb={1}>
|
||||
{alert.time}
|
||||
</Text>
|
||||
|
||||
{/* 分数 + Alpha */}
|
||||
<HStack justify="space-between" fontSize="xs">
|
||||
{/* 第二行:概念名称 + 类型标签 + (选中时显示板块统计) */}
|
||||
<HStack justify="space-between" mb={1} flexWrap="wrap">
|
||||
<HStack spacing={2} flex={1} minW={0}>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
color={colors.text.primary}
|
||||
noOfLines={1}
|
||||
css={isSelected ? css`text-shadow: 0 0 20px ${config.color}40;` : undefined}
|
||||
>
|
||||
{alert.concept_name}
|
||||
</Text>
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
bg={`${config.color}20`}
|
||||
border={`1px solid ${config.color}30`}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Icon
|
||||
as={isUp ? TrendingUp : TrendingDown}
|
||||
boxSize={3}
|
||||
color={config.color}
|
||||
css={css`filter: drop-shadow(0 0 4px ${config.color}80);`}
|
||||
/>
|
||||
<Text fontSize="10px" fontWeight="bold" color={config.color}>
|
||||
{getAlertTypeLabel(alert.alert_type)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 选中时显示板块统计 */}
|
||||
{isSelected && (
|
||||
loadingStocks ? (
|
||||
<Spinner size="xs" color={config.color} />
|
||||
) : stats ? (
|
||||
<HStack spacing={2} fontSize="xs" flexShrink={0}>
|
||||
<Text color={colors.text.muted}>板块均涨:</Text>
|
||||
<Text fontWeight="bold" color={stats.avgChange >= 0 ? colors.market.up : colors.market.down}>
|
||||
{stats.avgChange >= 0 ? '+' : ''}{stats.avgChange.toFixed(2)}%
|
||||
</Text>
|
||||
<Text color={colors.market.up} fontWeight="medium">{stats.upCount}涨</Text>
|
||||
<Text color={colors.text.muted}>/</Text>
|
||||
<Text color={colors.market.down} fontWeight="medium">{stats.downCount}跌</Text>
|
||||
</HStack>
|
||||
) : null
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 第三行:评分 + Alpha + (选中时显示更多V2指标) */}
|
||||
<HStack justify="space-between" spacing={3} fontSize="xs">
|
||||
{/* 左侧:评分 */}
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>评分</Text>
|
||||
<Text
|
||||
@@ -172,16 +207,226 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
||||
{Math.round(alert.final_score || 0)}
|
||||
</Text>
|
||||
</HStack>
|
||||
{alert.alpha != null && (
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={getMarketColor(alert.alpha)}
|
||||
css={css`text-shadow: 0 0 10px ${getMarketColor(alert.alpha)}50;`}
|
||||
>
|
||||
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(1)}%
|
||||
|
||||
{/* 右侧:Alpha + (选中时更多V2指标) */}
|
||||
<HStack spacing={3}>
|
||||
{alert.alpha != null && (
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>α</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={getMarketColor(alert.alpha)}
|
||||
css={css`text-shadow: 0 0 10px ${getMarketColor(alert.alpha)}50;`}
|
||||
>
|
||||
{alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(1)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 选中时显示更多V2指标 */}
|
||||
{isSelected && alert.is_v2 && (
|
||||
<>
|
||||
{alert.confirm_ratio != null && (
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>确认率</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={
|
||||
alert.confirm_ratio >= 0.8 ? '#52c41a' :
|
||||
alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'
|
||||
}
|
||||
>
|
||||
{Math.round(alert.confirm_ratio * 100)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{alert.alpha_zscore != null && (
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>Z-Score</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={alert.alpha_zscore >= 0 ? colors.market.up : colors.market.down}
|
||||
>
|
||||
{alert.alpha_zscore >= 0 ? '+' : ''}{alert.alpha_zscore.toFixed(1)}σ
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{alert.amt_zscore != null && alert.amt_zscore > 0.5 && (
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>成交额</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={alert.amt_zscore >= 2 ? '#eb2f96' : '#faad14'}
|
||||
>
|
||||
{alert.amt_zscore.toFixed(1)}σ
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{alert.momentum_3m != null && Math.abs(alert.momentum_3m) > 0.3 && (
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.text.tertiary}>动量</Text>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
color={alert.momentum_3m >= 0 ? colors.market.up : colors.market.down}
|
||||
>
|
||||
{alert.momentum_3m >= 0 ? '+' : ''}{alert.momentum_3m.toFixed(2)}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 股票列表面板 - 选中卡片后展开显示
|
||||
*/
|
||||
const StockListPanel = ({ stocks, loading, alert }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStockClick = (stockCode) => {
|
||||
navigate(`/company?scode=${stockCode}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
mt={3}
|
||||
p={4}
|
||||
bg={glassEffect.light.bg}
|
||||
borderRadius="16px"
|
||||
border={glassEffect.light.border}
|
||||
>
|
||||
<HStack justify="center">
|
||||
<Spinner size="sm" color={colors.accent.purple} />
|
||||
<Text fontSize="sm" color={colors.text.secondary}>加载相关股票...</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
mt={3}
|
||||
p={4}
|
||||
bg={glassEffect.light.bg}
|
||||
borderRadius="16px"
|
||||
border={glassEffect.light.border}
|
||||
>
|
||||
<Text fontSize="sm" color={colors.text.muted} textAlign="center">
|
||||
暂无相关股票数据
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算统计
|
||||
const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct));
|
||||
const avgChange = validStocks.length > 0
|
||||
? validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length
|
||||
: 0;
|
||||
const upCount = validStocks.filter(s => s.change_pct > 0).length;
|
||||
const downCount = validStocks.filter(s => s.change_pct < 0).length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt={3}
|
||||
bg={glassEffect.light.bg}
|
||||
borderRadius="16px"
|
||||
border={glassEffect.light.border}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 统计信息栏 */}
|
||||
<HStack
|
||||
spacing={6}
|
||||
px={4}
|
||||
py={3}
|
||||
bg="rgba(255, 255, 255, 0.02)"
|
||||
borderBottom="1px solid rgba(255, 255, 255, 0.05)"
|
||||
fontSize="xs"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Text color={colors.text.muted}>均涨:</Text>
|
||||
<Text fontWeight="bold" color={avgChange >= 0 ? colors.market.up : colors.market.down}>
|
||||
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<Text color={colors.market.up} fontWeight="medium">{upCount}涨</Text>
|
||||
<Text color={colors.text.muted}>/</Text>
|
||||
<Text color={colors.market.down} fontWeight="medium">{downCount}跌</Text>
|
||||
</HStack>
|
||||
{(alert?.limit_up_ratio || 0) > 0.03 && (
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Flame} boxSize={3} color="#fa541c" />
|
||||
<Text color={colors.text.muted}>涨停比</Text>
|
||||
<Text fontWeight="bold" color="#fa541c">
|
||||
{Math.round(alert.limit_up_ratio * 100)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 股票列表 */}
|
||||
<Box maxH="250px" overflowY="auto" px={3} py={2}>
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{stocks.slice(0, 15).map((stock, idx) => {
|
||||
const changePct = stock.change_pct;
|
||||
const hasChange = changePct != null && !isNaN(changePct);
|
||||
const stockCode = stock.code || stock.stock_code;
|
||||
const stockName = stock.name || stock.stock_name || '-';
|
||||
|
||||
return (
|
||||
<HStack
|
||||
key={idx}
|
||||
px={2}
|
||||
py={1.5}
|
||||
borderRadius="8px"
|
||||
cursor="pointer"
|
||||
onClick={() => handleStockClick(stockCode)}
|
||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||
transition="background 0.15s"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack spacing={3} flex={1}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="#60a5fa"
|
||||
fontWeight="medium"
|
||||
_hover={{ color: '#93c5fd', textDecoration: 'underline' }}
|
||||
>
|
||||
{stockName}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
{stockCode}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={
|
||||
hasChange && changePct > 0 ? colors.market.up :
|
||||
hasChange && changePct < 0 ? colors.market.down :
|
||||
colors.text.muted
|
||||
}
|
||||
>
|
||||
{hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{stocks.length > 15 && (
|
||||
<Text fontSize="xs" color={colors.text.muted} textAlign="center" py={2}>
|
||||
共 {stocks.length} 只相关股票,显示前 15 只
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -199,10 +444,17 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
||||
// 选中的异动类型过滤器(null 表示全部)
|
||||
const [selectedAlertType, setSelectedAlertType] = useState(null);
|
||||
// 分时图点击的数据(用于筛选异动记录)
|
||||
const [chartClickedData, setChartClickedData] = useState(null);
|
||||
|
||||
// 右边栏抽屉控制
|
||||
// 右边栏抽屉控制(仅用于分时图点击)
|
||||
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
|
||||
|
||||
// 内联展开状态管理
|
||||
const [expandedAlertId, setExpandedAlertId] = useState(null);
|
||||
const [conceptStocksMap, setConceptStocksMap] = useState({});
|
||||
const [loadingStocksMap, setLoadingStocksMap] = useState({});
|
||||
|
||||
// 获取数据
|
||||
const { loading, refreshing, error, data } = useHotspotData(selectedDate);
|
||||
|
||||
@@ -220,25 +472,68 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
const sectionBg = glassEffect.light.bg;
|
||||
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
|
||||
|
||||
// 点击分时图上的异动标注 - 打开右边栏抽屉显示详情
|
||||
// 点击分时图上的异动标注 - 筛选异动记录并滚动
|
||||
const handleChartAlertClick = useCallback((alertGroupData) => {
|
||||
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
|
||||
setDrawerAlertData(alertGroupData);
|
||||
onDrawerOpen();
|
||||
}, [onDrawerOpen]);
|
||||
|
||||
// 点击底部异动卡片 - 打开右边栏抽屉显示单个异动详情
|
||||
const handleCardAlertClick = useCallback((alert) => {
|
||||
setSelectedAlert(alert);
|
||||
// 构造单个异动的数据格式
|
||||
setDrawerAlertData({
|
||||
alerts: [alert],
|
||||
timeRange: alert.time,
|
||||
alertCount: 1,
|
||||
time: alert.time,
|
||||
console.log('Chart clicked:', alertGroupData);
|
||||
setChartClickedData({
|
||||
timeRange: alertGroupData.timeRange,
|
||||
alerts: alertGroupData.alerts || [],
|
||||
alertCount: alertGroupData.alertCount,
|
||||
});
|
||||
onDrawerOpen();
|
||||
}, [onDrawerOpen]);
|
||||
// 滚动到异动记录区域
|
||||
setTimeout(() => {
|
||||
const alertRecordSection = document.getElementById('alert-record-section');
|
||||
if (alertRecordSection) {
|
||||
alertRecordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// 获取概念相关股票
|
||||
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||
console.log('[HotspotOverview] fetchConceptStocks called:', { conceptId, alreadyLoaded: !!conceptStocksMap[conceptId], loading: !!loadingStocksMap[conceptId] });
|
||||
if (loadingStocksMap[conceptId] || conceptStocksMap[conceptId]) return;
|
||||
|
||||
setLoadingStocksMap(prev => ({ ...prev, [conceptId]: true }));
|
||||
try {
|
||||
const url = `${getApiBase()}/api/concept/${encodeURIComponent(conceptId)}/stocks`;
|
||||
console.log('[HotspotOverview] Fetching stocks from:', url);
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
console.log('[HotspotOverview] Stocks response:', data);
|
||||
if (data.success && data.data?.stocks) {
|
||||
setConceptStocksMap(prev => ({ ...prev, [conceptId]: data.data.stocks }));
|
||||
} else {
|
||||
setConceptStocksMap(prev => ({ ...prev, [conceptId]: [] }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取概念股票失败:', error);
|
||||
setConceptStocksMap(prev => ({ ...prev, [conceptId]: [] }));
|
||||
} finally {
|
||||
setLoadingStocksMap(prev => ({ ...prev, [conceptId]: false }));
|
||||
}
|
||||
}, [loadingStocksMap, conceptStocksMap]);
|
||||
|
||||
// 点击底部异动卡片 - 选中/取消选中
|
||||
const handleCardAlertClick = useCallback((alert) => {
|
||||
const alertId = `${alert.concept_id}-${alert.time}`;
|
||||
console.log('[HotspotOverview] Card clicked:', { alertId, concept_id: alert.concept_id, currentSelected: expandedAlertId });
|
||||
|
||||
if (expandedAlertId === alertId) {
|
||||
// 取消选中
|
||||
setExpandedAlertId(null);
|
||||
setSelectedAlert(null);
|
||||
} else {
|
||||
// 选中
|
||||
setExpandedAlertId(alertId);
|
||||
setSelectedAlert(alert);
|
||||
// 加载概念股票(如果尚未加载且不在加载中)
|
||||
if (alert.concept_id && conceptStocksMap[alert.concept_id] === undefined && !loadingStocksMap[alert.concept_id]) {
|
||||
fetchConceptStocks(alert.concept_id);
|
||||
}
|
||||
}
|
||||
}, [expandedAlertId, conceptStocksMap, loadingStocksMap, fetchConceptStocks]);
|
||||
|
||||
// 点击异动类型标签 - 切换过滤器
|
||||
const handleAlertTypeClick = useCallback((type) => {
|
||||
@@ -390,8 +685,9 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
/>
|
||||
|
||||
<Box p={6} position="relative">
|
||||
{/* 头部 - Glassmorphism */}
|
||||
<Flex align="center" mb={5}>
|
||||
{/* 头部 - 标题 + 筛选区同一行 */}
|
||||
<Flex align="center" justify="space-between" mb={5} flexWrap="wrap" gap={3}>
|
||||
{/* 左侧:标题 */}
|
||||
<HStack spacing={4}>
|
||||
<Box
|
||||
p={3}
|
||||
@@ -421,14 +717,42 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
fontWeight="bold"
|
||||
css={css`text-shadow: 0 0 30px rgba(139, 92, 246, 0.3);`}
|
||||
>
|
||||
热点概览
|
||||
概念异动监控
|
||||
</Heading>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
|
||||
<Text fontSize="xs" color={subTextColor}>实时概念异动监控</Text>
|
||||
<Text fontSize="xs" color={subTextColor}>大盘分时 · 实时追踪</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:筛选区 */}
|
||||
<AlertFilterSection
|
||||
alertSummary={chartClickedData ?
|
||||
// 计算点击时间段的异动统计
|
||||
(chartClickedData.alerts || []).reduce((acc, alert) => {
|
||||
acc[alert.alert_type] = (acc[alert.alert_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {}) : alert_summary}
|
||||
selectedAlertType={selectedAlertType}
|
||||
onAlertTypeClick={handleAlertTypeClick}
|
||||
onClearFilter={() => {
|
||||
setSelectedAlertType(null);
|
||||
setChartClickedData(null);
|
||||
}}
|
||||
totalCount={chartClickedData ? chartClickedData.alertCount : alerts.length}
|
||||
filteredCount={chartClickedData ?
|
||||
(selectedAlertType ?
|
||||
(chartClickedData.alerts || []).filter(a => a.alert_type === selectedAlertType).length :
|
||||
chartClickedData.alertCount) :
|
||||
filteredAlerts.length}
|
||||
selectedDate={selectedDate}
|
||||
onDateChange={onDateChange}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
chartClickedTimeRange={chartClickedData?.timeRange}
|
||||
onClearTimeRange={() => setChartClickedData(null)}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* 大尺寸分时图 - Glassmorphism */}
|
||||
@@ -455,49 +779,6 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
filter="blur(40px)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{/* 标题行:大盘分时走势 + 筛选区 */}
|
||||
<Flex align="center" mb={4} flexWrap="wrap" gap={3}>
|
||||
<HStack spacing={3} flexShrink={0}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="12px"
|
||||
bg="rgba(139, 92, 246, 0.15)"
|
||||
border="1px solid rgba(139, 92, 246, 0.25)"
|
||||
>
|
||||
<Icon
|
||||
as={LineChart}
|
||||
boxSize={5}
|
||||
color={colors.accent.purple}
|
||||
css={css`filter: drop-shadow(0 0 6px #8b5cf6);`}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.3);`}
|
||||
>
|
||||
大盘分时走势
|
||||
</Text>
|
||||
<Tooltip label="图表上的标记点表示概念异动时刻,点击可查看详情" hasArrow>
|
||||
<Icon as={Info} boxSize={3.5} color={colors.text.muted} cursor="help" />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
{/* 筛选区内联 */}
|
||||
<AlertFilterSection
|
||||
alertSummary={alert_summary}
|
||||
selectedAlertType={selectedAlertType}
|
||||
onAlertTypeClick={handleAlertTypeClick}
|
||||
onClearFilter={() => setSelectedAlertType(null)}
|
||||
totalCount={alerts.length}
|
||||
filteredCount={filteredAlerts.length}
|
||||
selectedDate={selectedDate}
|
||||
onDateChange={onDateChange}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
/>
|
||||
</Flex>
|
||||
<Box position="relative">
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
@@ -525,7 +806,7 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
|
||||
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
||||
{alerts.length > 0 && (
|
||||
<Box>
|
||||
<Box id="alert-record-section">
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Box
|
||||
p={2}
|
||||
@@ -541,11 +822,15 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
{selectedAlertType
|
||||
? `(已筛选 ${ALERT_TYPE_CONFIG[selectedAlertType]?.label || selectedAlertType},共 ${filteredAlerts.length} 条)`
|
||||
: '(点击卡片查看详情)'}
|
||||
</Text>
|
||||
{chartClickedData ? (
|
||||
<Text fontSize="xs" color={colors.accent.purple} fontWeight="bold">
|
||||
({chartClickedData.timeRange})
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="xs" color={colors.text.muted}>
|
||||
(点击卡片查看详情)
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 横向滚动卡片 */}
|
||||
@@ -563,7 +848,7 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
}}
|
||||
>
|
||||
<HStack spacing={3} pb={1}>
|
||||
{[...filteredAlerts]
|
||||
{(chartClickedData ? chartClickedData.alerts : [...filteredAlerts])
|
||||
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
||||
.map((alert, idx) => (
|
||||
<CompactAlertCard
|
||||
@@ -571,10 +856,23 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
||||
alert={alert}
|
||||
onClick={handleCardAlertClick}
|
||||
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
||||
stocks={conceptStocksMap[alert.concept_id]}
|
||||
loadingStocks={loadingStocksMap[alert.concept_id]}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 选中卡片后展开的股票列表 */}
|
||||
<Collapse in={!!selectedAlert} animateOpacity>
|
||||
{selectedAlert && (
|
||||
<StockListPanel
|
||||
stocks={conceptStocksMap[selectedAlert.concept_id]}
|
||||
loading={loadingStocksMap[selectedAlert.concept_id]}
|
||||
alert={selectedAlert}
|
||||
/>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
import {
|
||||
@@ -6,32 +6,25 @@ import {
|
||||
Heading,
|
||||
Text,
|
||||
Button,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Flex,
|
||||
Spacer,
|
||||
Icon,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { TrendingUp, Info, ChevronRight, Flame, Rocket, ArrowUp, ArrowDown, BarChart2, Layers, Zap, Wallet, Banknote, Scale } from 'lucide-react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import HotspotOverview from './components/HotspotOverview';
|
||||
import FlexScreen from './components/FlexScreen';
|
||||
import { HeroSection } from '@components/HeroSection';
|
||||
import { echarts } from '@lib/echarts';
|
||||
import StockOverviewHeader from './components/StockOverviewHeader';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getConceptHtmlUrl } from '../../utils/textUtils';
|
||||
import tradingDays from '../../data/tradingDays.json';
|
||||
@@ -47,8 +40,6 @@ const StockOverview = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const colorMode = 'dark'; // 固定为 dark 深色模式
|
||||
const heatmapRef = useRef(null);
|
||||
const heatmapChart = useRef(null);
|
||||
|
||||
// 🎯 事件追踪 Hook
|
||||
const {
|
||||
@@ -180,15 +171,12 @@ const StockOverview = () => {
|
||||
: 0
|
||||
}));
|
||||
|
||||
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date,
|
||||
limitUpCount,
|
||||
limitDownCount
|
||||
});
|
||||
// 延迟渲染热力图,确保DOM已经准备好
|
||||
setTimeout(() => renderHeatmap(data.data), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('StockOverview', 'fetchHeatmapData', error, { date });
|
||||
@@ -234,221 +222,6 @@ const StockOverview = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染热力图
|
||||
const renderHeatmap = useCallback((data) => {
|
||||
if (!heatmapRef.current || !data || !data.length) return;
|
||||
|
||||
try {
|
||||
// 初始化或获取ECharts实例
|
||||
if (!heatmapChart.current) {
|
||||
heatmapChart.current = echarts.init(heatmapRef.current, colorMode === 'dark' ? 'dark' : null);
|
||||
}
|
||||
|
||||
// 按市值分组
|
||||
const groupedData = {};
|
||||
data.forEach(item => {
|
||||
const capRange = getMarketCapRange(item.market_cap);
|
||||
if (!groupedData[capRange]) {
|
||||
groupedData[capRange] = [];
|
||||
}
|
||||
groupedData[capRange].push(item);
|
||||
});
|
||||
|
||||
// 构建树图数据 - 修复格式问题
|
||||
const treeData = Object.entries(groupedData).map(([range, stocks]) => ({
|
||||
name: range,
|
||||
children: stocks.map(stock => {
|
||||
const change = stock.change_percent || 0;
|
||||
let color = colorMode === 'dark' ? '#333333' : '#9ca3af'; // 默认灰色
|
||||
|
||||
if (change > 0) {
|
||||
const intensity = Math.min(change / 10, 1);
|
||||
if (colorMode === 'dark') {
|
||||
// 夜间模式:红色带金色调
|
||||
color = `rgba(255, 77, 77, ${0.4 + intensity * 0.6})`;
|
||||
} else {
|
||||
color = `rgba(239, 68, 68, ${0.3 + intensity * 0.7})`;
|
||||
}
|
||||
} else if (change < 0) {
|
||||
const intensity = Math.min(Math.abs(change) / 10, 1);
|
||||
if (colorMode === 'dark') {
|
||||
// 夜间模式:绿色带暗色调
|
||||
color = `rgba(34, 197, 94, ${0.3 + intensity * 0.5})`;
|
||||
} else {
|
||||
color = `rgba(34, 197, 94, ${0.3 + intensity * 0.7})`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: stock.stock_name,
|
||||
value: Math.abs(stock.market_cap),
|
||||
change: stock.change_percent,
|
||||
code: stock.stock_code,
|
||||
amount: stock.amount,
|
||||
industry: stock.industry,
|
||||
province: stock.province,
|
||||
itemStyle: {
|
||||
color: color
|
||||
}
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
const option = {
|
||||
backgroundColor: colorMode === 'dark' ? '#0a0a0a' : 'transparent',
|
||||
tooltip: {
|
||||
backgroundColor: colorMode === 'dark' ? '#1a1a1a' : 'white',
|
||||
borderColor: colorMode === 'dark' ? goldColor : '#ccc',
|
||||
borderWidth: colorMode === 'dark' ? 2 : 1,
|
||||
textStyle: {
|
||||
color: colorMode === 'dark' ? 'white' : '#333'
|
||||
},
|
||||
formatter: function(info) {
|
||||
const data = info.data;
|
||||
const isDark = colorMode === 'dark';
|
||||
// 如果是父节点(市值分组)
|
||||
if (data.children) {
|
||||
return `
|
||||
<div style="padding: 10px; color: ${isDark ? 'white' : '#333'};">
|
||||
<div style="font-weight: bold; margin-bottom: 6px; font-size: 14px; color: ${isDark ? goldColor : '#333'};">${data.name}</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">包含 ${data.children.length} 只股票</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">总市值: <span style="color: ${isDark ? goldColor : '#333'}; font-weight: bold;">${data.children.reduce((sum, item) => sum + item.value, 0).toFixed(2)}</span> 亿元</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// 个股详情
|
||||
return `
|
||||
<div style="padding: 10px; color: ${isDark ? 'white' : '#333'};">
|
||||
<div style="font-weight: bold; margin-bottom: 6px; font-size: 14px; color: ${isDark ? goldColor : '#333'};">${data.name}</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">代码: ${data.code || '-'}</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">涨跌幅: <span style="color: ${data.change > 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">
|
||||
${data.change > 0 ? '+' : ''}${data.change?.toFixed(2) || 0}%
|
||||
</span></div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">市值: <span style="font-weight: bold;">${data.value?.toFixed(2) || 0}</span> 亿元</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">成交额: <span style="font-weight: bold;">${data.amount?.toFixed(2) || 0}</span> 亿元</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">行业: ${data.industry || '未知'}</div>
|
||||
<div style="color: ${isDark ? '#ccc' : '#666'};">地区: ${data.province || '未知'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: 'A股市场',
|
||||
type: 'treemap',
|
||||
data: treeData,
|
||||
leafDepth: 1,
|
||||
roam: false,
|
||||
breadcrumb: {
|
||||
show: true,
|
||||
top: 10,
|
||||
left: 10,
|
||||
itemStyle: {
|
||||
color: colorMode === 'dark' ? '#1a1a2e' : '#f0f0f0',
|
||||
borderColor: colorMode === 'dark' ? goldColor : '#ccc',
|
||||
borderWidth: 1,
|
||||
shadowBlur: colorMode === 'dark' ? 5 : 0,
|
||||
shadowColor: colorMode === 'dark' ? `${goldColor}40` : 'transparent',
|
||||
textStyle: {
|
||||
color: colorMode === 'dark' ? goldColor : '#333'
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: colorMode === 'dark' ? goldColor : '#e0e0e0',
|
||||
textStyle: {
|
||||
color: colorMode === 'dark' ? '#0a0a0a' : '#333'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
levels: [
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: colorMode === 'dark' ? '#1a1a1a' : '#fff',
|
||||
borderWidth: 3,
|
||||
gapWidth: 3
|
||||
}
|
||||
},
|
||||
{
|
||||
itemStyle: {
|
||||
borderColor: colorMode === 'dark' ? '#0a0a0a' : '#fff',
|
||||
borderWidth: 1,
|
||||
gapWidth: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
itemStyle: {
|
||||
borderColor: colorMode === 'dark' ? '#0a0a0a' : '#fff',
|
||||
borderWidth: 1
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: function(params) {
|
||||
const data = params.data;
|
||||
// 父节点(市值分组)显示名称
|
||||
if (data.children) {
|
||||
return params.name;
|
||||
}
|
||||
// 子节点(个股)根据市值大小决定是否显示
|
||||
return data.value > 5 ? data.name : '';
|
||||
},
|
||||
fontSize: 12,
|
||||
color: function(params) {
|
||||
if (colorMode === 'dark') {
|
||||
// 夜间模式:根据背景色调整文字颜色
|
||||
const change = params.data.change || 0;
|
||||
if (Math.abs(change) > 5) {
|
||||
return 'white';
|
||||
}
|
||||
return '#ccc';
|
||||
}
|
||||
return '#333';
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
// 设置配置项
|
||||
heatmapChart.current.setOption(option);
|
||||
|
||||
// 先移除之前的点击事件,避免重复绑定
|
||||
heatmapChart.current.off('click');
|
||||
|
||||
// 添加点击事件
|
||||
heatmapChart.current.on('click', function(params) {
|
||||
// 只有点击个股(有code的节点)才跳转
|
||||
if (params.data && params.data.code && !params.data.children) {
|
||||
const stock = {
|
||||
code: params.data.code,
|
||||
name: params.data.name,
|
||||
change_percent: params.data.change
|
||||
};
|
||||
const marketCapRange = getMarketCapRange(params.data.value);
|
||||
|
||||
// 🎯 追踪热力图股票点击
|
||||
trackHeatmapStockClicked(stock, marketCapRange);
|
||||
|
||||
navigate(`/company?scode=${params.data.code}`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('StockOverview', 'renderHeatmap', error, {
|
||||
dataLength: data?.length || 0
|
||||
});
|
||||
// ❌ 移除热力图渲染失败 toast(非关键操作)
|
||||
}
|
||||
}, [colorMode, goldColor, navigate, trackHeatmapStockClicked]); // ✅ 添加追踪函数依赖
|
||||
|
||||
// 获取市值区间
|
||||
const getMarketCapRange = (cap) => {
|
||||
if (cap >= 1000) return '超大盘股(>1000亿)';
|
||||
if (cap >= 500) return '大盘股(500-1000亿)';
|
||||
if (cap >= 100) return '中盘股(100-500亿)';
|
||||
if (cap >= 50) return '小盘股(50-100亿)';
|
||||
return '微盘股(<50亿)';
|
||||
};
|
||||
|
||||
// 查看概念详情(模仿概念中心:打开对应HTML页)
|
||||
const handleConceptClick = (concept, rank = 0) => {
|
||||
// 🎯 追踪概念点击
|
||||
@@ -477,35 +250,8 @@ const StockOverview = () => {
|
||||
fetchTopConcepts();
|
||||
fetchHeatmapData();
|
||||
fetchMarketStats();
|
||||
|
||||
// 监听窗口大小变化,重新渲染热力图
|
||||
const handleResize = () => {
|
||||
if (heatmapChart.current) {
|
||||
heatmapChart.current.resize();
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (heatmapChart.current) {
|
||||
heatmapChart.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听colorMode和heatmapData变化,重新渲染热力图
|
||||
useEffect(() => {
|
||||
if (heatmapData.length > 0) {
|
||||
// 如果已有实例,先销毁再重新创建
|
||||
if (heatmapChart.current) {
|
||||
heatmapChart.current.dispose();
|
||||
heatmapChart.current = null;
|
||||
}
|
||||
renderHeatmap(heatmapData);
|
||||
}
|
||||
}, [heatmapData, colorMode, renderHeatmap]);
|
||||
|
||||
// 概念卡片骨架屏
|
||||
const ConceptSkeleton = () => (
|
||||
<Card bg={bgColor} borderWidth="1px" borderColor={borderColor}>
|
||||
@@ -542,114 +288,26 @@ const StockOverview = () => {
|
||||
zIndex={0}
|
||||
/>
|
||||
|
||||
{/* Hero Section - 使用通用 HeroSection 组件 */}
|
||||
<HeroSection
|
||||
icon={TrendingUp}
|
||||
title="个股中心"
|
||||
subtitle="实时追踪市场动态,洞察投资机会"
|
||||
themePreset="purple"
|
||||
themeColors={{
|
||||
titleGradient: `linear(to-r, ${goldColor}, white)`,
|
||||
iconColor: goldColor,
|
||||
statLabelColor: goldColor,
|
||||
statCardBorder: `${goldColor}50`,
|
||||
}}
|
||||
decorations={[
|
||||
{
|
||||
type: 'glowOrbs',
|
||||
intensity: 0.4,
|
||||
orbs: [
|
||||
{ top: '-20%', right: '-10%', size: '40%', color: `${goldColor}15`, blur: '60px' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
stats={{
|
||||
columns: { base: 1, sm: 2, md: 3 },
|
||||
items: [
|
||||
// 第一行:核心指标
|
||||
{
|
||||
key: 'indexChange',
|
||||
label: '大盘涨跌幅',
|
||||
value: hotspotData?.index?.change_pct != null
|
||||
? `${hotspotData.index.change_pct >= 0 ? '+' : ''}${hotspotData.index.change_pct.toFixed(2)}%`
|
||||
: null,
|
||||
valueColor: (hotspotData?.index?.change_pct ?? 0) >= 0 ? '#ff4d4d' : '#22c55e',
|
||||
watermark: { icon: TrendingUp, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
key: 'limitUpDown',
|
||||
label: '涨停/跌停',
|
||||
value: (limitStats.limitUpCount > 0 || limitStats.limitDownCount > 0)
|
||||
? `${limitStats.limitUpCount}/${limitStats.limitDownCount}`
|
||||
: null,
|
||||
progressBar: (limitStats.limitUpCount > 0 || limitStats.limitDownCount > 0) ? {
|
||||
value: limitStats.limitUpCount,
|
||||
total: limitStats.limitDownCount || 1, // 避免除零
|
||||
positiveColor: '#ff4d4d',
|
||||
negativeColor: '#22c55e',
|
||||
} : undefined,
|
||||
watermark: { icon: Zap, color: '#ff4d4d', opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
key: 'rising',
|
||||
label: '多空对比',
|
||||
value: (marketStats?.rising_count && marketStats?.falling_count)
|
||||
? `${marketStats.rising_count}/${marketStats.falling_count}`
|
||||
: null,
|
||||
progressBar: (marketStats?.rising_count && marketStats?.falling_count) ? {
|
||||
value: marketStats.rising_count,
|
||||
total: marketStats.falling_count,
|
||||
positiveColor: '#ff4d4d',
|
||||
negativeColor: '#22c55e',
|
||||
} : undefined,
|
||||
watermark: { icon: Scale, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
// 第二行:辅助指标
|
||||
{
|
||||
key: 'amount',
|
||||
label: '今日成交额',
|
||||
value: marketStats ? `${(marketStats.total_amount / 10000).toFixed(1)}万亿` : null,
|
||||
trend: marketStats?.yesterday?.total_amount ? (() => {
|
||||
const change = ((marketStats.total_amount - marketStats.yesterday.total_amount) / marketStats.yesterday.total_amount) * 100;
|
||||
return {
|
||||
direction: change > 0.01 ? 'up' : change < -0.01 ? 'down' : 'flat',
|
||||
percent: Math.abs(change),
|
||||
label: change > 5 ? '放量' : change < -5 ? '缩量' : undefined,
|
||||
};
|
||||
})() : undefined,
|
||||
watermark: { icon: Banknote, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
key: 'marketCap',
|
||||
label: 'A股总市值',
|
||||
value: marketStats ? `${(marketStats.total_market_cap / 10000).toFixed(1)}万亿` : null,
|
||||
trend: marketStats?.yesterday?.total_market_cap ? (() => {
|
||||
const change = ((marketStats.total_market_cap - marketStats.yesterday.total_market_cap) / marketStats.yesterday.total_market_cap) * 100;
|
||||
return {
|
||||
direction: change > 0.01 ? 'up' : change < -0.01 ? 'down' : 'flat',
|
||||
percent: Math.abs(change),
|
||||
};
|
||||
})() : undefined,
|
||||
watermark: { icon: Wallet, color: goldColor, opacity: 0.1 },
|
||||
},
|
||||
{
|
||||
key: 'continuousLimit',
|
||||
label: '连板龙头',
|
||||
value: limitStats.limitUpCount > 0
|
||||
? `${limitStats.limitUpCount}只`
|
||||
: '暂无',
|
||||
helpText: limitStats.maxContinuousDays > 1
|
||||
? `最高${limitStats.maxContinuousDays}天`
|
||||
: undefined,
|
||||
valueColor: '#f59e0b',
|
||||
watermark: { icon: Rocket, color: '#f59e0b', opacity: 0.1 },
|
||||
},
|
||||
],
|
||||
{/* 新头部组件 - 左右分栏:左侧标题+指标,右侧热力图 */}
|
||||
<StockOverviewHeader
|
||||
hotspotData={hotspotData}
|
||||
limitStats={limitStats}
|
||||
marketStats={marketStats}
|
||||
heatmapData={heatmapData}
|
||||
loadingHeatmap={loadingHeatmap}
|
||||
onStockClick={(code, name) => {
|
||||
trackHeatmapStockClicked({ code, name }, '');
|
||||
navigate(`/company?scode=${code}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 主内容区 - 负 margin 使卡片向上浮动,与 Hero 产生重叠纵深感 */}
|
||||
<Box pt={6} pb={10} px={6} mt={-6} position="relative" zIndex={2}>
|
||||
{/* 灵活屏 - 实时行情监控 */}
|
||||
<Box mb={10}>
|
||||
<FlexScreen />
|
||||
</Box>
|
||||
|
||||
{/* 热点概览 - 大盘走势 + 概念异动 */}
|
||||
{/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
|
||||
<Box mb={10}>
|
||||
@@ -690,288 +348,8 @@ const StockOverview = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 灵活屏 - 实时行情监控 */}
|
||||
<Box mb={10}>
|
||||
<FlexScreen />
|
||||
</Box>
|
||||
|
||||
{/* 今日热门概念 */}
|
||||
<Box mb={10}>
|
||||
<Flex align="center" mb={6}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={Flame} boxSize={6} color={colorMode === 'dark' ? goldColor : 'orange.500'} />
|
||||
<Heading size="lg" color={textColor}>今日热门概念</Heading>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronRight size={16} />}
|
||||
onClick={() => navigate('/concepts')}
|
||||
color={colorMode === 'dark' ? goldColor : 'purple.600'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'purple.50'
|
||||
}}
|
||||
>
|
||||
查看更多
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loadingConcepts ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<ConceptSkeleton key={i} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{topConcepts.map((concept, index) => (
|
||||
<Card
|
||||
key={concept.concept_id}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: colorMode === 'dark' ? `0 10px 30px -5px ${goldColor}30` : 'lg',
|
||||
borderColor: colorMode === 'dark' ? goldColor : 'purple.300',
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept, index)}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 排名标签 */}
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={2}
|
||||
left={2}
|
||||
bg={colorMode === 'dark' ? goldColor : 'purple.500'}
|
||||
color={colorMode === 'dark' ? 'black' : 'white'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
>
|
||||
TOP {index + 1}
|
||||
</Badge>
|
||||
|
||||
{/* 涨跌幅标签 */}
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
colorScheme={getChangeColor(concept.change_percent)}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
border={colorMode === 'dark' ? '1px solid' : 'none'}
|
||||
borderColor={colorMode === 'dark' ? concept.change_percent > 0 ? '#ff4d4d' : '#22c55e' : 'transparent'}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Icon
|
||||
as={concept.change_percent > 0 ? ArrowUp : ArrowDown}
|
||||
boxSize={3}
|
||||
/>
|
||||
<Text>{formatChangePercent(concept.change_percent)}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
|
||||
<CardBody pt={12}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{/* 概念名称 */}
|
||||
<Heading size="md" noOfLines={1} color={textColor}>
|
||||
{concept.concept_name}
|
||||
</Heading>
|
||||
|
||||
{/* 层级信息 */}
|
||||
{concept.hierarchy && (
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Icon as={Layers} boxSize={3} color="gray.400" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{[concept.hierarchy.lv1, concept.hierarchy.lv2, concept.hierarchy.lv3]
|
||||
.filter(Boolean)
|
||||
.join(' > ')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
<Text fontSize="sm" color={subTextColor} noOfLines={2}>
|
||||
{concept.description || '暂无描述'}
|
||||
</Text>
|
||||
|
||||
{/* 标签 */}
|
||||
{concept.tags && concept.tags.length > 0 && (
|
||||
<Flex flexWrap="wrap" gap={1}>
|
||||
{concept.tags.slice(0, 4).map((tag, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
borderRadius="full"
|
||||
>
|
||||
<Icon as={Tag} boxSize={2} mr={1} />
|
||||
<TagLabel fontSize="xs">{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
{concept.tags.length > 4 && (
|
||||
<Tag size="sm" variant="ghost" colorScheme="gray">
|
||||
<TagLabel fontSize="xs">+{concept.tags.length - 4}</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 爆发日期 */}
|
||||
{concept.outbreak_dates && concept.outbreak_dates.length > 0 && (
|
||||
<HStack spacing={2} fontSize="xs" color="orange.500">
|
||||
<Icon as={Zap} />
|
||||
<Text>
|
||||
近期爆发: {concept.outbreak_dates.slice(0, 2).join(', ')}
|
||||
{concept.outbreak_dates.length > 2 && ` 等${concept.outbreak_dates.length}次`}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 相关股票 */}
|
||||
<Box
|
||||
w="100%"
|
||||
cursor="pointer"
|
||||
onClick={(e) => handleViewStocks(e, concept)}
|
||||
_hover={{ bg: hoverBg }}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
包含 {concept.stock_count} 只个股
|
||||
</Text>
|
||||
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Flex
|
||||
flexWrap="nowrap"
|
||||
gap={2}
|
||||
overflow="hidden"
|
||||
maxH="24px"
|
||||
>
|
||||
{concept.stocks.slice(0, 5).map((stock, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
flexShrink={0}
|
||||
>
|
||||
<TagLabel>{stock.stock_name || stock.name}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
{concept.stocks.length > 5 && (
|
||||
<Tag size="sm" variant="ghost" colorScheme="gray" flexShrink={0}>
|
||||
<TagLabel>+{concept.stocks.length - 5}</TagLabel>
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<HStack spacing={2} w="100%">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronRight />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConceptClick(concept, index);
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 市值热力图 */}
|
||||
<Box mb={10}>
|
||||
<Flex align="center" mb={6}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={BarChart2} boxSize={6} color={accentColor} />
|
||||
<Heading size="lg" color={textColor}>市值热力图</Heading>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Tooltip label="基于市值大小和涨跌幅展示的市场全景图">
|
||||
<Icon as={Info} color={subTextColor} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow={colorMode === 'dark' ? `0 0 20px ${goldColor}15` : 'lg'}
|
||||
p={6}
|
||||
>
|
||||
{loadingHeatmap ? (
|
||||
<Center h="500px">
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color={accentColor} thickness="4px" />
|
||||
<Text color={subTextColor}>正在加载热力图数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Box>
|
||||
{/* 图例说明 */}
|
||||
<HStack spacing={8} mb={6} justify="center">
|
||||
<HStack>
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
bg={colorMode === 'dark' ? '#ff4d4d' : 'red.500'}
|
||||
borderRadius="sm"
|
||||
boxShadow={colorMode === 'dark' ? `0 0 10px #ff4d4d50` : 'none'}
|
||||
/>
|
||||
<Text fontSize="sm" color={textColor} fontWeight="medium">上涨</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
bg={colorMode === 'dark' ? '#333333' : 'gray.400'}
|
||||
borderRadius="sm"
|
||||
/>
|
||||
<Text fontSize="sm" color={textColor} fontWeight="medium">平盘</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
bg="#22c55e"
|
||||
borderRadius="sm"
|
||||
boxShadow={colorMode === 'dark' ? `0 0 10px #22c55e50` : 'none'}
|
||||
/>
|
||||
<Text fontSize="sm" color={textColor} fontWeight="medium">下跌</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 热力图容器 */}
|
||||
<Box ref={heatmapRef} h="500px" w="100%" />
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 个股列表弹窗 */}
|
||||
<ConceptStocksModal
|
||||
|
||||
Reference in New Issue
Block a user