diff --git a/app.py b/app.py index 549ffe60..5c8aba8c 100755 --- a/app.py +++ b/app.py @@ -12824,6 +12824,9 @@ def get_hotspot_overview(): 'surge': len([a for a in alerts if a['alert_type'] == 'surge']), 'surge_up': len([a for a in alerts if a['alert_type'] == 'surge_up']), 'surge_down': len([a for a in alerts if a['alert_type'] == 'surge_down']), + 'volume_surge_up': len([a for a in alerts if a['alert_type'] == 'volume_surge_up']), + 'shrink_surge_up': len([a for a in alerts if a['alert_type'] == 'shrink_surge_up']), + 'volume_oscillation': len([a for a in alerts if a['alert_type'] == 'volume_oscillation']), 'limit_up': len([a for a in alerts if a['alert_type'] == 'limit_up']), 'volume_spike': len([a for a in alerts if a['alert_type'] == 'volume_spike']), 'rank_jump': len([a for a in alerts if a['alert_type'] == 'rank_jump']) @@ -12848,7 +12851,7 @@ def get_concept_stocks(concept_id): 获取概念的相关股票列表(带实时涨跌幅) Args: - concept_id: 概念 ID(来自 ES concept_library_v3) + concept_id: 概念 ID 或概念名称(支持两种方式查询) Returns: - stocks: 股票列表 [{code, name, reason, change_pct}, ...] @@ -12857,18 +12860,48 @@ def get_concept_stocks(concept_id): from elasticsearch import Elasticsearch from clickhouse_driver import Client - # 1. 从 ES 获取概念的股票列表 es_client = Elasticsearch(["http://222.128.1.157:19200"]) - es_result = es_client.get(index='concept_library_v3', id=concept_id) - if not es_result.get('found'): + # 1. 尝试多种方式获取概念数据 + source = None + concept_name = concept_id + + # 方式1: 先尝试按 ID 查询 + try: + es_result = es_client.get(index='concept_library_v3', id=concept_id) + if es_result.get('found'): + source = es_result.get('_source', {}) + concept_name = source.get('concept', concept_id) + except: + pass + + # 方式2: 如果按 ID 没找到,尝试按概念名称搜索 + if not source: + try: + search_result = es_client.search( + index='concept_library_v3', + body={ + 'query': { + 'term': { + 'concept.keyword': concept_id + } + }, + 'size': 1 + } + ) + hits = search_result.get('hits', {}).get('hits', []) + if hits: + source = hits[0].get('_source', {}) + concept_name = source.get('concept', concept_id) + except Exception as search_err: + app.logger.debug(f"ES 搜索概念失败: {search_err}") + + if not source: return jsonify({ 'success': False, 'error': f'概念 {concept_id} 不存在' }), 404 - source = es_result.get('_source', {}) - concept_name = source.get('concept', concept_id) raw_stocks = source.get('stocks', []) if not raw_stocks: diff --git a/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js b/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js index 37a871c6..4837c6ed 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js +++ b/src/views/StockOverview/components/HotspotOverview/components/AlertSummary.js @@ -1,5 +1,5 @@ /** - * 异动统计摘要组件 + * 异动统计摘要组件 - 科技感设计 * 展示指数统计和异动类型统计 */ import React from 'react'; @@ -17,84 +17,250 @@ import { StatArrow, SimpleGrid, useColorModeValue, + Tooltip, + Flex, + keyframes, } from '@chakra-ui/react'; -import { FaBolt, FaArrowDown, FaRocket, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa'; +import { + TrendingUp, + TrendingDown, + Zap, + Activity, + Flame, + BarChart3, + Target, + Waves, + Rocket, + ArrowUp, + ArrowDown, + Minus, +} from 'lucide-react'; +import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from '../utils/chartHelpers'; + +// 动画效果 +const pulseAnimation = keyframes` + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +`; + +const glowAnimation = keyframes` + 0%, 100% { box-shadow: 0 0 5px currentColor; } + 50% { box-shadow: 0 0 15px currentColor; } +`; /** - * 异动类型徽章 + * 获取异动类型对应的图标 + */ +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 AlertTypeBadge = ({ type, count }) => { - const config = { - surge: { label: '急涨', color: 'red', icon: FaBolt }, - surge_up: { label: '暴涨', color: 'red', icon: FaBolt }, - surge_down: { label: '暴跌', color: 'green', icon: FaArrowDown }, - limit_up: { label: '涨停', color: 'orange', icon: FaRocket }, - rank_jump: { label: '排名跃升', color: 'blue', icon: FaChartLine }, - volume_spike: { label: '放量', color: 'purple', icon: FaVolumeUp }, - }; + const config = ALERT_TYPE_CONFIG[type] || { label: type, color: '#8c8c8c', gradient: ['#8c8c8c', '#a6a6a6'] }; + const AlertIcon = getAlertIcon(type); - const cfg = config[type] || { label: type, color: 'gray', icon: FaFire }; + const bgOpacity = useColorModeValue('15', '20'); + const hoverBg = useColorModeValue(`${config.color}25`, `${config.color}30`); return ( - - - - {cfg.label} - {count} - - + + + + {getAlertTypeLabel(type)} + + {count} + + + ); }; /** - * 指数统计卡片 + * 迷你统计卡片 + */ +const MiniStatCard = ({ label, value, change, isUp, color, tooltip }) => { + const cardBg = useColorModeValue('white', '#0d0d0d'); + const borderColor = useColorModeValue('gray.200', '#2d2d2d'); + const labelColor = useColorModeValue('gray.500', 'gray.400'); + + const content = ( + + + {label} + + + + {value} + + {change !== undefined && ( + + + {Math.abs(change).toFixed(2)}% + + )} + + + ); + + if (tooltip) { + return {content}; + } + return content; +}; + +/** + * 指数统计卡片 - 科技感设计 */ const IndexStatCard = ({ indexData }) => { - const cardBg = useColorModeValue('white', '#1a1a1a'); - const borderColor = useColorModeValue('gray.200', '#333'); - const subTextColor = useColorModeValue('gray.600', 'gray.400'); + const cardBg = useColorModeValue('white', '#0d0d0d'); + const borderColor = useColorModeValue('gray.200', '#2d2d2d'); + const labelColor = useColorModeValue('gray.500', 'gray.400'); + const gradientBg = useColorModeValue( + 'linear(to-br, white, gray.50)', + 'linear(to-br, #0d0d0d, #1a1a1a)' + ); if (!indexData) return null; const changePct = indexData.change_pct || 0; const isUp = changePct >= 0; + const mainColor = isUp ? '#ff4d4f' : '#52c41a'; + + // 计算振幅 + const amplitude = indexData.high && indexData.low && indexData.prev_close + ? ((indexData.high - indexData.low) / indexData.prev_close * 100).toFixed(2) + : null; return ( - - - {indexData.name || '上证指数'} - - {indexData.latest_price?.toFixed(2) || '-'} - - - - {changePct?.toFixed(2)}% - - + + {/* 背景装饰 */} + - - 最高 - - {indexData.high?.toFixed(2) || '-'} - - + + {/* 主指数信息 */} + + + {indexData.name || '上证指数'} + + + + {indexData.latest_price?.toFixed(2) || '-'} + + + + + {isUp ? '+' : ''}{changePct?.toFixed(2)}% + + + + - - 最低 - - {indexData.low?.toFixed(2) || '-'} - - - - - 振幅 - - {indexData.high && indexData.low && indexData.prev_close - ? (((indexData.high - indexData.low) / indexData.prev_close) * 100).toFixed(2) + '%' - : '-'} - - - + {/* 详细数据 */} + + + 最高 + + {indexData.high?.toFixed(2) || '-'} + + + + 最低 + + {indexData.low?.toFixed(2) || '-'} + + + + 振幅 + + {amplitude ? `${amplitude}%` : '-'} + + + + + ); }; @@ -106,8 +272,9 @@ const IndexStatCard = ({ indexData }) => { * @param {Object} props.alertSummary - 异动类型统计 */ const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => { - const cardBg = useColorModeValue('white', '#1a1a1a'); - const borderColor = useColorModeValue('gray.200', '#333'); + const cardBg = useColorModeValue('white', '#0d0d0d'); + const borderColor = useColorModeValue('gray.200', '#2d2d2d'); + const labelColor = useColorModeValue('gray.500', 'gray.400'); // 如果没有 alertSummary,从 alerts 中统计 const summary = alertSummary && Object.keys(alertSummary).length > 0 @@ -120,6 +287,22 @@ const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => { const totalAlerts = alerts.length; + // 按优先级排序的异动类型 + const sortedTypes = [ + 'surge_up', + 'volume_surge_up', + 'shrink_surge_up', + 'surge', + 'surge_down', + 'volume_oscillation', + 'limit_up', + 'rank_jump', + 'volume_spike', + ]; + + // 获取有数据的类型 + const activeTypes = sortedTypes.filter(type => (summary[type] || 0) > 0); + return ( {/* 指数统计 */} @@ -127,18 +310,67 @@ const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => { {/* 异动统计 */} {totalAlerts > 0 && ( - - - 异动 {totalAlerts} 次: + + + + + + 今日异动 + + + + {totalAlerts} 次 + + + + + {activeTypes.map(type => ( + + ))} + + + {/* 如果有其他未分类的类型 */} + {Object.entries(summary) + .filter(([type, count]) => !sortedTypes.includes(type) && count > 0) + .map(([type, count]) => ( + + ))} + + )} + + {/* 无异动提示 */} + {totalAlerts === 0 && ( + + + + 暂无异动数据 - {(summary.surge_up > 0 || summary.surge > 0) && ( - - )} - {summary.surge_down > 0 && } - {summary.limit_up > 0 && } - {summary.volume_spike > 0 && } - {summary.rank_jump > 0 && } - + )} ); diff --git a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js index ff1d9c2b..eaabc3ed 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js +++ b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js @@ -1,5 +1,5 @@ /** - * 概念异动列表组件 - V2 + * 概念异动列表组件 - V2 科技感设计 * 展示当日的概念异动记录,点击可展开显示相关股票 */ import React, { useState, useCallback } from 'react'; @@ -15,7 +15,6 @@ import { Flex, Collapse, Spinner, - Progress, Table, Thead, Tbody, @@ -28,28 +27,216 @@ import { PopoverContent, PopoverBody, Portal, + chakra, + keyframes, } from '@chakra-ui/react'; -import { FaArrowUp, FaArrowDown, FaFire, FaChevronDown, FaChevronRight } from 'react-icons/fa'; +import { + TrendingUp, + TrendingDown, + Zap, + Activity, + Flame, + BarChart3, + ChevronDown, + ChevronRight, + CircleHelp, + Sparkles, + Target, + Gauge, + Waves, + Rocket, +} from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; -import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers'; +import { + ALERT_TYPE_CONFIG, + METRIC_CONFIG, + TRIGGERED_RULES_CONFIG, + getAlertTypeLabel, + getAlertTypeDescription, + getAlertTypeColor, + formatScore, + getScoreColor, + getScoreLevel, + formatMetric, +} from '../utils/chartHelpers'; import MiniTimelineChart from '@components/Charts/Stock/MiniTimelineChart'; +// 动画效果 +const pulseGlow = keyframes` + 0%, 100% { box-shadow: 0 0 5px currentColor; } + 50% { box-shadow: 0 0 15px currentColor, 0 0 25px currentColor; } +`; + +const shimmer = keyframes` + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +`; + /** - * 紧凑型异动卡片 + * 获取异动类型对应的图标 + */ +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 MetricTooltip = ({ metricKey, children }) => { + const config = METRIC_CONFIG[metricKey]; + if (!config) return children; + + const tooltipBg = useColorModeValue('gray.800', 'gray.700'); + + return ( + + + + {config.label} + + {config.tooltip} + + } + bg={tooltipBg} + color="white" + px={3} + py={2} + borderRadius="md" + maxW="280px" + hasArrow + placement="top" + > + {children} + + ); +}; + +/** + * 迷你进度条组件 + */ +const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => { + const bgColor = useColorModeValue('gray.200', 'gray.700'); + const percent = Math.min((value / maxValue) * 100, 100); + + return ( + + + + ); +}; + +/** + * Z-Score 双向进度条组件 + */ +const ZScoreBar = ({ value, color }) => { + const bgColor = useColorModeValue('gray.200', 'gray.700'); + const absValue = Math.abs(value || 0); + const percent = Math.min(absValue / 4 * 50, 50); + const isPositive = (value || 0) >= 0; + + return ( + + 2 ? `0 0 6px ${color}` : 'none'} + /> + {/* 中心线 */} + + + ); +}; + +/** + * 触发规则标签组件 + */ +const TriggeredRuleBadge = ({ rule }) => { + const config = TRIGGERED_RULES_CONFIG[rule]; + if (!config) return null; + + return ( + + + {config.label} + + + ); +}; + +/** + * 科技感异动卡片 */ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { const navigate = useNavigate(); - const bgColor = useColorModeValue('white', '#1a1a1a'); - const hoverBg = useColorModeValue('gray.50', '#252525'); - const borderColor = useColorModeValue('gray.200', '#333'); - const expandedBg = useColorModeValue('purple.50', '#1e1e2e'); - const tableBg = useColorModeValue('gray.50', '#151520'); - const popoverBg = useColorModeValue('white', '#1a1a1a'); + // 颜色主题 + 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'); + + const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge; const isUp = alert.alert_type !== 'surge_down'; - const typeColor = isUp ? 'red' : 'green'; - const isV2 = alert.is_v2; + const AlertIcon = getAlertIcon(alert.alert_type); + const scoreLevel = getScoreLevel(alert.final_score); // 点击股票跳转 const handleStockClick = (e, stockCode) => { @@ -59,122 +246,280 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { return ( + {/* 顶部渐变装饰条 */} + + {/* 主卡片 - 点击展开 */} - - {/* 左侧:名称 + 类型 */} + {/* 第一行:类型图标 + 名称 + 分数 */} + + {/* 展开箭头 */} - - + + {/* 类型图标 - 带发光效果 */} + + + + + {/* 概念名称 */} + {alert.concept_name} - {isV2 && ( - + + {/* V2 标记 */} + {alert.is_v2 && ( + V2 )} - {/* 右侧:分数 */} - - {formatScore(alert.final_score)}分 - + {/* 综合评分 */} + + + + + {formatScore(alert.final_score)} + + + + - {/* 第二行:时间 + 关键指标 */} - - - {alert.time} - - {getAlertTypeLabel(alert.alert_type)} - - {/* 确认率 */} - {isV2 && alert.confirm_ratio != null && ( - - - = 0.8 ? 'green.500' : 'orange.500'} - /> - - {Math.round((alert.confirm_ratio || 0) * 100)}% - - )} - + {/* 第二行:时间 + 类型标签 + 确认率 */} + + + {/* 时间 */} + + {alert.time} + - {/* Alpha + Z-Score 简化显示 */} - - {alert.alpha != null && ( - = 0 ? 'red.500' : 'green.500'} fontWeight="medium"> - α {(alert.alpha || 0) >= 0 ? '+' : ''}{(alert.alpha || 0).toFixed(2)}% - - )} - {isV2 && alert.alpha_zscore != null && ( - - - + + {getAlertTypeLabel(alert.alert_type)} + + + + {/* 确认率 */} + {alert.is_v2 && alert.confirm_ratio != null && ( + + + = 0.8 ? '#52c41a' : + alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f' + } + width="35px" + showGlow={alert.confirm_ratio >= 0.8} + /> + = 0.8 ? '#52c41a' : + alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f' + } + fontWeight="medium" > - = 0 ? '50%' : undefined} - right={(alert.alpha_zscore || 0) < 0 ? '50%' : undefined} - w={`${Math.min(Math.abs(alert.alpha_zscore || 0) / 5 * 50, 50)}%`} - h="100%" - bg={(alert.alpha_zscore || 0) >= 0 ? 'red.500' : 'green.500'} - /> - - = 0 ? 'red.400' : 'green.400'}> - {(alert.alpha_zscore || 0) >= 0 ? '+' : ''}{(alert.alpha_zscore || 0).toFixed(1)}σ + {Math.round(alert.confirm_ratio * 100)}% - - )} - {(alert.limit_up_ratio || 0) > 0.05 && ( - - - {Math.round((alert.limit_up_ratio || 0) * 100)}% - + )} + + {/* 触发原因简述 */} + {alert.trigger_reason && ( + + + {alert.trigger_reason} + + + )} + + {/* 第三行:核心指标 */} + + {/* Alpha 超额收益 */} + {alert.alpha != null && ( + + + Alpha + = 0 ? '#ff4d4f' : '#52c41a'} + > + {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}% + + + + )} + + {/* Alpha Z-Score */} + {alert.is_v2 && alert.alpha_zscore != null && ( + + + = 0 ? '#ff4d4f' : '#52c41a'} + /> + = 0 ? '#ff4d4f' : '#52c41a'} + > + {alert.alpha_zscore >= 0 ? '+' : ''}{alert.alpha_zscore.toFixed(1)}σ + + + + )} + + {/* 成交额强度 */} + {alert.is_v2 && alert.amt_zscore != null && alert.amt_zscore > 0.5 && ( + + + + = 2 ? '#eb2f96' : '#faad14'} + > + {alert.amt_zscore.toFixed(1)}σ + + + + )} + + {/* 涨停占比 */} + {(alert.limit_up_ratio || 0) > 0.03 && ( + + = 0.1 ? '#ff4d4f' : '#fa8c16'} + cursor="help" + > + = 0.15 ? `${pulseGlow} 2s infinite` : undefined} + /> + + {Math.round(alert.limit_up_ratio * 100)}% + + + + )} + + {/* 动量指标 */} + {alert.is_v2 && alert.momentum_3m != null && Math.abs(alert.momentum_3m) > 0.3 && ( + + + + = 0 ? '#ff4d4f' : '#52c41a'} + > + {alert.momentum_3m >= 0 ? '+' : ''}{alert.momentum_3m.toFixed(2)} + + + + )} + + + {/* 触发规则标签 */} + {alert.triggered_rules && alert.triggered_rules.length > 0 && ( + + {alert.triggered_rules.slice(0, 4).map((rule, idx) => ( + + ))} + {alert.triggered_rules.length > 4 && ( + + +{alert.triggered_rules.length - 4} + + )} + + )} {/* 展开的股票列表 */} @@ -183,12 +528,12 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { borderTopWidth="1px" borderColor={borderColor} p={3} - bg={tableBg} + bg={expandedBg} > {loadingStocks ? ( - - 加载相关股票... + + 加载相关股票... ) : stocks && stocks.length > 0 ? ( <> @@ -200,28 +545,43 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { const upCount = validStocks.filter(s => s.change_pct > 0).length; const downCount = validStocks.filter(s => s.change_pct < 0).length; return ( - + - 概念均涨: - = 0 ? 'red.400' : 'green.400'}> + 板块均涨: + = 0 ? '#ff4d4f' : '#52c41a'}> {avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}% + - {upCount}涨 - / - {downCount}跌 + {upCount}涨 + / + {downCount}跌 ); })()} + - + - - - + + + @@ -236,15 +596,16 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { cursor="pointer" _hover={{ bg: hoverBg }} onClick={(e) => handleStockClick(e, stockCode)} + transition="background 0.15s" > - - - @@ -293,15 +656,16 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { })}
股票涨跌原因 + 股票 + + 涨跌幅 + + 原因 +
+ {stockName} @@ -255,11 +616,13 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { h="80px" bg={popoverBg} borderColor={borderColor} - boxShadow="lg" + boxShadow="xl" onClick={(e) => e.stopPropagation()} > - {stockName} 分时 + + {stockName} 分时 + @@ -268,13 +631,13 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { + 0 ? 'red.400' : - hasChange && changePct < 0 ? 'green.400' : 'gray.400' + hasChange && changePct > 0 ? '#ff4d4f' : + hasChange && changePct < 0 ? '#52c41a' : subTextColor } > {hasChange @@ -283,8 +646,8 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { } - + + {stock.reason || '-'}
- {stocks.length > 10 && ( - - 共 {stocks.length} 只相关股票,显示前 10 只 - - )}
+ + {stocks.length > 10 && ( + + 共 {stocks.length} 只相关股票,显示前 10 只 + + )} ) : ( - + 暂无相关股票数据 )} @@ -320,6 +684,7 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight const [loadingConcepts, setLoadingConcepts] = useState({}); const subTextColor = useColorModeValue('gray.500', 'gray.400'); + const emptyBg = useColorModeValue('gray.50', '#111111'); // 获取概念相关股票 const fetchConceptStocks = useCallback(async (conceptId) => { @@ -330,29 +695,18 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight setLoadingConcepts(prev => ({ ...prev, [conceptId]: true })); try { - // 调用后端 API 获取概念股票 - const response = await axios.get(`/api/concept/${conceptId}/stocks`); + 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); - // 如果 API 失败,尝试从 ES 直接获取 - try { - const esResponse = await axios.get(`/api/es/concept/${conceptId}`); - if (esResponse.data?.stocks) { - setConceptStocks(prev => ({ - ...prev, - [conceptId]: esResponse.data.stocks - })); - } - } catch (esError) { - console.error('ES 获取也失败:', esError); - setConceptStocks(prev => ({ ...prev, [conceptId]: [] })); - } + setConceptStocks(prev => ({ ...prev, [conceptId]: [] })); } finally { setLoadingConcepts(prev => ({ ...prev, [conceptId]: false })); } @@ -366,19 +720,23 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight setExpandedId(null); } else { setExpandedId(alertKey); - // 获取股票数据 if (alert.concept_id) { fetchConceptStocks(alert.concept_id); } } - // 通知父组件 onAlertClick?.(alert); }, [expandedId, fetchConceptStocks, onAlertClick]); if (!alerts || alerts.length === 0) { return ( - + + 当日暂无概念异动 diff --git a/src/views/StockOverview/components/HotspotOverview/index.js b/src/views/StockOverview/components/HotspotOverview/index.js index ccef8cbb..10dc7bf7 100644 --- a/src/views/StockOverview/components/HotspotOverview/index.js +++ b/src/views/StockOverview/components/HotspotOverview/index.js @@ -1,5 +1,5 @@ /** - * 热点概览组件 + * 热点概览组件 - 科技感设计 * 展示大盘分时走势 + 概念异动标注 * * 模块化结构: @@ -12,8 +12,6 @@ import React, { useState, useCallback } from 'react'; import { Box, - Card, - CardBody, Heading, Text, HStack, @@ -27,16 +25,36 @@ import { useColorModeValue, Grid, GridItem, - Divider, IconButton, Collapse, + keyframes, } from '@chakra-ui/react'; -import { FaFire, FaList, FaChartArea, FaChevronDown, FaChevronUp } from 'react-icons/fa'; -import { InfoIcon } from '@chakra-ui/icons'; +import { + Flame, + List, + LineChart, + ChevronDown, + ChevronUp, + Info, + Zap, + AlertCircle, +} from 'lucide-react'; import { useHotspotData } from './hooks'; import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components'; +// 动画效果 +const gradientShift = keyframes` + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +`; + +const pulseGlow = keyframes` + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +`; + /** * 热点概览主组件 * @param {Object} props @@ -50,46 +68,102 @@ const HotspotOverview = ({ selectedDate }) => { const { loading, error, data } = useHotspotData(selectedDate); // 颜色主题 - const cardBg = useColorModeValue('white', '#1a1a1a'); - const borderColor = useColorModeValue('gray.200', '#333333'); + const cardBg = useColorModeValue('white', '#0a0a0a'); + const borderColor = useColorModeValue('gray.200', '#1f1f1f'); const textColor = useColorModeValue('gray.800', 'white'); const subTextColor = useColorModeValue('gray.600', 'gray.400'); + const headerGradient = useColorModeValue( + 'linear(to-r, orange.500, red.500)', + 'linear(to-r, orange.400, red.400)' + ); // 点击异动标注 const handleAlertClick = useCallback((alert) => { setSelectedAlert(alert); - // 可以在这里添加滚动到对应位置的逻辑 }, []); // 渲染加载状态 if (loading) { return ( - - -
- - - 加载热点概览数据... + + {/* 顶部装饰条 */} + + +
+ + + + + + + + 加载热点概览数据 + + + 正在获取市场异动信息... + -
- - +
+
+
); } // 渲染错误状态 if (error) { return ( - - -
- - - {error} + + + +
+ + + + + + + 数据加载失败 + + + {error} + -
- - +
+
+
); } @@ -101,53 +175,131 @@ const HotspotOverview = ({ selectedDate }) => { const { index, alerts, alert_summary } = data; return ( - - + + {/* 顶部装饰条 */} + + + {/* 头部 */} - + - - - 热点概览 - + + + + + + 热点概览 + + + 实时概念异动监控 + + - + {/* 异动数量徽章 */} + {alerts.length > 0 && ( + + + + {alerts.length} + + + )} + + {/* 切换按钮 */} + : } + icon={} size="sm" variant="ghost" + borderRadius="lg" onClick={() => setShowAlertList(!showAlertList)} aria-label="切换异动列表" + _hover={{ + bg: useColorModeValue('gray.100', 'gray.800'), + }} /> - - + + {/* 信息提示 */} + + + + {/* 统计摘要 */} - + - - {/* 主体内容:图表 + 异动列表 */} {/* 分时图 */} - - - - + + + + + + 大盘分时走势 + + + { {/* 异动列表(可收起) */} - + - - - - + + + + + + 异动记录 @@ -175,7 +341,7 @@ const HotspotOverview = ({ selectedDate }) => { alerts={alerts} onAlertClick={handleAlertClick} selectedAlert={selectedAlert} - maxHeight="350px" + maxHeight="380px" /> @@ -184,14 +350,22 @@ const HotspotOverview = ({ selectedDate }) => { {/* 无异动提示 */} {alerts.length === 0 && ( -
- - 当日暂无概念异动数据 - +
+ + + + 当日暂无概念异动数据 + +
)} - - + + ); }; diff --git a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js index 54ff9eba..cd28aeff 100644 --- a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js +++ b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js @@ -3,6 +3,207 @@ * 用于处理异动标注等图表相关逻辑 */ +/** + * 异动类型配置 - 科技感配色方案 + */ +export const ALERT_TYPE_CONFIG = { + surge_up: { + label: '急涨', + color: '#ff4d4f', + gradient: ['#ff4d4f', '#ff7a45'], + icon: 'TrendingUp', + bgAlpha: 0.15, + description: '概念板块出现快速上涨异动', + }, + surge: { + label: '异动', + color: '#ff7a45', + gradient: ['#ff7a45', '#ffa940'], + icon: 'Zap', + bgAlpha: 0.12, + description: '概念板块出现明显异动信号', + }, + surge_down: { + label: '急跌', + color: '#52c41a', + gradient: ['#52c41a', '#73d13d'], + icon: 'TrendingDown', + bgAlpha: 0.15, + description: '概念板块出现快速下跌异动', + }, + volume_surge_up: { + label: '放量急涨', + color: '#eb2f96', + gradient: ['#eb2f96', '#f759ab'], + icon: 'Activity', + bgAlpha: 0.15, + description: '成交量放大伴随价格急涨,资金关注度高', + }, + shrink_surge_up: { + label: '缩量急涨', + color: '#722ed1', + gradient: ['#722ed1', '#9254de'], + icon: 'Rocket', + bgAlpha: 0.15, + description: '成交量萎缩但价格急涨,筹码锁定良好', + }, + volume_oscillation: { + label: '放量震荡', + color: '#13c2c2', + gradient: ['#13c2c2', '#36cfc9'], + icon: 'Waves', + bgAlpha: 0.12, + description: '成交量放大但价格震荡,多空分歧加大', + }, + limit_up: { + label: '涨停潮', + color: '#fa541c', + gradient: ['#fa541c', '#ff7a45'], + icon: 'Flame', + bgAlpha: 0.15, + description: '板块内多只股票涨停,热度极高', + }, + rank_jump: { + label: '排名跃升', + color: '#1890ff', + gradient: ['#1890ff', '#40a9ff'], + icon: 'ArrowUpCircle', + bgAlpha: 0.12, + description: '概念板块排名快速上升,关注度提升', + }, + volume_spike: { + label: '放量异动', + color: '#faad14', + gradient: ['#faad14', '#ffc53d'], + icon: 'BarChart3', + bgAlpha: 0.12, + description: '成交量出现突发性放大', + }, +}; + +/** + * 指标配置 - 包含详细提示说明 + */ +export const METRIC_CONFIG = { + final_score: { + label: '综合评分', + unit: '分', + tooltip: '综合规则评分和机器学习评分的最终得分,分数越高表示异动信号越强。60分以上值得关注,80分以上为强信号。', + format: (v) => Math.round(v), + getColor: (v) => { + if (v >= 80) return '#ff4d4f'; + if (v >= 60) return '#fa8c16'; + if (v >= 40) return '#faad14'; + return '#8c8c8c'; + }, + }, + rule_score: { + label: '规则评分', + unit: '分', + tooltip: '基于预设规则计算的评分,包括涨跌幅、成交量、涨停数等多维度指标。', + format: (v) => Math.round(v), + }, + ml_score: { + label: 'AI评分', + unit: '分', + tooltip: '机器学习模型预测的评分,基于历史数据训练,预测该异动后续表现概率。', + format: (v) => Math.round(v), + }, + confirm_ratio: { + label: '确认率', + unit: '%', + tooltip: '异动信号的确认程度。100%表示信号完全确认,数值越高表示异动越稳定、越可靠。低于60%的信号可能是噪音。', + format: (v) => Math.round(v * 100), + getColor: (v) => { + if (v >= 0.8) return '#52c41a'; + if (v >= 0.6) return '#faad14'; + return '#ff4d4f'; + }, + }, + alpha: { + label: '超额收益', + unit: '%', + tooltip: '概念板块相对于大盘的超额涨跌幅(Alpha)。正值表示跑赢大盘,负值表示跑输大盘。该指标反映板块的相对强弱。', + format: (v) => v?.toFixed(2), + getColor: (v) => v >= 0 ? '#ff4d4f' : '#52c41a', + showSign: true, + }, + alpha_zscore: { + label: 'Alpha强度', + unit: 'σ', + tooltip: 'Alpha的Z-Score标准化值,衡量超额收益的统计显著性。|值|>2表示异常强,|值|>1.5表示较强。正值表示异常上涨,负值表示异常下跌。', + format: (v) => v?.toFixed(2), + getColor: (v) => v >= 0 ? '#ff4d4f' : '#52c41a', + showSign: true, + }, + amt_zscore: { + label: '成交额强度', + unit: 'σ', + tooltip: '成交额的Z-Score标准化值,衡量当前成交额相对于历史的异常程度。>2表示成交额异常放大,资金活跃度高。', + format: (v) => v?.toFixed(2), + getColor: (v) => { + if (v >= 2) return '#eb2f96'; + if (v >= 1) return '#faad14'; + return '#8c8c8c'; + }, + }, + rank_zscore: { + label: '排名变化强度', + unit: 'σ', + tooltip: '板块排名变化的Z-Score值。正值表示排名上升速度异常,>2表示排名跃升显著。', + format: (v) => v?.toFixed(2), + showSign: true, + }, + momentum_3m: { + label: '3分钟动量', + unit: '', + tooltip: '过去3分钟的价格动量指标,反映短期趋势强度。正值表示上涨动量,负值表示下跌动量。', + format: (v) => v?.toFixed(3), + getColor: (v) => v >= 0 ? '#ff4d4f' : '#52c41a', + showSign: true, + }, + momentum_5m: { + label: '5分钟动量', + unit: '', + tooltip: '过去5分钟的价格动量指标,比3分钟动量更稳定,过滤掉更多噪音。', + format: (v) => v?.toFixed(3), + getColor: (v) => v >= 0 ? '#ff4d4f' : '#52c41a', + showSign: true, + }, + limit_up_ratio: { + label: '涨停占比', + unit: '%', + tooltip: '板块内涨停股票数量占总股票数的比例。>10%表示板块热度高,>20%表示涨停潮。', + format: (v) => Math.round(v * 100), + getColor: (v) => { + if (v >= 0.2) return '#ff4d4f'; + if (v >= 0.1) return '#fa8c16'; + if (v >= 0.05) return '#faad14'; + return '#8c8c8c'; + }, + }, +}; + +/** + * 触发规则配置 + */ +export const TRIGGERED_RULES_CONFIG = { + alpha_moderate: { label: 'Alpha中等', color: '#ff7a45', description: '超额收益达到中等水平' }, + alpha_strong: { label: 'Alpha强', color: '#ff4d4f', description: '超额收益达到强势水平' }, + amt_moderate: { label: '成交额中等', color: '#faad14', description: '成交额异常放大中等' }, + amt_strong: { label: '成交额强', color: '#fa8c16', description: '成交额异常放大明显' }, + limit_up_moderate: { label: '涨停中等', color: '#eb2f96', description: '涨停股票数量适中' }, + limit_up_extreme: { label: '涨停极端', color: '#ff4d4f', description: '涨停股票数量很多' }, + momentum_3m_moderate: { label: '3分钟动量', color: '#1890ff', description: '短期动量信号触发' }, + momentum_3m_strong: { label: '3分钟强动量', color: '#096dd9', description: '短期强动量信号' }, + combo_alpha_amt: { label: 'Alpha+成交额', color: '#722ed1', description: '超额收益和成交额双重确认' }, + combo_alpha_limitup: { label: 'Alpha+涨停', color: '#eb2f96', description: '超额收益和涨停双重确认' }, + early_session: { label: '早盘信号', color: '#13c2c2', description: '开盘30分钟内的异动' }, + 'decay:accelerating': { label: '加速中', color: '#52c41a', description: '异动正在加速' }, + 'decay:stable': { label: '稳定', color: '#1890ff', description: '异动保持稳定' }, + 'decay:fading': { label: '衰减中', color: '#8c8c8c', description: '异动正在衰减' }, +}; + /** * 获取异动标注的配色和符号 * @param {string} alertType - 异动类型 @@ -10,42 +211,41 @@ * @returns {Object} { color, symbol, symbolSize } */ export const getAlertStyle = (alertType, importanceScore = 0.5) => { - let color = '#ff6b6b'; - let symbol = 'pin'; - let symbolSize = 35; + const config = ALERT_TYPE_CONFIG[alertType] || ALERT_TYPE_CONFIG.surge; + const baseSize = 30; + const sizeBonus = Math.min(importanceScore * 20, 15); + let symbol = 'pin'; switch (alertType) { case 'surge_up': case 'surge': - color = '#ff4757'; + case 'volume_surge_up': + case 'shrink_surge_up': symbol = 'triangle'; - symbolSize = 30 + Math.min(importanceScore * 20, 15); break; case 'surge_down': - color = '#2ed573'; - symbol = 'path://M0,0 L10,0 L5,10 Z'; // 向下三角形 - symbolSize = 30 + Math.min(importanceScore * 20, 15); + symbol = 'path://M0,0 L10,0 L5,10 Z'; break; case 'limit_up': - color = '#ff6348'; symbol = 'diamond'; - symbolSize = 28; break; case 'rank_jump': - color = '#3742fa'; symbol = 'circle'; - symbolSize = 25; break; case 'volume_spike': - color = '#ffa502'; + case 'volume_oscillation': symbol = 'rect'; - symbolSize = 25; break; default: - break; + symbol = 'pin'; } - return { color, symbol, symbolSize }; + return { + color: config.color, + gradient: config.gradient, + symbol, + symbolSize: baseSize + sizeBonus, + }; }; /** @@ -54,16 +254,30 @@ export const getAlertStyle = (alertType, importanceScore = 0.5) => { * @returns {string} 显示标签 */ export const getAlertTypeLabel = (alertType) => { - const labels = { - surge: '急涨', - surge_up: '暴涨', - surge_down: '暴跌', - limit_up: '涨停增加', - rank_jump: '排名跃升', - volume_spike: '放量', - unknown: '异动', + return ALERT_TYPE_CONFIG[alertType]?.label || alertType || '异动'; +}; + +/** + * 获取异动类型的详细描述 + * @param {string} alertType - 异动类型 + * @returns {string} 描述 + */ +export const getAlertTypeDescription = (alertType) => { + return ALERT_TYPE_CONFIG[alertType]?.description || '概念板块出现异动信号'; +}; + +/** + * 获取异动类型的配色 + * @param {string} alertType - 异动类型 + * @returns {Object} { color, gradient, bgAlpha } + */ +export const getAlertTypeColor = (alertType) => { + const config = ALERT_TYPE_CONFIG[alertType] || ALERT_TYPE_CONFIG.surge; + return { + color: config.color, + gradient: config.gradient, + bgAlpha: config.bgAlpha, }; - return labels[alertType] || alertType; }; /** @@ -88,7 +302,7 @@ export const getAlertMarkPoints = (alerts, times, prices, priceMax, maxCount = 1 const timeIndex = times.indexOf(alert.time); const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax); - const { color, symbol, symbolSize } = getAlertStyle( + const { color, gradient, symbol, symbolSize } = getAlertStyle( alert.alert_type, alert.final_score / 100 || alert.importance_score || 0.5 ); @@ -113,23 +327,33 @@ export const getAlertMarkPoints = (alerts, times, prices, priceMax, maxCount = 1 symbol, symbolSize, itemStyle: { - color, - borderColor: '#fff', - borderWidth: 1, - shadowBlur: 3, - shadowColor: 'rgba(0,0,0,0.2)', + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: gradient[0] }, + { offset: 1, color: gradient[1] }, + ], + }, + borderColor: 'rgba(255,255,255,0.8)', + borderWidth: 2, + shadowBlur: 8, + shadowColor: `${color}66`, }, label: { show: true, position: isDown ? 'bottom' : 'top', formatter: '{b}', - fontSize: 9, - color: '#333', - backgroundColor: isDown ? 'rgba(46, 213, 115, 0.9)' : 'rgba(255,255,255,0.9)', - padding: [2, 4], - borderRadius: 2, + fontSize: 10, + fontWeight: 500, + color: isDown ? '#52c41a' : '#ff4d4f', + backgroundColor: 'rgba(255,255,255,0.95)', + padding: [3, 6], + borderRadius: 4, borderColor: color, borderWidth: 1, + shadowBlur: 4, + shadowColor: 'rgba(0,0,0,0.1)', }, alertData: alert, // 存储原始数据 }; @@ -153,8 +377,45 @@ export const formatScore = (score) => { */ export const getScoreColor = (score) => { const s = score || 0; - if (s >= 80) return '#ff4757'; - if (s >= 60) return '#ff6348'; - if (s >= 40) return '#ffa502'; - return '#747d8c'; + if (s >= 80) return '#ff4d4f'; + if (s >= 60) return '#fa8c16'; + if (s >= 40) return '#faad14'; + return '#8c8c8c'; +}; + +/** + * 获取分数等级标签 + * @param {number} score - 分数 (0-100) + * @returns {Object} { label, color } + */ +export const getScoreLevel = (score) => { + const s = score || 0; + if (s >= 80) return { label: '强信号', color: '#ff4d4f' }; + if (s >= 60) return { label: '中等', color: '#fa8c16' }; + if (s >= 40) return { label: '一般', color: '#faad14' }; + return { label: '弱信号', color: '#8c8c8c' }; +}; + +/** + * 格式化指标值 + * @param {string} metricKey - 指标键名 + * @param {number} value - 值 + * @returns {Object} { formatted, color, showSign } + */ +export const formatMetric = (metricKey, value) => { + const config = METRIC_CONFIG[metricKey]; + if (!config || value === null || value === undefined) { + return { formatted: '-', color: '#8c8c8c', showSign: false }; + } + + const formatted = config.format(value); + const color = config.getColor ? config.getColor(value) : '#8c8c8c'; + + return { + formatted: config.showSign && value > 0 ? `+${formatted}` : formatted, + color, + unit: config.unit, + label: config.label, + tooltip: config.tooltip, + }; };