update pay ui

This commit is contained in:
2025-12-11 07:32:30 +08:00
parent f545c9ec15
commit 8c6ebe01ed
5 changed files with 1379 additions and 321 deletions

45
app.py
View File

@@ -12824,6 +12824,9 @@ def get_hotspot_overview():
'surge': len([a for a in alerts if a['alert_type'] == 'surge']), '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_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']), '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']), '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']), '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']) '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: Args:
concept_id: 概念 ID(来自 ES concept_library_v3 concept_id: 概念 ID 或概念名称(支持两种方式查询
Returns: Returns:
- stocks: 股票列表 [{code, name, reason, change_pct}, ...] - stocks: 股票列表 [{code, name, reason, change_pct}, ...]
@@ -12857,18 +12860,48 @@ def get_concept_stocks(concept_id):
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from clickhouse_driver import Client from clickhouse_driver import Client
# 1. 从 ES 获取概念的股票列表
es_client = Elasticsearch(["http://222.128.1.157:19200"]) 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({ return jsonify({
'success': False, 'success': False,
'error': f'概念 {concept_id} 不存在' 'error': f'概念 {concept_id} 不存在'
}), 404 }), 404
source = es_result.get('_source', {})
concept_name = source.get('concept', concept_id)
raw_stocks = source.get('stocks', []) raw_stocks = source.get('stocks', [])
if not raw_stocks: if not raw_stocks:

View File

@@ -1,5 +1,5 @@
/** /**
* 异动统计摘要组件 * 异动统计摘要组件 - 科技感设计
* 展示指数统计和异动类型统计 * 展示指数统计和异动类型统计
*/ */
import React from 'react'; import React from 'react';
@@ -17,84 +17,250 @@ import {
StatArrow, StatArrow,
SimpleGrid, SimpleGrid,
useColorModeValue, useColorModeValue,
Tooltip,
Flex,
keyframes,
} from '@chakra-ui/react'; } 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 AlertTypeBadge = ({ type, count }) => {
const config = { const config = ALERT_TYPE_CONFIG[type] || { label: type, color: '#8c8c8c', gradient: ['#8c8c8c', '#a6a6a6'] };
surge: { label: '急涨', color: 'red', icon: FaBolt }, const AlertIcon = getAlertIcon(type);
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 cfg = config[type] || { label: type, color: 'gray', icon: FaFire }; const bgOpacity = useColorModeValue('15', '20');
const hoverBg = useColorModeValue(`${config.color}25`, `${config.color}30`);
return ( return (
<Badge colorScheme={cfg.color} variant="subtle" px={2} py={1} borderRadius="md"> <Tooltip
<HStack spacing={1}> label={config.description || `${getAlertTypeLabel(type)} ${count}`}
<Icon as={cfg.icon} boxSize={3} /> hasArrow
<Text>{cfg.label}</Text> placement="top"
<Text fontWeight="bold">{count}</Text> >
</HStack> <Badge
display="flex"
alignItems="center"
gap={1.5}
px={2.5}
py={1.5}
borderRadius="lg"
bg={`${config.color}${bgOpacity}`}
color={config.color}
cursor="pointer"
transition="all 0.2s"
_hover={{
bg: hoverBg,
transform: 'translateY(-1px)',
}}
>
<Icon as={AlertIcon} boxSize={3.5} />
<Text fontSize="xs" fontWeight="medium">{getAlertTypeLabel(type)}</Text>
<Box
px={1.5}
py={0.5}
bg={config.color}
color="white"
borderRadius="md"
fontSize="10px"
fontWeight="bold"
minW="18px"
textAlign="center"
>
{count}
</Box>
</Badge> </Badge>
</Tooltip>
); );
}; };
/** /**
* 指数统计卡片 * 迷你统计卡片
*/
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 = (
<Box
bg={cardBg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={3}
minW="100px"
transition="all 0.2s"
_hover={{
borderColor: color,
boxShadow: `0 2px 12px ${color}20`,
}}
>
<Text fontSize="xs" color={labelColor} mb={1}>
{label}
</Text>
<HStack spacing={1} align="baseline">
<Text fontSize="lg" fontWeight="bold" color={color}>
{value}
</Text>
{change !== undefined && (
<HStack spacing={0.5} fontSize="xs" color={isUp ? '#ff4d4f' : '#52c41a'}>
<Icon as={isUp ? ArrowUp : ArrowDown} boxSize={3} />
<Text>{Math.abs(change).toFixed(2)}%</Text>
</HStack>
)}
</HStack>
</Box>
);
if (tooltip) {
return <Tooltip label={tooltip} hasArrow>{content}</Tooltip>;
}
return content;
};
/**
* 指数统计卡片 - 科技感设计
*/ */
const IndexStatCard = ({ indexData }) => { const IndexStatCard = ({ indexData }) => {
const cardBg = useColorModeValue('white', '#1a1a1a'); const cardBg = useColorModeValue('white', '#0d0d0d');
const borderColor = useColorModeValue('gray.200', '#333'); const borderColor = useColorModeValue('gray.200', '#2d2d2d');
const subTextColor = useColorModeValue('gray.600', 'gray.400'); 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; if (!indexData) return null;
const changePct = indexData.change_pct || 0; const changePct = indexData.change_pct || 0;
const isUp = changePct >= 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 ( return (
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}> <Box
<Stat size="sm"> bg={cardBg}
<StatLabel color={subTextColor}>{indexData.name || '上证指数'}</StatLabel> bgGradient={gradientBg}
<StatNumber fontSize="xl" color={isUp ? 'red.500' : 'green.500'}> borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={4}
position="relative"
overflow="hidden"
>
{/* 背景装饰 */}
<Box
position="absolute"
top="-20px"
right="-20px"
w="100px"
h="100px"
borderRadius="full"
bg={`${mainColor}08`}
filter="blur(30px)"
/>
<Flex justify="space-between" align="flex-start" flexWrap="wrap" gap={4}>
{/* 主指数信息 */}
<VStack align="flex-start" spacing={1}>
<Text fontSize="sm" color={labelColor} fontWeight="medium">
{indexData.name || '上证指数'}
</Text>
<HStack spacing={2} align="baseline">
<Text fontSize="2xl" fontWeight="bold" color={mainColor}>
{indexData.latest_price?.toFixed(2) || '-'} {indexData.latest_price?.toFixed(2) || '-'}
</StatNumber> </Text>
<StatHelpText mb={0}> <HStack
<StatArrow type={isUp ? 'increase' : 'decrease'} /> spacing={1}
{changePct?.toFixed(2)}% px={2}
</StatHelpText> py={0.5}
</Stat> borderRadius="full"
bg={`${mainColor}15`}
>
<Icon
as={isUp ? TrendingUp : TrendingDown}
boxSize={3}
color={mainColor}
/>
<Text fontSize="sm" fontWeight="bold" color={mainColor}>
{isUp ? '+' : ''}{changePct?.toFixed(2)}%
</Text>
</HStack>
</HStack>
</VStack>
<Stat size="sm"> {/* 详细数据 */}
<StatLabel color={subTextColor}>最高</StatLabel> <SimpleGrid columns={3} spacing={3}>
<StatNumber fontSize="xl" color="red.500"> <VStack spacing={0} align="flex-start">
<Text fontSize="xs" color={labelColor}>最高</Text>
<Text fontSize="sm" fontWeight="bold" color="#ff4d4f">
{indexData.high?.toFixed(2) || '-'} {indexData.high?.toFixed(2) || '-'}
</StatNumber> </Text>
</Stat> </VStack>
<VStack spacing={0} align="flex-start">
<Stat size="sm"> <Text fontSize="xs" color={labelColor}>最低</Text>
<StatLabel color={subTextColor}>最低</StatLabel> <Text fontSize="sm" fontWeight="bold" color="#52c41a">
<StatNumber fontSize="xl" color="green.500">
{indexData.low?.toFixed(2) || '-'} {indexData.low?.toFixed(2) || '-'}
</StatNumber> </Text>
</Stat> </VStack>
<VStack spacing={0} align="flex-start">
<Stat size="sm"> <Text fontSize="xs" color={labelColor}>振幅</Text>
<StatLabel color={subTextColor}>振幅</StatLabel> <Text fontSize="sm" fontWeight="bold" color="#722ed1">
<StatNumber fontSize="xl" color="purple.500"> {amplitude ? `${amplitude}%` : '-'}
{indexData.high && indexData.low && indexData.prev_close </Text>
? (((indexData.high - indexData.low) / indexData.prev_close) * 100).toFixed(2) + '%' </VStack>
: '-'}
</StatNumber>
</Stat>
</SimpleGrid> </SimpleGrid>
</Flex>
</Box>
); );
}; };
@@ -106,8 +272,9 @@ const IndexStatCard = ({ indexData }) => {
* @param {Object} props.alertSummary - 异动类型统计 * @param {Object} props.alertSummary - 异动类型统计
*/ */
const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => { const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => {
const cardBg = useColorModeValue('white', '#1a1a1a'); const cardBg = useColorModeValue('white', '#0d0d0d');
const borderColor = useColorModeValue('gray.200', '#333'); const borderColor = useColorModeValue('gray.200', '#2d2d2d');
const labelColor = useColorModeValue('gray.500', 'gray.400');
// 如果没有 alertSummary从 alerts 中统计 // 如果没有 alertSummary从 alerts 中统计
const summary = alertSummary && Object.keys(alertSummary).length > 0 const summary = alertSummary && Object.keys(alertSummary).length > 0
@@ -120,6 +287,22 @@ const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => {
const totalAlerts = alerts.length; 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 ( return (
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{/* 指数统计 */} {/* 指数统计 */}
@@ -127,18 +310,67 @@ const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => {
{/* 异动统计 */} {/* 异动统计 */}
{totalAlerts > 0 && ( {totalAlerts > 0 && (
<HStack spacing={2} flexWrap="wrap"> <Box
<Text fontSize="sm" color="gray.500" mr={2}> bg={cardBg}
异动 {totalAlerts} : borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={4}
>
<Flex justify="space-between" align="center" mb={3}>
<HStack spacing={2}>
<Icon as={Zap} boxSize={4} color="#faad14" />
<Text fontSize="sm" fontWeight="medium">
今日异动
</Text> </Text>
{(summary.surge_up > 0 || summary.surge > 0) && (
<AlertTypeBadge type="surge_up" count={(summary.surge_up || 0) + (summary.surge || 0)} />
)}
{summary.surge_down > 0 && <AlertTypeBadge type="surge_down" count={summary.surge_down} />}
{summary.limit_up > 0 && <AlertTypeBadge type="limit_up" count={summary.limit_up} />}
{summary.volume_spike > 0 && <AlertTypeBadge type="volume_spike" count={summary.volume_spike} />}
{summary.rank_jump > 0 && <AlertTypeBadge type="rank_jump" count={summary.rank_jump} />}
</HStack> </HStack>
<Badge
px={2}
py={1}
borderRadius="full"
bg="#faad1415"
color="#faad14"
fontSize="xs"
fontWeight="bold"
>
{totalAlerts}
</Badge>
</Flex>
<Flex gap={2} flexWrap="wrap">
{activeTypes.map(type => (
<AlertTypeBadge
key={type}
type={type}
count={summary[type]}
/>
))}
</Flex>
{/* 如果有其他未分类的类型 */}
{Object.entries(summary)
.filter(([type, count]) => !sortedTypes.includes(type) && count > 0)
.map(([type, count]) => (
<AlertTypeBadge key={type} type={type} count={count} />
))}
</Box>
)}
{/* 无异动提示 */}
{totalAlerts === 0 && (
<Box
bg={cardBg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={4}
textAlign="center"
>
<Icon as={Minus} boxSize={5} color={labelColor} mb={2} />
<Text fontSize="sm" color={labelColor}>
暂无异动数据
</Text>
</Box>
)} )}
</VStack> </VStack>
); );

View File

@@ -1,5 +1,5 @@
/** /**
* 概念异动列表组件 - V2 * 概念异动列表组件 - V2 科技感设计
* 展示当日的概念异动记录,点击可展开显示相关股票 * 展示当日的概念异动记录,点击可展开显示相关股票
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
@@ -15,7 +15,6 @@ import {
Flex, Flex,
Collapse, Collapse,
Spinner, Spinner,
Progress,
Table, Table,
Thead, Thead,
Tbody, Tbody,
@@ -28,28 +27,216 @@ import {
PopoverContent, PopoverContent,
PopoverBody, PopoverBody,
Portal, Portal,
chakra,
keyframes,
} from '@chakra-ui/react'; } 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 { useNavigate } from 'react-router-dom';
import axios from 'axios'; 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'; 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 (
<Tooltip
label={
<VStack align="start" spacing={1} p={1}>
<HStack>
<Icon as={CircleHelp} boxSize={3} />
<Text fontWeight="bold">{config.label}</Text>
</HStack>
<Text fontSize="xs" opacity={0.9}>{config.tooltip}</Text>
</VStack>
}
bg={tooltipBg}
color="white"
px={3}
py={2}
borderRadius="md"
maxW="280px"
hasArrow
placement="top"
>
{children}
</Tooltip>
);
};
/**
* 迷你进度条组件
*/
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 (
<Box
w={width}
h="4px"
bg={bgColor}
borderRadius="full"
overflow="hidden"
position="relative"
>
<Box
h="100%"
w={`${percent}%`}
bg={color}
borderRadius="full"
transition="width 0.3s ease"
{...(showGlow && {
boxShadow: `0 0 6px ${color}`,
})}
/>
</Box>
);
};
/**
* 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 (
<Box w="50px" h="4px" bg={bgColor} borderRadius="full" position="relative">
<Box
position="absolute"
top={0}
h="100%"
w={`${percent}%`}
bg={color}
borderRadius="full"
transition="all 0.3s ease"
{...(isPositive ? { left: '50%' } : { right: '50%' })}
boxShadow={absValue > 2 ? `0 0 6px ${color}` : 'none'}
/>
{/* 中心线 */}
<Box
position="absolute"
left="50%"
top="-1px"
transform="translateX(-50%)"
w="2px"
h="6px"
bg={useColorModeValue('gray.400', 'gray.500')}
borderRadius="full"
/>
</Box>
);
};
/**
* 触发规则标签组件
*/
const TriggeredRuleBadge = ({ rule }) => {
const config = TRIGGERED_RULES_CONFIG[rule];
if (!config) return null;
return (
<Tooltip label={config.description} hasArrow placement="top">
<Badge
size="xs"
bg={`${config.color}20`}
color={config.color}
borderRadius="full"
px={2}
py={0.5}
fontSize="10px"
fontWeight="medium"
>
{config.label}
</Badge>
</Tooltip>
);
};
/**
* 科技感异动卡片
*/ */
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
const navigate = useNavigate(); 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 isUp = alert.alert_type !== 'surge_down';
const typeColor = isUp ? 'red' : 'green'; const AlertIcon = getAlertIcon(alert.alert_type);
const isV2 = alert.is_v2; const scoreLevel = getScoreLevel(alert.final_score);
// 点击股票跳转 // 点击股票跳转
const handleStockClick = (e, stockCode) => { const handleStockClick = (e, stockCode) => {
@@ -59,122 +246,280 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
return ( return (
<Box <Box
bg={isExpanded ? expandedBg : bgColor} bg={cardBg}
borderRadius="lg" borderRadius="xl"
borderWidth="1px" borderWidth="1px"
borderColor={isExpanded ? 'purple.400' : borderColor} borderColor={isExpanded ? alertConfig.color : borderColor}
overflow="hidden" overflow="hidden"
transition="all 0.2s" transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
_hover={{ borderColor: 'purple.300' }} _hover={{
borderColor: alertConfig.color,
transform: 'translateY(-1px)',
boxShadow: `0 4px 20px ${alertConfig.color}15`,
}}
position="relative"
> >
{/* 顶部渐变装饰条 */}
<Box
h="3px"
bgGradient={`linear(to-r, ${alertConfig.gradient[0]}, ${alertConfig.gradient[1]})`}
opacity={isExpanded ? 1 : 0.6}
transition="opacity 0.3s"
/>
{/* 主卡片 - 点击展开 */} {/* 主卡片 - 点击展开 */}
<Box <Box
p={3} p={3}
cursor="pointer" cursor="pointer"
onClick={onToggle} onClick={onToggle}
_hover={{ bg: hoverBg }} _hover={{ bg: hoverBg }}
transition="background 0.2s"
> >
<Flex justify="space-between" align="center"> {/* 第一行:类型图标 + 名称 + 分数 */}
{/* 左侧:名称 + 类型 */} <Flex justify="space-between" align="center" mb={2}>
<HStack spacing={2} flex={1} minW={0}> <HStack spacing={2} flex={1} minW={0}>
{/* 展开箭头 */}
<Icon <Icon
as={isExpanded ? FaChevronDown : FaChevronRight} as={isExpanded ? ChevronDown : ChevronRight}
color="gray.400" color={subTextColor}
boxSize={3} boxSize={4}
transition="transform 0.2s"
/> />
<Icon
as={isUp ? FaArrowUp : FaArrowDown} {/* 类型图标 - 带发光效果 */}
color={`${typeColor}.500`} <Box
boxSize={3} p={1.5}
/> borderRadius="lg"
<Text fontWeight="bold" fontSize="sm" noOfLines={1} flex={1}> bg={`${alertConfig.color}15`}
color={alertConfig.color}
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon as={AlertIcon} boxSize={4} />
</Box>
{/* 概念名称 */}
<Text
fontWeight="bold"
fontSize="sm"
color={textColor}
noOfLines={1}
flex={1}
>
{alert.concept_name} {alert.concept_name}
</Text> </Text>
{isV2 && (
<Badge colorScheme="purple" size="xs" variant="solid" fontSize="9px" px={1}> {/* V2 标记 */}
{alert.is_v2 && (
<Badge
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
color="white"
fontSize="9px"
px={1.5}
py={0.5}
borderRadius="md"
fontWeight="bold"
>
V2 V2
</Badge> </Badge>
)} )}
</HStack> </HStack>
{/* 右侧:分数 */} {/* 综合评分 */}
<MetricTooltip metricKey="final_score">
<HStack
spacing={1}
px={2.5}
py={1}
borderRadius="full"
bg={`${getScoreColor(alert.final_score)}15`}
cursor="help"
>
<Icon as={Gauge} boxSize={3} color={getScoreColor(alert.final_score)} />
<Text
color={getScoreColor(alert.final_score)}
fontSize="sm"
fontWeight="bold"
>
{formatScore(alert.final_score)}
</Text>
<Text fontSize="xs" color={subTextColor}></Text>
</HStack>
</MetricTooltip>
</Flex>
{/* 第二行:时间 + 类型标签 + 确认率 */}
<Flex justify="space-between" align="center" mb={2}>
<HStack spacing={2}>
{/* 时间 */}
<Text fontSize="xs" color={subTextColor} fontFamily="mono">
{alert.time}
</Text>
{/* 异动类型标签 */}
<Tooltip label={getAlertTypeDescription(alert.alert_type)} hasArrow>
<Badge <Badge
bg={`${alertConfig.color}15`}
color={alertConfig.color}
fontSize="xs"
px={2} px={2}
py={0.5} py={0.5}
borderRadius="full" borderRadius="md"
bg={getScoreColor(alert.final_score)} fontWeight="medium"
color="white" cursor="help"
fontSize="xs"
fontWeight="bold"
ml={2}
> >
{formatScore(alert.final_score)}
</Badge>
</Flex>
{/* 第二行:时间 + 关键指标 */}
<Flex mt={2} justify="space-between" align="center" fontSize="xs">
<HStack spacing={2} color="gray.500">
<Text>{alert.time}</Text>
<Badge colorScheme={typeColor} size="sm" variant="subtle">
{getAlertTypeLabel(alert.alert_type)} {getAlertTypeLabel(alert.alert_type)}
</Badge> </Badge>
</Tooltip>
{/* 确认率 */} {/* 确认率 */}
{isV2 && alert.confirm_ratio != null && ( {alert.is_v2 && alert.confirm_ratio != null && (
<HStack spacing={1}> <MetricTooltip metricKey="confirm_ratio">
<Box w="30px" h="4px" bg="gray.200" borderRadius="full" overflow="hidden"> <HStack spacing={1} cursor="help">
<Box <MiniProgressBar
w={`${(alert.confirm_ratio || 0) * 100}%`} value={alert.confirm_ratio * 100}
h="100%" color={
bg={(alert.confirm_ratio || 0) >= 0.8 ? 'green.500' : 'orange.500'} alert.confirm_ratio >= 0.8 ? '#52c41a' :
alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'
}
width="35px"
showGlow={alert.confirm_ratio >= 0.8}
/> />
</Box> <Text
<Text>{Math.round((alert.confirm_ratio || 0) * 100)}%</Text> fontSize="xs"
color={
alert.confirm_ratio >= 0.8 ? '#52c41a' :
alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'
}
fontWeight="medium"
>
{Math.round(alert.confirm_ratio * 100)}%
</Text>
</HStack> </HStack>
</MetricTooltip>
)} )}
</HStack> </HStack>
{/* Alpha + Z-Score 简化显示 */} {/* 触发原因简述 */}
<HStack spacing={3}> {alert.trigger_reason && (
{alert.alpha != null && ( <Tooltip label={alert.trigger_reason} hasArrow placement="left">
<Text color={(alert.alpha || 0) >= 0 ? 'red.500' : 'green.500'} fontWeight="medium"> <Text
α {(alert.alpha || 0) >= 0 ? '+' : ''}{(alert.alpha || 0).toFixed(2)}% fontSize="xs"
</Text> color={subTextColor}
)} noOfLines={1}
{isV2 && alert.alpha_zscore != null && ( maxW="120px"
<Tooltip label={`Alpha Z-Score: ${(alert.alpha_zscore || 0).toFixed(2)}σ`}> cursor="help"
<HStack spacing={0.5}>
<Box
w="24px"
h="4px"
bg="gray.200"
borderRadius="full"
overflow="hidden"
position="relative"
> >
<Box {alert.trigger_reason}
position="absolute"
left={(alert.alpha_zscore || 0) >= 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'}
/>
</Box>
<Text color={(alert.alpha_zscore || 0) >= 0 ? 'red.400' : 'green.400'}>
{(alert.alpha_zscore || 0) >= 0 ? '+' : ''}{(alert.alpha_zscore || 0).toFixed(1)}σ
</Text> </Text>
</HStack>
</Tooltip> </Tooltip>
)} )}
{(alert.limit_up_ratio || 0) > 0.05 && (
<HStack spacing={0.5} color="orange.500">
<Icon as={FaFire} boxSize={3} />
<Text>{Math.round((alert.limit_up_ratio || 0) * 100)}%</Text>
</HStack>
)}
</HStack>
</Flex> </Flex>
{/* 第三行:核心指标 */}
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
{/* Alpha 超额收益 */}
{alert.alpha != null && (
<MetricTooltip metricKey="alpha">
<HStack spacing={1} cursor="help">
<Text fontSize="xs" color={subTextColor}>Alpha</Text>
<Text
fontSize="xs"
fontWeight="bold"
color={alert.alpha >= 0 ? '#ff4d4f' : '#52c41a'}
>
{alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
</Text>
</HStack>
</MetricTooltip>
)}
{/* Alpha Z-Score */}
{alert.is_v2 && alert.alpha_zscore != null && (
<MetricTooltip metricKey="alpha_zscore">
<HStack spacing={1} cursor="help">
<ZScoreBar
value={alert.alpha_zscore}
color={alert.alpha_zscore >= 0 ? '#ff4d4f' : '#52c41a'}
/>
<Text
fontSize="xs"
fontWeight="medium"
color={alert.alpha_zscore >= 0 ? '#ff4d4f' : '#52c41a'}
>
{alert.alpha_zscore >= 0 ? '+' : ''}{alert.alpha_zscore.toFixed(1)}σ
</Text>
</HStack>
</MetricTooltip>
)}
{/* 成交额强度 */}
{alert.is_v2 && alert.amt_zscore != null && alert.amt_zscore > 0.5 && (
<MetricTooltip metricKey="amt_zscore">
<HStack spacing={1} cursor="help">
<Icon as={BarChart3} boxSize={3} color="#eb2f96" />
<Text
fontSize="xs"
fontWeight="medium"
color={alert.amt_zscore >= 2 ? '#eb2f96' : '#faad14'}
>
{alert.amt_zscore.toFixed(1)}σ
</Text>
</HStack>
</MetricTooltip>
)}
{/* 涨停占比 */}
{(alert.limit_up_ratio || 0) > 0.03 && (
<MetricTooltip metricKey="limit_up_ratio">
<HStack
spacing={1}
color={alert.limit_up_ratio >= 0.1 ? '#ff4d4f' : '#fa8c16'}
cursor="help"
>
<Icon
as={Flame}
boxSize={3}
animation={alert.limit_up_ratio >= 0.15 ? `${pulseGlow} 2s infinite` : undefined}
/>
<Text fontSize="xs" fontWeight="bold">
{Math.round(alert.limit_up_ratio * 100)}%
</Text>
</HStack>
</MetricTooltip>
)}
{/* 动量指标 */}
{alert.is_v2 && alert.momentum_3m != null && Math.abs(alert.momentum_3m) > 0.3 && (
<MetricTooltip metricKey="momentum_3m">
<HStack spacing={1} cursor="help">
<Icon as={Sparkles} boxSize={3} color="#1890ff" />
<Text
fontSize="xs"
fontWeight="medium"
color={alert.momentum_3m >= 0 ? '#ff4d4f' : '#52c41a'}
>
{alert.momentum_3m >= 0 ? '+' : ''}{alert.momentum_3m.toFixed(2)}
</Text>
</HStack>
</MetricTooltip>
)}
</Flex>
{/* 触发规则标签 */}
{alert.triggered_rules && alert.triggered_rules.length > 0 && (
<Flex mt={2} gap={1} flexWrap="wrap">
{alert.triggered_rules.slice(0, 4).map((rule, idx) => (
<TriggeredRuleBadge key={idx} rule={rule} />
))}
{alert.triggered_rules.length > 4 && (
<Text fontSize="10px" color={subTextColor}>
+{alert.triggered_rules.length - 4}
</Text>
)}
</Flex>
)}
</Box> </Box>
{/* 展开的股票列表 */} {/* 展开的股票列表 */}
@@ -183,12 +528,12 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
borderTopWidth="1px" borderTopWidth="1px"
borderColor={borderColor} borderColor={borderColor}
p={3} p={3}
bg={tableBg} bg={expandedBg}
> >
{loadingStocks ? ( {loadingStocks ? (
<HStack justify="center" py={4}> <HStack justify="center" py={4}>
<Spinner size="sm" color="purple.500" /> <Spinner size="sm" color={alertConfig.color} />
<Text fontSize="sm" color="gray.500">加载相关股票...</Text> <Text fontSize="sm" color={subTextColor}>加载相关股票...</Text>
</HStack> </HStack>
) : stocks && stocks.length > 0 ? ( ) : 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 upCount = validStocks.filter(s => s.change_pct > 0).length;
const downCount = validStocks.filter(s => s.change_pct < 0).length; const downCount = validStocks.filter(s => s.change_pct < 0).length;
return ( return (
<HStack spacing={4} mb={2} fontSize="xs" color="gray.500"> <HStack
spacing={4}
mb={3}
p={2}
bg={tableBg}
borderRadius="lg"
fontSize="xs"
>
<HStack> <HStack>
<Text>概念均涨:</Text> <Text color={subTextColor}>板块均涨:</Text>
<Text fontWeight="bold" color={avgChange >= 0 ? 'red.400' : 'green.400'}> <Text fontWeight="bold" color={avgChange >= 0 ? '#ff4d4f' : '#52c41a'}>
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}% {avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
</Text> </Text>
</HStack> </HStack>
<Box w="1px" h="12px" bg={borderColor} />
<HStack spacing={1}> <HStack spacing={1}>
<Text color="red.400">{upCount}</Text> <Text color="#ff4d4f" fontWeight="medium">{upCount}</Text>
<Text>/</Text> <Text color={subTextColor}>/</Text>
<Text color="green.400">{downCount}</Text> <Text color="#52c41a" fontWeight="medium">{downCount}</Text>
</HStack> </HStack>
</HStack> </HStack>
); );
})()} })()}
<TableContainer maxH="200px" overflowY="auto"> <TableContainer maxH="200px" overflowY="auto">
<Table size="sm" variant="simple"> <Table size="sm" variant="simple">
<Thead position="sticky" top={0} bg={tableBg} zIndex={1}> <Thead position="sticky" top={0} bg={expandedBg} zIndex={1}>
<Tr> <Tr>
<Th px={2} py={1} fontSize="xs" color="gray.500">股票</Th> <Th px={2} py={2} fontSize="xs" color={subTextColor} borderColor={borderColor}>
<Th px={2} py={1} fontSize="xs" color="gray.500" isNumeric>涨跌</Th> 股票
<Th px={2} py={1} fontSize="xs" color="gray.500" maxW="120px">原因</Th> </Th>
<Th px={2} py={2} fontSize="xs" color={subTextColor} isNumeric borderColor={borderColor}>
涨跌幅
</Th>
<Th px={2} py={2} fontSize="xs" color={subTextColor} maxW="120px" borderColor={borderColor}>
原因
</Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
@@ -236,15 +596,16 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
cursor="pointer" cursor="pointer"
_hover={{ bg: hoverBg }} _hover={{ bg: hoverBg }}
onClick={(e) => handleStockClick(e, stockCode)} onClick={(e) => handleStockClick(e, stockCode)}
transition="background 0.15s"
> >
<Td px={2} py={1.5}> <Td px={2} py={2} borderColor={borderColor}>
<Popover trigger="hover" placement="right" isLazy> <Popover trigger="hover" placement="right" isLazy>
<PopoverTrigger> <PopoverTrigger>
<Text <Text
fontSize="xs" fontSize="xs"
color="cyan.400" color="#1890ff"
fontWeight="medium" fontWeight="medium"
_hover={{ textDecoration: 'underline' }} _hover={{ color: '#40a9ff', textDecoration: 'underline' }}
> >
{stockName} {stockName}
</Text> </Text>
@@ -255,11 +616,13 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
h="80px" h="80px"
bg={popoverBg} bg={popoverBg}
borderColor={borderColor} borderColor={borderColor}
boxShadow="lg" boxShadow="xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<PopoverBody p={2}> <PopoverBody p={2}>
<Text fontSize="xs" color="gray.500" mb={1}>{stockName} 分时</Text> <Text fontSize="xs" color={subTextColor} mb={1}>
{stockName} 分时
</Text>
<Box h="50px"> <Box h="50px">
<MiniTimelineChart stockCode={stockCode} /> <MiniTimelineChart stockCode={stockCode} />
</Box> </Box>
@@ -268,13 +631,13 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
</Portal> </Portal>
</Popover> </Popover>
</Td> </Td>
<Td px={2} py={1.5} isNumeric> <Td px={2} py={2} isNumeric borderColor={borderColor}>
<Text <Text
fontSize="xs" fontSize="xs"
fontWeight="bold" fontWeight="bold"
color={ color={
hasChange && changePct > 0 ? 'red.400' : hasChange && changePct > 0 ? '#ff4d4f' :
hasChange && changePct < 0 ? 'green.400' : 'gray.400' hasChange && changePct < 0 ? '#52c41a' : subTextColor
} }
> >
{hasChange {hasChange
@@ -283,8 +646,8 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
} }
</Text> </Text>
</Td> </Td>
<Td px={2} py={1.5} maxW="120px"> <Td px={2} py={2} maxW="120px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" noOfLines={1}> <Text fontSize="xs" color={subTextColor} noOfLines={1}>
{stock.reason || '-'} {stock.reason || '-'}
</Text> </Text>
</Td> </Td>
@@ -293,15 +656,16 @@ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
})} })}
</Tbody> </Tbody>
</Table> </Table>
</TableContainer>
{stocks.length > 10 && ( {stocks.length > 10 && (
<Text fontSize="xs" color="gray.500" textAlign="center" mt={2}> <Text fontSize="xs" color={subTextColor} textAlign="center" mt={2}>
{stocks.length} 只相关股票显示前 10 {stocks.length} 只相关股票显示前 10
</Text> </Text>
)} )}
</TableContainer>
</> </>
) : ( ) : (
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}> <Text fontSize="sm" color={subTextColor} textAlign="center" py={2}>
暂无相关股票数据 暂无相关股票数据
</Text> </Text>
)} )}
@@ -320,6 +684,7 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight
const [loadingConcepts, setLoadingConcepts] = useState({}); const [loadingConcepts, setLoadingConcepts] = useState({});
const subTextColor = useColorModeValue('gray.500', 'gray.400'); const subTextColor = useColorModeValue('gray.500', 'gray.400');
const emptyBg = useColorModeValue('gray.50', '#111111');
// 获取概念相关股票 // 获取概念相关股票
const fetchConceptStocks = useCallback(async (conceptId) => { const fetchConceptStocks = useCallback(async (conceptId) => {
@@ -330,29 +695,18 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight
setLoadingConcepts(prev => ({ ...prev, [conceptId]: true })); setLoadingConcepts(prev => ({ ...prev, [conceptId]: true }));
try { try {
// 调用后端 API 获取概念股票 const response = await axios.get(`/api/concept/${encodeURIComponent(conceptId)}/stocks`);
const response = await axios.get(`/api/concept/${conceptId}/stocks`);
if (response.data?.success && response.data?.data?.stocks) { if (response.data?.success && response.data?.data?.stocks) {
setConceptStocks(prev => ({ setConceptStocks(prev => ({
...prev, ...prev,
[conceptId]: response.data.data.stocks [conceptId]: response.data.data.stocks
})); }));
} else {
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
} }
} catch (error) { } catch (error) {
console.error('获取概念股票失败:', 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 { } finally {
setLoadingConcepts(prev => ({ ...prev, [conceptId]: false })); setLoadingConcepts(prev => ({ ...prev, [conceptId]: false }));
} }
@@ -366,19 +720,23 @@ const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight
setExpandedId(null); setExpandedId(null);
} else { } else {
setExpandedId(alertKey); setExpandedId(alertKey);
// 获取股票数据
if (alert.concept_id) { if (alert.concept_id) {
fetchConceptStocks(alert.concept_id); fetchConceptStocks(alert.concept_id);
} }
} }
// 通知父组件
onAlertClick?.(alert); onAlertClick?.(alert);
}, [expandedId, fetchConceptStocks, onAlertClick]); }, [expandedId, fetchConceptStocks, onAlertClick]);
if (!alerts || alerts.length === 0) { if (!alerts || alerts.length === 0) {
return ( return (
<Box p={4} textAlign="center"> <Box
p={6}
textAlign="center"
bg={emptyBg}
borderRadius="xl"
>
<Icon as={Zap} boxSize={8} color={subTextColor} mb={2} opacity={0.5} />
<Text color={subTextColor} fontSize="sm"> <Text color={subTextColor} fontSize="sm">
当日暂无概念异动 当日暂无概念异动
</Text> </Text>

View File

@@ -1,5 +1,5 @@
/** /**
* 热点概览组件 * 热点概览组件 - 科技感设计
* 展示大盘分时走势 + 概念异动标注 * 展示大盘分时走势 + 概念异动标注
* *
* 模块化结构: * 模块化结构:
@@ -12,8 +12,6 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
Box, Box,
Card,
CardBody,
Heading, Heading,
Text, Text,
HStack, HStack,
@@ -27,16 +25,36 @@ import {
useColorModeValue, useColorModeValue,
Grid, Grid,
GridItem, GridItem,
Divider,
IconButton, IconButton,
Collapse, Collapse,
keyframes,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FaFire, FaList, FaChartArea, FaChevronDown, FaChevronUp } from 'react-icons/fa'; import {
import { InfoIcon } from '@chakra-ui/icons'; Flame,
List,
LineChart,
ChevronDown,
ChevronUp,
Info,
Zap,
AlertCircle,
} from 'lucide-react';
import { useHotspotData } from './hooks'; import { useHotspotData } from './hooks';
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components'; 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 * @param {Object} props
@@ -50,46 +68,102 @@ const HotspotOverview = ({ selectedDate }) => {
const { loading, error, data } = useHotspotData(selectedDate); const { loading, error, data } = useHotspotData(selectedDate);
// 颜色主题 // 颜色主题
const cardBg = useColorModeValue('white', '#1a1a1a'); const cardBg = useColorModeValue('white', '#0a0a0a');
const borderColor = useColorModeValue('gray.200', '#333333'); const borderColor = useColorModeValue('gray.200', '#1f1f1f');
const textColor = useColorModeValue('gray.800', 'white'); const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.600', 'gray.400'); 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) => { const handleAlertClick = useCallback((alert) => {
setSelectedAlert(alert); setSelectedAlert(alert);
// 可以在这里添加滚动到对应位置的逻辑
}, []); }, []);
// 渲染加载状态 // 渲染加载状态
if (loading) { if (loading) {
return ( return (
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}> <Box
<CardBody> bg={cardBg}
<Center h="400px"> borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
>
{/* 顶部装饰条 */}
<Box
h="4px"
bgGradient="linear(to-r, orange.400, red.500, pink.500)"
backgroundSize="200% 200%"
animation={`${gradientShift} 3s ease infinite`}
/>
<Center h="400px" p={6}>
<VStack spacing={4}> <VStack spacing={4}>
<Spinner size="xl" color="purple.500" thickness="4px" /> <Box position="relative">
<Text color={subTextColor}>加载热点概览数据...</Text> <Spinner
size="xl"
color="orange.400"
thickness="3px"
speed="0.8s"
/>
<Box
position="absolute"
inset={0}
borderRadius="full"
animation={`${pulseGlow} 2s ease-in-out infinite`}
boxShadow="0 0 30px rgba(251, 146, 60, 0.3)"
/>
</Box>
<VStack spacing={1}>
<Text color={textColor} fontWeight="medium">
加载热点概览数据
</Text>
<Text color={subTextColor} fontSize="sm">
正在获取市场异动信息...
</Text>
</VStack>
</VStack> </VStack>
</Center> </Center>
</CardBody> </Box>
</Card>
); );
} }
// 渲染错误状态 // 渲染错误状态
if (error) { if (error) {
return ( return (
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}> <Box
<CardBody> bg={cardBg}
<Center h="400px"> borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
>
<Box h="4px" bg="red.500" />
<Center h="400px" p={6}>
<VStack spacing={4}> <VStack spacing={4}>
<Icon as={InfoIcon} boxSize={10} color="red.400" /> <Box
<Text color="red.500">{error}</Text> p={4}
borderRadius="full"
bg="red.500"
bgOpacity={0.1}
>
<Icon as={AlertCircle} boxSize={10} color="red.400" />
</Box>
<VStack spacing={1}>
<Text color="red.400" fontWeight="medium">
数据加载失败
</Text>
<Text color={subTextColor} fontSize="sm" textAlign="center">
{error}
</Text>
</VStack>
</VStack> </VStack>
</Center> </Center>
</CardBody> </Box>
</Card>
); );
} }
@@ -101,53 +175,131 @@ const HotspotOverview = ({ selectedDate }) => {
const { index, alerts, alert_summary } = data; const { index, alerts, alert_summary } = data;
return ( return (
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}> <Box
<CardBody> bg={cardBg}
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
transition="all 0.3s"
_hover={{
boxShadow: useColorModeValue(
'0 4px 20px rgba(0,0,0,0.08)',
'0 4px 20px rgba(0,0,0,0.4)'
),
}}
>
{/* 顶部装饰条 */}
<Box
h="4px"
bgGradient="linear(to-r, orange.400, red.500, pink.500)"
backgroundSize="200% 200%"
animation={`${gradientShift} 3s ease infinite`}
/>
<Box p={5}>
{/* 头部 */} {/* 头部 */}
<Flex align="center" mb={4}> <Flex align="center" mb={5}>
<HStack spacing={3}> <HStack spacing={3}>
<Icon as={FaFire} boxSize={6} color="orange.500" /> <Box
<Heading size="md" color={textColor}> p={2}
borderRadius="xl"
bgGradient="linear(to-br, orange.400, red.500)"
boxShadow="0 4px 15px rgba(251, 146, 60, 0.4)"
>
<Icon as={Flame} boxSize={5} color="white" />
</Box>
<VStack align="flex-start" spacing={0}>
<Heading size="md" color={textColor} fontWeight="bold">
热点概览 热点概览
</Heading> </Heading>
<Text fontSize="xs" color={subTextColor}>
实时概念异动监控
</Text>
</VStack>
</HStack> </HStack>
<Spacer /> <Spacer />
<HStack spacing={2}> <HStack spacing={2}>
<Tooltip label={showAlertList ? '收起异动列表' : '展开异动列表'}> {/* 异动数量徽章 */}
{alerts.length > 0 && (
<HStack
spacing={1}
px={3}
py={1.5}
borderRadius="full"
bg="orange.500"
bgOpacity={0.15}
>
<Icon as={Zap} boxSize={3.5} color="orange.400" />
<Text fontSize="sm" fontWeight="bold" color="orange.400">
{alerts.length}
</Text>
</HStack>
)}
{/* 切换按钮 */}
<Tooltip label={showAlertList ? '收起异动列表' : '展开异动列表'} hasArrow>
<IconButton <IconButton
icon={showAlertList ? <FaChevronUp /> : <FaList />} icon={<Icon as={showAlertList ? ChevronUp : List} boxSize={4} />}
size="sm" size="sm"
variant="ghost" variant="ghost"
borderRadius="lg"
onClick={() => setShowAlertList(!showAlertList)} onClick={() => setShowAlertList(!showAlertList)}
aria-label="切换异动列表" aria-label="切换异动列表"
_hover={{
bg: useColorModeValue('gray.100', 'gray.800'),
}}
/> />
</Tooltip> </Tooltip>
<Tooltip label="展示大盘走势与概念异动的关联">
<Icon as={InfoIcon} color={subTextColor} /> {/* 信息提示 */}
<Tooltip
label="展示大盘走势与概念异动的关联,帮助发现市场热点"
hasArrow
maxW="200px"
>
<Box cursor="help">
<Icon as={Info} color={subTextColor} boxSize={4} />
</Box>
</Tooltip> </Tooltip>
</HStack> </HStack>
</Flex> </Flex>
{/* 统计摘要 */} {/* 统计摘要 */}
<Box mb={4}> <Box mb={5}>
<AlertSummary indexData={index} alerts={alerts} alertSummary={alert_summary} /> <AlertSummary indexData={index} alerts={alerts} alertSummary={alert_summary} />
</Box> </Box>
<Divider mb={4} />
{/* 主体内容:图表 + 异动列表 */} {/* 主体内容:图表 + 异动列表 */}
<Grid <Grid
templateColumns={{ base: '1fr', lg: showAlertList ? '1fr 300px' : '1fr' }} templateColumns={{ base: '1fr', lg: showAlertList ? '1fr 320px' : '1fr' }}
gap={4} gap={5}
> >
{/* 分时图 */} {/* 分时图 */}
<GridItem> <GridItem>
<Box> <Box
<HStack spacing={2} mb={2}> bg={useColorModeValue('gray.50', '#0d0d0d')}
<Icon as={FaChartArea} color="purple.500" boxSize={4} /> borderRadius="xl"
<Text fontSize="sm" fontWeight="medium" color={textColor}> borderWidth="1px"
borderColor={borderColor}
p={4}
transition="all 0.2s"
>
<HStack spacing={2} mb={3}>
<Box
p={1.5}
borderRadius="lg"
bg="purple.500"
bgOpacity={0.15}
>
<Icon as={LineChart} boxSize={4} color="purple.400" />
</Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>
大盘分时走势 大盘分时走势
</Text> </Text>
<Tooltip label="图表上的标记点表示概念异动时刻" hasArrow>
<Icon as={Info} boxSize={3} color={subTextColor} cursor="help" />
</Tooltip>
</HStack> </HStack>
<IndexMinuteChart <IndexMinuteChart
indexData={index} indexData={index}
@@ -159,12 +311,26 @@ const HotspotOverview = ({ selectedDate }) => {
</GridItem> </GridItem>
{/* 异动列表(可收起) */} {/* 异动列表(可收起) */}
<Collapse in={showAlertList} animateOpacity> <Collapse in={showAlertList} animateOpacity style={{ overflow: 'visible' }}>
<GridItem> <GridItem>
<Box> <Box
<HStack spacing={2} mb={2}> bg={useColorModeValue('gray.50', '#0d0d0d')}
<Icon as={FaList} color="orange.500" boxSize={4} /> borderRadius="xl"
<Text fontSize="sm" fontWeight="medium" color={textColor}> borderWidth="1px"
borderColor={borderColor}
p={4}
h="100%"
>
<HStack spacing={2} mb={3}>
<Box
p={1.5}
borderRadius="lg"
bg="orange.500"
bgOpacity={0.15}
>
<Icon as={List} boxSize={4} color="orange.400" />
</Box>
<Text fontSize="sm" fontWeight="bold" color={textColor}>
异动记录 异动记录
</Text> </Text>
<Text fontSize="xs" color={subTextColor}> <Text fontSize="xs" color={subTextColor}>
@@ -175,7 +341,7 @@ const HotspotOverview = ({ selectedDate }) => {
alerts={alerts} alerts={alerts}
onAlertClick={handleAlertClick} onAlertClick={handleAlertClick}
selectedAlert={selectedAlert} selectedAlert={selectedAlert}
maxHeight="350px" maxHeight="380px"
/> />
</Box> </Box>
</GridItem> </GridItem>
@@ -184,14 +350,22 @@ const HotspotOverview = ({ selectedDate }) => {
{/* 无异动提示 */} {/* 无异动提示 */}
{alerts.length === 0 && ( {alerts.length === 0 && (
<Center py={4}> <Center
py={8}
mt={4}
bg={useColorModeValue('gray.50', '#0d0d0d')}
borderRadius="xl"
>
<VStack spacing={2}>
<Icon as={Zap} boxSize={8} color={subTextColor} opacity={0.5} />
<Text color={subTextColor} fontSize="sm"> <Text color={subTextColor} fontSize="sm">
当日暂无概念异动数据 当日暂无概念异动数据
</Text> </Text>
</VStack>
</Center> </Center>
)} )}
</CardBody> </Box>
</Card> </Box>
); );
}; };

View File

@@ -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 - 异动类型 * @param {string} alertType - 异动类型
@@ -10,42 +211,41 @@
* @returns {Object} { color, symbol, symbolSize } * @returns {Object} { color, symbol, symbolSize }
*/ */
export const getAlertStyle = (alertType, importanceScore = 0.5) => { export const getAlertStyle = (alertType, importanceScore = 0.5) => {
let color = '#ff6b6b'; const config = ALERT_TYPE_CONFIG[alertType] || ALERT_TYPE_CONFIG.surge;
let symbol = 'pin'; const baseSize = 30;
let symbolSize = 35; const sizeBonus = Math.min(importanceScore * 20, 15);
let symbol = 'pin';
switch (alertType) { switch (alertType) {
case 'surge_up': case 'surge_up':
case 'surge': case 'surge':
color = '#ff4757'; case 'volume_surge_up':
case 'shrink_surge_up':
symbol = 'triangle'; symbol = 'triangle';
symbolSize = 30 + Math.min(importanceScore * 20, 15);
break; break;
case 'surge_down': case 'surge_down':
color = '#2ed573'; symbol = 'path://M0,0 L10,0 L5,10 Z';
symbol = 'path://M0,0 L10,0 L5,10 Z'; // 向下三角形
symbolSize = 30 + Math.min(importanceScore * 20, 15);
break; break;
case 'limit_up': case 'limit_up':
color = '#ff6348';
symbol = 'diamond'; symbol = 'diamond';
symbolSize = 28;
break; break;
case 'rank_jump': case 'rank_jump':
color = '#3742fa';
symbol = 'circle'; symbol = 'circle';
symbolSize = 25;
break; break;
case 'volume_spike': case 'volume_spike':
color = '#ffa502'; case 'volume_oscillation':
symbol = 'rect'; symbol = 'rect';
symbolSize = 25;
break; break;
default: 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} 显示标签 * @returns {string} 显示标签
*/ */
export const getAlertTypeLabel = (alertType) => { export const getAlertTypeLabel = (alertType) => {
const labels = { return ALERT_TYPE_CONFIG[alertType]?.label || alertType || '异动';
surge: '急涨', };
surge_up: '暴涨',
surge_down: '暴跌', /**
limit_up: '涨停增加', * 获取异动类型的详细描述
rank_jump: '排名跃升', * @param {string} alertType - 异动类型
volume_spike: '放量', * @returns {string} 描述
unknown: '异动', */
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 timeIndex = times.indexOf(alert.time);
const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax); 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.alert_type,
alert.final_score / 100 || alert.importance_score || 0.5 alert.final_score / 100 || alert.importance_score || 0.5
); );
@@ -113,23 +327,33 @@ export const getAlertMarkPoints = (alerts, times, prices, priceMax, maxCount = 1
symbol, symbol,
symbolSize, symbolSize,
itemStyle: { itemStyle: {
color, color: {
borderColor: '#fff', type: 'linear',
borderWidth: 1, x: 0, y: 0, x2: 0, y2: 1,
shadowBlur: 3, colorStops: [
shadowColor: 'rgba(0,0,0,0.2)', { 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: { label: {
show: true, show: true,
position: isDown ? 'bottom' : 'top', position: isDown ? 'bottom' : 'top',
formatter: '{b}', formatter: '{b}',
fontSize: 9, fontSize: 10,
color: '#333', fontWeight: 500,
backgroundColor: isDown ? 'rgba(46, 213, 115, 0.9)' : 'rgba(255,255,255,0.9)', color: isDown ? '#52c41a' : '#ff4d4f',
padding: [2, 4], backgroundColor: 'rgba(255,255,255,0.95)',
borderRadius: 2, padding: [3, 6],
borderRadius: 4,
borderColor: color, borderColor: color,
borderWidth: 1, borderWidth: 1,
shadowBlur: 4,
shadowColor: 'rgba(0,0,0,0.1)',
}, },
alertData: alert, // 存储原始数据 alertData: alert, // 存储原始数据
}; };
@@ -153,8 +377,45 @@ export const formatScore = (score) => {
*/ */
export const getScoreColor = (score) => { export const getScoreColor = (score) => {
const s = score || 0; const s = score || 0;
if (s >= 80) return '#ff4757'; if (s >= 80) return '#ff4d4f';
if (s >= 60) return '#ff6348'; if (s >= 60) return '#fa8c16';
if (s >= 40) return '#ffa502'; if (s >= 40) return '#faad14';
return '#747d8c'; 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,
};
}; };