From 3cc7f2ca6e576aa9be999a3a4f1d496080c75a27 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 13:53:23 +0800 Subject: [PATCH] update pay ui --- .../components/AlertDetailDrawer.js | 523 ++++++++++++++++++ .../components/ConceptAlertList.js | 40 +- .../components/IndexMinuteChart.js | 70 ++- .../HotspotOverview/components/index.js | 1 + .../components/HotspotOverview/index.js | 25 +- .../HotspotOverview/utils/chartHelpers.js | 131 ++++- 6 files changed, 736 insertions(+), 54 deletions(-) create mode 100644 src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js 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 - 价格数组