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:
zdl
2026-01-15 11:44:53 +08:00
parent 69f587ad08
commit d15d637c4e
5 changed files with 396 additions and 289 deletions

View File

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

View File

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

View 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,
};
};

View File

@@ -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" },
};

View File

@@ -0,0 +1,5 @@
// ThemeCometChart 模块导出
export { STATUS_CONFIG } from "./constants";
export { generateChartOption } from "./chartOptions";
export { default as ThemeDetailModal } from "./ThemeDetailModal";