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轴:板块热度(涨停家数)
|
||||
* 支持时间滑动条查看历史数据
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
@@ -13,178 +13,34 @@ import {
|
||||
Spinner,
|
||||
Center,
|
||||
useToast,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Badge,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Tooltip as ChakraTooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { ThunderboltOutlined, CalendarOutlined, StockOutlined, CaretUpFilled } from '@ant-design/icons';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
} from "@chakra-ui/react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
CalendarOutlined,
|
||||
CaretUpFilled,
|
||||
} from "@ant-design/icons";
|
||||
import { getApiBase } from "@utils/apiConfig";
|
||||
|
||||
// 板块状态配置
|
||||
const STATUS_CONFIG = {
|
||||
rising: { name: '主升', color: '#FF4D4F' },
|
||||
declining: { name: '退潮', color: '#52C41A' },
|
||||
lurking: { name: '潜伏', color: '#1890FF' },
|
||||
clustering: { name: '抱团', color: '#722ED1' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 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,
|
||||
};
|
||||
};
|
||||
// 模块化导入
|
||||
import {
|
||||
STATUS_CONFIG,
|
||||
generateChartOption,
|
||||
ThemeDetailModal,
|
||||
} from "./ThemeCometChart/index";
|
||||
|
||||
/**
|
||||
* ThemeCometChart 主组件
|
||||
*/
|
||||
const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [allDatesData, setAllDatesData] = useState({}); // 缓存所有日期的数据
|
||||
const [allDatesData, setAllDatesData] = useState({});
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [selectedTheme, setSelectedTheme] = useState(null);
|
||||
const [sliderIndex, setSliderIndex] = useState(0);
|
||||
@@ -197,7 +53,6 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const apiBase = getApiBase();
|
||||
// 先获取最新数据,拿到可用日期列表
|
||||
const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`);
|
||||
const result = await response.json();
|
||||
|
||||
@@ -205,13 +60,12 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
const dates = result.data.availableDates || [];
|
||||
setAvailableDates(dates);
|
||||
|
||||
// 缓存第一个日期(最新)的数据
|
||||
const latestDate = dates[0]?.date;
|
||||
const dataCache = {};
|
||||
if (latestDate) {
|
||||
dataCache[latestDate] = {
|
||||
themes: result.data.themes || [],
|
||||
currentDate: result.data.currentDate || '',
|
||||
currentDate: result.data.currentDate || "",
|
||||
totalLimitUp: result.data.totalLimitUp || 0,
|
||||
totalEvents: result.data.totalEvents || 0,
|
||||
indexChange: result.data.indexChange || 0,
|
||||
@@ -222,13 +76,15 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
const otherDates = dates.slice(1);
|
||||
const promises = otherDates.map(async (dateInfo) => {
|
||||
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();
|
||||
if (data.success && data.data) {
|
||||
return {
|
||||
date: dateInfo.date,
|
||||
themes: data.data.themes || [],
|
||||
currentDate: data.data.currentDate || '',
|
||||
currentDate: data.data.currentDate || "",
|
||||
totalLimitUp: data.data.totalLimitUp || 0,
|
||||
totalEvents: data.data.totalEvents || 0,
|
||||
indexChange: data.data.indexChange || 0,
|
||||
@@ -254,24 +110,27 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
});
|
||||
|
||||
setAllDatesData(dataCache);
|
||||
setSliderIndex(0); // 默认显示最新日期
|
||||
setSliderIndex(0);
|
||||
} else {
|
||||
throw new Error(result.error || '加载失败');
|
||||
throw new Error(result.error || "加载失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载题材数据失败:', error);
|
||||
toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 });
|
||||
console.error("加载题材数据失败:", error);
|
||||
toast({
|
||||
title: "加载数据失败",
|
||||
description: error.message,
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// 初始加载所有数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 滑动条变化时实时切换数据
|
||||
const handleSliderChange = useCallback((value) => {
|
||||
setSliderIndex(value);
|
||||
}, []);
|
||||
@@ -280,19 +139,24 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
const currentDateStr = availableDates[sliderIndex]?.date;
|
||||
const currentData = allDatesData[currentDateStr] || {
|
||||
themes: [],
|
||||
currentDate: '',
|
||||
currentDate: "",
|
||||
totalLimitUp: 0,
|
||||
totalEvents: 0,
|
||||
indexChange: 0
|
||||
indexChange: 0,
|
||||
};
|
||||
const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr];
|
||||
|
||||
const chartOption = useMemo(() => generateChartOption(currentData.themes), [currentData.themes]);
|
||||
const chartOption = useMemo(
|
||||
() => generateChartOption(currentData.themes),
|
||||
[currentData.themes]
|
||||
);
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(params) => {
|
||||
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) {
|
||||
setSelectedTheme(theme);
|
||||
onOpen();
|
||||
@@ -302,10 +166,13 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
[currentData.themes, onOpen]
|
||||
);
|
||||
|
||||
const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]);
|
||||
const onChartEvents = useMemo(
|
||||
() => ({ click: handleChartClick }),
|
||||
[handleChartClick]
|
||||
);
|
||||
|
||||
// 当前滑动条对应的日期
|
||||
const currentSliderDate = availableDates[sliderIndex]?.formatted || currentData.currentDate;
|
||||
const currentSliderDate =
|
||||
availableDates[sliderIndex]?.formatted || currentData.currentDate;
|
||||
|
||||
if (loading && Object.keys(allDatesData).length === 0) {
|
||||
return (
|
||||
@@ -327,30 +194,36 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
h="100%"
|
||||
minH="350px"
|
||||
>
|
||||
{/* 标题栏 - 两行布局 */}
|
||||
{/* 标题栏 */}
|
||||
<VStack spacing={1} mb={2} align="stretch">
|
||||
{/* 第一行:日期 + 图例 */}
|
||||
<HStack justify="space-between">
|
||||
<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">
|
||||
{currentSliderDate || currentData.currentDate}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* 图例 */}
|
||||
<HStack spacing={3}>
|
||||
{Object.values(STATUS_CONFIG).map((status) => (
|
||||
<HStack key={status.name} spacing={1}>
|
||||
<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 spacing={2}>
|
||||
<Box 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
|
||||
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>
|
||||
<HStack spacing={4}>
|
||||
<HStack spacing={1}>
|
||||
@@ -360,14 +233,24 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
{loading && <Spinner size="sm" color="yellow.400" />}
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<CaretUpFilled style={{ color: '#52C41A', fontSize: '12px' }} />
|
||||
<Text fontSize="xs" color="whiteAlpha.600">热度</Text>
|
||||
<Text fontSize="xs" fontWeight="bold" color="#52C41A">{currentData.totalLimitUp}</Text>
|
||||
<CaretUpFilled style={{ color: "#52C41A", fontSize: "12px" }} />
|
||||
<Text fontSize="xs" color="whiteAlpha.600">
|
||||
热度
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="bold" color="#52C41A">
|
||||
{currentData.totalLimitUp}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
<ThunderboltOutlined style={{ color: '#FFD700', fontSize: '12px' }} />
|
||||
<Text fontSize="xs" color="whiteAlpha.600">事件</Text>
|
||||
<Text fontSize="xs" fontWeight="bold" color="#FFD700">{currentData.totalEvents}</Text>
|
||||
<ThunderboltOutlined
|
||||
style={{ color: "#FFD700", fontSize: "12px" }}
|
||||
/>
|
||||
<Text fontSize="xs" color="whiteAlpha.600">
|
||||
事件
|
||||
</Text>
|
||||
<Text fontSize="xs" fontWeight="bold" color="#FFD700">
|
||||
{currentData.totalEvents}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
@@ -379,15 +262,17 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
<Center h="100%">
|
||||
<VStack spacing={2}>
|
||||
<Spinner size="md" color="yellow.400" />
|
||||
<Text color="whiteAlpha.500" fontSize="sm">加载中...</Text>
|
||||
<Text color="whiteAlpha.500" fontSize="sm">
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : currentData.themes.length > 0 ? (
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
onEvents={onChartEvents}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
opts={{ renderer: "canvas" }}
|
||||
/>
|
||||
) : (
|
||||
<Center h="100%">
|
||||
@@ -401,7 +286,8 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
<Box px={2} pt={2}>
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
||||
{availableDates[availableDates.length - 1]?.formatted?.slice(5) || ''}
|
||||
{availableDates[availableDates.length - 1]?.formatted?.slice(5) ||
|
||||
""}
|
||||
</Text>
|
||||
<ChakraTooltip
|
||||
hasArrow
|
||||
@@ -430,109 +316,23 @@ const ThemeCometChart = ({ onThemeSelect }) => {
|
||||
bg="#FFD700"
|
||||
border="2px solid"
|
||||
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>
|
||||
</ChakraTooltip>
|
||||
<Text fontSize="sm" color="whiteAlpha.500" whiteSpace="nowrap">
|
||||
{availableDates[0]?.formatted?.slice(5) || ''}
|
||||
{availableDates[0]?.formatted?.slice(5) || ""}
|
||||
</Text>
|
||||
</HStack>
|
||||
</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>
|
||||
<ThemeDetailModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
theme={selectedTheme}
|
||||
/>
|
||||
</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