community增加事件详情
This commit is contained in:
@@ -57,6 +57,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
||||||
import { FullCalendarPro } from '@components/Calendar';
|
import { FullCalendarPro } from '@components/Calendar';
|
||||||
|
import ThemeCometChart from './ThemeCometChart';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
const { Text: AntText } = Typography;
|
const { Text: AntText } = Typography;
|
||||||
@@ -2708,15 +2709,30 @@ const HeroPanel = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 综合日历区域 - 左侧1/3空白,右侧2/3日历 */}
|
{/* AI舆情时空决策驾驶舱 - 左侧词频流星图,右侧日历 */}
|
||||||
<Flex gap={6}>
|
<Flex gap={6}>
|
||||||
{/* 左侧留白区域 - 占 1/3 */}
|
{/* 左侧:词频流星图 - 占 1/2 */}
|
||||||
<Box flex="1" minW="0">
|
<Box flex="1" minW="0">
|
||||||
{/* 预留空间供后续使用 */}
|
<ThemeCometChart
|
||||||
|
onThemeSelect={(data) => {
|
||||||
|
// 当用户点击某个主题时,可以展示相关股票
|
||||||
|
if (data?.stocks?.length > 0) {
|
||||||
|
setSelectedSectorInfo({
|
||||||
|
name: data.theme.label,
|
||||||
|
count: data.stocks.length,
|
||||||
|
stocks: data.stocks.map((s) => ({
|
||||||
|
...s,
|
||||||
|
_continuousDays: parseInt(s.continuous_days) || 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setSectorStocksModalVisible(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 右侧日历区域 - 占 2/3 */}
|
{/* 右侧:综合日历 - 占 1/2 */}
|
||||||
<Box flex="2" minW="0">
|
<Box flex="1" minW="0">
|
||||||
<CombinedCalendar />
|
<CombinedCalendar />
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
456
src/views/Community/components/ThemeCometChart.js
Normal file
456
src/views/Community/components/ThemeCometChart.js
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
/**
|
||||||
|
* ThemeCometChart - 词频流星图
|
||||||
|
* 展示核心主题词频的时间演变轨迹,用流星拖尾效果展示趋势
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Spinner,
|
||||||
|
Center,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { RocketOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
// 核心主题词配置(过滤噪音词,保留有意义的主题)
|
||||||
|
const CORE_THEMES = [
|
||||||
|
{ word: '航天', label: '商业航天', color: '#FF6B6B' },
|
||||||
|
{ word: '机器人', label: '机器人', color: '#4ECDC4' },
|
||||||
|
{ word: '脑机', label: '脑机接口', color: '#A78BFA' },
|
||||||
|
{ word: '光刻胶', label: '光刻胶', color: '#F59E0B' },
|
||||||
|
{ word: '算力', label: 'AI算力', color: '#3B82F6' },
|
||||||
|
{ word: '电池', label: '固态电池', color: '#10B981' },
|
||||||
|
{ word: '液冷', label: 'AI液冷', color: '#06B6D4' },
|
||||||
|
{ word: '军工', label: '军工', color: '#EF4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 噪音词黑名单
|
||||||
|
const NOISE_WORDS = [
|
||||||
|
'落地', '科技', '智能', '标的', '资产', '传闻', '业绩', '估值',
|
||||||
|
'商业', '卫星', '国产', '替代', '材料', '概念', '板块', '公司',
|
||||||
|
'市场', '预期', '政策', '产业', '技术', '研发', '布局', '龙头',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 public/data/zt 读取多日词频数据
|
||||||
|
*/
|
||||||
|
const fetchMultiDayWordFreq = async (days = 7) => {
|
||||||
|
try {
|
||||||
|
// 先获取日期列表
|
||||||
|
const datesRes = await fetch('/data/zt/dates.json');
|
||||||
|
const datesData = await datesRes.json();
|
||||||
|
const recentDates = datesData.dates.slice(0, days);
|
||||||
|
|
||||||
|
// 并行获取每日数据
|
||||||
|
const dailyDataPromises = recentDates.map(async (dateInfo) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/data/zt/daily/${dateInfo.date}.json`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
date: dateInfo.date,
|
||||||
|
formattedDate: dateInfo.formatted_date,
|
||||||
|
wordFreq: data.word_freq_data || [],
|
||||||
|
sectorData: data.sector_data || {},
|
||||||
|
stocks: data.stocks || [],
|
||||||
|
totalStocks: dateInfo.count,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(dailyDataPromises);
|
||||||
|
return results.filter(Boolean).reverse(); // 按时间正序排列
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取词频数据失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理词频数据,提取核心主题的时间序列
|
||||||
|
*/
|
||||||
|
const processWordFreqData = (dailyData) => {
|
||||||
|
if (!dailyData || dailyData.length === 0) return { themes: [], dates: [] };
|
||||||
|
|
||||||
|
const dates = dailyData.map((d) => d.formattedDate.slice(5)); // MM-DD 格式
|
||||||
|
const themes = [];
|
||||||
|
|
||||||
|
CORE_THEMES.forEach((theme) => {
|
||||||
|
const values = dailyData.map((day) => {
|
||||||
|
// 查找匹配的词频
|
||||||
|
const found = day.wordFreq.find((w) => w.name.includes(theme.word));
|
||||||
|
return found ? found.value : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 归一化到 0-100
|
||||||
|
const maxVal = Math.max(...values, 1);
|
||||||
|
const normalizedValues = values.map((v) => Math.round((v / maxVal) * 100));
|
||||||
|
|
||||||
|
// 计算趋势(最近两天的变化)
|
||||||
|
const trend =
|
||||||
|
normalizedValues.length >= 2
|
||||||
|
? normalizedValues[normalizedValues.length - 1] -
|
||||||
|
normalizedValues[normalizedValues.length - 2]
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
themes.push({
|
||||||
|
...theme,
|
||||||
|
values: normalizedValues,
|
||||||
|
rawValues: values,
|
||||||
|
trend,
|
||||||
|
currentValue: normalizedValues[normalizedValues.length - 1] || 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按当前热度排序
|
||||||
|
themes.sort((a, b) => b.currentValue - a.currentValue);
|
||||||
|
|
||||||
|
return { themes, dates, dailyData };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 ECharts 配置
|
||||||
|
*/
|
||||||
|
const generateChartOption = (themes, dates, onThemeClick) => {
|
||||||
|
if (!themes || themes.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只显示 Top 6 主题
|
||||||
|
const topThemes = themes.slice(0, 6);
|
||||||
|
|
||||||
|
const series = [];
|
||||||
|
|
||||||
|
topThemes.forEach((theme, index) => {
|
||||||
|
// 主线条 - 带渐变的折线
|
||||||
|
series.push({
|
||||||
|
name: theme.label,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: {
|
||||||
|
width: 3,
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 1,
|
||||||
|
y2: 0,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: `${theme.color}33` }, // 起点透明
|
||||||
|
{ offset: 0.5, color: `${theme.color}88` },
|
||||||
|
{ offset: 1, color: theme.color }, // 终点实色
|
||||||
|
],
|
||||||
|
},
|
||||||
|
shadowColor: theme.color,
|
||||||
|
shadowBlur: 10,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: `${theme.color}40` },
|
||||||
|
{ offset: 1, color: `${theme.color}05` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: theme.values,
|
||||||
|
z: 10 + index,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 流星头 - 只在最后一个点显示
|
||||||
|
const lastValue = theme.values[theme.values.length - 1];
|
||||||
|
const effectData = new Array(theme.values.length - 1).fill(null);
|
||||||
|
effectData.push(lastValue);
|
||||||
|
|
||||||
|
series.push({
|
||||||
|
name: `${theme.label}-head`,
|
||||||
|
type: 'effectScatter',
|
||||||
|
coordinateSystem: 'cartesian2d',
|
||||||
|
data: effectData.map((v, i) => (v !== null ? [i, v] : null)).filter(Boolean),
|
||||||
|
symbolSize: (val) => Math.max(12, val[1] / 5),
|
||||||
|
showEffectOn: 'render',
|
||||||
|
rippleEffect: {
|
||||||
|
brushType: 'stroke',
|
||||||
|
scale: 3,
|
||||||
|
period: 4,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: theme.color,
|
||||||
|
shadowBlur: 20,
|
||||||
|
shadowColor: theme.color,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: theme.label,
|
||||||
|
position: 'right',
|
||||||
|
color: theme.color,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.8)',
|
||||||
|
textShadowBlur: 4,
|
||||||
|
},
|
||||||
|
z: 20 + index,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
backgroundColor: 'rgba(15, 15, 30, 0.95)',
|
||||||
|
borderColor: 'rgba(255, 215, 0, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
formatter: (params) => {
|
||||||
|
if (!params || params.length === 0) return '';
|
||||||
|
const dateIndex = params[0].dataIndex;
|
||||||
|
const date = dates[dateIndex];
|
||||||
|
let html = `<div style="font-weight:bold;margin-bottom:8px;color:#FFD700">${date}</div>`;
|
||||||
|
params
|
||||||
|
.filter((p) => p.seriesName && !p.seriesName.includes('-head'))
|
||||||
|
.forEach((p) => {
|
||||||
|
const theme = topThemes.find((t) => t.label === p.seriesName);
|
||||||
|
if (theme) {
|
||||||
|
const trend = p.dataIndex > 0 ? theme.values[p.dataIndex] - theme.values[p.dataIndex - 1] : 0;
|
||||||
|
const trendIcon = trend > 0 ? '🔥' : trend < 0 ? '❄️' : '➡️';
|
||||||
|
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin:4px 0;">
|
||||||
|
<span style="color:${theme.color}">${p.seriesName}</span>
|
||||||
|
<span style="margin-left:20px">${p.value} ${trendIcon}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '15%',
|
||||||
|
top: '10%',
|
||||||
|
bottom: '12%',
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: dates,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
fontSize: 10,
|
||||||
|
formatter: '{value}',
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeCometChart 主组件
|
||||||
|
*/
|
||||||
|
const ThemeCometChart = ({ onThemeSelect }) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState({ themes: [], dates: [], dailyData: [] });
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState(null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dailyData = await fetchMultiDayWordFreq(7);
|
||||||
|
const processed = processWordFreqData(dailyData);
|
||||||
|
setData(processed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载词频数据失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '加载数据失败',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// 图表配置
|
||||||
|
const chartOption = useMemo(() => {
|
||||||
|
return generateChartOption(data.themes, data.dates, setSelectedTheme);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 点击主题
|
||||||
|
const handleThemeClick = useCallback(
|
||||||
|
(theme) => {
|
||||||
|
setSelectedTheme(theme);
|
||||||
|
if (onThemeSelect) {
|
||||||
|
// 找出该主题相关的股票
|
||||||
|
const latestDay = data.dailyData[data.dailyData.length - 1];
|
||||||
|
if (latestDay) {
|
||||||
|
const relatedStocks = latestDay.stocks.filter(
|
||||||
|
(stock) =>
|
||||||
|
stock.brief?.includes(theme.word) ||
|
||||||
|
stock.core_sectors?.some((s) => s.includes(theme.word))
|
||||||
|
);
|
||||||
|
onThemeSelect({
|
||||||
|
theme,
|
||||||
|
stocks: relatedStocks,
|
||||||
|
date: latestDay.formattedDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data, onThemeSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 图表事件
|
||||||
|
const onChartEvents = useMemo(
|
||||||
|
() => ({
|
||||||
|
click: (params) => {
|
||||||
|
if (params.seriesName && !params.seriesName.includes('-head')) {
|
||||||
|
const theme = data.themes.find((t) => t.label === params.seriesName);
|
||||||
|
if (theme) {
|
||||||
|
handleThemeClick(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[data.themes, handleThemeClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Center h="300px">
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<Spinner size="lg" color="yellow.400" />
|
||||||
|
<Text color="whiteAlpha.600">加载词频数据...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.themes || data.themes.length === 0) {
|
||||||
|
return (
|
||||||
|
<Center h="300px">
|
||||||
|
<Text color="whiteAlpha.500">暂无词频数据</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="linear-gradient(135deg, rgba(15,15,30,0.6) 0%, rgba(25,25,50,0.6) 100%)"
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid rgba(255,215,0,0.15)"
|
||||||
|
p={4}
|
||||||
|
h="100%"
|
||||||
|
minH="350px"
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<HStack spacing={3} mb={4}>
|
||||||
|
<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}>
|
||||||
|
<Text fontSize="md" fontWeight="bold" color="#FFD700">
|
||||||
|
题材热力追踪
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color="whiteAlpha.500">
|
||||||
|
核心主题词频演变 · 最近7个交易日
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 图表区域 */}
|
||||||
|
<Box h="calc(100% - 80px)">
|
||||||
|
<ReactECharts
|
||||||
|
option={chartOption}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
onEvents={onChartEvents}
|
||||||
|
opts={{ renderer: 'canvas' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 底部图例 */}
|
||||||
|
<HStack spacing={2} flexWrap="wrap" mt={2}>
|
||||||
|
{data.themes.slice(0, 6).map((theme) => (
|
||||||
|
<Tooltip
|
||||||
|
key={theme.word}
|
||||||
|
label={`当前热度: ${theme.currentValue} | 趋势: ${theme.trend > 0 ? '+' : ''}${theme.trend}`}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
bg={`${theme.color}20`}
|
||||||
|
color={theme.color}
|
||||||
|
border={`1px solid ${theme.color}50`}
|
||||||
|
cursor="pointer"
|
||||||
|
fontSize="xs"
|
||||||
|
onClick={() => handleThemeClick(theme)}
|
||||||
|
_hover={{
|
||||||
|
bg: `${theme.color}40`,
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
{theme.label}
|
||||||
|
{theme.trend > 5 && ' 🔥'}
|
||||||
|
{theme.trend < -5 && ' ❄️'}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeCometChart;
|
||||||
Reference in New Issue
Block a user