diff --git a/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js b/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js
new file mode 100644
index 00000000..7244b4c5
--- /dev/null
+++ b/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js
@@ -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 (
+
+ {/* 顶部渐变条 */}
+
+
+ {/* 主内容区 - 可点击展开 */}
+
+ {/* 第一行:展开箭头 + 概念名称 + 评分 */}
+
+
+
+
+
+
+
+
+
+ {alert.concept_name}
+
+
+
+
+
+
+
+
+ {alert.time}
+
+
+
+
+ {/* 评分 */}
+
+
+
+ {formatScore(alert.final_score)}
+
+ 分
+
+
+
+ {/* 第二行:类型标签 + Alpha + 其他指标 */}
+
+
+
+ {getAlertTypeLabel(alert.alert_type)}
+
+
+
+ {alert.alpha != null && (
+
+ Alpha
+ = 0 ? colors.market.up : colors.market.down}
+ >
+ {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
+
+
+ )}
+
+ {(alert.limit_up_ratio || 0) > 0.03 && (
+
+
+
+ {Math.round(alert.limit_up_ratio * 100)}%
+
+
+ )}
+
+ {alert.is_v2 && alert.confirm_ratio != null && (
+
+ 确认
+ = 0.8 ? '#52c41a' : alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'}
+ >
+ {Math.round(alert.confirm_ratio * 100)}%
+
+
+ )}
+
+
+
+ {/* 展开内容 - 相关股票 */}
+
+
+ {loadingStocks ? (
+
+
+ 加载相关股票...
+
+ ) : stocks && stocks.length > 0 ? (
+
+ {/* 统计信息 */}
+ {(() => {
+ 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 (
+
+
+ 均涨:
+ = 0 ? colors.market.up : colors.market.down}>
+ {avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
+
+
+
+ {upCount}涨
+ /
+ {downCount}跌
+
+
+ );
+ })()}
+
+ {/* 股票列表 */}
+
+
+ {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 (
+ handleStockClick(e, stockCode)}
+ _hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
+ transition="background 0.15s"
+ justify="space-between"
+ >
+
+
+ {stockName}
+
+
+ {stockCode}
+
+
+ 0 ? colors.market.up :
+ hasChange && changePct < 0 ? colors.market.down :
+ colors.text.muted
+ }
+ >
+ {hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
+
+
+ );
+ })}
+
+
+
+ {stocks.length > 15 && (
+
+ 共 {stocks.length} 只相关股票,显示前 15 只
+
+ )}
+
+ ) : (
+
+ 暂无相关股票数据
+
+ )}
+
+
+
+ );
+};
+
+/**
+ * 异动详情抽屉主组件
+ */
+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 (
+
+
+
+
+
+ {/* 头部 */}
+
+
+
+
+
+
+
+ 异动详情
+
+
+
+ {/* 时间段和数量信息 */}
+
+
+
+
+ {timeRange || '未知时段'}
+
+
+
+
+
+ {alertCount || alerts.length} 个异动
+
+
+
+
+
+
+ {/* 内容区 */}
+
+ {alerts.length === 0 ? (
+
+
+ 暂无异动数据
+
+ ) : (
+
+ {sortedAlerts.map((alert, idx) => {
+ const alertId = `${alert.concept_id}-${alert.time}`;
+ return (
+ handleToggle(alert)}
+ stocks={conceptStocks[alert.concept_id]}
+ loadingStocks={loadingConcepts[alert.concept_id]}
+ />
+ );
+ })}
+
+ )}
+
+
+
+ );
+};
+
+export default AlertDetailDrawer;
diff --git a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js
index 930027e0..9842b75d 100644
--- a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js
+++ b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js
@@ -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"
/>
@@ -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) => {
diff --git a/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js b/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js
index c78e0e86..3e8e410a 100644
--- a/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js
+++ b/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js
@@ -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 = `
-
-
${time}
-
指数: ${price?.toFixed(2)}
-
涨跌: ${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%
-
成交量: ${(volume / 10000).toFixed(0)}万手
+
+
${time}
+
指数: ${price?.toFixed(2)}
+
涨跌: ${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%
+
成交量: ${(volume / 10000).toFixed(0)}万手
`;
// 检查是否有异动
const alertsAtTime = alerts.filter((a) => a.time === time);
if (alertsAtTime.length > 0) {
- html += '
';
- html += `
📍 概念异动 (${alertsAtTime.length})
`;
+ html += '
';
+ html += `
📍 概念异动 (${alertsAtTime.length})
`;
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 += `
• ${alert.concept_name} (${typeLabel}${alpha}${score})
`;
+ html += `
• ${alert.concept_name} (${typeLabel}${alpha}${score})
`;
});
if (alertsAtTime.length > 5) {
- html += `
还有 ${alertsAtTime.length - 5} 个异动...
`;
+ html += `
还有 ${alertsAtTime.length - 5} 个异动...
`;
}
html += '
';
}
@@ -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,
+ });
}
});
}
diff --git a/src/views/StockOverview/components/HotspotOverview/components/index.js b/src/views/StockOverview/components/HotspotOverview/components/index.js
index 2401b9bd..91c8c1a5 100644
--- a/src/views/StockOverview/components/HotspotOverview/components/index.js
+++ b/src/views/StockOverview/components/HotspotOverview/components/index.js
@@ -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';
diff --git a/src/views/StockOverview/components/HotspotOverview/index.js b/src/views/StockOverview/components/HotspotOverview/index.js
index cf20bc6f..fb641b01 100644
--- a/src/views/StockOverview/components/HotspotOverview/index.js
+++ b/src/views/StockOverview/components/HotspotOverview/index.js
@@ -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 }) => {
@@ -790,6 +802,13 @@ const HotspotOverview = ({ selectedDate }) => {
)}
+
+ {/* 异动详情右边栏抽屉 */}
+
);
};
diff --git a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js
index b9539054..ff61dd1b 100644
--- a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js
+++ b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js
@@ -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 - 价格数组