update pay ui

This commit is contained in:
2025-12-09 16:27:56 +08:00
parent e8763331cc
commit b40ca0e23c
22 changed files with 3637 additions and 8176 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
export { default as IndexMinuteChart } from './IndexMinuteChart';
export { default as ConceptAlertList } from './ConceptAlertList';
export { default as AlertSummary } from './AlertSummary';

View File

@@ -0,0 +1 @@
export { useHotspotData } from './useHotspotData';

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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';
};

View File

@@ -0,0 +1 @@
export * from './chartHelpers';