update pay ui
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* 异动详情右边栏抽屉组件
|
||||
* 点击分时图上的异动标记后显示,展示该时间段的所有异动详情
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Collapse,
|
||||
Spinner,
|
||||
Divider,
|
||||
Tooltip,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { keyframes, css } from '@emotion/react';
|
||||
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 { colors, glassEffect } 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}`);
|
||||
}
|
||||
};
|
||||
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
{/* 顶部渐变条 */}
|
||||
<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}
|
||||
>
|
||||
{/* 第一行:展开箭头 + 概念名称 + 评分 */}
|
||||
<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;`}
|
||||
>
|
||||
{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)}%
|
||||
</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>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 股票列表 */}
|
||||
<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 || '-';
|
||||
|
||||
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}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="#60a5fa"
|
||||
fontWeight="medium"
|
||||
_hover={{ color: '#93c5fd' }}
|
||||
>
|
||||
{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">
|
||||
共 {stocks.length} 只相关股票,显示前 15 只
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
|
||||
暂无相关股票数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 异动详情抽屉主组件
|
||||
*/
|
||||
const AlertDetailDrawer = ({ isOpen, onClose, alertData }) => {
|
||||
const [expandedAlertId, setExpandedAlertId] = useState(null);
|
||||
const [conceptStocks, setConceptStocks] = useState({});
|
||||
const [loadingConcepts, setLoadingConcepts] = useState({});
|
||||
|
||||
const { alerts = [], timeRange, alertCount } = alertData || {};
|
||||
|
||||
// 重置状态当抽屉关闭或数据变化
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setExpandedAlertId(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 获取概念相关股票
|
||||
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||
if (loadingConcepts[conceptId] || conceptStocks[conceptId]) return;
|
||||
|
||||
setLoadingConcepts(prev => ({ ...prev, [conceptId]: true }));
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/concept/${encodeURIComponent(conceptId)}/stocks`);
|
||||
if (response.data?.success && response.data?.data?.stocks) {
|
||||
setConceptStocks(prev => ({
|
||||
...prev,
|
||||
[conceptId]: response.data.data.stocks
|
||||
}));
|
||||
} else {
|
||||
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取概念股票失败:', error);
|
||||
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
|
||||
} finally {
|
||||
setLoadingConcepts(prev => ({ ...prev, [conceptId]: false }));
|
||||
}
|
||||
}, [loadingConcepts, conceptStocks]);
|
||||
|
||||
// 处理展开/收起
|
||||
const handleToggle = useCallback((alert) => {
|
||||
const alertId = `${alert.concept_id}-${alert.time}`;
|
||||
if (expandedAlertId === alertId) {
|
||||
setExpandedAlertId(null);
|
||||
} else {
|
||||
setExpandedAlertId(alertId);
|
||||
if (alert.concept_id) {
|
||||
fetchConceptStocks(alert.concept_id);
|
||||
}
|
||||
}
|
||||
}, [expandedAlertId, fetchConceptStocks]);
|
||||
|
||||
// 按分数排序
|
||||
const sortedAlerts = [...alerts].sort((a, b) =>
|
||||
(b.final_score || 0) - (a.final_score || 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
>
|
||||
<DrawerOverlay bg="rgba(0, 0, 0, 0.6)" backdropFilter="blur(4px)" />
|
||||
<DrawerContent
|
||||
bg="rgba(10, 10, 15, 0.95)"
|
||||
backdropFilter="blur(20px)"
|
||||
borderLeft="1px solid rgba(255, 255, 255, 0.1)"
|
||||
>
|
||||
<DrawerCloseButton
|
||||
color={colors.text.secondary}
|
||||
_hover={{ color: colors.text.primary, bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||
/>
|
||||
|
||||
{/* 头部 */}
|
||||
<DrawerHeader
|
||||
borderBottomWidth="1px"
|
||||
borderColor="rgba(255, 255, 255, 0.08)"
|
||||
pb={4}
|
||||
>
|
||||
<VStack align="flex-start" spacing={2}>
|
||||
<HStack spacing={3}>
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="12px"
|
||||
bgGradient="linear(to-br, #8b5cf6, #ec4899)"
|
||||
boxShadow="0 4px 15px rgba(139, 92, 246, 0.4)"
|
||||
>
|
||||
<Icon as={Zap} boxSize={5} color="white" />
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color={colors.text.primary}
|
||||
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.4);`}
|
||||
>
|
||||
异动详情
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 时间段和数量信息 */}
|
||||
<HStack spacing={4}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={Clock} boxSize={4} color={colors.accent.purple} />
|
||||
<Text fontSize="sm" color={colors.text.secondary}>
|
||||
{timeRange || '未知时段'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
bg="rgba(139, 92, 246, 0.15)"
|
||||
border="1px solid rgba(139, 92, 246, 0.3)"
|
||||
>
|
||||
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
|
||||
<Text fontSize="sm" fontWeight="bold" color={colors.accent.purple}>
|
||||
{alertCount || alerts.length} 个异动
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</DrawerHeader>
|
||||
|
||||
{/* 内容区 */}
|
||||
<DrawerBody py={4}>
|
||||
{alerts.length === 0 ? (
|
||||
<Box
|
||||
p={8}
|
||||
textAlign="center"
|
||||
bg="rgba(255, 255, 255, 0.02)"
|
||||
borderRadius="16px"
|
||||
border="1px solid rgba(255, 255, 255, 0.05)"
|
||||
>
|
||||
<Icon as={Zap} boxSize={10} color={colors.text.muted} mb={3} opacity={0.5} />
|
||||
<Text color={colors.text.muted}>暂无异动数据</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{sortedAlerts.map((alert, idx) => {
|
||||
const alertId = `${alert.concept_id}-${alert.time}`;
|
||||
return (
|
||||
<AlertDetailCard
|
||||
key={alertId || idx}
|
||||
alert={alert}
|
||||
isExpanded={expandedAlertId === alertId}
|
||||
onToggle={() => handleToggle(alert)}
|
||||
stocks={conceptStocks[alert.concept_id]}
|
||||
loadingStocks={loadingConcepts[alert.concept_id]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertDetailDrawer;
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Badge,
|
||||
Icon,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Flex,
|
||||
Collapse,
|
||||
Spinner,
|
||||
@@ -84,10 +83,10 @@ const getAlertIcon = (alertType) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 指标提示组件 - 带详细说明
|
||||
* 指标提示组件 - 带详细说明(深色主题)
|
||||
*/
|
||||
const MetricTooltip = ({ metricKey, children }) => {
|
||||
const tooltipBg = useColorModeValue('gray.800', 'gray.700');
|
||||
const tooltipBg = 'rgba(15, 15, 25, 0.95)';
|
||||
const config = METRIC_CONFIG[metricKey];
|
||||
if (!config) return children;
|
||||
|
||||
@@ -117,10 +116,10 @@ const MetricTooltip = ({ metricKey, children }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 迷你进度条组件
|
||||
* 迷你进度条组件(深色主题)
|
||||
*/
|
||||
const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => {
|
||||
const bgColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const bgColor = 'rgba(255, 255, 255, 0.1)';
|
||||
const percent = Math.min((value / maxValue) * 100, 100);
|
||||
|
||||
return (
|
||||
@@ -147,10 +146,10 @@ const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlo
|
||||
};
|
||||
|
||||
/**
|
||||
* Z-Score 双向进度条组件
|
||||
* Z-Score 双向进度条组件(深色主题)
|
||||
*/
|
||||
const ZScoreBar = ({ value, color }) => {
|
||||
const bgColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const bgColor = 'rgba(255, 255, 255, 0.1)';
|
||||
const absValue = Math.abs(value || 0);
|
||||
const percent = Math.min(absValue / 4 * 50, 50);
|
||||
const isPositive = (value || 0) >= 0;
|
||||
@@ -176,7 +175,7 @@ const ZScoreBar = ({ value, color }) => {
|
||||
transform="translateX(-50%)"
|
||||
w="2px"
|
||||
h="6px"
|
||||
bg={useColorModeValue('gray.400', 'gray.500')}
|
||||
bg="rgba(255, 255, 255, 0.3)"
|
||||
borderRadius="full"
|
||||
/>
|
||||
</Box>
|
||||
@@ -209,20 +208,20 @@ const TriggeredRuleBadge = ({ rule }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 科技感异动卡片
|
||||
* 科技感异动卡片 - 统一使用深色主题
|
||||
*/
|
||||
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#0d0d0d');
|
||||
const hoverBg = useColorModeValue('gray.50', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#2d2d2d');
|
||||
const expandedBg = useColorModeValue('gray.50', '#111111');
|
||||
const tableBg = useColorModeValue('gray.50', '#0a0a0a');
|
||||
const popoverBg = useColorModeValue('white', '#1a1a1a');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
// 统一深色主题配色(与 glassTheme 保持一致)
|
||||
const cardBg = 'rgba(255, 255, 255, 0.03)';
|
||||
const hoverBg = 'rgba(255, 255, 255, 0.06)';
|
||||
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
||||
const expandedBg = 'rgba(0, 0, 0, 0.2)';
|
||||
const tableBg = 'rgba(255, 255, 255, 0.02)';
|
||||
const popoverBg = 'rgba(15, 15, 25, 0.95)';
|
||||
const textColor = 'rgba(255, 255, 255, 0.95)';
|
||||
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
||||
|
||||
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||
const isUp = alert.alert_type !== 'surge_down';
|
||||
@@ -681,8 +680,9 @@ const ConceptAlertList = ({
|
||||
const [conceptStocks, setConceptStocks] = useState({});
|
||||
const [loadingConcepts, setLoadingConcepts] = useState({});
|
||||
|
||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const emptyBg = useColorModeValue('gray.50', '#111111');
|
||||
// 统一深色主题配色
|
||||
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
||||
const emptyBg = 'rgba(255, 255, 255, 0.02)';
|
||||
|
||||
// 获取概念相关股票 - 使用 ref 避免依赖循环
|
||||
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
/**
|
||||
* 指数分时图组件
|
||||
* 展示大盘分时走势,支持概念异动标注
|
||||
* 展示大盘分时走势,支持概念异动标注(按10分钟分组)
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { getAlertMarkPoints } from '../utils/chartHelpers';
|
||||
import { getAlertMarkPointsGrouped } from '../utils/chartHelpers';
|
||||
import { colors, glassEffect } from '../../../theme/glassTheme';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... }
|
||||
* @param {Array} props.alerts - 异动数据数组
|
||||
* @param {Function} props.onAlertClick - 点击异动标注的回调
|
||||
* @param {Function} props.onAlertClick - 点击异动标注的回调(传递该时间段所有异动)
|
||||
* @param {string} props.height - 图表高度
|
||||
*/
|
||||
const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const gridLineColor = useColorModeValue('#eee', '#333');
|
||||
// 使用 glassTheme 的深色主题颜色
|
||||
const textColor = colors.text.primary;
|
||||
const subTextColor = colors.text.secondary;
|
||||
const gridLineColor = 'rgba(255, 255, 255, 0.08)';
|
||||
|
||||
// 计算图表配置
|
||||
const chartOption = useMemo(() => {
|
||||
@@ -44,8 +46,8 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
||||
const yAxisMin = priceMin - priceRange * 0.1;
|
||||
const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注
|
||||
|
||||
// 准备异动标注
|
||||
const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax);
|
||||
// 准备异动标注 - 按10分钟分组
|
||||
const markPoints = getAlertMarkPointsGrouped(alerts, times, prices, priceMax, 10);
|
||||
|
||||
// 渐变色 - 根据涨跌
|
||||
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
||||
@@ -67,8 +69,17 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: { color: '#999' },
|
||||
crossStyle: { color: 'rgba(255, 255, 255, 0.3)' },
|
||||
lineStyle: { color: 'rgba(139, 92, 246, 0.5)' },
|
||||
},
|
||||
backgroundColor: 'rgba(15, 15, 25, 0.95)',
|
||||
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||
borderWidth: 1,
|
||||
padding: 0,
|
||||
textStyle: {
|
||||
color: colors.text.primary,
|
||||
},
|
||||
extraCssText: 'backdrop-filter: blur(12px); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);',
|
||||
formatter: (params) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
|
||||
@@ -79,19 +90,19 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
||||
const volume = volumes[dataIndex];
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>
|
||||
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||
<div style="padding: 12px; color: rgba(255,255,255,0.95);">
|
||||
<div style="font-weight: bold; margin-bottom: 6px; color: rgba(255,255,255,0.7); font-size: 12px;">${time}</div>
|
||||
<div style="margin-bottom: 4px;">指数: <span style="color: ${changePct >= 0 ? '#ef4444' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||
<div style="margin-bottom: 4px;">涨跌: <span style="color: ${changePct >= 0 ? '#ef4444' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||
<div style="color: rgba(255,255,255,0.7);">成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 检查是否有异动
|
||||
const alertsAtTime = alerts.filter((a) => a.time === time);
|
||||
if (alertsAtTime.length > 0) {
|
||||
html += '<div style="border-top: 1px solid rgba(139,92,246,0.3); margin-top: 6px; padding-top: 6px;">';
|
||||
html += `<div style="font-weight: bold; color: #8b5cf6; margin-bottom: 4px;">📍 概念异动 (${alertsAtTime.length})</div>`;
|
||||
html += '<div style="border-top: 1px solid rgba(139,92,246,0.3); margin: 0 12px; padding: 10px 0;">';
|
||||
html += `<div style="font-weight: bold; color: #a78bfa; margin-bottom: 6px; font-size: 12px;">📍 概念异动 (${alertsAtTime.length})</div>`;
|
||||
alertsAtTime.slice(0, 5).forEach((alert) => {
|
||||
const typeLabel = {
|
||||
surge: '异动',
|
||||
@@ -104,13 +115,13 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
||||
rank_jump: '排名跃升',
|
||||
volume_spike: '放量',
|
||||
}[alert.alert_type] || alert.alert_type;
|
||||
const typeColor = alert.alert_type === 'surge_down' ? '#52c41a' : '#ff4d4f';
|
||||
const typeColor = alert.alert_type === 'surge_down' ? '#4ade80' : '#f87171';
|
||||
const alpha = alert.alpha ? ` α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(1)}%` : '';
|
||||
const score = alert.final_score ? ` [${Math.round(alert.final_score)}分]` : '';
|
||||
html += `<div style="color: ${typeColor}; font-size: 11px; margin: 2px 0;">• ${alert.concept_name} <span style="opacity:0.8">(${typeLabel}${alpha}${score})</span></div>`;
|
||||
html += `<div style="color: ${typeColor}; font-size: 11px; margin: 3px 0;">• ${alert.concept_name} <span style="opacity:0.7">(${typeLabel}${alpha}${score})</span></div>`;
|
||||
});
|
||||
if (alertsAtTime.length > 5) {
|
||||
html += `<div style="color: #8c8c8c; font-size: 10px; margin-top: 4px;">还有 ${alertsAtTime.length - 5} 个异动...</div>`;
|
||||
html += `<div style="color: rgba(255,255,255,0.4); font-size: 10px; margin-top: 6px;">还有 ${alertsAtTime.length - 5} 个异动...</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
@@ -223,19 +234,18 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
||||
|
||||
chartInstance.current.setOption(chartOption, true);
|
||||
|
||||
// 点击事件 - 支持多个异动
|
||||
// 点击事件 - 传递该时间段所有异动数据
|
||||
if (onAlertClick) {
|
||||
chartInstance.current.off('click');
|
||||
chartInstance.current.on('click', 'series.line.markPoint', (params) => {
|
||||
if (params.data && params.data.alertData) {
|
||||
const alertData = params.data.alertData;
|
||||
// 如果是数组(多个异动),传递第一个(最高分)
|
||||
// 调用方可以从 alertData 中获取所有异动
|
||||
if (Array.isArray(alertData)) {
|
||||
onAlertClick(alertData[0]);
|
||||
} else {
|
||||
onAlertClick(alertData);
|
||||
}
|
||||
if (params.data) {
|
||||
// 传递完整的标记点数据,包含 alertData(所有异动)、timeRange、alertCount 等
|
||||
onAlertClick({
|
||||
alerts: params.data.alertData || [],
|
||||
timeRange: params.data.timeRange,
|
||||
alertCount: params.data.alertCount || 1,
|
||||
time: params.data.time,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||
export { default as AlertSummary } from './AlertSummary';
|
||||
export { default as AlertDetailDrawer } from './AlertDetailDrawer';
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
IconButton,
|
||||
Collapse,
|
||||
SimpleGrid,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { keyframes, css } from '@emotion/react';
|
||||
import {
|
||||
@@ -40,7 +41,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useHotspotData } from './hooks';
|
||||
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components';
|
||||
import { IndexMinuteChart, ConceptAlertList, AlertSummary, AlertDetailDrawer } from './components';
|
||||
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
||||
import {
|
||||
glassEffect,
|
||||
@@ -200,6 +201,10 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
const [selectedAlert, setSelectedAlert] = useState(null);
|
||||
const [showDetailList, setShowDetailList] = useState(false);
|
||||
const [autoExpandAlertKey, setAutoExpandAlertKey] = useState(null);
|
||||
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
||||
|
||||
// 右边栏抽屉控制
|
||||
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
|
||||
|
||||
// 获取数据
|
||||
const { loading, error, data } = useHotspotData(selectedDate);
|
||||
@@ -212,7 +217,14 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
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 handleAlertClick = useCallback((alert) => {
|
||||
setSelectedAlert(alert);
|
||||
// 自动展开详细列表并设置需要展开的项
|
||||
@@ -637,7 +649,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
onAlertClick={handleChartAlertClick}
|
||||
height="420px"
|
||||
/>
|
||||
</Box>
|
||||
@@ -790,6 +802,13 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 异动详情右边栏抽屉 */}
|
||||
<AlertDetailDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={onDrawerClose}
|
||||
alertData={drawerAlertData}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -281,7 +281,136 @@ export const getAlertTypeColor = (alertType) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图表标注点数据 - 支持同一时间多个异动折叠显示
|
||||
* 将时间字符串转换为分钟数
|
||||
* @param {string} timeStr - 时间字符串,如 "09:30"
|
||||
* @returns {number} 分钟数
|
||||
*/
|
||||
const timeToMinutes = (timeStr) => {
|
||||
if (!timeStr) return 0;
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取时间所属的分组区间
|
||||
* @param {string} timeStr - 时间字符串
|
||||
* @param {number} intervalMinutes - 分组间隔(分钟)
|
||||
* @returns {string} 时间区间,如 "09:30-09:40"
|
||||
*/
|
||||
const getTimeGroup = (timeStr, intervalMinutes = 10) => {
|
||||
const minutes = timeToMinutes(timeStr);
|
||||
const groupStart = Math.floor(minutes / intervalMinutes) * intervalMinutes;
|
||||
const groupEnd = groupStart + intervalMinutes;
|
||||
|
||||
const startHour = Math.floor(groupStart / 60);
|
||||
const startMin = groupStart % 60;
|
||||
const endHour = Math.floor(groupEnd / 60);
|
||||
const endMin = groupEnd % 60;
|
||||
|
||||
const formatTime = (h, m) => `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||
return `${formatTime(startHour, startMin)}-${formatTime(endHour, endMin)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图表标注点数据 - 按时间间隔分组
|
||||
* @param {Array} alerts - 异动数据数组
|
||||
* @param {Array} times - 时间数组
|
||||
* @param {Array} prices - 价格数组
|
||||
* @param {number} priceMax - 最高价格
|
||||
* @param {number} intervalMinutes - 分组间隔(分钟),默认10分钟
|
||||
* @returns {Array} ECharts markPoint data
|
||||
*/
|
||||
export const getAlertMarkPointsGrouped = (alerts, times, prices, priceMax, intervalMinutes = 10) => {
|
||||
if (!alerts || alerts.length === 0) return [];
|
||||
|
||||
// 1. 按时间间隔分组
|
||||
const alertsByGroup = {};
|
||||
alerts.forEach(alert => {
|
||||
const group = getTimeGroup(alert.time, intervalMinutes);
|
||||
if (!alertsByGroup[group]) {
|
||||
alertsByGroup[group] = [];
|
||||
}
|
||||
alertsByGroup[group].push(alert);
|
||||
});
|
||||
|
||||
// 2. 对每个分组内的异动按分数排序
|
||||
Object.keys(alertsByGroup).forEach(group => {
|
||||
alertsByGroup[group].sort((a, b) =>
|
||||
(b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0)
|
||||
);
|
||||
});
|
||||
|
||||
// 3. 生成标记点
|
||||
return Object.entries(alertsByGroup).map(([timeRange, groupAlerts]) => {
|
||||
// 找到该分组中间时间点对应的坐标
|
||||
const midTime = groupAlerts[Math.floor(groupAlerts.length / 2)]?.time;
|
||||
const timeIndex = times.indexOf(midTime);
|
||||
const price = timeIndex >= 0 ? prices[timeIndex] : priceMax;
|
||||
|
||||
const alertCount = groupAlerts.length;
|
||||
const topAlert = groupAlerts[0];
|
||||
const hasMultiple = alertCount > 1;
|
||||
|
||||
// 使用最高分异动的样式
|
||||
const { color, gradient } = getAlertStyle(
|
||||
topAlert.alert_type,
|
||||
topAlert.final_score / 100 || topAlert.importance_score || 0.5
|
||||
);
|
||||
|
||||
// 生成显示标签
|
||||
const [startTime] = timeRange.split('-');
|
||||
const label = hasMultiple ? `${startTime} (${alertCount})` : topAlert.concept_name?.substring(0, 4) || startTime;
|
||||
|
||||
const isDown = topAlert.alert_type === 'surge_down';
|
||||
const symbolSize = hasMultiple ? 45 + Math.min(alertCount * 2, 15) : 35;
|
||||
|
||||
return {
|
||||
name: timeRange,
|
||||
coord: [midTime || times[0], price],
|
||||
value: label,
|
||||
symbol: 'pin',
|
||||
symbolSize,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'radial',
|
||||
x: 0.5, y: 0.5, r: 0.8,
|
||||
colorStops: [
|
||||
{ offset: 0, color: gradient[0] },
|
||||
{ offset: 0.7, color: gradient[1] },
|
||||
{ offset: 1, color: `${color}88` },
|
||||
],
|
||||
},
|
||||
borderColor: hasMultiple ? '#ffffff' : 'rgba(255,255,255,0.8)',
|
||||
borderWidth: hasMultiple ? 3 : 2,
|
||||
shadowBlur: hasMultiple ? 20 : 10,
|
||||
shadowColor: `${color}${hasMultiple ? 'aa' : '66'}`,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: isDown ? 'bottom' : 'top',
|
||||
formatter: label,
|
||||
fontSize: hasMultiple ? 11 : 10,
|
||||
fontWeight: hasMultiple ? 700 : 500,
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
backgroundColor: 'rgba(15, 15, 25, 0.9)',
|
||||
padding: hasMultiple ? [5, 10] : [3, 6],
|
||||
borderRadius: 6,
|
||||
borderColor: `${color}80`,
|
||||
borderWidth: 1,
|
||||
shadowBlur: 8,
|
||||
shadowColor: `${color}40`,
|
||||
},
|
||||
// 存储该时间段所有异动数据
|
||||
alertData: groupAlerts,
|
||||
alertCount,
|
||||
timeRange,
|
||||
time: midTime,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图表标注点数据 - 支持同一时间多个异动折叠显示(原有函数保留)
|
||||
* @param {Array} alerts - 异动数据数组
|
||||
* @param {Array} times - 时间数组
|
||||
* @param {Array} prices - 价格数组
|
||||
|
||||
Reference in New Issue
Block a user