refactor(ThemeCometChart): 提取常量、图表配置、弹窗到子模块
- constants.js: STATUS_CONFIG 板块状态配置 - chartOptions.js: generateChartOption 图表配置生成 - ThemeDetailModal.js: 板块详情弹窗组件 - index.js: 模块统一导出 主文件从 ~400 行精简到 ~180 行 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
* Y轴:板块热度(涨停家数)
|
* Y轴:板块热度(涨停家数)
|
||||||
* 支持时间滑动条查看历史数据
|
* 支持时间滑动条查看历史数据
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
@@ -13,178 +13,34 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
useToast,
|
useToast,
|
||||||
Modal,
|
|
||||||
ModalOverlay,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
Table,
|
|
||||||
Thead,
|
|
||||||
Tbody,
|
|
||||||
Tr,
|
|
||||||
Th,
|
|
||||||
Td,
|
|
||||||
Badge,
|
|
||||||
Slider,
|
Slider,
|
||||||
SliderTrack,
|
SliderTrack,
|
||||||
SliderFilledTrack,
|
SliderFilledTrack,
|
||||||
SliderThumb,
|
SliderThumb,
|
||||||
Tooltip as ChakraTooltip,
|
Tooltip as ChakraTooltip,
|
||||||
} from '@chakra-ui/react';
|
} from "@chakra-ui/react";
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from "echarts-for-react";
|
||||||
import { ThunderboltOutlined, CalendarOutlined, StockOutlined, CaretUpFilled } from '@ant-design/icons';
|
import {
|
||||||
import { getApiBase } from '@utils/apiConfig';
|
ThunderboltOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
CaretUpFilled,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { getApiBase } from "@utils/apiConfig";
|
||||||
|
|
||||||
// 板块状态配置
|
// 模块化导入
|
||||||
const STATUS_CONFIG = {
|
import {
|
||||||
rising: { name: '主升', color: '#FF4D4F' },
|
STATUS_CONFIG,
|
||||||
declining: { name: '退潮', color: '#52C41A' },
|
generateChartOption,
|
||||||
lurking: { name: '潜伏', color: '#1890FF' },
|
ThemeDetailModal,
|
||||||
clustering: { name: '抱团', color: '#722ED1' },
|
} from "./ThemeCometChart/index";
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成 ECharts 配置
|
|
||||||
*/
|
|
||||||
const generateChartOption = (themes) => {
|
|
||||||
if (!themes || themes.length === 0) return {};
|
|
||||||
|
|
||||||
const groupedData = {
|
|
||||||
主升: [],
|
|
||||||
退潮: [],
|
|
||||||
潜伏: [],
|
|
||||||
抱团: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
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(STATUS_CONFIG).map(([key, config]) => ({
|
|
||||||
name: config.name,
|
|
||||||
type: 'scatter',
|
|
||||||
data: groupedData[config.name] || [],
|
|
||||||
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
|
|
||||||
itemStyle: {
|
|
||||||
color: (params) => params.data.themeColor || config.color,
|
|
||||||
shadowBlur: 12,
|
|
||||||
shadowColor: (params) => params.data.themeColor || config.color,
|
|
||||||
opacity: 0.85,
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
itemStyle: { opacity: 1, shadowBlur: 25 },
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
formatter: (params) => params.data.name,
|
|
||||||
position: 'right',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textShadowColor: 'rgba(0,0,0,0.8)',
|
|
||||||
textShadowBlur: 4,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
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',
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'item',
|
|
||||||
backgroundColor: 'rgba(15, 15, 30, 0.95)',
|
|
||||||
borderColor: 'rgba(255, 215, 0, 0.3)',
|
|
||||||
borderWidth: 1,
|
|
||||||
textStyle: { color: '#fff' },
|
|
||||||
formatter: (params) => {
|
|
||||||
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 ? '❄️' : '➡️';
|
|
||||||
|
|
||||||
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} ${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:${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-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: false,
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '10%',
|
|
||||||
right: '8%',
|
|
||||||
top: '12%',
|
|
||||||
bottom: '8%',
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'value',
|
|
||||||
name: '辨识度(最高板)',
|
|
||||||
nameLocation: 'middle',
|
|
||||||
nameGap: 28,
|
|
||||||
nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 13 },
|
|
||||||
min: 0,
|
|
||||||
max: maxX,
|
|
||||||
interval: 1,
|
|
||||||
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } },
|
|
||||||
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12, 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: 13 },
|
|
||||||
min: 0,
|
|
||||||
max: maxY,
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 },
|
|
||||||
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } },
|
|
||||||
},
|
|
||||||
series,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeCometChart 主组件
|
* ThemeCometChart 主组件
|
||||||
*/
|
*/
|
||||||
const ThemeCometChart = ({ onThemeSelect }) => {
|
const ThemeCometChart = ({ onThemeSelect }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [allDatesData, setAllDatesData] = useState({}); // 缓存所有日期的数据
|
const [allDatesData, setAllDatesData] = useState({});
|
||||||
const [availableDates, setAvailableDates] = useState([]);
|
const [availableDates, setAvailableDates] = useState([]);
|
||||||
const [selectedTheme, setSelectedTheme] = useState(null);
|
const [selectedTheme, setSelectedTheme] = useState(null);
|
||||||
const [sliderIndex, setSliderIndex] = useState(0);
|
const [sliderIndex, setSliderIndex] = useState(0);
|
||||||
@@ -197,7 +53,6 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiBase = getApiBase();
|
const apiBase = getApiBase();
|
||||||
// 先获取最新数据,拿到可用日期列表
|
|
||||||
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
|
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
@@ -205,13 +60,12 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
const dates = result.data.availableDates || [];
|
const dates = result.data.availableDates || [];
|
||||||
setAvailableDates(dates);
|
setAvailableDates(dates);
|
||||||
|
|
||||||
// 缓存第一个日期(最新)的数据
|
|
||||||
const latestDate = dates[0]?.date;
|
const latestDate = dates[0]?.date;
|
||||||
const dataCache = {};
|
const dataCache = {};
|
||||||
if (latestDate) {
|
if (latestDate) {
|
||||||
dataCache[latestDate] = {
|
dataCache[latestDate] = {
|
||||||
themes: result.data.themes || [],
|
themes: result.data.themes || [],
|
||||||
currentDate: result.data.currentDate || '',
|
currentDate: result.data.currentDate || "",
|
||||||
totalLimitUp: result.data.totalLimitUp || 0,
|
totalLimitUp: result.data.totalLimitUp || 0,
|
||||||
totalEvents: result.data.totalEvents || 0,
|
totalEvents: result.data.totalEvents || 0,
|
||||||
indexChange: result.data.indexChange || 0,
|
indexChange: result.data.indexChange || 0,
|
||||||
@@ -222,13 +76,15 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
const otherDates = dates.slice(1);
|
const otherDates = dates.slice(1);
|
||||||
const promises = otherDates.map(async (dateInfo) => {
|
const promises = otherDates.map(async (dateInfo) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5`);
|
const res = await fetch(
|
||||||
|
`${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5`
|
||||||
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
return {
|
return {
|
||||||
date: dateInfo.date,
|
date: dateInfo.date,
|
||||||
themes: data.data.themes || [],
|
themes: data.data.themes || [],
|
||||||
currentDate: data.data.currentDate || '',
|
currentDate: data.data.currentDate || "",
|
||||||
totalLimitUp: data.data.totalLimitUp || 0,
|
totalLimitUp: data.data.totalLimitUp || 0,
|
||||||
totalEvents: data.data.totalEvents || 0,
|
totalEvents: data.data.totalEvents || 0,
|
||||||
indexChange: data.data.indexChange || 0,
|
indexChange: data.data.indexChange || 0,
|
||||||
@@ -254,24 +110,27 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setAllDatesData(dataCache);
|
setAllDatesData(dataCache);
|
||||||
setSliderIndex(0); // 默认显示最新日期
|
setSliderIndex(0);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || '加载失败');
|
throw new Error(result.error || "加载失败");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载题材数据失败:', error);
|
console.error("加载题材数据失败:", error);
|
||||||
toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 });
|
toast({
|
||||||
|
title: "加载数据失败",
|
||||||
|
description: error.message,
|
||||||
|
status: "error",
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
// 初始加载所有数据
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAllData();
|
loadAllData();
|
||||||
}, [loadAllData]);
|
}, [loadAllData]);
|
||||||
|
|
||||||
// 滑动条变化时实时切换数据
|
|
||||||
const handleSliderChange = useCallback((value) => {
|
const handleSliderChange = useCallback((value) => {
|
||||||
setSliderIndex(value);
|
setSliderIndex(value);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -280,19 +139,24 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
const currentDateStr = availableDates[sliderIndex]?.date;
|
const currentDateStr = availableDates[sliderIndex]?.date;
|
||||||
const currentData = allDatesData[currentDateStr] || {
|
const currentData = allDatesData[currentDateStr] || {
|
||||||
themes: [],
|
themes: [],
|
||||||
currentDate: '',
|
currentDate: "",
|
||||||
totalLimitUp: 0,
|
totalLimitUp: 0,
|
||||||
totalEvents: 0,
|
totalEvents: 0,
|
||||||
indexChange: 0
|
indexChange: 0,
|
||||||
};
|
};
|
||||||
const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr];
|
const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr];
|
||||||
|
|
||||||
const chartOption = useMemo(() => generateChartOption(currentData.themes), [currentData.themes]);
|
const chartOption = useMemo(
|
||||||
|
() => generateChartOption(currentData.themes),
|
||||||
|
[currentData.themes]
|
||||||
|
);
|
||||||
|
|
||||||
const handleChartClick = useCallback(
|
const handleChartClick = useCallback(
|
||||||
(params) => {
|
(params) => {
|
||||||
if (params.data) {
|
if (params.data) {
|
||||||
const theme = currentData.themes.find((t) => t.label === params.data.name);
|
const theme = currentData.themes.find(
|
||||||
|
(t) => t.label === params.data.name
|
||||||
|
);
|
||||||
if (theme) {
|
if (theme) {
|
||||||
setSelectedTheme(theme);
|
setSelectedTheme(theme);
|
||||||
onOpen();
|
onOpen();
|
||||||
@@ -302,10 +166,13 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
[currentData.themes, onOpen]
|
[currentData.themes, onOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]);
|
const onChartEvents = useMemo(
|
||||||
|
() => ({ click: handleChartClick }),
|
||||||
|
[handleChartClick]
|
||||||
|
);
|
||||||
|
|
||||||
// 当前滑动条对应的日期
|
const currentSliderDate =
|
||||||
const currentSliderDate = availableDates[sliderIndex]?.formatted || currentData.currentDate;
|
availableDates[sliderIndex]?.formatted || currentData.currentDate;
|
||||||
|
|
||||||
if (loading && Object.keys(allDatesData).length === 0) {
|
if (loading && Object.keys(allDatesData).length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -327,30 +194,36 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
h="100%"
|
h="100%"
|
||||||
minH="350px"
|
minH="350px"
|
||||||
>
|
>
|
||||||
{/* 标题栏 - 两行布局 */}
|
{/* 标题栏 */}
|
||||||
<VStack spacing={1} mb={2} align="stretch">
|
<VStack spacing={1} mb={2} align="stretch">
|
||||||
{/* 第一行:日期 + 图例 */}
|
|
||||||
<HStack justify="space-between">
|
<HStack justify="space-between">
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<CalendarOutlined style={{ color: 'rgba(255,255,255,0.5)', fontSize: '12px' }} />
|
<CalendarOutlined
|
||||||
|
style={{ color: "rgba(255,255,255,0.5)", fontSize: "12px" }}
|
||||||
|
/>
|
||||||
<Text fontSize="xs" color="whiteAlpha.600">
|
<Text fontSize="xs" color="whiteAlpha.600">
|
||||||
{currentSliderDate || currentData.currentDate}
|
{currentSliderDate || currentData.currentDate}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
{/* 图例 */}
|
|
||||||
<HStack spacing={3}>
|
<HStack spacing={3}>
|
||||||
{Object.values(STATUS_CONFIG).map((status) => (
|
{Object.values(STATUS_CONFIG).map((status) => (
|
||||||
<HStack key={status.name} spacing={1}>
|
<HStack key={status.name} spacing={1}>
|
||||||
<Box w="8px" h="8px" borderRadius="full" bg={status.color} />
|
<Box w="8px" h="8px" borderRadius="full" bg={status.color} />
|
||||||
<Text fontSize="xs" color="whiteAlpha.700">{status.name}</Text>
|
<Text fontSize="xs" color="whiteAlpha.700">
|
||||||
|
{status.name}
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
{/* 第二行:标题 + 热度 + 事件 */}
|
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
<Box p={1.5} bg="rgba(255,215,0,0.15)" borderRadius="md" border="1px solid rgba(255,215,0,0.3)">
|
<Box
|
||||||
<ThunderboltOutlined style={{ color: '#FFD700', fontSize: '14px' }} />
|
p={1.5}
|
||||||
|
bg="rgba(255,215,0,0.15)"
|
||||||
|
borderRadius="md"
|
||||||
|
border="1px solid rgba(255,215,0,0.3)"
|
||||||
|
>
|
||||||
|
<ThunderboltOutlined style={{ color: "#FFD700", fontSize: "14px" }} />
|
||||||
</Box>
|
</Box>
|
||||||
<HStack spacing={4}>
|
<HStack spacing={4}>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
@@ -360,14 +233,24 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
{loading && <Spinner size="sm" color="yellow.400" />}
|
{loading && <Spinner size="sm" color="yellow.400" />}
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<CaretUpFilled style={{ color: '#52C41A', fontSize: '12px' }} />
|
<CaretUpFilled style={{ color: "#52C41A", fontSize: "12px" }} />
|
||||||
<Text fontSize="xs" color="whiteAlpha.600">热度</Text>
|
<Text fontSize="xs" color="whiteAlpha.600">
|
||||||
<Text fontSize="xs" fontWeight="bold" color="#52C41A">{currentData.totalLimitUp}</Text>
|
热度
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="#52C41A">
|
||||||
|
{currentData.totalLimitUp}
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<ThunderboltOutlined style={{ color: '#FFD700', fontSize: '12px' }} />
|
<ThunderboltOutlined
|
||||||
<Text fontSize="xs" color="whiteAlpha.600">事件</Text>
|
style={{ color: "#FFD700", fontSize: "12px" }}
|
||||||
<Text fontSize="xs" fontWeight="bold" color="#FFD700">{currentData.totalEvents}</Text>
|
/>
|
||||||
|
<Text fontSize="xs" color="whiteAlpha.600">
|
||||||
|
事件
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="#FFD700">
|
||||||
|
{currentData.totalEvents}
|
||||||
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -379,15 +262,17 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<VStack spacing={2}>
|
<VStack spacing={2}>
|
||||||
<Spinner size="md" color="yellow.400" />
|
<Spinner size="md" color="yellow.400" />
|
||||||
<Text color="whiteAlpha.500" fontSize="sm">加载中...</Text>
|
<Text color="whiteAlpha.500" fontSize="sm">
|
||||||
|
加载中...
|
||||||
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Center>
|
</Center>
|
||||||
) : currentData.themes.length > 0 ? (
|
) : currentData.themes.length > 0 ? (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={chartOption}
|
option={chartOption}
|
||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
onEvents={onChartEvents}
|
onEvents={onChartEvents}
|
||||||
opts={{ renderer: 'canvas' }}
|
opts={{ renderer: "canvas" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -401,7 +286,8 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
<Box px={2} pt={2}>
|
<Box px={2} pt={2}>
|
||||||
<HStack spacing={3}>
|
<HStack spacing={3}>
|
||||||
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
||||||
{availableDates[availableDates.length - 1]?.formatted?.slice(5) || ''}
|
{availableDates[availableDates.length - 1]?.formatted?.slice(5) ||
|
||||||
|
""}
|
||||||
</Text>
|
</Text>
|
||||||
<ChakraTooltip
|
<ChakraTooltip
|
||||||
hasArrow
|
hasArrow
|
||||||
@@ -430,109 +316,23 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
|||||||
bg="#FFD700"
|
bg="#FFD700"
|
||||||
border="2px solid"
|
border="2px solid"
|
||||||
borderColor="orange.400"
|
borderColor="orange.400"
|
||||||
_focus={{ boxShadow: '0 0 0 3px rgba(255,215,0,0.3)' }}
|
_focus={{ boxShadow: "0 0 0 3px rgba(255,215,0,0.3)" }}
|
||||||
/>
|
/>
|
||||||
</Slider>
|
</Slider>
|
||||||
</ChakraTooltip>
|
</ChakraTooltip>
|
||||||
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
||||||
{availableDates[0]?.formatted?.slice(5) || ''}
|
{availableDates[0]?.formatted?.slice(5) || ""}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 详情弹窗 */}
|
{/* 详情弹窗 */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered>
|
<ThemeDetailModal
|
||||||
<ModalOverlay bg="blackAlpha.700" />
|
isOpen={isOpen}
|
||||||
<ModalContent bg="gray.900" border="1px solid" borderColor="yellow.500" maxH="80vh">
|
onClose={onClose}
|
||||||
<ModalHeader color="yellow.400">
|
theme={selectedTheme}
|
||||||
{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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// 题材详情弹窗组件
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
Badge,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题材详情弹窗
|
||||||
|
*/
|
||||||
|
const ThemeDetailModal = ({ isOpen, onClose, theme }) => {
|
||||||
|
if (!theme) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{theme.label} - 近5日趋势</ModalHeader>
|
||||||
|
<ModalCloseButton color="white" />
|
||||||
|
<ModalBody pb={6} overflowY="auto">
|
||||||
|
<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>
|
||||||
|
{theme.history?.map((h, idx) => {
|
||||||
|
const prev = theme.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}>
|
||||||
|
涨停股票({theme.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>
|
||||||
|
{theme.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>
|
||||||
|
|
||||||
|
{/* 匹配板块 */}
|
||||||
|
{theme.matchedSectors?.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text color="whiteAlpha.700" fontSize="sm" mb={2}>
|
||||||
|
匹配板块
|
||||||
|
</Text>
|
||||||
|
<HStack flexWrap="wrap" spacing={2}>
|
||||||
|
{theme.matchedSectors.map((sector) => (
|
||||||
|
<Badge key={sector} colorScheme="purple" variant="subtle">
|
||||||
|
{sector}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeDetailModal;
|
||||||
143
src/views/Community/components/ThemeCometChart/chartOptions.js
Normal file
143
src/views/Community/components/ThemeCometChart/chartOptions.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// ThemeCometChart ECharts 配置生成
|
||||||
|
|
||||||
|
import { STATUS_CONFIG } from "./constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 ECharts 配置
|
||||||
|
* @param {Array} themes - 题材数据列表
|
||||||
|
* @returns {Object} - ECharts 配置对象
|
||||||
|
*/
|
||||||
|
export const generateChartOption = (themes) => {
|
||||||
|
if (!themes || themes.length === 0) return {};
|
||||||
|
|
||||||
|
const groupedData = {
|
||||||
|
主升: [],
|
||||||
|
退潮: [],
|
||||||
|
潜伏: [],
|
||||||
|
抱团: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
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(STATUS_CONFIG).map(([key, config]) => ({
|
||||||
|
name: config.name,
|
||||||
|
type: "scatter",
|
||||||
|
data: groupedData[config.name] || [],
|
||||||
|
symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)),
|
||||||
|
itemStyle: {
|
||||||
|
color: (params) => params.data.themeColor || config.color,
|
||||||
|
shadowBlur: 12,
|
||||||
|
shadowColor: (params) => params.data.themeColor || config.color,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: { opacity: 1, shadowBlur: 25 },
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: (params) => params.data.name,
|
||||||
|
position: "right",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
|
textShadowBlur: 4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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",
|
||||||
|
tooltip: {
|
||||||
|
trigger: "item",
|
||||||
|
backgroundColor: "rgba(15, 15, 30, 0.95)",
|
||||||
|
borderColor: "rgba(255, 215, 0, 0.3)",
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: "#fff" },
|
||||||
|
formatter: (params) => {
|
||||||
|
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 ? "❄️" : "➡️";
|
||||||
|
|
||||||
|
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} ${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:${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-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: false,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: "10%",
|
||||||
|
right: "8%",
|
||||||
|
top: "12%",
|
||||||
|
bottom: "8%",
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "value",
|
||||||
|
name: "辨识度(最高板)",
|
||||||
|
nameLocation: "middle",
|
||||||
|
nameGap: 28,
|
||||||
|
nameTextStyle: { color: "rgba(255, 255, 255, 0.6)", fontSize: 13 },
|
||||||
|
min: 0,
|
||||||
|
max: maxX,
|
||||||
|
interval: 1,
|
||||||
|
axisLine: { lineStyle: { color: "rgba(255, 255, 255, 0.2)" } },
|
||||||
|
axisLabel: {
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontSize: 12,
|
||||||
|
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: 13 },
|
||||||
|
min: 0,
|
||||||
|
max: maxY,
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: "rgba(255, 255, 255, 0.6)", fontSize: 12 },
|
||||||
|
splitLine: { lineStyle: { color: "rgba(255, 255, 255, 0.05)" } },
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// ThemeCometChart 常量定义
|
||||||
|
|
||||||
|
// 板块状态配置
|
||||||
|
export const STATUS_CONFIG = {
|
||||||
|
rising: { name: "主升", color: "#FF4D4F" },
|
||||||
|
declining: { name: "退潮", color: "#52C41A" },
|
||||||
|
lurking: { name: "潜伏", color: "#1890FF" },
|
||||||
|
clustering: { name: "抱团", color: "#722ED1" },
|
||||||
|
};
|
||||||
5
src/views/Community/components/ThemeCometChart/index.js
Normal file
5
src/views/Community/components/ThemeCometChart/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// ThemeCometChart 模块导出
|
||||||
|
|
||||||
|
export { STATUS_CONFIG } from "./constants";
|
||||||
|
export { generateChartOption } from "./chartOptions";
|
||||||
|
export { default as ThemeDetailModal } from "./ThemeDetailModal";
|
||||||
Reference in New Issue
Block a user