community增加事件详情

This commit is contained in:
2026-01-08 16:42:37 +08:00
parent 73f52ee73a
commit 6bd83cd133
2 changed files with 442 additions and 277 deletions

View File

@@ -1,8 +1,8 @@
/**
* ThemeCometChart - 题材流星图(散点图版本)
* X轴辨识度最高板高度,即板块内最大连板数
* X轴辨识度最高板高度
* Y轴板块热度涨停家数
* 散点大小/颜色:表示不同状态(主升、退潮、潜伏、抱团)
* 数据由后端 API /api/v1/zt/theme-scatter 提供
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
@@ -13,206 +13,88 @@ import {
Spinner,
Center,
useToast,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
} from '@chakra-ui/react';
import ReactECharts from 'echarts-for-react';
import { RocketOutlined } from '@ant-design/icons';
import { getApiBase } from '@/utils/env';
// 板块状态分类
const SECTOR_STATUS = {
RISING: { name: '主升', color: '#FF4D4F', icon: '🔥' }, // 高辨识度+高热度+上升趋势
DECLINING: { name: '退潮', color: '#52C41A', icon: '❄️' }, // 高辨识度+热度下降
LURKING: { name: '潜伏', color: '#1890FF', icon: '●' }, // 低辨识度+热度上升
CLUSTERING: { name: '抱团', color: '#722ED1', icon: '●' }, // 中等辨识度+稳定热度
};
// 噪音板块黑名单(过滤无意义的板块)
const NOISE_SECTORS = [
'公告', '业绩预告', '重大合同签署', '重组', '股权激励',
'增持', '回购', '解禁', '减持', '限售股解禁',
];
/**
* 从 public/data/zt 读取最近两天数据用于比较趋势
*/
const fetchSectorData = async () => {
try {
// 获取日期列表
const datesRes = await fetch('/data/zt/dates.json');
const datesData = await datesRes.json();
const recentDates = datesData.dates.slice(0, 2); // 最近两天
if (recentDates.length === 0) return { today: null, yesterday: null };
// 获取今日数据
const todayRes = await fetch(`/data/zt/daily/${recentDates[0].date}.json`);
const todayData = await todayRes.json();
// 获取昨日数据(用于计算趋势)
let yesterdayData = null;
if (recentDates.length > 1) {
try {
const yesterdayRes = await fetch(`/data/zt/daily/${recentDates[1].date}.json`);
yesterdayData = await yesterdayRes.json();
} catch {
// 忽略
}
}
return {
today: {
date: recentDates[0].formatted_date,
sectorData: todayData.sector_data || {},
stocks: todayData.stocks || [],
},
yesterday: yesterdayData ? {
date: recentDates[1].formatted_date,
sectorData: yesterdayData.sector_data || {},
stocks: yesterdayData.stocks || [],
} : null,
};
} catch (error) {
console.error('获取板块数据失败:', error);
return { today: null, yesterday: null };
}
};
/**
* 处理板块数据,计算散点图坐标
*/
const processSectorData = (data) => {
if (!data.today) return [];
const { sectorData, stocks } = data.today;
const yesterdaySectors = data.yesterday?.sectorData || {};
// 构建股票代码到连板数的映射
const stockContinuousDays = {};
stocks.forEach((stock) => {
// continuous_days 可能是 "1板" 或数字
let days = 1;
if (stock.continuous_days) {
const match = String(stock.continuous_days).match(/(\d+)/);
if (match) days = parseInt(match[1]);
}
stockContinuousDays[stock.scode] = days;
});
const sectors = [];
Object.entries(sectorData).forEach(([sectorName, sectorInfo]) => {
// 过滤噪音板块
if (NOISE_SECTORS.some((noise) => sectorName.includes(noise))) return;
const stockCodes = sectorInfo.stock_codes || [];
const count = sectorInfo.count || stockCodes.length;
// 计算该板块的最高连板数(辨识度)
let maxContinuousDays = 1;
stockCodes.forEach((code) => {
const days = stockContinuousDays[code] || 1;
if (days > maxContinuousDays) maxContinuousDays = days;
});
// 获取昨日数据计算趋势
const yesterdayCount = yesterdaySectors[sectorName]?.count || 0;
const trend = count - yesterdayCount;
// 判断板块状态
let status;
if (maxContinuousDays >= 3 && trend > 0) {
status = SECTOR_STATUS.RISING; // 主升:高辨识度+上升
} else if (maxContinuousDays >= 3 && trend < 0) {
status = SECTOR_STATUS.DECLINING; // 退潮:高辨识度+下降
} else if (maxContinuousDays < 3 && trend > 0) {
status = SECTOR_STATUS.LURKING; // 潜伏:低辨识度+上升
} else {
status = SECTOR_STATUS.CLUSTERING; // 抱团:其他
}
sectors.push({
name: sectorName,
x: maxContinuousDays, // X轴最高板高度
y: count, // Y轴涨停家数
trend,
status,
stockCodes,
stocks: stockCodes.map((code) => {
const stockInfo = stocks.find((s) => s.scode === code);
return stockInfo || { scode: code, sname: code };
}),
});
});
// 按热度排序
sectors.sort((a, b) => b.y - a.y);
return sectors;
// 板块状态配置
const STATUS_CONFIG = {
rising: { name: '主升', color: '#FF4D4F' },
declining: { name: '退潮', color: '#52C41A' },
lurking: { name: '潜伏', color: '#1890FF' },
clustering: { name: '抱团', color: '#722ED1' },
};
/**
* 生成 ECharts 配置
*/
const generateChartOption = (sectors) => {
if (!sectors || sectors.length === 0) return {};
const generateChartOption = (themes) => {
if (!themes || themes.length === 0) return {};
// 按状态分组
const groupedData = {
[SECTOR_STATUS.RISING.name]: [],
[SECTOR_STATUS.DECLINING.name]: [],
[SECTOR_STATUS.LURKING.name]: [],
[SECTOR_STATUS.CLUSTERING.name]: [],
主升: [],
退潮: [],
潜伏: [],
抱团: [],
};
sectors.forEach((sector) => {
groupedData[sector.status.name].push({
name: sector.name,
value: [sector.x, sector.y],
trend: sector.trend,
stockCount: sector.y,
maxBoard: sector.x,
stocks: sector.stocks,
themes.forEach((theme) => {
const statusName = STATUS_CONFIG[theme.status]?.name || '抱团';
groupedData[statusName].push({
name: theme.label,
value: [theme.x, theme.y],
countTrend: theme.countTrend,
boardTrend: theme.boardTrend,
themeColor: theme.color,
history: theme.history,
});
});
// 创建系列
const series = Object.entries(SECTOR_STATUS).map(([key, status]) => ({
name: status.name,
const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({
name: config.name,
type: 'scatter',
data: groupedData[status.name],
symbolSize: (data) => {
// 根据热度调整大小
const size = Math.max(20, Math.min(60, data[1] * 4));
return size;
},
data: groupedData[config.name] || [],
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
itemStyle: {
color: status.color,
shadowBlur: 10,
shadowColor: status.color,
opacity: 0.8,
color: (params) => params.data.themeColor || config.color,
shadowBlur: 12,
shadowColor: (params) => params.data.themeColor || config.color,
opacity: 0.85,
},
emphasis: {
itemStyle: {
opacity: 1,
shadowBlur: 20,
},
label: {
show: true,
},
itemStyle: { opacity: 1, shadowBlur: 25 },
},
label: {
show: true,
formatter: (params) => params.data.name,
position: 'right',
color: '#fff',
fontSize: 11,
fontSize: 12,
fontWeight: 'bold',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowBlur: 4,
},
}));
// 计算坐标轴范围
const maxX = Math.max(...sectors.map((s) => s.x), 5) + 1;
const maxY = Math.max(...sectors.map((s) => s.y), 10) + 2;
const maxX = Math.max(...themes.map((t) => t.x), 5) + 1;
const maxY = Math.max(...themes.map((t) => t.y), 10) + 3;
return {
backgroundColor: 'transparent',
@@ -221,60 +103,58 @@ const generateChartOption = (sectors) => {
backgroundColor: 'rgba(15, 15, 30, 0.95)',
borderColor: 'rgba(255, 215, 0, 0.3)',
borderWidth: 1,
textStyle: {
color: '#fff',
},
textStyle: { color: '#fff' },
formatter: (params) => {
const { name, value, trend, stocks } = params.data;
const trendText = trend > 0 ? `+${trend}` : trend;
const trendIcon = trend > 0 ? '🔥' : trend < 0 ? '❄️' : '➡️';
const { name, value, countTrend, boardTrend, history } = params.data;
const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend;
const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend;
const countIcon = countTrend > 0 ? '🔥' : countTrend < 0 ? '❄️' : '➡️';
// 显示前5只股票
const topStocks = stocks.slice(0, 5);
const stockList = topStocks
.map((s) => `${s.sname || s.scode}`)
.join('、');
const historyText = (history || [])
.slice(0, 5)
.map((h) => `${h.date?.slice(5) || ''}: ${h.count}家/${h.maxBoard}`)
.join('<br>');
return `
<div style="font-weight:bold;margin-bottom:8px;color:#FFD700;font-size:14px;">
${name} ${trendIcon}
${name} ${countIcon}
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">涨停家数:</span>
<span style="color:#fff;margin-left:8px;">${value[1]}家</span>
<span style="color:${trend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${trendText})</span>
<span style="color:${countTrend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${countTrendText})</span>
</div>
<div style="margin:4px 0;">
<span style="color:#aaa">最高连板:</span>
<span style="color:#fff;margin-left:8px;">${value[0]}板</span>
<span style="color:${boardTrend >= 0 ? '#52c41a' : '#ff4d4f'};margin-left:8px;">(${boardTrendText})</span>
</div>
<div style="margin:4px 0;color:#aaa;font-size:11px;">
${stockList}${stocks.length > 5 ? '...' : ''}
<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.1);">
<div style="color:#aaa;font-size:11px;margin-bottom:4px;">近5日趋势:</div>
<div style="font-size:11px;line-height:1.6;">${historyText}</div>
</div>
<div style="margin-top:8px;color:#888;font-size:10px;">点击查看详情</div>
`;
},
},
legend: {
show: true,
top: 10,
top: 5,
right: 10,
orient: 'horizontal',
textStyle: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 11,
},
itemWidth: 14,
itemHeight: 14,
data: Object.values(SECTOR_STATUS).map((s) => ({
textStyle: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 11 },
itemWidth: 12,
itemHeight: 12,
data: Object.values(STATUS_CONFIG).map((s) => ({
name: s.name,
icon: 'circle',
itemStyle: { color: s.color },
})),
},
grid: {
left: '8%',
right: '5%',
top: '15%',
left: '10%',
right: '8%',
top: '12%',
bottom: '15%',
containLabel: true,
},
@@ -283,52 +163,25 @@ const generateChartOption = (sectors) => {
name: '辨识度(最高板)',
nameLocation: 'middle',
nameGap: 30,
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
},
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
min: 0,
max: maxX,
interval: 1,
axisLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.2)',
},
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 11,
formatter: '{value}板',
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } },
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 11, formatter: '{value}板' },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
},
yAxis: {
type: 'value',
name: '板块热度(家数)',
nameLocation: 'middle',
nameGap: 40,
nameTextStyle: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
},
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
min: 0,
max: maxY,
axisLine: {
show: false,
},
axisLabel: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 11,
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
axisLine: { show: false },
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
},
series,
};
@@ -339,28 +192,30 @@ const generateChartOption = (sectors) => {
*/
const ThemeCometChart = ({ onThemeSelect }) => {
const [loading, setLoading] = useState(true);
const [sectors, setSectors] = useState([]);
const [dateInfo, setDateInfo] = useState('');
const [data, setData] = useState({ themes: [], latestDate: '' });
const [selectedTheme, setSelectedTheme] = useState(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// 加载数据
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
const data = await fetchSectorData();
const processed = processSectorData(data);
setSectors(processed);
if (data.today) {
setDateInfo(data.today.date);
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
const result = await response.json();
if (result.success && result.data) {
setData({
themes: result.data.themes || [],
latestDate: result.data.latestDate || '',
});
} else {
throw new Error(result.error || '加载失败');
}
} catch (error) {
console.error('加载板块数据失败:', error);
toast({
title: '加载数据失败',
status: 'error',
duration: 3000,
});
console.error('加载题材数据失败:', error);
toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 });
} finally {
setLoading(false);
}
@@ -368,53 +223,38 @@ const ThemeCometChart = ({ onThemeSelect }) => {
loadData();
}, [toast]);
// 图表配置
const chartOption = useMemo(() => {
return generateChartOption(sectors);
}, [sectors]);
const chartOption = useMemo(() => generateChartOption(data.themes), [data.themes]);
// 点击事件
const handleChartClick = useCallback(
(params) => {
if (params.data && onThemeSelect) {
const sector = sectors.find((s) => s.name === params.data.name);
if (sector) {
onThemeSelect({
theme: { label: sector.name, color: sector.status.color },
stocks: sector.stocks.map((s) => ({
...s,
_continuousDays: 1,
})),
date: dateInfo,
});
if (params.data) {
const theme = data.themes.find((t) => t.label === params.data.name);
if (theme) {
setSelectedTheme(theme);
onOpen();
}
}
},
[sectors, dateInfo, onThemeSelect]
[data.themes, onOpen]
);
const onChartEvents = useMemo(
() => ({
click: handleChartClick,
}),
[handleChartClick]
);
const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]);
if (loading) {
return (
<Center h="300px">
<VStack spacing={4}>
<Spinner size="lg" color="yellow.400" />
<Text color="whiteAlpha.600">加载板块数据...</Text>
<Text color="whiteAlpha.600">加载数据...</Text>
</VStack>
</Center>
);
}
if (!sectors || sectors.length === 0) {
if (!data.themes || data.themes.length === 0) {
return (
<Center h="300px">
<Text color="whiteAlpha.500">暂无板块数据</Text>
<Text color="whiteAlpha.500">暂无数据</Text>
</Center>
);
}
@@ -430,12 +270,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
>
{/* 标题栏 */}
<HStack spacing={3} mb={2}>
<Box
p={2}
bg="rgba(255,215,0,0.15)"
borderRadius="lg"
border="1px solid rgba(255,215,0,0.3)"
>
<Box p={2} bg="rgba(255,215,0,0.15)" borderRadius="lg" border="1px solid rgba(255,215,0,0.3)">
<RocketOutlined style={{ color: '#FFD700', fontSize: '18px' }} />
</Box>
<VStack align="start" spacing={0}>
@@ -443,7 +278,7 @@ const ThemeCometChart = ({ onThemeSelect }) => {
AI 舆情 · 时空决策驾驶舱
</Text>
<Text fontSize="xs" color="whiteAlpha.500">
题材流星图 · {dateInfo}
题材流星图 · {data.latestDate}
</Text>
</VStack>
</HStack>
@@ -457,6 +292,102 @@ const ThemeCometChart = ({ onThemeSelect }) => {
opts={{ renderer: 'canvas' }}
/>
</Box>
{/* 详情弹窗 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent bg="gray.900" border="1px solid" borderColor="yellow.500" maxH="80vh">
<ModalHeader color="yellow.400">
{selectedTheme?.label} - 近5日趋势
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody pb={6} overflowY="auto">
{selectedTheme && (
<VStack spacing={4} align="stretch">
{/* 趋势表格 */}
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>历史数据</Text>
<Table size="sm" variant="simple">
<Thead>
<Tr>
<Th color="whiteAlpha.600">日期</Th>
<Th color="whiteAlpha.600" isNumeric>涨停家数</Th>
<Th color="whiteAlpha.600" isNumeric>最高连板</Th>
<Th color="whiteAlpha.600">变化</Th>
</Tr>
</Thead>
<Tbody>
{selectedTheme.history?.map((h, idx) => {
const prev = selectedTheme.history[idx + 1];
const countChange = prev ? h.count - prev.count : 0;
return (
<Tr key={h.date}>
<Td color="white">{h.date}</Td>
<Td color="white" isNumeric>{h.count}</Td>
<Td color="white" isNumeric>{h.maxBoard}</Td>
<Td>
{countChange !== 0 && (
<Badge colorScheme={countChange > 0 ? 'green' : 'red'}>
{countChange > 0 ? '+' : ''}{countChange}
</Badge>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* 今日涨停股票 */}
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
今日涨停股票{selectedTheme.stocks?.length || 0}
</Text>
<Box maxH="200px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} bg="gray.900">
<Tr>
<Th color="whiteAlpha.600">代码</Th>
<Th color="whiteAlpha.600">名称</Th>
<Th color="whiteAlpha.600">连板</Th>
</Tr>
</Thead>
<Tbody>
{selectedTheme.stocks?.slice(0, 20).map((stock) => (
<Tr key={stock.scode}>
<Td color="whiteAlpha.800" fontSize="xs">{stock.scode}</Td>
<Td color="white">{stock.sname}</Td>
<Td>
<Badge colorScheme={stock.continuous_days >= 3 ? 'red' : 'gray'}>
{stock.continuous_days}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
{/* 匹配的板块 */}
{selectedTheme.matchedSectors?.length > 0 && (
<Box>
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>匹配板块</Text>
<HStack flexWrap="wrap" spacing={2}>
{selectedTheme.matchedSectors.map((sector) => (
<Badge key={sector} colorScheme="purple" variant="subtle">
{sector}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};