fix(ForceGraphView): 标签显示优化

- 第一层 upperLabel 添加 overflow: break,超长标题折行
- 添加 getConceptLabel 辅助函数
- 所有概念节点添加 label 配置,确保详情按钮始终显示
- 支持外部 drillPath 控制(externalDrillPath, onDrillPathChange)
- hideNavigation 模式支持(供 ChartContainer 使用)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2026-01-08 19:03:59 +08:00
parent 5ae05eebd8
commit 582cc073a0

View File

@@ -26,20 +26,13 @@ import {
Button,
IconButton,
Tooltip,
Badge,
useBreakpointValue,
} from '@chakra-ui/react';
import { keyframes } from '@emotion/react';
import {
Layers,
RefreshCw,
Maximize2,
Minimize2,
Home,
ArrowUp,
ArrowDown,
Circle,
Grid3x3,
ChevronRight,
ArrowLeft,
} from 'lucide-react';
@@ -175,6 +168,11 @@ const ForceGraphView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
// 外部控制的 drillPath可选用于状态共享
externalDrillPath,
onDrillPathChange,
// 是否隐藏导航和背景(由外部 ChartContainer 提供时设为 true
hideNavigation = false,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
@@ -183,8 +181,10 @@ const ForceGraphView = ({
const [priceLoading, setPriceLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// 钻取状态:记录当前查看的路径
const [drillPath, setDrillPath] = useState(null);
// 钻取状态:支持受控和非受控模式
const [internalDrillPath, setInternalDrillPath] = useState(null);
const drillPath = externalDrillPath !== undefined ? externalDrillPath : internalDrillPath;
const setDrillPath = onDrillPathChange || setInternalDrillPath;
const chartRef = useRef();
const containerRef = useRef();
@@ -295,6 +295,40 @@ const ForceGraphView = ({
return count || 1;
}, []);
// 生成概念节点的 label 配置(确保详情按钮始终显示)
const getConceptLabel = useCallback((conceptName, changePct) => {
const changeStr = changePct !== undefined && changePct !== null
? ` {change|${formatChangePercent(changePct)}}`
: '';
return {
show: true,
position: 'insideTopLeft',
fontSize: 11,
padding: [4, 6],
overflow: 'break',
formatter: `{name|${conceptName}}${changeStr}\n{btn|详情 >}`,
rich: {
name: {
fontSize: 11,
fontWeight: 'bold',
lineHeight: 16,
},
change: {
fontSize: 10,
lineHeight: 16,
},
btn: {
fontSize: 9,
color: '#A78BFA',
backgroundColor: 'rgba(139, 92, 246, 0.3)',
borderRadius: 3,
padding: [2, 6],
lineHeight: 18,
}
}
};
}, []);
// 根据钻取路径构建 Treemap 数据(使用概念数量作为面积)
const treemapData = useMemo(() => {
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
@@ -322,6 +356,7 @@ const ForceGraphView = ({
conceptCount: lv1.concept_count,
baseColor: lv1BaseColor,
bgColor: bgColor,
topStocks: lv1Price.top_stocks,
},
children: [],
};
@@ -350,6 +385,7 @@ const ForceGraphView = ({
conceptCount: lv2ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv2BgColor,
topStocks: lv2Price.top_stocks,
},
children: [],
};
@@ -379,6 +415,7 @@ const ForceGraphView = ({
conceptCount: lv3ConceptCount,
baseColor: lv1BaseColor,
bgColor: lv3BgColor,
topStocks: lv3Price.top_stocks,
hasChildren: lv3.concepts && lv3.concepts.length > 0,
},
});
@@ -469,6 +506,7 @@ const ForceGraphView = ({
borderWidth: 1,
borderRadius: 6,
},
label: getConceptLabel(conceptName, conceptPrice.avg_change_pct),
data: {
level: 'concept',
parentLv1: lv1.name,
@@ -501,6 +539,7 @@ const ForceGraphView = ({
borderWidth: 1,
borderRadius: 6,
},
label: getConceptLabel(conceptName, conceptPrice.avg_change_pct),
data: {
level: 'concept',
parentLv1: lv1.name,
@@ -573,6 +612,7 @@ const ForceGraphView = ({
borderWidth: 1,
borderRadius: 8,
},
label: getConceptLabel(conceptName, conceptPrice.avg_change_pct),
data: {
level: 'concept',
parentLv1: lv1.name,
@@ -605,6 +645,7 @@ const ForceGraphView = ({
borderWidth: 2,
borderRadius: 12,
},
label: getConceptLabel(conceptName, conceptPrice.avg_change_pct),
data: {
level: 'concept',
parentLv1: lv1.name,
@@ -646,6 +687,7 @@ const ForceGraphView = ({
borderWidth: 2,
borderRadius: 12,
},
label: getConceptLabel(conceptName, conceptPrice.avg_change_pct),
data: {
level: 'concept',
parentLv1: lv1.name,
@@ -661,7 +703,7 @@ const ForceGraphView = ({
}
return [];
}, [hierarchy, priceData, drillPath, getConceptCount]);
}, [hierarchy, priceData, drillPath, getConceptCount, getConceptLabel]);
// ECharts 配置 - 玻璃态深空风格
const chartOption = useMemo(() => {
@@ -687,22 +729,29 @@ const ForceGraphView = ({
},
upperLabel: {
show: true,
height: 32,
height: 36,
fontSize: 14,
fontWeight: 'bold',
padding: [0, 8],
padding: [8, 8, 0, 8],
overflow: 'break',
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
const changeStr = changePct !== undefined && changePct !== null
? ` ${formatChangePercent(changePct)}`
: '';
return `${params.name}${changeStr}`;
return `${params.name}${changeStr} {icon|⤢}`;
},
rich: {
name: {
fontSize: 14,
fontWeight: 'bold',
},
icon: {
fontSize: 20,
color: 'rgba(255, 255, 255, 0.7)',
verticalAlign: 'middle',
padding: [0, 0, 0, 8],
}
}
},
@@ -723,18 +772,26 @@ const ForceGraphView = ({
},
upperLabel: {
show: true,
height: 26,
height: 30,
fontSize: 12,
fontWeight: 'bold',
padding: [0, 6],
padding: [6, 6, 0, 6],
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
const changeStr = changePct !== undefined && changePct !== null
? ` ${formatChangePercent(changePct)}`
: '';
return `${params.name}${changeStr}`;
return `${params.name}${changeStr} {icon|⤢}`;
},
rich: {
icon: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.6)',
verticalAlign: 'middle',
padding: [0, 0, 0, 6],
}
}
},
},
{
@@ -750,9 +807,9 @@ const ForceGraphView = ({
position: 'insideTopLeft',
fontSize: 11,
padding: [4, 6],
overflow: 'break',
formatter: (params) => {
const data = params.data?.data || {};
const bgColor = data.bgColor;
const changePct = data.changePct;
if (changePct !== undefined && changePct !== null) {
return `{name|${params.name}}\n{change|${formatChangePercent(changePct)}}`;
@@ -785,23 +842,32 @@ const ForceGraphView = ({
position: 'insideTopLeft',
fontSize: 10,
padding: [3, 5],
overflow: 'break',
formatter: (params) => {
const data = params.data?.data || {};
const changePct = data.changePct;
if (changePct !== undefined && changePct !== null) {
return `{name|${params.name}}\n{change|${formatChangePercent(changePct)}}`;
return `{name|${params.name}} {change|${formatChangePercent(changePct)}}\n{btn|详情 >}`;
}
return params.name;
return `{name|${params.name}}\n{btn|详情 >}`;
},
rich: {
name: {
fontSize: 10,
fontWeight: 'bold',
lineHeight: 14,
lineHeight: 16,
},
change: {
fontSize: 9,
lineHeight: 12,
lineHeight: 16,
},
btn: {
fontSize: 9,
color: '#A78BFA',
backgroundColor: 'rgba(139, 92, 246, 0.3)',
borderRadius: 3,
padding: [2, 6],
lineHeight: 18,
}
}
},
@@ -824,61 +890,30 @@ const ForceGraphView = ({
},
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 ? '▼' : '●';
const topStocksStr = data.topStocks?.slice(0, 3).join('、') || '';
return `
<div style="min-width: 200px;">
<div style="min-width: 180px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span style="
background: linear-gradient(135deg, ${data.baseColor || '#8B5CF6'}, ${data.baseColor || '#8B5CF6'}88);
color: white;
padding: 3px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
box-shadow: 0 2px 8px ${data.baseColor || '#8B5CF6'}44;
">${levelMap[data.level] || '分类'}</span>
</div>
<div style="font-size: 17px; font-weight: bold; margin-bottom: 12px; color: #FFFFFF; text-shadow: 0 2px 4px rgba(0,0,0,0.3);">
${params.name}
</div>
${changePct !== undefined && changePct !== null ? `
<div style="
display: inline-flex;
align-items: center;
gap: 8px;
background: ${changePct > 0 ? 'rgba(248, 113, 113, 0.15)' : changePct < 0 ? 'rgba(74, 222, 128, 0.15)' : 'rgba(148, 163, 184, 0.15)'};
padding: 8px 14px;
border-radius: 12px;
margin-bottom: 12px;
border: 1px solid ${changePct > 0 ? 'rgba(248, 113, 113, 0.3)' : changePct < 0 ? 'rgba(74, 222, 128, 0.3)' : 'rgba(148, 163, 184, 0.3)'};
">
<span style="color: ${changeColor}; font-size: 14px;">${changeIcon}</span>
<span style="color: ${changeColor}; font-weight: bold; font-size: 20px; font-family: 'SF Mono', monospace;">
${changePct !== undefined && changePct !== null ? `
<span style="color: ${changeColor}; font-size: 12px;">${changeIcon}</span>
<span style="color: ${changeColor}; font-weight: bold; font-size: 16px; font-family: 'SF Mono', monospace;">
${formatChangePercent(changePct)}
</span>
</div>
` : ''}
<div style="color: #94A3B8; font-size: 12px; display: flex; gap: 12px;">
${data.stockCount ? `<span>📊 ${data.stockCount} 只股票</span>` : ''}
${data.conceptCount ? `<span>📁 ${data.conceptCount} 个概念</span>` : ''}
` : ''}
<span style="font-size: 15px; font-weight: bold; color: #FFFFFF;">
${params.name}
</span>
</div>
${data.level === 'concept' ? `
<div style="color: #A78BFA; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
🔗 点击查看概念详情
</div>
` : data.level !== 'concept' ? `
<div style="color: #64748B; font-size: 11px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1);">
📂 点击进入查看子分类
<div style="color: #94A3B8; font-size: 12px; margin-bottom: 6px;">
${data.stockCount ? `${data.stockCount} 只股票` : ''}${data.stockCount && data.conceptCount ? ' · ' : ''}${data.conceptCount ? `${data.conceptCount} 个概念` : ''}
</div>
${topStocksStr ? `
<div style="color: #A78BFA; font-size: 11px;">
热门: ${topStocksStr}
</div>
` : ''}
</div>
@@ -974,16 +1009,6 @@ const ForceGraphView = ({
}
}, [drillPath]);
// 返回根视图
const handleGoHome = useCallback(() => {
setDrillPath(null);
}, []);
// 刷新数据
const handleRefresh = useCallback(() => {
fetchHierarchyPrice();
}, [fetchHierarchyPrice]);
// 全屏切换
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
@@ -1070,159 +1095,47 @@ const ForceGraphView = ({
);
}
// 数据为空时显示紧凑提示
if (!loading && !error && hierarchy.length === 0) {
return (
<Center
h="200px"
bg="rgba(15, 23, 42, 0.6)"
borderRadius="3xl"
backdropFilter={GLASS_BLUR.lg}
border="1px solid"
borderColor="whiteAlpha.100"
>
<VStack spacing={3}>
<Icon as={Layers} boxSize={12} color="whiteAlpha.300" />
<Text color="whiteAlpha.600" fontSize="sm">暂无概念层级数据</Text>
</VStack>
</Center>
);
}
// 当 hideNavigation 为 true 时,只渲染纯图表内容
if (hideNavigation) {
return (
<ReactECharts
ref={chartRef}
option={chartOption}
style={{ height: '100%', width: '100%' }}
onEvents={onChartEvents}
opts={{ renderer: 'canvas' }}
/>
);
}
return (
<VStack spacing={3} align="stretch" w="100%">
{/* 外部工具栏 - 在蓝紫色背景外面 */}
<HStack spacing={3} px={2} justify="space-between" align="center">
<HStack spacing={3}>
{/* 返回按钮 */}
{drillPath && (
<Tooltip label="返回上一层" placement="bottom">
<IconButton
size="sm"
icon={<ArrowLeft />}
onClick={handleGoBack}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'purple.500',
borderColor: 'purple.400',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label="返回"
/>
</Tooltip>
)}
<HStack
bg="whiteAlpha.100"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
>
<Icon as={Grid3x3} color="purple.300" />
<Text color="white" fontWeight="bold" fontSize="sm">
概念矩形树图
</Text>
</HStack>
{/* 面包屑导航 */}
<HStack
bg="whiteAlpha.50"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={2}
>
{breadcrumbItems.map((item, index) => (
<HStack key={index} spacing={2}>
{index > 0 && (
<Icon as={ChevronRight} color="whiteAlpha.400" boxSize={3} />
)}
<Text
color={index === breadcrumbItems.length - 1 ? 'purple.300' : 'whiteAlpha.700'}
fontSize="sm"
fontWeight={index === breadcrumbItems.length - 1 ? 'bold' : 'normal'}
cursor={index < breadcrumbItems.length - 1 ? 'pointer' : 'default'}
_hover={index < breadcrumbItems.length - 1 ? { color: 'white' } : {}}
transition="color 0.2s"
onClick={() => {
if (index < breadcrumbItems.length - 1) {
setDrillPath(item.path);
}
}}
>
{item.label}
</Text>
</HStack>
))}
</HStack>
</HStack>
{/* 右侧控制按钮 */}
<HStack spacing={2}>
{priceLoading && <Spinner size="sm" color="purple.300" />}
{drillPath && (
<Tooltip label="返回全部" placement="left">
<IconButton
size="sm"
icon={<Home />}
onClick={handleGoHome}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'purple.500',
borderColor: 'purple.400',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label="返回全部"
/>
</Tooltip>
)}
<Tooltip label="刷新数据" placement="left">
<IconButton
size="sm"
icon={<RefreshCw />}
onClick={handleRefresh}
isLoading={priceLoading}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'whiteAlpha.200',
borderColor: 'whiteAlpha.300',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label="刷新"
/>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left">
<IconButton
size="sm"
icon={isFullscreen ? <Minimize2 /> : <Maximize2 />}
onClick={toggleFullscreen}
bg="whiteAlpha.100"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius="full"
_hover={{
bg: 'whiteAlpha.200',
borderColor: 'whiteAlpha.300',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
</Tooltip>
</HStack>
</HStack>
{/* 蓝紫色背景容器 */}
<VStack spacing={0} align="stretch" w="100%">
{/* 图表容器 */}
<Box
ref={containerRef}
position={isFullscreen ? 'fixed' : 'relative'}
top={isFullscreen ? '60px' : 'auto'}
left={isFullscreen ? 0 : 'auto'}
right={isFullscreen ? 0 : 'auto'}
right={isFullscreen ? '72px' : 'auto'}
bottom={isFullscreen ? 0 : 'auto'}
zIndex={isFullscreen ? 1000 : 'auto'}
borderRadius={isFullscreen ? '0' : '3xl'}
@@ -1279,73 +1192,95 @@ const ForceGraphView = ({
animation={`${glowPulse} 6s ease-in-out infinite 2s`}
/>
{/* 底部图例 - 毛玻璃风格 */}
<Flex
position="absolute"
bottom={4}
left={4}
zIndex={10}
gap={2}
flexWrap="wrap"
pointerEvents="none"
>
{/* 左上角面包屑导航 */}
<HStack
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={3}
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2)"
position="absolute"
top={4}
left={4}
zIndex={10}
spacing={2}
>
<HStack spacing={2}>
<Box
w={3}
h={3}
borderRadius="full"
bg="linear-gradient(135deg, #EF4444, #DC2626)"
boxShadow="0 0 8px rgba(239, 68, 68, 0.5)"
/>
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
<Box w="1px" h={4} bg="whiteAlpha.200" />
<HStack spacing={2}>
<Box
w={3}
h={3}
borderRadius="full"
bg="linear-gradient(135deg, #22C55E, #16A34A)"
boxShadow="0 0 8px rgba(34, 197, 94, 0.5)"
/>
<Text color="whiteAlpha.800" fontSize="xs"></Text>
{drillPath && (
<Tooltip label="返回上一层">
<IconButton
icon={<ArrowLeft size={16} />}
size="sm"
variant="ghost"
onClick={handleGoBack}
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
border="1px solid"
borderColor="whiteAlpha.100"
color="whiteAlpha.800"
borderRadius="full"
_hover={{
bg: 'rgba(255, 255, 255, 0.15)',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
/>
</Tooltip>
)}
<HStack
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
px={3}
py={1.5}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
spacing={1}
boxShadow="0 4px 16px rgba(0, 0, 0, 0.2)"
>
{breadcrumbItems.map((item, index) => (
<React.Fragment key={index}>
{index > 0 && (
<Icon as={ChevronRight} boxSize={3} color="whiteAlpha.400" />
)}
<Text
fontSize="xs"
color={index === breadcrumbItems.length - 1 ? 'purple.300' : 'whiteAlpha.700'}
fontWeight={index === breadcrumbItems.length - 1 ? 'bold' : 'normal'}
cursor={index < breadcrumbItems.length - 1 ? 'pointer' : 'default'}
_hover={index < breadcrumbItems.length - 1 ? { color: 'purple.300' } : {}}
onClick={() => {
if (index < breadcrumbItems.length - 1) {
setDrillPath(item.path);
}
}}
transition="color 0.2s"
>
{item.label}
</Text>
</React.Fragment>
))}
</HStack>
</HStack>
</Flex>
{/* 操作提示 */}
<Box
position="absolute"
bottom={4}
right={4}
zIndex={10}
pointerEvents="none"
>
<HStack
bg="rgba(255, 255, 255, 0.06)"
backdropFilter={GLASS_BLUR.lg}
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.100"
>
<Text color="whiteAlpha.500" fontSize="xs">
点击分类进入 · 点击概念查看详情
</Text>
</HStack>
</Box>
{/* 右上角放大按钮 */}
<Tooltip label={isFullscreen ? '退出全屏' : '全屏查看'}>
<IconButton
position="absolute"
top={4}
right={4}
zIndex={10}
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
size="sm"
variant="ghost"
onClick={toggleFullscreen}
bg="rgba(255, 255, 255, 0.08)"
backdropFilter={GLASS_BLUR.lg}
border="1px solid"
borderColor="whiteAlpha.100"
color="whiteAlpha.800"
borderRadius="full"
_hover={{
bg: 'rgba(255, 255, 255, 0.15)',
transform: 'scale(1.05)',
}}
transition="all 0.2s"
/>
</Tooltip>
{/* ECharts 图表 */}
<ReactECharts