update pay ui
This commit is contained in:
@@ -346,7 +346,173 @@ export const marketHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 市场统计数据(个股中心页面使用)
|
||||
// 11. 热点概览数据(大盘分时 + 概念异动)
|
||||
http.get('/api/market/hotspot-overview', async ({ request }) => {
|
||||
await delay(300);
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
const tradeDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// 生成分时数据(240个点,9:30-11:30 + 13:00-15:00)
|
||||
const timeline = [];
|
||||
const basePrice = 3900 + Math.random() * 100; // 基准价格 3900-4000
|
||||
const prevClose = basePrice;
|
||||
let currentPrice = basePrice;
|
||||
let cumulativeVolume = 0;
|
||||
|
||||
// 上午时段 9:30-11:30 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 9 + Math.floor((i + 30) / 60);
|
||||
const minute = (i + 30) % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
// 模拟价格波动
|
||||
const volatility = 0.002; // 0.2%波动
|
||||
const drift = (Math.random() - 0.5) * 0.001; // 微小趋势
|
||||
currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift);
|
||||
|
||||
const volume = Math.floor(Math.random() * 500000 + 100000); // 成交量
|
||||
cumulativeVolume += volume;
|
||||
|
||||
timeline.push({
|
||||
time,
|
||||
price: parseFloat(currentPrice.toFixed(2)),
|
||||
volume: cumulativeVolume,
|
||||
change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2))
|
||||
});
|
||||
}
|
||||
|
||||
// 下午时段 13:00-15:00 (120分钟)
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const hour = 13 + Math.floor(i / 60);
|
||||
const minute = i % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
// 下午波动略小
|
||||
const volatility = 0.0015;
|
||||
const drift = (Math.random() - 0.5) * 0.0008;
|
||||
currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift);
|
||||
|
||||
const volume = Math.floor(Math.random() * 400000 + 80000);
|
||||
cumulativeVolume += volume;
|
||||
|
||||
timeline.push({
|
||||
time,
|
||||
price: parseFloat(currentPrice.toFixed(2)),
|
||||
volume: cumulativeVolume,
|
||||
change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2))
|
||||
});
|
||||
}
|
||||
|
||||
// 生成概念异动数据
|
||||
const conceptNames = [
|
||||
'人工智能', 'AI眼镜', '机器人', '核电', '国企', '卫星导航',
|
||||
'福建自贸区', '两岸融合', 'CRO', '三季报增长', '百货零售',
|
||||
'人形机器人', '央企', '数据中心', 'CPO', '新能源', '电网设备',
|
||||
'氢能源', '算力租赁', '厦门国资', '乳业', '低空安防', '创新药',
|
||||
'商业航天', '控制权变更', '文化传媒', '海峡两岸'
|
||||
];
|
||||
|
||||
const alertTypes = ['surge_up', 'surge_down', 'volume_spike', 'limit_up', 'rank_jump'];
|
||||
|
||||
// 生成 15-25 个异动
|
||||
const alertCount = Math.floor(Math.random() * 10) + 15;
|
||||
const alerts = [];
|
||||
const usedTimes = new Set();
|
||||
|
||||
for (let i = 0; i < alertCount; i++) {
|
||||
// 随机选择一个时间点
|
||||
let timeIdx;
|
||||
let attempts = 0;
|
||||
do {
|
||||
timeIdx = Math.floor(Math.random() * timeline.length);
|
||||
attempts++;
|
||||
} while (usedTimes.has(timeIdx) && attempts < 50);
|
||||
|
||||
if (attempts >= 50) continue;
|
||||
|
||||
// 同一时间可以有多个异动
|
||||
const time = timeline[timeIdx].time;
|
||||
const conceptName = conceptNames[Math.floor(Math.random() * conceptNames.length)];
|
||||
const alertType = alertTypes[Math.floor(Math.random() * alertTypes.length)];
|
||||
|
||||
// 根据类型生成 alpha
|
||||
let alpha;
|
||||
if (alertType === 'surge_up') {
|
||||
alpha = parseFloat((Math.random() * 3 + 2).toFixed(2)); // +2% ~ +5%
|
||||
} else if (alertType === 'surge_down') {
|
||||
alpha = parseFloat((-Math.random() * 3 - 1.5).toFixed(2)); // -1.5% ~ -4.5%
|
||||
} else {
|
||||
alpha = parseFloat((Math.random() * 4 - 1).toFixed(2)); // -1% ~ +3%
|
||||
}
|
||||
|
||||
const finalScore = Math.floor(Math.random() * 40 + 45); // 45-85分
|
||||
const ruleScore = Math.floor(Math.random() * 30 + 40);
|
||||
const mlScore = Math.floor(Math.random() * 30 + 40);
|
||||
|
||||
alerts.push({
|
||||
concept_id: `CONCEPT_${1000 + i}`,
|
||||
concept_name: conceptName,
|
||||
time,
|
||||
alert_type: alertType,
|
||||
alpha,
|
||||
alpha_delta: parseFloat((Math.random() * 2 - 0.5).toFixed(2)),
|
||||
amt_ratio: parseFloat((Math.random() * 5 + 1).toFixed(2)),
|
||||
limit_up_count: alertType === 'limit_up' ? Math.floor(Math.random() * 5 + 1) : 0,
|
||||
limit_up_ratio: parseFloat((Math.random() * 0.3).toFixed(3)),
|
||||
final_score: finalScore,
|
||||
rule_score: ruleScore,
|
||||
ml_score: mlScore,
|
||||
trigger_reason: finalScore >= 65 ? '规则强信号' : (mlScore >= 70 ? 'ML强信号' : '融合触发'),
|
||||
importance_score: parseFloat((finalScore / 100).toFixed(2)),
|
||||
index_price: timeline[timeIdx].price
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
alerts.sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
// 统计异动类型
|
||||
const alertSummary = alerts.reduce((acc, alert) => {
|
||||
acc[alert.alert_type] = (acc[alert.alert_type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 计算指数统计
|
||||
const prices = timeline.map(t => t.price);
|
||||
const latestPrice = prices[prices.length - 1];
|
||||
const highPrice = Math.max(...prices);
|
||||
const lowPrice = Math.min(...prices);
|
||||
const changePct = ((latestPrice - prevClose) / prevClose * 100);
|
||||
|
||||
console.log('[Mock Market] 获取热点概览数据:', {
|
||||
date: tradeDate,
|
||||
timelinePoints: timeline.length,
|
||||
alertCount: alerts.length
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
index: {
|
||||
code: '000001.SH',
|
||||
name: '上证指数',
|
||||
latest_price: latestPrice,
|
||||
prev_close: prevClose,
|
||||
high: highPrice,
|
||||
low: lowPrice,
|
||||
change_pct: parseFloat(changePct.toFixed(2)),
|
||||
timeline
|
||||
},
|
||||
alerts,
|
||||
alert_summary: alertSummary
|
||||
},
|
||||
trade_date: tradeDate
|
||||
});
|
||||
}),
|
||||
|
||||
// 12. 市场统计数据(个股中心页面使用)
|
||||
http.get('/api/market/statistics', async ({ request }) => {
|
||||
await delay(200);
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 异动统计摘要组件
|
||||
* 展示指数统计和异动类型统计
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBolt, FaArrowDown, FaRocket, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa';
|
||||
|
||||
/**
|
||||
* 异动类型徽章
|
||||
*/
|
||||
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 cfg = config[type] || { label: type, color: 'gray', icon: FaFire };
|
||||
|
||||
return (
|
||||
<Badge colorScheme={cfg.color} variant="subtle" px={2} py={1} borderRadius="md">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={cfg.icon} boxSize={3} />
|
||||
<Text>{cfg.label}</Text>
|
||||
<Text fontWeight="bold">{count}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 指数统计卡片
|
||||
*/
|
||||
const IndexStatCard = ({ indexData }) => {
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
if (!indexData) return null;
|
||||
|
||||
const changePct = indexData.change_pct || 0;
|
||||
const isUp = changePct >= 0;
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>{indexData.name || '上证指数'}</StatLabel>
|
||||
<StatNumber fontSize="xl" color={isUp ? 'red.500' : 'green.500'}>
|
||||
{indexData.latest_price?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<StatArrow type={isUp ? 'increase' : 'decrease'} />
|
||||
{changePct?.toFixed(2)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最高</StatLabel>
|
||||
<StatNumber fontSize="xl" color="red.500">
|
||||
{indexData.high?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最低</StatLabel>
|
||||
<StatNumber fontSize="xl" color="green.500">
|
||||
{indexData.low?.toFixed(2) || '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>振幅</StatLabel>
|
||||
<StatNumber fontSize="xl" color="purple.500">
|
||||
{indexData.high && indexData.low && indexData.prev_close
|
||||
? (((indexData.high - indexData.low) / indexData.prev_close) * 100).toFixed(2) + '%'
|
||||
: '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 异动统计摘要
|
||||
* @param {Object} props
|
||||
* @param {Object} props.indexData - 指数数据
|
||||
* @param {Array} props.alerts - 异动数组
|
||||
* @param {Object} props.alertSummary - 异动类型统计
|
||||
*/
|
||||
const AlertSummary = ({ indexData, alerts = [], alertSummary = {} }) => {
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
|
||||
// 如果没有 alertSummary,从 alerts 中统计
|
||||
const summary = alertSummary && Object.keys(alertSummary).length > 0
|
||||
? alertSummary
|
||||
: alerts.reduce((acc, alert) => {
|
||||
const type = alert.alert_type || 'unknown';
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const totalAlerts = alerts.length;
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 指数统计 */}
|
||||
<IndexStatCard indexData={indexData} />
|
||||
|
||||
{/* 异动统计 */}
|
||||
{totalAlerts > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color="gray.500" mr={2}>
|
||||
异动 {totalAlerts} 次:
|
||||
</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>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertSummary;
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 概念异动列表组件
|
||||
* 展示当日的概念异动记录
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Flex,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaBolt, FaArrowUp, FaArrowDown, FaChartLine, FaFire, FaVolumeUp } from 'react-icons/fa';
|
||||
import { getAlertTypeLabel, formatScore, getScoreColor } from '../utils/chartHelpers';
|
||||
|
||||
/**
|
||||
* 单个异动项组件
|
||||
*/
|
||||
const AlertItem = ({ alert, onClick, isSelected }) => {
|
||||
const bgColor = useColorModeValue('white', '#1a1a1a');
|
||||
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||
const borderColor = useColorModeValue('gray.200', '#333');
|
||||
const selectedBg = useColorModeValue('purple.50', '#2a2a3a');
|
||||
|
||||
const isUp = alert.alert_type !== 'surge_down';
|
||||
const typeColor = isUp ? 'red' : 'green';
|
||||
|
||||
// 获取异动类型图标
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'surge_up':
|
||||
case 'surge':
|
||||
return FaArrowUp;
|
||||
case 'surge_down':
|
||||
return FaArrowDown;
|
||||
case 'limit_up':
|
||||
return FaFire;
|
||||
case 'volume_spike':
|
||||
return FaVolumeUp;
|
||||
case 'rank_jump':
|
||||
return FaChartLine;
|
||||
default:
|
||||
return FaBolt;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg={isSelected ? selectedBg : bgColor}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={isSelected ? 'purple.400' : borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: hoverBg, transform: 'translateX(4px)' }}
|
||||
onClick={() => onClick?.(alert)}
|
||||
>
|
||||
<Flex justify="space-between" align="flex-start">
|
||||
{/* 左侧:概念名称和时间 */}
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={getTypeIcon(alert.alert_type)} color={`${typeColor}.500`} boxSize={4} />
|
||||
<Text fontWeight="bold" fontSize="sm" noOfLines={1}>
|
||||
{alert.concept_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2} fontSize="xs" color="gray.500">
|
||||
<Text>{alert.time}</Text>
|
||||
<Badge colorScheme={typeColor} size="sm" variant="subtle">
|
||||
{getAlertTypeLabel(alert.alert_type)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:分数和关键指标 */}
|
||||
<VStack align="end" spacing={1}>
|
||||
{/* 综合得分 */}
|
||||
{alert.final_score !== undefined && (
|
||||
<Tooltip label={`规则: ${formatScore(alert.rule_score)} / ML: ${formatScore(alert.ml_score)}`}>
|
||||
<Badge
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
bg={getScoreColor(alert.final_score)}
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{formatScore(alert.final_score)}分
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Alpha 值 */}
|
||||
{alert.alpha !== undefined && (
|
||||
<Text fontSize="xs" color={alert.alpha >= 0 ? 'red.500' : 'green.500'} fontWeight="medium">
|
||||
α {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 涨停数量 */}
|
||||
{alert.limit_up_count > 0 && (
|
||||
<HStack spacing={1}>
|
||||
<Icon as={FaFire} color="orange.500" boxSize={3} />
|
||||
<Text fontSize="xs" color="orange.500">
|
||||
涨停 {alert.limit_up_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 概念异动列表
|
||||
* @param {Object} props
|
||||
* @param {Array} props.alerts - 异动数据数组
|
||||
* @param {Function} props.onAlertClick - 点击异动的回调
|
||||
* @param {Object} props.selectedAlert - 当前选中的异动
|
||||
* @param {number} props.maxHeight - 最大高度
|
||||
*/
|
||||
const ConceptAlertList = ({ alerts = [], onAlertClick, selectedAlert, maxHeight = '400px' }) => {
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
if (!alerts || alerts.length === 0) {
|
||||
return (
|
||||
<Box p={4} textAlign="center">
|
||||
<Text color={subTextColor} fontSize="sm">
|
||||
当日暂无概念异动
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 按时间分组
|
||||
const groupedAlerts = alerts.reduce((acc, alert) => {
|
||||
const time = alert.time || '未知时间';
|
||||
if (!acc[time]) {
|
||||
acc[time] = [];
|
||||
}
|
||||
acc[time].push(alert);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 按时间排序
|
||||
const sortedTimes = Object.keys(groupedAlerts).sort();
|
||||
|
||||
return (
|
||||
<Box maxH={maxHeight} overflowY="auto" pr={2}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{sortedTimes.map((time, timeIndex) => (
|
||||
<Box key={time}>
|
||||
{/* 时间分隔线 */}
|
||||
{timeIndex > 0 && <Divider my={2} />}
|
||||
|
||||
{/* 时间标签 */}
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Box w={2} h={2} borderRadius="full" bg="purple.500" />
|
||||
<Text fontSize="xs" fontWeight="bold" color={subTextColor}>
|
||||
{time}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
({groupedAlerts[time].length}个异动)
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 该时间点的异动 */}
|
||||
<VStack spacing={2} align="stretch" pl={4}>
|
||||
{groupedAlerts[time].map((alert, idx) => (
|
||||
<AlertItem
|
||||
key={`${alert.concept_id || alert.concept_name}-${idx}`}
|
||||
alert={alert}
|
||||
onClick={onAlertClick}
|
||||
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptAlertList;
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 指数分时图组件
|
||||
* 展示大盘分时走势,支持概念异动标注
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { getAlertMarkPoints } from '../utils/chartHelpers';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... }
|
||||
* @param {Array} props.alerts - 异动数据数组
|
||||
* @param {Function} props.onAlertClick - 点击异动标注的回调
|
||||
* @param {string} props.height - 图表高度
|
||||
*/
|
||||
const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const gridLineColor = useColorModeValue('#eee', '#333');
|
||||
|
||||
// 计算图表配置
|
||||
const chartOption = useMemo(() => {
|
||||
if (!indexData || !indexData.timeline || indexData.timeline.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeline = indexData.timeline || [];
|
||||
const times = timeline.map((d) => d.time);
|
||||
const prices = timeline.map((d) => d.price);
|
||||
const volumes = timeline.map((d) => d.volume);
|
||||
const changePcts = timeline.map((d) => d.change_pct);
|
||||
|
||||
// 计算Y轴范围
|
||||
const validPrices = prices.filter(Boolean);
|
||||
if (validPrices.length === 0) return null;
|
||||
|
||||
const priceMin = Math.min(...validPrices);
|
||||
const priceMax = Math.max(...validPrices);
|
||||
const priceRange = priceMax - priceMin;
|
||||
const yAxisMin = priceMin - priceRange * 0.1;
|
||||
const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注
|
||||
|
||||
// 准备异动标注
|
||||
const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax);
|
||||
|
||||
// 渐变色 - 根据涨跌
|
||||
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
||||
const isUp = latestChangePct >= 0;
|
||||
const lineColor = isUp ? '#ff4d4d' : '#22c55e';
|
||||
const areaColorStops = isUp
|
||||
? [
|
||||
{ offset: 0, color: 'rgba(255, 77, 77, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(255, 77, 77, 0.05)' },
|
||||
]
|
||||
: [
|
||||
{ offset: 0, color: 'rgba(34, 197, 94, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(34, 197, 94, 0.05)' },
|
||||
];
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: { color: '#999' },
|
||||
},
|
||||
formatter: (params) => {
|
||||
if (!params || params.length === 0) return '';
|
||||
|
||||
const dataIndex = params[0].dataIndex;
|
||||
const time = times[dataIndex];
|
||||
const price = prices[dataIndex];
|
||||
const changePct = changePcts[dataIndex];
|
||||
const volume = volumes[dataIndex];
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>
|
||||
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 检查是否有异动
|
||||
const alertsAtTime = alerts.filter((a) => a.time === time);
|
||||
if (alertsAtTime.length > 0) {
|
||||
html += '<div style="border-top: 1px solid #eee; margin-top: 4px; padding-top: 4px;">';
|
||||
html += '<div style="font-weight: bold; color: #ff6b6b;">概念异动:</div>';
|
||||
alertsAtTime.forEach((alert) => {
|
||||
const typeLabel = {
|
||||
surge: '急涨',
|
||||
surge_up: '暴涨',
|
||||
surge_down: '暴跌',
|
||||
limit_up: '涨停增加',
|
||||
rank_jump: '排名跃升',
|
||||
volume_spike: '放量',
|
||||
}[alert.alert_type] || alert.alert_type;
|
||||
const typeColor = alert.alert_type === 'surge_down' ? '#2ed573' : '#ff6b6b';
|
||||
const alpha = alert.alpha ? ` (α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(2)}%)` : '';
|
||||
html += `<div style="color: ${typeColor}">• ${alert.concept_name} (${typeLabel}${alpha})</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: { show: false },
|
||||
grid: [
|
||||
{ left: '8%', right: '3%', top: '8%', height: '58%' },
|
||||
{ left: '8%', right: '3%', top: '72%', height: '18%' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: gridLineColor } },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
interval: Math.floor(times.length / 6),
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: gridLineColor } },
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
formatter: (val) => val.toFixed(0),
|
||||
},
|
||||
splitLine: { lineStyle: { color: gridLineColor, type: 'dashed' } },
|
||||
axisPointer: {
|
||||
label: {
|
||||
formatter: (params) => {
|
||||
if (!indexData.prev_close) return params.value.toFixed(2);
|
||||
const pct = ((params.value - indexData.prev_close) / indexData.prev_close) * 100;
|
||||
return `${params.value.toFixed(2)} (${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
gridIndex: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// 分时线
|
||||
{
|
||||
name: indexData.name || '上证指数',
|
||||
type: 'line',
|
||||
data: prices,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: lineColor, width: 1.5 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, areaColorStops),
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'pin',
|
||||
symbolSize: 40,
|
||||
data: markPoints,
|
||||
animation: true,
|
||||
},
|
||||
},
|
||||
// 成交量
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes.map((v, i) => ({
|
||||
value: v,
|
||||
itemStyle: {
|
||||
color: changePcts[i] >= 0 ? 'rgba(255, 77, 77, 0.6)' : 'rgba(34, 197, 94, 0.6)',
|
||||
},
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [indexData, alerts, subTextColor, gridLineColor]);
|
||||
|
||||
// 渲染图表
|
||||
const renderChart = useCallback(() => {
|
||||
if (!chartRef.current || !chartOption) return;
|
||||
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
chartInstance.current.setOption(chartOption, true);
|
||||
|
||||
// 点击事件
|
||||
if (onAlertClick) {
|
||||
chartInstance.current.off('click');
|
||||
chartInstance.current.on('click', 'series.line.markPoint', (params) => {
|
||||
if (params.data && params.data.alertData) {
|
||||
onAlertClick(params.data.alertData);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [chartOption, onAlertClick]);
|
||||
|
||||
// 数据变化时重新渲染
|
||||
useEffect(() => {
|
||||
renderChart();
|
||||
}, [renderChart]);
|
||||
|
||||
// 窗口大小变化时重新渲染
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.resize();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!chartOption) {
|
||||
return (
|
||||
<Box h={height} display="flex" alignItems="center" justifyContent="center" color={subTextColor}>
|
||||
暂无数据
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box ref={chartRef} h={height} w="100%" />;
|
||||
};
|
||||
|
||||
export default IndexMinuteChart;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||
export { default as AlertSummary } from './AlertSummary';
|
||||
@@ -0,0 +1 @@
|
||||
export { useHotspotData } from './useHotspotData';
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 热点概览数据获取 Hook
|
||||
* 负责获取指数分时数据和概念异动数据
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
/**
|
||||
* @param {Date|null} selectedDate - 选中的交易日期
|
||||
* @returns {Object} 数据和状态
|
||||
*/
|
||||
export const useHotspotData = (selectedDate) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dateParam = selectedDate
|
||||
? `?date=${selectedDate.toISOString().split('T')[0]}`
|
||||
: '';
|
||||
const response = await fetch(`/api/market/hotspot-overview${dateParam}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('useHotspotData', 'fetchData', err);
|
||||
setError('网络请求失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
data,
|
||||
refetch: fetchData,
|
||||
};
|
||||
};
|
||||
|
||||
export default useHotspotData;
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* 热点概览组件
|
||||
* 展示大盘分时走势 + 概念异动标注
|
||||
*
|
||||
* 模块化结构:
|
||||
* - hooks/useHotspotData.js - 数据获取
|
||||
* - components/IndexMinuteChart.js - 分时图
|
||||
* - components/ConceptAlertList.js - 异动列表
|
||||
* - components/AlertSummary.js - 统计摘要
|
||||
* - utils/chartHelpers.js - 图表辅助函数
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
@@ -11,7 +18,6 @@ import {
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
@@ -19,24 +25,29 @@ import {
|
||||
Spacer,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
SimpleGrid,
|
||||
Grid,
|
||||
GridItem,
|
||||
Divider,
|
||||
IconButton,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaFire, FaRocket, FaChartLine, FaBolt, FaArrowDown } from 'react-icons/fa';
|
||||
import { FaFire, FaList, FaChartArea, FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
import { useHotspotData } from './hooks';
|
||||
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components';
|
||||
|
||||
/**
|
||||
* 热点概览主组件
|
||||
* @param {Object} props
|
||||
* @param {Date|null} props.selectedDate - 选中的交易日期
|
||||
*/
|
||||
const HotspotOverview = ({ selectedDate }) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedAlert, setSelectedAlert] = useState(null);
|
||||
const [showAlertList, setShowAlertList] = useState(true);
|
||||
|
||||
// 获取数据
|
||||
const { loading, error, data } = useHotspotData(selectedDate);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
@@ -44,373 +55,13 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 获取数据
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dateParam = selectedDate
|
||||
? `?date=${selectedDate.toISOString().split('T')[0]}`
|
||||
: '';
|
||||
const response = await fetch(`/api/market/hotspot-overview${dateParam}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('HotspotOverview', 'fetchData', err);
|
||||
setError('网络请求失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 渲染图表
|
||||
const renderChart = useCallback(() => {
|
||||
if (!chartRef.current || !data) return;
|
||||
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
const { index, alerts } = data;
|
||||
const timeline = index.timeline || [];
|
||||
|
||||
// 准备数据
|
||||
const times = timeline.map((d) => d.time);
|
||||
const prices = timeline.map((d) => d.price);
|
||||
const volumes = timeline.map((d) => d.volume);
|
||||
const changePcts = timeline.map((d) => d.change_pct);
|
||||
|
||||
// 计算Y轴范围
|
||||
const priceMin = Math.min(...prices.filter(Boolean));
|
||||
const priceMax = Math.max(...prices.filter(Boolean));
|
||||
const priceRange = priceMax - priceMin;
|
||||
const yAxisMin = priceMin - priceRange * 0.1;
|
||||
const yAxisMax = priceMax + priceRange * 0.2; // 上方留更多空间给标注
|
||||
|
||||
// 准备异动标注 - 按重要性排序,限制显示数量
|
||||
const sortedAlerts = [...alerts]
|
||||
.sort((a, b) => (b.importance_score || 0) - (a.importance_score || 0))
|
||||
.slice(0, 15); // 最多显示15个标注,避免图表过于密集
|
||||
|
||||
const markPoints = sortedAlerts.map((alert) => {
|
||||
// 找到对应时间的价格
|
||||
const timeIndex = times.indexOf(alert.time);
|
||||
const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax);
|
||||
|
||||
// 根据异动类型设置颜色和符号
|
||||
let color = '#ff6b6b';
|
||||
let symbol = 'pin';
|
||||
let symbolSize = 35;
|
||||
|
||||
// 暴涨
|
||||
if (alert.alert_type === 'surge_up' || alert.alert_type === 'surge') {
|
||||
color = '#ff4757';
|
||||
symbol = 'triangle';
|
||||
symbolSize = 30 + Math.min((alert.importance_score || 0.5) * 20, 15); // 根据重要性调整大小
|
||||
}
|
||||
// 暴跌
|
||||
else if (alert.alert_type === 'surge_down') {
|
||||
color = '#2ed573';
|
||||
symbol = 'path://M0,0 L10,0 L5,10 Z'; // 向下三角形
|
||||
symbolSize = 30 + Math.min((alert.importance_score || 0.5) * 20, 15);
|
||||
}
|
||||
// 涨停增加
|
||||
else if (alert.alert_type === 'limit_up') {
|
||||
color = '#ff6348';
|
||||
symbol = 'diamond';
|
||||
symbolSize = 28;
|
||||
}
|
||||
// 排名跃升
|
||||
else if (alert.alert_type === 'rank_jump') {
|
||||
color = '#3742fa';
|
||||
symbol = 'circle';
|
||||
symbolSize = 25;
|
||||
}
|
||||
|
||||
// 格式化标签 - 简化显示
|
||||
let label = alert.concept_name;
|
||||
// 截断过长的名称
|
||||
if (label.length > 8) {
|
||||
label = label.substring(0, 7) + '...';
|
||||
}
|
||||
|
||||
// 添加变化信息
|
||||
const changeDelta = alert.change_delta;
|
||||
if (changeDelta) {
|
||||
const sign = changeDelta > 0 ? '+' : '';
|
||||
label += `\n${sign}${changeDelta.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
return {
|
||||
name: alert.concept_name,
|
||||
coord: [alert.time, price],
|
||||
value: label,
|
||||
symbol: symbol,
|
||||
symbolSize: symbolSize,
|
||||
itemStyle: {
|
||||
color: color,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: alert.alert_type === 'surge_down' ? 'bottom' : 'top', // 暴跌标签在下方
|
||||
formatter: '{b}',
|
||||
fontSize: 9,
|
||||
color: textColor,
|
||||
backgroundColor: alert.alert_type === 'surge_down'
|
||||
? 'rgba(46, 213, 115, 0.9)'
|
||||
: 'rgba(255,255,255,0.9)',
|
||||
padding: [2, 4],
|
||||
borderRadius: 2,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
},
|
||||
// 存储额外信息用于 tooltip
|
||||
alertData: alert,
|
||||
};
|
||||
});
|
||||
|
||||
// 渐变色 - 根据涨跌
|
||||
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
||||
const areaColorStops = latestChangePct >= 0
|
||||
? [
|
||||
{ offset: 0, color: 'rgba(255, 77, 77, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(255, 77, 77, 0.05)' },
|
||||
]
|
||||
: [
|
||||
{ offset: 0, color: 'rgba(34, 197, 94, 0.4)' },
|
||||
{ offset: 1, color: 'rgba(34, 197, 94, 0.05)' },
|
||||
];
|
||||
|
||||
const lineColor = latestChangePct >= 0 ? '#ff4d4d' : '#22c55e';
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
crossStyle: {
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
formatter: function (params) {
|
||||
if (!params || params.length === 0) return '';
|
||||
|
||||
const dataIndex = params[0].dataIndex;
|
||||
const time = times[dataIndex];
|
||||
const price = prices[dataIndex];
|
||||
const changePct = changePcts[dataIndex];
|
||||
const volume = volumes[dataIndex];
|
||||
|
||||
let html = `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>
|
||||
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 检查是否有异动
|
||||
const alertsAtTime = alerts.filter((a) => a.time === time);
|
||||
if (alertsAtTime.length > 0) {
|
||||
html += '<div style="border-top: 1px solid #eee; margin-top: 4px; padding-top: 4px;">';
|
||||
html += '<div style="font-weight: bold; color: #ff6b6b;">概念异动:</div>';
|
||||
alertsAtTime.forEach((alert) => {
|
||||
const typeLabel = {
|
||||
surge: '急涨',
|
||||
surge_up: '暴涨',
|
||||
surge_down: '暴跌',
|
||||
limit_up: '涨停增加',
|
||||
rank_jump: '排名跃升',
|
||||
}[alert.alert_type] || alert.alert_type;
|
||||
const typeColor = alert.alert_type === 'surge_down' ? '#2ed573' : '#ff6b6b';
|
||||
const delta = alert.change_delta ? ` (${alert.change_delta > 0 ? '+' : ''}${alert.change_delta.toFixed(2)}%)` : '';
|
||||
const zscore = alert.zscore ? ` Z=${alert.zscore.toFixed(1)}` : '';
|
||||
html += `<div style="color: ${typeColor}">• ${alert.concept_name} (${typeLabel}${delta}${zscore})</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '8%',
|
||||
right: '3%',
|
||||
top: '8%',
|
||||
height: '55%',
|
||||
},
|
||||
{
|
||||
left: '8%',
|
||||
right: '3%',
|
||||
top: '70%',
|
||||
height: '20%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
interval: Math.floor(times.length / 6),
|
||||
},
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: times,
|
||||
axisLine: { lineStyle: { color: '#ddd' } },
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: yAxisMin,
|
||||
max: yAxisMax,
|
||||
axisLine: { show: false },
|
||||
axisLabel: {
|
||||
color: subTextColor,
|
||||
fontSize: 10,
|
||||
formatter: (val) => val.toFixed(0),
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#eee', type: 'dashed' },
|
||||
},
|
||||
// 右侧显示涨跌幅
|
||||
axisPointer: {
|
||||
label: {
|
||||
formatter: function (params) {
|
||||
const pct = ((params.value - index.prev_close) / index.prev_close) * 100;
|
||||
return `${params.value.toFixed(2)} (${pct >= 0 ? '+' : ''}${pct.toFixed(2)}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
gridIndex: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
// 分时线
|
||||
{
|
||||
name: '上证指数',
|
||||
type: 'line',
|
||||
data: prices,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: lineColor,
|
||||
width: 1.5,
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, areaColorStops),
|
||||
},
|
||||
markPoint: {
|
||||
symbol: 'pin',
|
||||
symbolSize: 40,
|
||||
data: markPoints,
|
||||
animation: true,
|
||||
},
|
||||
},
|
||||
// 成交量
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes.map((v, i) => ({
|
||||
value: v,
|
||||
itemStyle: {
|
||||
color: changePcts[i] >= 0 ? 'rgba(255, 77, 77, 0.6)' : 'rgba(34, 197, 94, 0.6)',
|
||||
},
|
||||
})),
|
||||
barWidth: '60%',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartInstance.current.setOption(option, true);
|
||||
}, [data, textColor, subTextColor]);
|
||||
|
||||
// 数据变化时重新渲染
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
renderChart();
|
||||
}
|
||||
}, [data, renderChart]);
|
||||
|
||||
// 窗口大小变化时重新渲染
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.resize();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
}
|
||||
};
|
||||
// 点击异动标注
|
||||
const handleAlertClick = useCallback((alert) => {
|
||||
setSelectedAlert(alert);
|
||||
// 可以在这里添加滚动到对应位置的逻辑
|
||||
}, []);
|
||||
|
||||
// 异动类型标签
|
||||
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 },
|
||||
};
|
||||
|
||||
const cfg = config[type] || { label: type, color: 'gray', icon: FaFire };
|
||||
|
||||
return (
|
||||
<Badge colorScheme={cfg.color} variant="subtle" px={2} py={1} borderRadius="md">
|
||||
<HStack spacing={1}>
|
||||
<Icon as={cfg.icon} boxSize={3} />
|
||||
<Text>{cfg.label}</Text>
|
||||
<Text fontWeight="bold">{count}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
@@ -426,6 +77,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
@@ -441,6 +93,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
@@ -450,7 +103,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
return (
|
||||
<Card bg={cardBg} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardBody>
|
||||
{/* 头部信息 */}
|
||||
{/* 头部 */}
|
||||
<Flex align="center" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaFire} boxSize={6} color="orange.500" />
|
||||
@@ -459,69 +112,75 @@ const HotspotOverview = ({ selectedDate }) => {
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Spacer />
|
||||
<Tooltip label="展示大盘走势与概念异动的关联">
|
||||
<Icon as={InfoIcon} color={subTextColor} />
|
||||
</Tooltip>
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label={showAlertList ? '收起异动列表' : '展开异动列表'}>
|
||||
<IconButton
|
||||
icon={showAlertList ? <FaChevronUp /> : <FaList />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowAlertList(!showAlertList)}
|
||||
aria-label="切换异动列表"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="展示大盘走势与概念异动的关联">
|
||||
<Icon as={InfoIcon} color={subTextColor} />
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 指数统计 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={4}>
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>{index.name}</StatLabel>
|
||||
<StatNumber
|
||||
fontSize="xl"
|
||||
color={index.change_pct >= 0 ? 'red.500' : 'green.500'}
|
||||
>
|
||||
{index.latest_price?.toFixed(2)}
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<StatArrow type={index.change_pct >= 0 ? 'increase' : 'decrease'} />
|
||||
{index.change_pct?.toFixed(2)}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
{/* 统计摘要 */}
|
||||
<Box mb={4}>
|
||||
<AlertSummary indexData={index} alerts={alerts} alertSummary={alert_summary} />
|
||||
</Box>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最高</StatLabel>
|
||||
<StatNumber fontSize="xl" color="red.500">
|
||||
{index.high?.toFixed(2)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Divider mb={4} />
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>最低</StatLabel>
|
||||
<StatNumber fontSize="xl" color="green.500">
|
||||
{index.low?.toFixed(2)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
{/* 主体内容:图表 + 异动列表 */}
|
||||
<Grid
|
||||
templateColumns={{ base: '1fr', lg: showAlertList ? '1fr 300px' : '1fr' }}
|
||||
gap={4}
|
||||
>
|
||||
{/* 分时图 */}
|
||||
<GridItem>
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FaChartArea} color="purple.500" boxSize={4} />
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor}>
|
||||
大盘分时走势
|
||||
</Text>
|
||||
</HStack>
|
||||
<IndexMinuteChart
|
||||
indexData={index}
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
height="350px"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
<Stat size="sm">
|
||||
<StatLabel color={subTextColor}>异动次数</StatLabel>
|
||||
<StatNumber fontSize="xl" color="orange.500">
|
||||
{alerts.length}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 异动类型统计 */}
|
||||
{alerts.length > 0 && (
|
||||
<HStack spacing={2} mb={4} flexWrap="wrap">
|
||||
{(alert_summary.surge_up > 0 || alert_summary.surge > 0) && (
|
||||
<AlertTypeBadge type="surge_up" count={(alert_summary.surge_up || 0) + (alert_summary.surge || 0)} />
|
||||
)}
|
||||
{alert_summary.surge_down > 0 && (
|
||||
<AlertTypeBadge type="surge_down" count={alert_summary.surge_down} />
|
||||
)}
|
||||
{alert_summary.limit_up > 0 && (
|
||||
<AlertTypeBadge type="limit_up" count={alert_summary.limit_up} />
|
||||
)}
|
||||
{alert_summary.rank_jump > 0 && (
|
||||
<AlertTypeBadge type="rank_jump" count={alert_summary.rank_jump} />
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 图表 */}
|
||||
<Box ref={chartRef} h="400px" w="100%" />
|
||||
{/* 异动列表(可收起) */}
|
||||
<Collapse in={showAlertList} animateOpacity>
|
||||
<GridItem>
|
||||
<Box>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FaList} color="orange.500" boxSize={4} />
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor}>
|
||||
异动记录
|
||||
</Text>
|
||||
<Text fontSize="xs" color={subTextColor}>
|
||||
({alerts.length})
|
||||
</Text>
|
||||
</HStack>
|
||||
<ConceptAlertList
|
||||
alerts={alerts}
|
||||
onAlertClick={handleAlertClick}
|
||||
selectedAlert={selectedAlert}
|
||||
maxHeight="350px"
|
||||
/>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Collapse>
|
||||
</Grid>
|
||||
|
||||
{/* 无异动提示 */}
|
||||
{alerts.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 图表辅助函数
|
||||
* 用于处理异动标注等图表相关逻辑
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取异动标注的配色和符号
|
||||
* @param {string} alertType - 异动类型
|
||||
* @param {number} importanceScore - 重要性得分
|
||||
* @returns {Object} { color, symbol, symbolSize }
|
||||
*/
|
||||
export const getAlertStyle = (alertType, importanceScore = 0.5) => {
|
||||
let color = '#ff6b6b';
|
||||
let symbol = 'pin';
|
||||
let symbolSize = 35;
|
||||
|
||||
switch (alertType) {
|
||||
case 'surge_up':
|
||||
case 'surge':
|
||||
color = '#ff4757';
|
||||
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);
|
||||
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';
|
||||
symbol = 'rect';
|
||||
symbolSize = 25;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return { color, symbol, symbolSize };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取异动类型的显示标签
|
||||
* @param {string} alertType - 异动类型
|
||||
* @returns {string} 显示标签
|
||||
*/
|
||||
export const getAlertTypeLabel = (alertType) => {
|
||||
const labels = {
|
||||
surge: '急涨',
|
||||
surge_up: '暴涨',
|
||||
surge_down: '暴跌',
|
||||
limit_up: '涨停增加',
|
||||
rank_jump: '排名跃升',
|
||||
volume_spike: '放量',
|
||||
unknown: '异动',
|
||||
};
|
||||
return labels[alertType] || alertType;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图表标注点数据
|
||||
* @param {Array} alerts - 异动数据数组
|
||||
* @param {Array} times - 时间数组
|
||||
* @param {Array} prices - 价格数组
|
||||
* @param {number} priceMax - 最高价格(用于无法匹配时间时的默认位置)
|
||||
* @param {number} maxCount - 最大显示数量
|
||||
* @returns {Array} ECharts markPoint data
|
||||
*/
|
||||
export const getAlertMarkPoints = (alerts, times, prices, priceMax, maxCount = 15) => {
|
||||
if (!alerts || alerts.length === 0) return [];
|
||||
|
||||
// 按重要性排序,限制显示数量
|
||||
const sortedAlerts = [...alerts]
|
||||
.sort((a, b) => (b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0))
|
||||
.slice(0, maxCount);
|
||||
|
||||
return sortedAlerts.map((alert) => {
|
||||
// 找到对应时间的价格
|
||||
const timeIndex = times.indexOf(alert.time);
|
||||
const price = timeIndex >= 0 ? prices[timeIndex] : (alert.index_price || priceMax);
|
||||
|
||||
const { color, symbol, symbolSize } = getAlertStyle(
|
||||
alert.alert_type,
|
||||
alert.final_score / 100 || alert.importance_score || 0.5
|
||||
);
|
||||
|
||||
// 格式化标签
|
||||
let label = alert.concept_name || '';
|
||||
if (label.length > 6) {
|
||||
label = label.substring(0, 5) + '...';
|
||||
}
|
||||
|
||||
// 添加涨停数量(如果有)
|
||||
if (alert.limit_up_count > 0) {
|
||||
label += `\n涨停: ${alert.limit_up_count}`;
|
||||
}
|
||||
|
||||
const isDown = alert.alert_type === 'surge_down';
|
||||
|
||||
return {
|
||||
name: alert.concept_name,
|
||||
coord: [alert.time, price],
|
||||
value: label,
|
||||
symbol,
|
||||
symbolSize,
|
||||
itemStyle: {
|
||||
color,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
shadowBlur: 3,
|
||||
shadowColor: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
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,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
},
|
||||
alertData: alert, // 存储原始数据
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化分数显示
|
||||
* @param {number} score - 分数
|
||||
* @returns {string} 格式化后的分数
|
||||
*/
|
||||
export const formatScore = (score) => {
|
||||
if (score === null || score === undefined) return '-';
|
||||
return Math.round(score).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取分数对应的颜色
|
||||
* @param {number} score - 分数 (0-100)
|
||||
* @returns {string} 颜色代码
|
||||
*/
|
||||
export const getScoreColor = (score) => {
|
||||
if (score >= 80) return '#ff4757';
|
||||
if (score >= 60) return '#ff6348';
|
||||
if (score >= 40) return '#ffa502';
|
||||
return '#747d8c';
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './chartHelpers';
|
||||
Reference in New Issue
Block a user