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:
zdl
2025-12-31 19:02:19 +08:00
parent c120c1c65b
commit e37a8875f8
5 changed files with 625 additions and 1103 deletions

View File

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

View File

@@ -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}

View File

@@ -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';

View File

@@ -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>
)}

View File

@@ -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