update pay ui

This commit is contained in:
2025-12-05 17:59:19 +08:00
parent 82ef4d4391
commit 1cdfb732f6

View File

@@ -1,20 +1,15 @@
/** /**
* ForceGraphView - 概念层级关系图(使用 reagraph * SunburstView - 概念层级旭日图
* *
* 特性: * 特性:
* 1. 清晰的层级布局Radial/Hierarchical * 1. 同心圆环展示层级关系,从内到外:根 → 一级 → 二级 → 三级 → 概念
* 2. 节点大小根据股票数量动态调整 * 2. 涨红跌绿颜色映射
* 3. 涨红跌绿颜色映射 * 3. 点击扇区可钻取到子层级
* 4. 悬停显示涨跌幅详情 * 4. 悬停显示详细信息
* 5. 点击节点可聚焦或跳转 * 5. 支持返回上级
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import ReactECharts from 'echarts-for-react';
GraphCanvas,
useSelection,
lightTheme,
darkTheme,
} from 'reagraph';
import { import {
Box, Box,
VStack, VStack,
@@ -29,29 +24,22 @@ import {
Tooltip, Tooltip,
Badge, Badge,
useBreakpointValue, useBreakpointValue,
Select,
FormControl,
FormLabel,
Collapse,
useDisclosure,
Divider,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { import {
FaLayerGroup, FaLayerGroup,
FaSync, FaSync,
FaExpand, FaExpand,
FaCompress, FaCompress,
FaCog,
FaHome, FaHome,
FaChevronUp,
FaArrowUp, FaArrowUp,
FaArrowDown, FaArrowDown,
FaProjectDiagram,
FaCircle, FaCircle,
FaChartPie,
FaUndo,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
// 一级分类颜色映射 // 一级分类颜色映射(基础色)
const LV1_COLORS = { const LV1_COLORS = {
'人工智能': '#8B5CF6', '人工智能': '#8B5CF6',
'半导体': '#3B82F6', '半导体': '#3B82F6',
@@ -74,33 +62,23 @@ const LV1_COLORS = {
const getChangeColor = (value, baseColor = '#64748B') => { const getChangeColor = (value, baseColor = '#64748B') => {
if (value === null || value === undefined) return baseColor; if (value === null || value === undefined) return baseColor;
// 涨 - 红色系(更鲜艳) // 涨 - 红色系
if (value > 7) return '#DC2626'; if (value > 7) return '#DC2626';
if (value > 5) return '#EF4444'; if (value > 5) return '#EF4444';
if (value > 3) return '#F87171'; if (value > 3) return '#F87171';
if (value > 1) return '#FCA5A5'; if (value > 1) return '#FCA5A5';
if (value > 0) return '#FECACA'; if (value > 0) return '#FED7D7';
// 跌 - 绿色系 // 跌 - 绿色系
if (value < -7) return '#15803D'; if (value < -7) return '#15803D';
if (value < -5) return '#16A34A'; if (value < -5) return '#16A34A';
if (value < -3) return '#22C55E'; if (value < -3) return '#22C55E';
if (value < -1) return '#4ADE80'; if (value < -1) return '#4ADE80';
if (value < 0) return '#86EFAC'; if (value < 0) return '#BBF7D0';
return baseColor; // 平盘 return baseColor; // 平盘
}; };
// 根据涨跌幅获取发光颜色
const getGlowColor = (value) => {
if (value === null || value === undefined) return 'rgba(100, 116, 139, 0.5)';
if (value > 3) return 'rgba(239, 68, 68, 0.6)';
if (value > 0) return 'rgba(252, 165, 165, 0.5)';
if (value < -3) return 'rgba(34, 197, 94, 0.6)';
if (value < 0) return 'rgba(134, 239, 172, 0.5)';
return 'rgba(100, 116, 139, 0.5)';
};
// 从 API 返回的名称中提取纯名称 // 从 API 返回的名称中提取纯名称
const extractPureName = (apiName) => { const extractPureName = (apiName) => {
if (!apiName) return ''; if (!apiName) return '';
@@ -114,50 +92,6 @@ const formatChangePercent = (value) => {
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%'; return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
}; };
/**
* 自定义深色主题
*/
const customDarkTheme = {
...darkTheme,
canvas: {
background: 'transparent',
},
node: {
...darkTheme.node,
fill: '#8B5CF6',
activeFill: '#A78BFA',
label: {
color: '#E2E8F0',
stroke: '#0F172A',
activeColor: '#FFFFFF',
},
},
edge: {
...darkTheme.edge,
fill: '#475569',
activeFill: '#8B5CF6',
},
ring: {
...darkTheme.ring,
fill: '#8B5CF6',
activeFill: '#A78BFA',
},
arrow: {
...darkTheme.arrow,
fill: '#475569',
activeFill: '#8B5CF6',
},
cluster: {
...darkTheme.cluster,
stroke: '#475569',
fill: 'rgba(139, 92, 246, 0.1)',
label: {
...darkTheme.cluster?.label,
color: '#94A3B8',
},
},
};
/** /**
* 主组件 * 主组件
*/ */
@@ -172,13 +106,11 @@ const ForceGraphView = ({
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} }); const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} });
const [priceLoading, setPriceLoading] = useState(false); const [priceLoading, setPriceLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [layoutType, setLayoutType] = useState('radialOut2d'); // 'radialOut2d', 'treeTd2d', 'treeLr2d', 'forceDirected2d' const [hoveredItem, setHoveredItem] = useState(null);
const [displayLevel, setDisplayLevel] = useState('lv2'); // 'lv1', 'lv2', 'lv3', 'all' const [drillPath, setDrillPath] = useState([]); // 钻取路径
const [selectedNode, setSelectedNode] = useState(null);
const graphRef = useRef(); const chartRef = useRef();
const containerRef = useRef(); const containerRef = useRef();
const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure({ defaultIsOpen: false });
const isMobile = useBreakpointValue({ base: true, md: false }); const isMobile = useBreakpointValue({ base: true, md: false });
@@ -194,12 +126,12 @@ const ForceGraphView = ({
const data = await response.json(); const data = await response.json();
setHierarchy(data.hierarchy || []); setHierarchy(data.hierarchy || []);
logger.info('ForceGraphView', '层级结构加载完成', { logger.info('SunburstView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length, totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts totalConcepts: data.total_concepts
}); });
} catch (err) { } catch (err) {
logger.error('ForceGraphView', 'fetchHierarchy', err); logger.error('SunburstView', 'fetchHierarchy', err);
setError(err.message); setError(err.message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -219,7 +151,7 @@ const ForceGraphView = ({
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
logger.warn('ForceGraphView', '获取层级涨跌幅失败', { status: response.status }); logger.warn('SunburstView', '获取层级涨跌幅失败', { status: response.status });
return; return;
} }
@@ -248,12 +180,12 @@ const ForceGraphView = ({
setPriceData({ lv1Map, lv2Map, lv3Map, leafMap }); setPriceData({ lv1Map, lv2Map, lv3Map, leafMap });
logger.info('ForceGraphView', '层级涨跌幅加载完成', { logger.info('SunburstView', '层级涨跌幅加载完成', {
lv1Count: Object.keys(lv1Map).length, lv1Count: Object.keys(lv1Map).length,
lv2Count: Object.keys(lv2Map).length, lv2Count: Object.keys(lv2Map).length,
}); });
} catch (err) { } catch (err) {
logger.warn('ForceGraphView', '获取层级涨跌幅失败', { error: err.message }); logger.warn('SunburstView', '获取层级涨跌幅失败', { error: err.message });
} finally { } finally {
setPriceLoading(false); setPriceLoading(false);
} }
@@ -269,31 +201,20 @@ const ForceGraphView = ({
} }
}, [hierarchy, fetchHierarchyPrice]); }, [hierarchy, fetchHierarchyPrice]);
// 构建图数据 // 构建旭日图数据
const graphData = useMemo(() => { const sunburstData = useMemo(() => {
const nodes = [];
const edges = [];
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData; const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
// 根节点 const buildChildren = (lv1) => {
nodes.push({
id: 'root',
label: '概念中心',
data: { level: 'root', changePct: null },
fill: '#8B5CF6',
size: 60,
});
hierarchy.forEach((lv1) => {
const lv1Id = `lv1_${lv1.name}`;
const lv1Price = lv1Map[lv1.name] || {};
const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6'; const lv1BaseColor = LV1_COLORS[lv1.name] || '#8B5CF6';
const lv1Color = getChangeColor(lv1Price.avg_change_pct, lv1BaseColor); const lv1Price = lv1Map[lv1.name] || {};
// 一级节点 const lv1Node = {
nodes.push({ name: lv1.name,
id: lv1Id, value: lv1Price.stock_count || lv1.concept_count * 10 || 100,
label: lv1.name, itemStyle: {
color: getChangeColor(lv1Price.avg_change_pct, lv1BaseColor),
},
data: { data: {
level: 'lv1', level: 'lv1',
changePct: lv1Price.avg_change_pct, changePct: lv1Price.avg_change_pct,
@@ -301,27 +222,20 @@ const ForceGraphView = ({
conceptCount: lv1.concept_count, conceptCount: lv1.concept_count,
baseColor: lv1BaseColor, baseColor: lv1BaseColor,
}, },
fill: lv1Color, children: [],
size: Math.max(35, Math.min(55, 35 + (lv1Price.stock_count || 0) / 50)), };
});
edges.push({ // 二级
id: `root-${lv1Id}`, if (lv1.children) {
source: 'root',
target: lv1Id,
size: 2,
});
// 二级节点
if (lv1.children && (displayLevel === 'lv2' || displayLevel === 'lv3' || displayLevel === 'all')) {
lv1.children.forEach((lv2) => { lv1.children.forEach((lv2) => {
const lv2Id = `lv2_${lv1.name}_${lv2.name}`;
const lv2Price = lv2Map[lv2.name] || {}; const lv2Price = lv2Map[lv2.name] || {};
const lv2Color = getChangeColor(lv2Price.avg_change_pct, lv1BaseColor);
nodes.push({ const lv2Node = {
id: lv2Id, name: lv2.name,
label: lv2.name, value: lv2Price.stock_count || lv2.concept_count * 5 || 50,
itemStyle: {
color: getChangeColor(lv2Price.avg_change_pct, lv1BaseColor),
},
data: { data: {
level: 'lv2', level: 'lv2',
parentLv1: lv1.name, parentLv1: lv1.name,
@@ -330,27 +244,20 @@ const ForceGraphView = ({
conceptCount: lv2.concept_count, conceptCount: lv2.concept_count,
baseColor: lv1BaseColor, baseColor: lv1BaseColor,
}, },
fill: lv2Color, children: [],
size: Math.max(25, Math.min(40, 25 + (lv2Price.stock_count || 0) / 80)), };
});
edges.push({ // 三级
id: `${lv1Id}-${lv2Id}`, if (lv2.children) {
source: lv1Id,
target: lv2Id,
size: 1.5,
});
// 三级节点
if (lv2.children && (displayLevel === 'lv3' || displayLevel === 'all')) {
lv2.children.forEach((lv3) => { lv2.children.forEach((lv3) => {
const lv3Id = `lv3_${lv1.name}_${lv2.name}_${lv3.name}`;
const lv3Price = lv3Map[lv3.name] || {}; const lv3Price = lv3Map[lv3.name] || {};
const lv3Color = getChangeColor(lv3Price.avg_change_pct, lv1BaseColor);
nodes.push({ const lv3Node = {
id: lv3Id, name: lv3.name,
label: lv3.name, value: lv3Price.stock_count || 30,
itemStyle: {
color: getChangeColor(lv3Price.avg_change_pct, lv1BaseColor),
},
data: { data: {
level: 'lv3', level: 'lv3',
parentLv1: lv1.name, parentLv1: lv1.name,
@@ -359,29 +266,19 @@ const ForceGraphView = ({
stockCount: lv3Price.stock_count, stockCount: lv3Price.stock_count,
baseColor: lv1BaseColor, baseColor: lv1BaseColor,
}, },
fill: lv3Color, children: [],
size: Math.max(18, Math.min(30, 18 + (lv3Price.stock_count || 0) / 100)), };
});
edges.push({
id: `${lv2Id}-${lv3Id}`,
source: lv2Id,
target: lv3Id,
size: 1,
});
// 叶子概念 // 叶子概念
if (displayLevel === 'all' && lv3.concepts) { if (lv3.concepts) {
lv3.concepts.slice(0, 5).forEach((conceptName) => { // 限制每个lv3只显示5个概念 lv3.concepts.forEach((conceptName) => {
const conceptId = `concept_${conceptName}`;
const conceptPrice = leafMap[conceptName] || {}; const conceptPrice = leafMap[conceptName] || {};
const conceptColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor); lv3Node.children.push({
name: conceptName,
// 避免重复添加节点 value: conceptPrice.stock_count || 10,
if (!nodes.find(n => n.id === conceptId)) { itemStyle: {
nodes.push({ color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
id: conceptId, },
label: conceptName,
data: { data: {
level: 'concept', level: 'concept',
parentLv1: lv1.name, parentLv1: lv1.name,
@@ -391,33 +288,24 @@ const ForceGraphView = ({
stockCount: conceptPrice.stock_count, stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor, baseColor: lv1BaseColor,
}, },
fill: conceptColor, });
size: 12,
}); });
} }
edges.push({ lv2Node.children.push(lv3Node);
id: `${lv3Id}-${conceptId}`,
source: lv3Id,
target: conceptId,
size: 0.5,
});
});
}
}); });
} }
// lv2 直接包含的概念 // lv2 直接包含的概念
if (displayLevel === 'all' && lv2.concepts) { if (lv2.concepts) {
lv2.concepts.slice(0, 5).forEach((conceptName) => { lv2.concepts.forEach((conceptName) => {
const conceptId = `concept_${conceptName}`;
const conceptPrice = leafMap[conceptName] || {}; const conceptPrice = leafMap[conceptName] || {};
const conceptColor = getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor); lv2Node.children.push({
name: conceptName,
if (!nodes.find(n => n.id === conceptId)) { value: conceptPrice.stock_count || 10,
nodes.push({ itemStyle: {
id: conceptId, color: getChangeColor(conceptPrice.avg_change_pct, lv1BaseColor),
label: conceptName, },
data: { data: {
level: 'concept', level: 'concept',
parentLv1: lv1.name, parentLv1: lv1.name,
@@ -426,37 +314,216 @@ const ForceGraphView = ({
stockCount: conceptPrice.stock_count, stockCount: conceptPrice.stock_count,
baseColor: lv1BaseColor, baseColor: lv1BaseColor,
}, },
fill: conceptColor,
size: 12,
});
}
edges.push({
id: `${lv2Id}-${conceptId}`,
source: lv2Id,
target: conceptId,
size: 0.5,
}); });
}); });
} }
lv1Node.children.push(lv2Node);
}); });
} }
});
return { nodes, edges }; return lv1Node;
}, [hierarchy, priceData, displayLevel]); };
// 节点点击处理 return hierarchy.map(buildChildren);
const handleNodeClick = useCallback((node) => { }, [hierarchy, priceData]);
setSelectedNode(node);
if (node.data?.level === 'concept') { // ECharts 配置
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(node.label)}.html`; const chartOption = useMemo(() => {
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.95)',
borderColor: 'rgba(139, 92, 246, 0.5)',
borderWidth: 1,
borderRadius: 12,
padding: [12, 16],
textStyle: {
color: '#E2E8F0',
fontSize: 13,
},
formatter: (params) => {
const data = params.data?.data || {};
const levelMap = {
'lv1': '一级分类',
'lv2': '二级分类',
'lv3': '三级分类',
'concept': '概念',
};
const changePct = data.changePct;
const changeColor = changePct > 0 ? '#F87171' : changePct < 0 ? '#4ADE80' : '#94A3B8';
const changeIcon = changePct > 0 ? '▲' : changePct < 0 ? '▼' : '●';
return `
<div style="min-width: 160px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="
background: ${data.baseColor || '#8B5CF6'};
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
">${levelMap[data.level] || '分类'}</span>
</div>
<div style="font-size: 16px; font-weight: bold; margin-bottom: 8px; color: #FFFFFF;">
${params.name}
</div>
${changePct !== undefined && changePct !== null ? `
<div style="
display: inline-flex;
align-items: center;
gap: 6px;
background: ${changePct > 0 ? 'rgba(248, 113, 113, 0.2)' : changePct < 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(148, 163, 184, 0.2)'};
padding: 4px 10px;
border-radius: 8px;
margin-bottom: 8px;
">
<span style="color: ${changeColor}; font-size: 12px;">${changeIcon}</span>
<span style="color: ${changeColor}; font-weight: bold; font-size: 15px; font-family: monospace;">
${formatChangePercent(changePct)}
</span>
</div>
` : ''}
<div style="color: #94A3B8; font-size: 12px;">
${data.stockCount ? `${data.stockCount} 只股票` : ''}
${data.conceptCount ? ` · ${data.conceptCount} 个概念` : ''}
</div>
${data.level === 'concept' ? `
<div style="color: #A78BFA; font-size: 11px; margin-top: 6px;">
点击查看详情 →
</div>
` : `
<div style="color: #64748B; font-size: 11px; margin-top: 6px;">
点击钻取到下级
</div>
`}
</div>
`;
},
},
series: [
{
type: 'sunburst',
data: sunburstData,
radius: ['12%', '95%'],
center: ['50%', '50%'],
sort: 'desc',
emphasis: {
focus: 'ancestor',
itemStyle: {
shadowBlur: 20,
shadowColor: 'rgba(139, 92, 246, 0.5)',
},
},
levels: [
{},
// 一级 - 最内圈
{
r0: '12%',
r: '35%',
itemStyle: {
borderWidth: 3,
borderColor: '#0F172A',
borderRadius: 4,
},
label: {
show: true,
rotate: 'tangential',
fontSize: 12,
fontWeight: 'bold',
color: '#FFFFFF',
textShadowColor: '#000',
textShadowBlur: 4,
},
},
// 二级
{
r0: '35%',
r: '58%',
itemStyle: {
borderWidth: 2,
borderColor: '#0F172A',
borderRadius: 3,
},
label: {
show: true,
rotate: 'tangential',
fontSize: 10,
color: '#F1F5F9',
textShadowColor: '#000',
textShadowBlur: 3,
},
},
// 三级
{
r0: '58%',
r: '78%',
itemStyle: {
borderWidth: 1,
borderColor: '#0F172A',
borderRadius: 2,
},
label: {
show: true,
rotate: 'radial',
fontSize: 9,
color: '#E2E8F0',
align: 'center',
},
},
// 概念 - 最外圈
{
r0: '78%',
r: '95%',
itemStyle: {
borderWidth: 1,
borderColor: '#1E293B',
borderRadius: 1,
},
label: {
show: false, // 概念层太多,隐藏标签
position: 'outside',
fontSize: 8,
color: '#94A3B8',
},
},
],
itemStyle: {
borderRadius: 4,
},
animation: true,
animationDuration: 800,
animationEasing: 'cubicOut',
},
],
};
}, [sunburstData]);
// 图表事件
const onChartEvents = useMemo(() => ({
click: (params) => {
const data = params.data?.data || {};
if (data.level === 'concept') {
// 跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(params.name)}.html`;
window.open(htmlPath, '_blank'); window.open(htmlPath, '_blank');
} }
logger.info('ForceGraphView', '节点点击', { level: node.data?.level, name: node.label }); logger.info('SunburstView', '点击', { level: data.level, name: params.name });
}, []); },
mouseover: (params) => {
setHoveredItem({
name: params.name,
data: params.data?.data,
});
},
mouseout: () => {
setHoveredItem(null);
},
}), []);
// 刷新数据 // 刷新数据
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
@@ -479,7 +546,7 @@ const ForceGraphView = ({
<Center h="500px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl"> <Center h="500px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
<VStack spacing={4}> <VStack spacing={4}>
<Spinner size="xl" color="purple.400" thickness="4px" /> <Spinner size="xl" color="purple.400" thickness="4px" />
<Text color="gray.400">正在构建概念关系...</Text> <Text color="gray.400">正在构建旭日...</Text>
</VStack> </VStack>
</Center> </Center>
); );
@@ -522,9 +589,8 @@ const ForceGraphView = ({
left={0} left={0}
right={0} right={0}
bottom={0} bottom={0}
opacity={0.1} opacity={0.15}
bgImage="radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.3) 0%, transparent 50%), bgImage="radial-gradient(circle at 50% 50%, rgba(139, 92, 246, 0.3) 0%, transparent 60%)"
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.3) 0%, transparent 50%)"
pointerEvents="none" pointerEvents="none"
/> />
@@ -539,7 +605,7 @@ const ForceGraphView = ({
zIndex={10} zIndex={10}
pointerEvents="none" pointerEvents="none"
> >
{/* 左侧标题和信息 */} {/* 左侧标题 */}
<VStack align="start" spacing={3} pointerEvents="auto"> <VStack align="start" spacing={3} pointerEvents="auto">
<HStack <HStack
bg="rgba(0, 0, 0, 0.7)" bg="rgba(0, 0, 0, 0.7)"
@@ -551,9 +617,9 @@ const ForceGraphView = ({
borderColor="whiteAlpha.200" borderColor="whiteAlpha.200"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)" boxShadow="0 4px 20px rgba(0, 0, 0, 0.3)"
> >
<Icon as={FaProjectDiagram} color="purple.300" /> <Icon as={FaChartPie} color="purple.300" />
<Text color="white" fontWeight="bold" fontSize="sm"> <Text color="white" fontWeight="bold" fontSize="sm">
概念关系 概念旭日
</Text> </Text>
<Badge <Badge
bg="purple.500" bg="purple.500"
@@ -562,12 +628,12 @@ const ForceGraphView = ({
px={2} px={2}
fontSize="xs" fontSize="xs"
> >
{graphData.nodes.length} 节点 {hierarchy.length} 板块
</Badge> </Badge>
</HStack> </HStack>
{/* 选中节点信息卡片 */} {/* 悬停信息 */}
{selectedNode && selectedNode.data?.level !== 'root' && ( {hoveredItem && hoveredItem.data?.level !== 'root' && (
<Box <Box
bg="rgba(0, 0, 0, 0.85)" bg="rgba(0, 0, 0, 0.85)"
backdropFilter="blur(12px)" backdropFilter="blur(12px)"
@@ -575,77 +641,69 @@ const ForceGraphView = ({
py={3} py={3}
borderRadius="xl" borderRadius="xl"
border="1px solid" border="1px solid"
borderColor={selectedNode.fill || 'purple.500'} borderColor={hoveredItem.data?.baseColor || 'purple.500'}
boxShadow={`0 4px 20px ${getGlowColor(selectedNode.data?.changePct)}`} boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
maxW="280px" maxW="280px"
> >
<VStack align="start" spacing={2}> <VStack align="start" spacing={2}>
<HStack spacing={2}> <HStack spacing={2}>
<Badge <Badge
bg={selectedNode.data?.baseColor || 'purple.500'} bg={hoveredItem.data?.baseColor || 'purple.500'}
color="white" color="white"
borderRadius="full" borderRadius="full"
px={2} px={2}
fontSize="xs" fontSize="xs"
> >
{selectedNode.data?.level === 'lv1' ? '一级分类' : {hoveredItem.data?.level === 'lv1' ? '一级分类' :
selectedNode.data?.level === 'lv2' ? '二级分类' : hoveredItem.data?.level === 'lv2' ? '二级分类' :
selectedNode.data?.level === 'lv3' ? '三级分类' : '概念'} hoveredItem.data?.level === 'lv3' ? '三级分类' : '概念'}
</Badge> </Badge>
</HStack> </HStack>
<Text color="white" fontWeight="bold" fontSize="md"> <Text color="white" fontWeight="bold" fontSize="md">
{selectedNode.label} {hoveredItem.name}
</Text> </Text>
{/* 涨跌幅显示 */} {hoveredItem.data?.changePct !== undefined && hoveredItem.data?.changePct !== null && (
{selectedNode.data?.changePct !== undefined && selectedNode.data?.changePct !== null && (
<HStack <HStack
bg={selectedNode.data.changePct > 0 ? 'rgba(239, 68, 68, 0.2)' : selectedNode.data.changePct < 0 ? 'rgba(34, 197, 94, 0.2)' : 'rgba(100, 116, 139, 0.2)'} bg={hoveredItem.data.changePct > 0 ? 'rgba(248, 113, 113, 0.2)' : hoveredItem.data.changePct < 0 ? 'rgba(74, 222, 128, 0.2)' : 'rgba(148, 163, 184, 0.2)'}
px={3} px={3}
py={1} py={1}
borderRadius="full" borderRadius="full"
border="1px solid" border="1px solid"
borderColor={selectedNode.data.changePct > 0 ? 'red.400' : selectedNode.data.changePct < 0 ? 'green.400' : 'gray.500'} borderColor={hoveredItem.data.changePct > 0 ? 'red.400' : hoveredItem.data.changePct < 0 ? 'green.400' : 'gray.500'}
> >
<Icon <Icon
as={selectedNode.data.changePct > 0 ? FaArrowUp : selectedNode.data.changePct < 0 ? FaArrowDown : FaCircle} as={hoveredItem.data.changePct > 0 ? FaArrowUp : hoveredItem.data.changePct < 0 ? FaArrowDown : FaCircle}
color={selectedNode.data.changePct > 0 ? 'red.400' : selectedNode.data.changePct < 0 ? 'green.400' : 'gray.400'} color={hoveredItem.data.changePct > 0 ? 'red.400' : hoveredItem.data.changePct < 0 ? 'green.400' : 'gray.400'}
boxSize={3} boxSize={3}
/> />
<Text <Text
color={selectedNode.data.changePct > 0 ? 'red.300' : selectedNode.data.changePct < 0 ? 'green.300' : 'gray.300'} color={hoveredItem.data.changePct > 0 ? 'red.300' : hoveredItem.data.changePct < 0 ? 'green.300' : 'gray.300'}
fontWeight="bold" fontWeight="bold"
fontSize="lg" fontSize="lg"
fontFamily="mono" fontFamily="mono"
> >
{formatChangePercent(selectedNode.data.changePct)} {formatChangePercent(hoveredItem.data.changePct)}
</Text> </Text>
</HStack> </HStack>
)} )}
<HStack spacing={4} color="whiteAlpha.700" fontSize="xs"> <HStack spacing={4} color="whiteAlpha.700" fontSize="xs">
{selectedNode.data?.stockCount && ( {hoveredItem.data?.stockCount && (
<Text>{selectedNode.data.stockCount} 只股票</Text> <Text>{hoveredItem.data.stockCount} 只股票</Text>
)} )}
{selectedNode.data?.conceptCount && ( {hoveredItem.data?.conceptCount && (
<Text>{selectedNode.data.conceptCount} 个概念</Text> <Text>{hoveredItem.data.conceptCount} 个概念</Text>
)} )}
</HStack> </HStack>
{selectedNode.data?.level === 'concept' && (
<Text color="purple.300" fontSize="xs">
点击查看详情
</Text>
)}
</VStack> </VStack>
</Box> </Box>
)} )}
</VStack> </VStack>
{/* 右侧控制按钮 */} {/* 右侧控制按钮 */}
<VStack spacing={2} pointerEvents="auto"> <HStack spacing={2} pointerEvents="auto">
<HStack spacing={2}>
{priceLoading && <Spinner size="sm" color="purple.300" />} {priceLoading && <Spinner size="sm" color="purple.300" />}
<Tooltip label="刷新数据" placement="left"> <Tooltip label="刷新数据" placement="left">
@@ -663,20 +721,6 @@ const ForceGraphView = ({
/> />
</Tooltip> </Tooltip>
<Tooltip label="设置" placement="left">
<IconButton
size="sm"
icon={isSettingsOpen ? <FaChevronUp /> : <FaCog />}
onClick={onSettingsToggle}
bg={isSettingsOpen ? 'purple.500' : 'rgba(0, 0, 0, 0.7)'}
color="white"
border="1px solid"
borderColor={isSettingsOpen ? 'purple.400' : 'whiteAlpha.200'}
_hover={{ bg: isSettingsOpen ? 'purple.400' : 'whiteAlpha.200' }}
aria-label="设置"
/>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left"> <Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left">
<IconButton <IconButton
size="sm" size="sm"
@@ -691,65 +735,6 @@ const ForceGraphView = ({
/> />
</Tooltip> </Tooltip>
</HStack> </HStack>
{/* 设置面板 */}
<Collapse in={isSettingsOpen}>
<Box
bg="rgba(0, 0, 0, 0.85)"
backdropFilter="blur(12px)"
p={4}
borderRadius="xl"
border="1px solid"
borderColor="whiteAlpha.200"
w="220px"
boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
>
<VStack spacing={4} align="stretch">
<FormControl>
<FormLabel color="whiteAlpha.800" fontSize="xs" mb={1}>
布局方式
</FormLabel>
<Select
value={layoutType}
onChange={(e) => setLayoutType(e.target.value)}
size="sm"
bg="whiteAlpha.100"
color="white"
borderColor="whiteAlpha.300"
_hover={{ borderColor: 'purple.400' }}
sx={{ option: { bg: '#1a1a2e', color: 'white' } }}
>
<option value="radialOut2d">放射状</option>
<option value="treeTd2d">树形(上下)</option>
<option value="treeLr2d">树形(左右)</option>
<option value="forceDirected2d">力导向</option>
</Select>
</FormControl>
<FormControl>
<FormLabel color="whiteAlpha.800" fontSize="xs" mb={1}>
显示层级
</FormLabel>
<Select
value={displayLevel}
onChange={(e) => setDisplayLevel(e.target.value)}
size="sm"
bg="whiteAlpha.100"
color="white"
borderColor="whiteAlpha.300"
_hover={{ borderColor: 'purple.400' }}
sx={{ option: { bg: '#1a1a2e', color: 'white' } }}
>
<option value="lv1">一级分类</option>
<option value="lv2">到二级</option>
<option value="lv3">到三级</option>
<option value="all">全部(含概念)</option>
</Select>
</FormControl>
</VStack>
</Box>
</Collapse>
</VStack>
</Flex> </Flex>
{/* 底部图例 */} {/* 底部图例 */}
@@ -821,25 +806,59 @@ const ForceGraphView = ({
borderColor="whiteAlpha.200" borderColor="whiteAlpha.200"
> >
<Text color="whiteAlpha.600" fontSize="xs"> <Text color="whiteAlpha.600" fontSize="xs">
滚轮缩放 · 拖拽平移 · 点击节点查看详情 悬停查看详情 · 点击概念跳转
</Text> </Text>
</HStack> </HStack>
</Box> </Box>
{/* 图表 */} {/* 层级说明 */}
<GraphCanvas <Box
ref={graphRef} position="absolute"
nodes={graphData.nodes} bottom={4}
edges={graphData.edges} left="50%"
theme={customDarkTheme} transform="translateX(-50%)"
layoutType={layoutType} zIndex={10}
labelType="all" pointerEvents="none"
edgeArrowPosition="none" >
draggable <HStack
onNodeClick={handleNodeClick} bg="rgba(0, 0, 0, 0.7)"
contextMenu={null} backdropFilter="blur(10px)"
cameraMode="pan" px={4}
sizingType="centrality" py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={3}
>
<HStack spacing={1}>
<Box w={2} h={2} borderRadius="full" bg="purple.400" />
<Text color="whiteAlpha.700" fontSize="xs">一级</Text>
</HStack>
<Text color="whiteAlpha.400"></Text>
<HStack spacing={1}>
<Box w={2} h={2} borderRadius="full" bg="blue.400" />
<Text color="whiteAlpha.700" fontSize="xs">二级</Text>
</HStack>
<Text color="whiteAlpha.400"></Text>
<HStack spacing={1}>
<Box w={2} h={2} borderRadius="full" bg="cyan.400" />
<Text color="whiteAlpha.700" fontSize="xs">三级</Text>
</HStack>
<Text color="whiteAlpha.400"></Text>
<HStack spacing={1}>
<Box w={2} h={2} borderRadius="full" bg="gray.400" />
<Text color="whiteAlpha.700" fontSize="xs">概念</Text>
</HStack>
</HStack>
</Box>
{/* ECharts 图表 */}
<ReactECharts
ref={chartRef}
option={chartOption}
style={{ height: '100%', width: '100%' }}
onEvents={onChartEvents}
opts={{ renderer: 'canvas' }}
/> />
</Box> </Box>
); );