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,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
Badge,
|
|
||||||
Icon,
|
Icon,
|
||||||
Collapse,
|
Collapse,
|
||||||
Spinner,
|
Spinner,
|
||||||
Tooltip,
|
|
||||||
Flex,
|
|
||||||
Popover,
|
|
||||||
PopoverTrigger,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverBody,
|
|
||||||
Portal,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { keyframes, css } from '@emotion/react';
|
import { css } from '@emotion/react';
|
||||||
import { GLASS_BLUR } from '@/constants/glassConfig';
|
import { GLASS_BLUR } from '@/constants/glassConfig';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Zap,
|
Zap,
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
BarChart3,
|
|
||||||
Flame,
|
Flame,
|
||||||
Target,
|
|
||||||
Activity,
|
|
||||||
Rocket,
|
|
||||||
Waves,
|
|
||||||
Gauge,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import { getApiBase } from '@utils/apiConfig';
|
||||||
import { colors, glassEffect } from '../../../theme/glassTheme';
|
import { colors } from '../../../theme/glassTheme';
|
||||||
import {
|
import {
|
||||||
ALERT_TYPE_CONFIG,
|
ALERT_TYPE_CONFIG,
|
||||||
getAlertTypeLabel,
|
|
||||||
getAlertTypeDescription,
|
|
||||||
getScoreColor,
|
|
||||||
formatScore,
|
|
||||||
} from '../utils/chartHelpers';
|
} 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 AlertDetailCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
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) => {
|
const handleStockClick = (e, stockCode) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`/company?scode=${stockCode}`);
|
navigate(`/company?scode=${stockCode}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConceptClick = (e) => {
|
// 计算统计信息
|
||||||
e.stopPropagation();
|
const getStockStats = () => {
|
||||||
if (alert.concept_id) {
|
if (!stocks || stocks.length === 0) return null;
|
||||||
navigate(`/concept/${alert.concept_id}`);
|
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 (
|
return (
|
||||||
<Box
|
/* 展开内容 - 只显示统计和股票列表(去除重复字段) */
|
||||||
bg="rgba(255, 255, 255, 0.03)"
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 顶部渐变条 */}
|
|
||||||
<Box
|
<Box
|
||||||
h="2px"
|
bg="rgba(0, 0, 0, 0.15)"
|
||||||
bgGradient={`linear(to-r, ${alertConfig.gradient[0]}, ${alertConfig.gradient[1]})`}
|
borderRadius="12px"
|
||||||
opacity={isExpanded ? 1 : 0.6}
|
mx={2}
|
||||||
/>
|
mb={2}
|
||||||
|
overflow="hidden"
|
||||||
{/* 主内容区 - 可点击展开 */}
|
|
||||||
<Box
|
|
||||||
p={4}
|
|
||||||
cursor="pointer"
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
>
|
||||||
{/* 第一行:展开箭头 + 概念名称 + 评分 */}
|
{loadingStocks ? (
|
||||||
<Flex justify="space-between" align="center" mb={3}>
|
<HStack justify="center" py={4}>
|
||||||
<HStack spacing={3} flex={1}>
|
<Spinner size="sm" color={alertConfig.color} />
|
||||||
<Icon
|
<Text fontSize="sm" color={colors.text.secondary}>加载相关股票...</Text>
|
||||||
as={isExpanded ? ChevronDown : ChevronRight}
|
</HStack>
|
||||||
color={colors.text.secondary}
|
) : stocks && stocks.length > 0 ? (
|
||||||
boxSize={4}
|
<VStack align="stretch" spacing={0}>
|
||||||
transition="transform 0.2s"
|
{/* 统计信息栏 */}
|
||||||
/>
|
{(() => {
|
||||||
<Box
|
const stats = getStockStats();
|
||||||
p={2}
|
if (!stats) return null;
|
||||||
borderRadius="10px"
|
const { avgChange, upCount, downCount } = stats;
|
||||||
bg={`${alertConfig.color}20`}
|
return (
|
||||||
>
|
<HStack
|
||||||
<Icon
|
spacing={6}
|
||||||
as={AlertIcon}
|
px={4}
|
||||||
boxSize={4}
|
py={3}
|
||||||
color={alertConfig.color}
|
bg="rgba(255, 255, 255, 0.02)"
|
||||||
css={css`filter: drop-shadow(0 0 4px ${alertConfig.color}80);`}
|
borderBottom="1px solid rgba(255, 255, 255, 0.05)"
|
||||||
/>
|
fontSize="xs"
|
||||||
</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;`}
|
|
||||||
>
|
>
|
||||||
{alert.concept_name}
|
<HStack spacing={2}>
|
||||||
</Text>
|
<Text color={colors.text.muted}>均涨:</Text>
|
||||||
<Tooltip label="查看概念详情" hasArrow>
|
<Text fontWeight="bold" color={avgChange >= 0 ? colors.market.up : colors.market.down}>
|
||||||
<Box
|
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
|
||||||
as="span"
|
</Text>
|
||||||
cursor="pointer"
|
</HStack>
|
||||||
onClick={handleConceptClick}
|
<HStack spacing={1}>
|
||||||
_hover={{ color: alertConfig.color }}
|
<Text color={colors.market.up} fontWeight="medium">{upCount}涨</Text>
|
||||||
>
|
<Text color={colors.text.muted}>/</Text>
|
||||||
<Icon as={ExternalLink} boxSize={3} color={colors.text.muted} />
|
<Text color={colors.market.down} fontWeight="medium">{downCount}跌</Text>
|
||||||
</Box>
|
</HStack>
|
||||||
</Tooltip>
|
{(alert.limit_up_ratio || 0) > 0.03 && (
|
||||||
</HStack>
|
<HStack spacing={1}>
|
||||||
<Text fontSize="xs" color={colors.text.muted}>
|
<Icon as={Flame} boxSize={3} color="#fa541c" />
|
||||||
{alert.time}
|
<Text color={colors.text.muted}>涨停比</Text>
|
||||||
</Text>
|
<Text fontWeight="bold" color="#fa541c">
|
||||||
</VStack>
|
{Math.round(alert.limit_up_ratio * 100)}%
|
||||||
</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)}%
|
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={1}>
|
)}
|
||||||
<Text color={colors.market.up} fontWeight="medium">{upCount}涨</Text>
|
</HStack>
|
||||||
<Text color={colors.text.muted}>/</Text>
|
);
|
||||||
<Text color={colors.market.down} fontWeight="medium">{downCount}跌</Text>
|
})()}
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* 股票列表 */}
|
{/* 股票列表 */}
|
||||||
<Box maxH="250px" overflowY="auto" pr={1}>
|
<Box maxH="250px" overflowY="auto" px={3} py={2}>
|
||||||
<VStack align="stretch" spacing={1}>
|
<VStack align="stretch" spacing={1}>
|
||||||
{stocks.slice(0, 15).map((stock, idx) => {
|
{stocks.slice(0, 15).map((stock, idx) => {
|
||||||
const changePct = stock.change_pct;
|
const changePct = stock.change_pct;
|
||||||
const hasChange = changePct != null && !isNaN(changePct);
|
const hasChange = changePct != null && !isNaN(changePct);
|
||||||
const stockCode = stock.code || stock.stock_code;
|
const stockCode = stock.code || stock.stock_code;
|
||||||
const stockName = stock.name || stock.stock_name || '-';
|
const stockName = stock.name || stock.stock_name || '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
key={idx}
|
key={idx}
|
||||||
p={2}
|
px={2}
|
||||||
borderRadius="8px"
|
py={1.5}
|
||||||
cursor="pointer"
|
borderRadius="8px"
|
||||||
onClick={(e) => handleStockClick(e, stockCode)}
|
cursor="pointer"
|
||||||
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
onClick={(e) => handleStockClick(e, stockCode)}
|
||||||
transition="background 0.15s"
|
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
justify="space-between"
|
transition="background 0.15s"
|
||||||
>
|
justify="space-between"
|
||||||
<HStack spacing={3} flex={1}>
|
>
|
||||||
{/* 股票名称 - 带迷你分时图悬停 */}
|
<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>
|
|
||||||
<Text
|
<Text
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
fontWeight="bold"
|
color="#60a5fa"
|
||||||
color={
|
fontWeight="medium"
|
||||||
hasChange && changePct > 0 ? colors.market.up :
|
_hover={{ color: '#93c5fd', textDecoration: 'underline' }}
|
||||||
hasChange && changePct < 0 ? colors.market.down :
|
|
||||||
colors.text.muted
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
|
{stockName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>
|
||||||
|
{stockCode}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
<Text
|
||||||
})}
|
fontSize="sm"
|
||||||
</VStack>
|
fontWeight="bold"
|
||||||
</Box>
|
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 && (
|
{stocks.length > 15 && (
|
||||||
<Text fontSize="xs" color={colors.text.muted} textAlign="center">
|
<Text fontSize="xs" color={colors.text.muted} textAlign="center" pb={2}>
|
||||||
共 {stocks.length} 只相关股票,显示前 15 只
|
共 {stocks.length} 只相关股票,显示前 15 只
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
) : (
|
) : (
|
||||||
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
|
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
|
||||||
暂无相关股票数据
|
暂无相关股票数据
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,3 +344,6 @@ const AlertDetailDrawer = ({ isOpen, onClose, alertData }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default AlertDetailDrawer;
|
export default AlertDetailDrawer;
|
||||||
|
|
||||||
|
// 导出 AlertDetailCard 以便在其他地方复用
|
||||||
|
export { AlertDetailCard };
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { Flex, HStack, Text, Icon } from '@chakra-ui/react';
|
import { Flex, HStack, Text, Icon } from '@chakra-ui/react';
|
||||||
import { css } from '@emotion/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 { ALERT_TYPE_CONFIG } from '../utils/chartHelpers';
|
||||||
import TradeDatePicker from '@components/TradeDatePicker';
|
import TradeDatePicker from '@components/TradeDatePicker';
|
||||||
@@ -74,7 +74,7 @@ const AlertCountBadge = memo(({ totalCount, filteredCount, selectedAlertType })
|
|||||||
color={colors.accent.purple}
|
color={colors.accent.purple}
|
||||||
css={css`text-shadow: 0 0 8px rgba(139, 92, 246, 0.5);`}
|
css={css`text-shadow: 0 0 8px rgba(139, 92, 246, 0.5);`}
|
||||||
>
|
>
|
||||||
{selectedAlertType ? `${filteredCount}/${totalCount}` : totalCount}
|
{filteredCount}/{totalCount}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
));
|
));
|
||||||
@@ -95,6 +95,10 @@ const AlertFilterSection = ({
|
|||||||
onDateChange,
|
onDateChange,
|
||||||
minDate,
|
minDate,
|
||||||
maxDate,
|
maxDate,
|
||||||
|
// 分时图点击的时间段
|
||||||
|
chartClickedTimeRange,
|
||||||
|
// 清除时间段筛选
|
||||||
|
onClearTimeRange,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
@@ -102,6 +106,65 @@ const AlertFilterSection = ({
|
|||||||
gap={2}
|
gap={2}
|
||||||
flexWrap="wrap"
|
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 || {})
|
{Object.entries(alertSummary || {})
|
||||||
.filter(([_, count]) => count > 0)
|
.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
|
<AlertCountBadge
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||||
export { default as AlertSummary } from './AlertSummary';
|
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';
|
export { default as AlertFilterSection } from './AlertFilterSection';
|
||||||
|
|||||||
@@ -11,31 +11,32 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Heading,
|
|
||||||
Text,
|
Text,
|
||||||
|
Heading,
|
||||||
HStack,
|
HStack,
|
||||||
VStack,
|
VStack,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Spacer,
|
|
||||||
Icon,
|
Icon,
|
||||||
Flex,
|
Flex,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
|
Collapse,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
import { getApiBase } from '@utils/apiConfig';
|
||||||
import { keyframes, css } from '@emotion/react';
|
import { keyframes, css } from '@emotion/react';
|
||||||
import {
|
import {
|
||||||
Flame,
|
Flame,
|
||||||
List,
|
List,
|
||||||
LineChart,
|
|
||||||
Info,
|
|
||||||
Zap,
|
Zap,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useHotspotData } from './hooks';
|
import { useHotspotData } from './hooks';
|
||||||
import { IndexMinuteChart, AlertDetailDrawer, AlertFilterSection } from './components';
|
import { IndexMinuteChart, AlertDetailDrawer, AlertFilterSection } from './components';
|
||||||
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
||||||
@@ -69,11 +70,23 @@ const shimmer = keyframes`
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 紧凑型异动卡片(用于横向滚动)- Glassmorphism 风格
|
* 紧凑型异动卡片(用于横向滚动)- 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 config = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||||
const isUp = alert.alert_type !== 'surge_down';
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
bg={glassEffect.light.bg}
|
bg={glassEffect.light.bg}
|
||||||
@@ -81,13 +94,15 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
|||||||
borderRadius="16px"
|
borderRadius="16px"
|
||||||
border={isSelected ? `1px solid ${config.color}60` : glassEffect.light.border}
|
border={isSelected ? `1px solid ${config.color}60` : glassEffect.light.border}
|
||||||
p={3}
|
p={3}
|
||||||
minW="180px"
|
minW={isSelected ? "340px" : "180px"}
|
||||||
maxW="200px"
|
maxW={isSelected ? "420px" : "200px"}
|
||||||
|
minH="80px"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
onClick={() => onClick?.(alert)}
|
onClick={() => onClick?.(alert)}
|
||||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||||
position="relative"
|
position="relative"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
|
flexShrink={0}
|
||||||
_hover={{
|
_hover={{
|
||||||
bg: 'rgba(255, 255, 255, 0.05)',
|
bg: 'rgba(255, 255, 255, 0.05)',
|
||||||
border: `1px solid ${config.color}50`,
|
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" mb={1}>
|
||||||
<Text fontSize="xs" color={colors.text.muted} fontFamily="mono">
|
{alert.time}
|
||||||
{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>
|
</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}>
|
<HStack spacing={1}>
|
||||||
<Text color={colors.text.tertiary}>评分</Text>
|
<Text color={colors.text.tertiary}>评分</Text>
|
||||||
<Text
|
<Text
|
||||||
@@ -172,16 +207,226 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
|||||||
{Math.round(alert.final_score || 0)}
|
{Math.round(alert.final_score || 0)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
{alert.alpha != null && (
|
|
||||||
<Text
|
{/* 右侧:Alpha + (选中时更多V2指标) */}
|
||||||
fontWeight="bold"
|
<HStack spacing={3}>
|
||||||
color={getMarketColor(alert.alpha)}
|
{alert.alpha != null && (
|
||||||
css={css`text-shadow: 0 0 10px ${getMarketColor(alert.alpha)}50;`}
|
<HStack spacing={1}>
|
||||||
>
|
<Text color={colors.text.tertiary}>α</Text>
|
||||||
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(1)}%
|
<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>
|
</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>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -199,10 +444,17 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
||||||
// 选中的异动类型过滤器(null 表示全部)
|
// 选中的异动类型过滤器(null 表示全部)
|
||||||
const [selectedAlertType, setSelectedAlertType] = useState(null);
|
const [selectedAlertType, setSelectedAlertType] = useState(null);
|
||||||
|
// 分时图点击的数据(用于筛选异动记录)
|
||||||
|
const [chartClickedData, setChartClickedData] = useState(null);
|
||||||
|
|
||||||
// 右边栏抽屉控制
|
// 右边栏抽屉控制(仅用于分时图点击)
|
||||||
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
|
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);
|
const { loading, refreshing, error, data } = useHotspotData(selectedDate);
|
||||||
|
|
||||||
@@ -220,25 +472,68 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
const sectionBg = glassEffect.light.bg;
|
const sectionBg = glassEffect.light.bg;
|
||||||
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
|
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
|
||||||
|
|
||||||
// 点击分时图上的异动标注 - 打开右边栏抽屉显示详情
|
// 点击分时图上的异动标注 - 筛选异动记录并滚动
|
||||||
const handleChartAlertClick = useCallback((alertGroupData) => {
|
const handleChartAlertClick = useCallback((alertGroupData) => {
|
||||||
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
|
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
|
||||||
setDrawerAlertData(alertGroupData);
|
console.log('Chart clicked:', alertGroupData);
|
||||||
onDrawerOpen();
|
setChartClickedData({
|
||||||
}, [onDrawerOpen]);
|
timeRange: alertGroupData.timeRange,
|
||||||
|
alerts: alertGroupData.alerts || [],
|
||||||
// 点击底部异动卡片 - 打开右边栏抽屉显示单个异动详情
|
alertCount: alertGroupData.alertCount,
|
||||||
const handleCardAlertClick = useCallback((alert) => {
|
|
||||||
setSelectedAlert(alert);
|
|
||||||
// 构造单个异动的数据格式
|
|
||||||
setDrawerAlertData({
|
|
||||||
alerts: [alert],
|
|
||||||
timeRange: alert.time,
|
|
||||||
alertCount: 1,
|
|
||||||
time: alert.time,
|
|
||||||
});
|
});
|
||||||
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) => {
|
const handleAlertTypeClick = useCallback((type) => {
|
||||||
@@ -390,8 +685,9 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Box p={6} position="relative">
|
<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}>
|
<HStack spacing={4}>
|
||||||
<Box
|
<Box
|
||||||
p={3}
|
p={3}
|
||||||
@@ -421,14 +717,42 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
css={css`text-shadow: 0 0 30px rgba(139, 92, 246, 0.3);`}
|
css={css`text-shadow: 0 0 30px rgba(139, 92, 246, 0.3);`}
|
||||||
>
|
>
|
||||||
热点概览
|
概念异动监控
|
||||||
</Heading>
|
</Heading>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
|
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
|
||||||
<Text fontSize="xs" color={subTextColor}>实时概念异动监控</Text>
|
<Text fontSize="xs" color={subTextColor}>大盘分时 · 实时追踪</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</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>
|
</Flex>
|
||||||
|
|
||||||
{/* 大尺寸分时图 - Glassmorphism */}
|
{/* 大尺寸分时图 - Glassmorphism */}
|
||||||
@@ -455,49 +779,6 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
filter="blur(40px)"
|
filter="blur(40px)"
|
||||||
pointerEvents="none"
|
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">
|
<Box position="relative">
|
||||||
<IndexMinuteChart
|
<IndexMinuteChart
|
||||||
indexData={index}
|
indexData={index}
|
||||||
@@ -525,7 +806,7 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
|
|
||||||
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<Box>
|
<Box id="alert-record-section">
|
||||||
<HStack spacing={3} mb={4}>
|
<HStack spacing={3} mb={4}>
|
||||||
<Box
|
<Box
|
||||||
p={2}
|
p={2}
|
||||||
@@ -541,11 +822,15 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
||||||
<Text fontSize="xs" color={colors.text.muted}>
|
{chartClickedData ? (
|
||||||
{selectedAlertType
|
<Text fontSize="xs" color={colors.accent.purple} fontWeight="bold">
|
||||||
? `(已筛选 ${ALERT_TYPE_CONFIG[selectedAlertType]?.label || selectedAlertType},共 ${filteredAlerts.length} 条)`
|
({chartClickedData.timeRange})
|
||||||
: '(点击卡片查看详情)'}
|
</Text>
|
||||||
</Text>
|
) : (
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>
|
||||||
|
(点击卡片查看详情)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* 横向滚动卡片 */}
|
{/* 横向滚动卡片 */}
|
||||||
@@ -563,7 +848,7 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HStack spacing={3} pb={1}>
|
<HStack spacing={3} pb={1}>
|
||||||
{[...filteredAlerts]
|
{(chartClickedData ? chartClickedData.alerts : [...filteredAlerts])
|
||||||
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
.sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
||||||
.map((alert, idx) => (
|
.map((alert, idx) => (
|
||||||
<CompactAlertCard
|
<CompactAlertCard
|
||||||
@@ -571,10 +856,23 @@ const HotspotOverview = ({ selectedDate, onDateChange, minDate, maxDate, onDataL
|
|||||||
alert={alert}
|
alert={alert}
|
||||||
onClick={handleCardAlertClick}
|
onClick={handleCardAlertClick}
|
||||||
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
||||||
|
stocks={conceptStocksMap[alert.concept_id]}
|
||||||
|
loadingStocks={loadingStocksMap[alert.concept_id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* 选中卡片后展开的股票列表 */}
|
||||||
|
<Collapse in={!!selectedAlert} animateOpacity>
|
||||||
|
{selectedAlert && (
|
||||||
|
<StockListPanel
|
||||||
|
stocks={conceptStocksMap[selectedAlert.concept_id]}
|
||||||
|
loading={loadingStocksMap[selectedAlert.concept_id]}
|
||||||
|
alert={selectedAlert}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Collapse>
|
||||||
</Box>
|
</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 { useNavigate } from 'react-router-dom';
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
import { getApiBase } from '@utils/apiConfig';
|
||||||
import {
|
import {
|
||||||
@@ -6,32 +6,25 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
Button,
|
Button,
|
||||||
SimpleGrid,
|
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
VStack,
|
VStack,
|
||||||
HStack,
|
HStack,
|
||||||
Badge,
|
|
||||||
Flex,
|
Flex,
|
||||||
Spacer,
|
Spacer,
|
||||||
Icon,
|
Icon,
|
||||||
useToast,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
Divider,
|
|
||||||
Tooltip,
|
|
||||||
Tag,
|
|
||||||
TagLabel,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
|
useToast,
|
||||||
} from '@chakra-ui/react';
|
} 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 ConceptStocksModal from '@components/ConceptStocksModal';
|
||||||
import TradeDatePicker from '@components/TradeDatePicker';
|
import TradeDatePicker from '@components/TradeDatePicker';
|
||||||
import HotspotOverview from './components/HotspotOverview';
|
import HotspotOverview from './components/HotspotOverview';
|
||||||
import FlexScreen from './components/FlexScreen';
|
import FlexScreen from './components/FlexScreen';
|
||||||
import { HeroSection } from '@components/HeroSection';
|
import StockOverviewHeader from './components/StockOverviewHeader';
|
||||||
import { echarts } from '@lib/echarts';
|
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getConceptHtmlUrl } from '../../utils/textUtils';
|
import { getConceptHtmlUrl } from '../../utils/textUtils';
|
||||||
import tradingDays from '../../data/tradingDays.json';
|
import tradingDays from '../../data/tradingDays.json';
|
||||||
@@ -47,8 +40,6 @@ const StockOverview = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const colorMode = 'dark'; // 固定为 dark 深色模式
|
const colorMode = 'dark'; // 固定为 dark 深色模式
|
||||||
const heatmapRef = useRef(null);
|
|
||||||
const heatmapChart = useRef(null);
|
|
||||||
|
|
||||||
// 🎯 事件追踪 Hook
|
// 🎯 事件追踪 Hook
|
||||||
const {
|
const {
|
||||||
@@ -180,15 +171,12 @@ const StockOverview = () => {
|
|||||||
: 0
|
: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
|
||||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||||
count: data.data?.length || 0,
|
count: data.data?.length || 0,
|
||||||
date: data.trade_date,
|
date: data.trade_date,
|
||||||
limitUpCount,
|
limitUpCount,
|
||||||
limitDownCount
|
limitDownCount
|
||||||
});
|
});
|
||||||
// 延迟渲染热力图,确保DOM已经准备好
|
|
||||||
setTimeout(() => renderHeatmap(data.data), 100);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('StockOverview', 'fetchHeatmapData', error, { date });
|
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页)
|
// 查看概念详情(模仿概念中心:打开对应HTML页)
|
||||||
const handleConceptClick = (concept, rank = 0) => {
|
const handleConceptClick = (concept, rank = 0) => {
|
||||||
// 🎯 追踪概念点击
|
// 🎯 追踪概念点击
|
||||||
@@ -477,35 +250,8 @@ const StockOverview = () => {
|
|||||||
fetchTopConcepts();
|
fetchTopConcepts();
|
||||||
fetchHeatmapData();
|
fetchHeatmapData();
|
||||||
fetchMarketStats();
|
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 = () => (
|
const ConceptSkeleton = () => (
|
||||||
<Card bg={bgColor} borderWidth="1px" borderColor={borderColor}>
|
<Card bg={bgColor} borderWidth="1px" borderColor={borderColor}>
|
||||||
@@ -542,114 +288,26 @@ const StockOverview = () => {
|
|||||||
zIndex={0}
|
zIndex={0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hero Section - 使用通用 HeroSection 组件 */}
|
{/* 新头部组件 - 左右分栏:左侧标题+指标,右侧热力图 */}
|
||||||
<HeroSection
|
<StockOverviewHeader
|
||||||
icon={TrendingUp}
|
hotspotData={hotspotData}
|
||||||
title="个股中心"
|
limitStats={limitStats}
|
||||||
subtitle="实时追踪市场动态,洞察投资机会"
|
marketStats={marketStats}
|
||||||
themePreset="purple"
|
heatmapData={heatmapData}
|
||||||
themeColors={{
|
loadingHeatmap={loadingHeatmap}
|
||||||
titleGradient: `linear(to-r, ${goldColor}, white)`,
|
onStockClick={(code, name) => {
|
||||||
iconColor: goldColor,
|
trackHeatmapStockClicked({ code, name }, '');
|
||||||
statLabelColor: goldColor,
|
navigate(`/company?scode=${code}`);
|
||||||
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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 主内容区 - 负 margin 使卡片向上浮动,与 Hero 产生重叠纵深感 */}
|
{/* 主内容区 - 负 margin 使卡片向上浮动,与 Hero 产生重叠纵深感 */}
|
||||||
<Box pt={6} pb={10} px={6} mt={-6} position="relative" zIndex={2}>
|
<Box pt={6} pb={10} px={6} mt={-6} position="relative" zIndex={2}>
|
||||||
|
{/* 灵活屏 - 实时行情监控 */}
|
||||||
|
<Box mb={10}>
|
||||||
|
<FlexScreen />
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 热点概览 - 大盘走势 + 概念异动 */}
|
{/* 热点概览 - 大盘走势 + 概念异动 */}
|
||||||
{/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
|
{/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
|
||||||
<Box mb={10}>
|
<Box mb={10}>
|
||||||
@@ -690,288 +348,8 @@ const StockOverview = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 灵活屏 - 实时行情监控 */}
|
|
||||||
<Box mb={10}>
|
|
||||||
<FlexScreen />
|
|
||||||
</Box>
|
</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
|
<ConceptStocksModal
|
||||||
|
|||||||
Reference in New Issue
Block a user