update pay ui

This commit is contained in:
2025-12-05 17:38:05 +08:00
parent 821e1a69d2
commit 0d3ed64768
3 changed files with 952 additions and 5 deletions

View File

@@ -52,6 +52,7 @@
"react-circular-slider-svg": "^0.1.5",
"react-custom-scrollbars-2": "^4.4.0",
"react-dom": "^19.0.0",
"react-force-graph-3d": "^1.29.0",
"react-github-btn": "^1.2.1",
"react-icons": "^4.12.0",
"react-input-pin-code": "^1.1.5",
@@ -76,6 +77,7 @@
"styled-components": "^5.3.11",
"stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1",
"three": "^0.181.2",
"typescript": "^5.9.3"
},
"resolutions": {

View File

@@ -0,0 +1,918 @@
/**
* ForceGraphView - 3D 力导向图概念层级视图
*
* 特性:
* 1. 3D 星空效果展示概念层级关系
* 2. 节点大小根据股票数量动态调整
* 3. 节点颜色根据涨跌幅显示(涨红跌绿)
* 4. 支持鼠标交互:旋转、缩放、拖拽
* 5. 点击节点可钻取或跳转
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import ForceGraph3D from 'react-force-graph-3d';
import {
Box,
VStack,
HStack,
Text,
Spinner,
Center,
Icon,
Flex,
Button,
IconButton,
Tooltip,
Badge,
useBreakpointValue,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Switch,
FormControl,
FormLabel,
Select,
Collapse,
useDisclosure,
} from '@chakra-ui/react';
import {
FaLayerGroup,
FaSync,
FaExpand,
FaCompress,
FaCog,
FaPlay,
FaPause,
FaHome,
FaArrowUp,
FaArrowDown,
FaEye,
FaEyeSlash,
FaCube,
FaChevronDown,
FaChevronUp,
} from 'react-icons/fa';
import * as THREE from 'three';
import { logger } from '../../../utils/logger';
// 一级分类颜色映射
const LV1_COLORS = {
'人工智能': '#8B5CF6', // 紫色
'半导体': '#3B82F6', // 蓝色
'机器人': '#10B981', // 绿色
'消费电子': '#F59E0B', // 橙色
'智能驾驶与汽车': '#EF4444', // 红色
'新能源与电力': '#06B6D4', // 青色
'空天经济': '#6366F1', // 靛蓝
'国防军工': '#EC4899', // 粉色
'政策与主题': '#14B8A6', // 青绿
'周期与材料': '#F97316', // 深橙
'大消费': '#A855F7', // 亮紫
'数字经济与金融科技': '#22D3EE', // 亮青
'全球宏观与贸易': '#84CC16', // 黄绿
'医药健康': '#E879F9', // 玫红
'前沿科技': '#38BDF8', // 天蓝
};
// 根据涨跌幅获取颜色
const getChangeColor = (value) => {
if (value === null || value === undefined) return '#64748B'; // 灰色
// 涨 - 红色系
if (value > 7) return '#DC2626';
if (value > 5) return '#EF4444';
if (value > 3) return '#F87171';
if (value > 1) return '#FCA5A5';
if (value > 0) return '#FECACA';
// 跌 - 绿色系
if (value < -7) return '#15803D';
if (value < -5) return '#16A34A';
if (value < -3) return '#22C55E';
if (value < -1) return '#4ADE80';
if (value < 0) return '#86EFAC';
return '#64748B'; // 平盘
};
// 从 API 返回的名称中提取纯名称
const extractPureName = (apiName) => {
if (!apiName) return '';
return apiName.replace(/^\[(一级|二级|三级)\]\s*/, '');
};
/**
* 主组件3D 力导向图视图
*/
const ForceGraphView = ({
apiBaseUrl,
onSelectCategory,
selectedDate,
}) => {
const [hierarchy, setHierarchy] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [priceData, setPriceData] = useState({ lv1Map: {}, lv2Map: {}, lv3Map: {}, leafMap: {} });
const [priceLoading, setPriceLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isRotating, setIsRotating] = useState(true);
const [showLabels, setShowLabels] = useState(true);
const [nodeSize, setNodeSize] = useState(1);
const [linkOpacity, setLinkOpacity] = useState(0.3);
const [displayLevel, setDisplayLevel] = useState('all'); // 'all', 'lv1', 'lv2', 'lv3', 'concept'
const [hoveredNode, setHoveredNode] = useState(null);
const fgRef = useRef();
const containerRef = useRef();
const { isOpen: isSettingsOpen, onToggle: onSettingsToggle } = useDisclosure();
const isMobile = useBreakpointValue({ base: true, md: false });
// 获取层级结构数据
const fetchHierarchy = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/hierarchy`);
if (!response.ok) throw new Error('获取层级结构失败');
const data = await response.json();
setHierarchy(data.hierarchy || []);
logger.info('ForceGraphView', '层级结构加载完成', {
totalLv1: data.hierarchy?.length,
totalConcepts: data.total_concepts
});
} catch (err) {
logger.error('ForceGraphView', 'fetchHierarchy', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
// 获取层级涨跌幅数据
const fetchHierarchyPrice = useCallback(async () => {
setPriceLoading(true);
try {
let url = `${apiBaseUrl}/hierarchy/price`;
if (selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
url += `?trade_date=${dateStr}`;
}
const response = await fetch(url);
if (!response.ok) {
logger.warn('ForceGraphView', '获取层级涨跌幅失败', { status: response.status });
return;
}
const data = await response.json();
// 构建映射表
const lv1Map = {};
const lv2Map = {};
const lv3Map = {};
const leafMap = {};
(data.lv1_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv1Map[pureName] = item;
});
(data.lv2_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv2Map[pureName] = item;
});
(data.lv3_concepts || []).forEach(item => {
const pureName = extractPureName(item.concept_name);
lv3Map[pureName] = item;
});
(data.leaf_concepts || []).forEach(item => {
leafMap[item.concept_name] = item;
});
setPriceData({ lv1Map, lv2Map, lv3Map, leafMap });
logger.info('ForceGraphView', '层级涨跌幅加载完成', {
lv1Count: Object.keys(lv1Map).length,
lv2Count: Object.keys(lv2Map).length,
lv3Count: Object.keys(lv3Map).length,
leafCount: Object.keys(leafMap).length,
});
} catch (err) {
logger.warn('ForceGraphView', '获取层级涨跌幅失败', { error: err.message });
} finally {
setPriceLoading(false);
}
}, [apiBaseUrl, selectedDate]);
useEffect(() => {
fetchHierarchy();
}, [fetchHierarchy]);
useEffect(() => {
if (hierarchy.length > 0) {
fetchHierarchyPrice();
}
}, [hierarchy, fetchHierarchyPrice]);
// 构建图数据
const graphData = useMemo(() => {
const nodes = [];
const links = [];
const { lv1Map, lv2Map, lv3Map, leafMap } = priceData;
// 添加根节点
nodes.push({
id: 'root',
name: '概念中心',
level: 'root',
val: 50,
color: '#8B5CF6',
});
// 遍历层级结构
hierarchy.forEach((lv1) => {
const lv1Id = `lv1_${lv1.name}`;
const lv1Price = lv1Map[lv1.name] || {};
const lv1Color = LV1_COLORS[lv1.name] || '#8B5CF6';
// 添加一级节点
if (displayLevel === 'all' || displayLevel === 'lv1') {
nodes.push({
id: lv1Id,
name: lv1.name,
level: 'lv1',
val: Math.max(10, (lv1Price.stock_count || 100) / 10),
color: lv1Price.avg_change_pct !== undefined
? getChangeColor(lv1Price.avg_change_pct)
: lv1Color,
baseColor: lv1Color,
changePct: lv1Price.avg_change_pct,
stockCount: lv1Price.stock_count,
conceptCount: lv1.concept_count,
});
links.push({
source: 'root',
target: lv1Id,
color: lv1Color,
});
}
// 添加二级节点
if (lv1.children && (displayLevel === 'all' || displayLevel === 'lv2')) {
lv1.children.forEach((lv2) => {
const lv2Id = `lv2_${lv1.name}_${lv2.name}`;
const lv2Price = lv2Map[lv2.name] || {};
nodes.push({
id: lv2Id,
name: lv2.name,
level: 'lv2',
parentLv1: lv1.name,
val: Math.max(5, (lv2Price.stock_count || 50) / 15),
color: lv2Price.avg_change_pct !== undefined
? getChangeColor(lv2Price.avg_change_pct)
: lv1Color,
baseColor: lv1Color,
changePct: lv2Price.avg_change_pct,
stockCount: lv2Price.stock_count,
conceptCount: lv2.concept_count,
});
links.push({
source: displayLevel === 'lv2' ? 'root' : lv1Id,
target: lv2Id,
color: `${lv1Color}80`,
});
// 添加三级节点
if (lv2.children && (displayLevel === 'all' || displayLevel === 'lv3')) {
lv2.children.forEach((lv3) => {
const lv3Id = `lv3_${lv1.name}_${lv2.name}_${lv3.name}`;
const lv3Price = lv3Map[lv3.name] || {};
nodes.push({
id: lv3Id,
name: lv3.name,
level: 'lv3',
parentLv1: lv1.name,
parentLv2: lv2.name,
val: Math.max(3, (lv3Price.stock_count || 30) / 20),
color: lv3Price.avg_change_pct !== undefined
? getChangeColor(lv3Price.avg_change_pct)
: lv1Color,
baseColor: lv1Color,
changePct: lv3Price.avg_change_pct,
stockCount: lv3Price.stock_count,
conceptCount: lv3.concept_count,
});
links.push({
source: displayLevel === 'lv3' ? 'root' : lv2Id,
target: lv3Id,
color: `${lv1Color}60`,
});
// 添加叶子概念节点
if (lv3.concepts && (displayLevel === 'all' || displayLevel === 'concept')) {
lv3.concepts.forEach((conceptName) => {
const conceptId = `concept_${lv1.name}_${lv2.name}_${lv3.name}_${conceptName}`;
const conceptPrice = leafMap[conceptName] || {};
nodes.push({
id: conceptId,
name: conceptName,
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
parentLv3: lv3.name,
val: Math.max(1, (conceptPrice.stock_count || 10) / 25),
color: conceptPrice.avg_change_pct !== undefined
? getChangeColor(conceptPrice.avg_change_pct)
: lv1Color,
baseColor: lv1Color,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
});
links.push({
source: displayLevel === 'concept' ? 'root' : lv3Id,
target: conceptId,
color: `${lv1Color}40`,
});
});
}
});
}
// 如果 lv2 直接包含概念(没有 lv3
if (lv2.concepts && (displayLevel === 'all' || displayLevel === 'concept')) {
lv2.concepts.forEach((conceptName) => {
const conceptId = `concept_${lv1.name}_${lv2.name}_${conceptName}`;
const conceptPrice = leafMap[conceptName] || {};
nodes.push({
id: conceptId,
name: conceptName,
level: 'concept',
parentLv1: lv1.name,
parentLv2: lv2.name,
val: Math.max(1, (conceptPrice.stock_count || 10) / 25),
color: conceptPrice.avg_change_pct !== undefined
? getChangeColor(conceptPrice.avg_change_pct)
: lv1Color,
baseColor: lv1Color,
changePct: conceptPrice.avg_change_pct,
stockCount: conceptPrice.stock_count,
});
links.push({
source: displayLevel === 'concept' ? 'root' : lv2Id,
target: conceptId,
color: `${lv1Color}40`,
});
});
}
});
}
});
return { nodes, links };
}, [hierarchy, priceData, displayLevel]);
// 自动旋转
useEffect(() => {
if (fgRef.current && isRotating) {
const controls = fgRef.current.controls();
if (controls) {
controls.autoRotate = true;
controls.autoRotateSpeed = 0.5;
}
} else if (fgRef.current) {
const controls = fgRef.current.controls();
if (controls) {
controls.autoRotate = false;
}
}
}, [isRotating]);
// 处理节点点击
const handleNodeClick = useCallback((node) => {
if (node.level === 'concept') {
// 跳转到概念详情页
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(node.name)}.html`;
window.open(htmlPath, '_blank');
} else if (node.level === 'root') {
// 重置视图
if (fgRef.current) {
fgRef.current.cameraPosition({ x: 0, y: 0, z: 500 }, { x: 0, y: 0, z: 0 }, 1000);
}
} else {
// 聚焦到节点
if (fgRef.current) {
const distance = 200;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
fgRef.current.cameraPosition(
{ x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio },
node,
1000
);
}
}
logger.info('ForceGraphView', '节点点击', { level: node.level, name: node.name });
}, []);
// 自定义节点渲染
const nodeThreeObject = useCallback((node) => {
if (node.level === 'root') {
// 根节点使用特殊球体
const geometry = new THREE.SphereGeometry(8);
const material = new THREE.MeshBasicMaterial({
color: node.color,
transparent: true,
opacity: 0.9,
});
const sphere = new THREE.Mesh(geometry, material);
// 添加发光效果
const glowGeometry = new THREE.SphereGeometry(12);
const glowMaterial = new THREE.MeshBasicMaterial({
color: node.color,
transparent: true,
opacity: 0.2,
});
const glow = new THREE.Mesh(glowGeometry, glowMaterial);
sphere.add(glow);
return sphere;
}
const size = (node.val || 5) * nodeSize;
const geometry = new THREE.SphereGeometry(size);
const material = new THREE.MeshBasicMaterial({
color: node.color,
transparent: true,
opacity: node.level === 'concept' ? 0.7 : 0.85,
});
const sphere = new THREE.Mesh(geometry, material);
// 为大节点添加发光效果
if (size > 5) {
const glowGeometry = new THREE.SphereGeometry(size * 1.3);
const glowMaterial = new THREE.MeshBasicMaterial({
color: node.color,
transparent: true,
opacity: 0.15,
});
const glow = new THREE.Mesh(glowGeometry, glowMaterial);
sphere.add(glow);
}
return sphere;
}, [nodeSize]);
// 刷新数据
const handleRefresh = useCallback(() => {
fetchHierarchyPrice();
}, [fetchHierarchyPrice]);
// 重置视图
const handleResetView = useCallback(() => {
if (fgRef.current) {
fgRef.current.cameraPosition({ x: 0, y: 0, z: 500 }, { x: 0, y: 0, z: 0 }, 1000);
}
}, []);
// 全屏切换
const toggleFullscreen = useCallback(() => {
setIsFullscreen(prev => !prev);
}, []);
// 格式化涨跌幅
const formatChangePercent = (value) => {
if (value === null || value === undefined) return '--';
const formatted = Math.abs(value).toFixed(2);
return value > 0 ? `+${formatted}%` : value < 0 ? `-${formatted}%` : '0.00%';
};
// 获取容器高度
const containerHeight = useMemo(() => {
if (isFullscreen) return '100vh';
return isMobile ? '500px' : '700px';
}, [isFullscreen, isMobile]);
if (loading) {
return (
<Center h="400px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
<VStack spacing={4}>
<Spinner size="xl" color="purple.400" thickness="4px" />
<Text color="gray.400">正在构建 3D 星图...</Text>
</VStack>
</Center>
);
}
if (error) {
return (
<Center h="400px" bg="rgba(15, 23, 42, 0.8)" borderRadius="2xl">
<VStack spacing={4}>
<Icon as={FaLayerGroup} boxSize={16} color="gray.600" />
<Text color="gray.400">加载失败{error}</Text>
<Button
colorScheme="purple"
size="sm"
onClick={fetchHierarchy}
>
重试
</Button>
</VStack>
</Center>
);
}
return (
<Box
ref={containerRef}
position={isFullscreen ? 'fixed' : 'relative'}
top={isFullscreen ? 0 : 'auto'}
left={isFullscreen ? 0 : 'auto'}
right={isFullscreen ? 0 : 'auto'}
bottom={isFullscreen ? 0 : 'auto'}
zIndex={isFullscreen ? 1000 : 'auto'}
bg="rgba(15, 23, 42, 0.95)"
borderRadius={isFullscreen ? '0' : '2xl'}
overflow="hidden"
border={isFullscreen ? 'none' : '1px solid'}
borderColor="whiteAlpha.100"
>
{/* 工具栏 */}
<Flex
position="absolute"
top={4}
left={4}
right={4}
justify="space-between"
align="flex-start"
zIndex={10}
pointerEvents="none"
>
{/* 左侧信息 */}
<VStack align="start" spacing={2} pointerEvents="auto">
<HStack
bg="rgba(0, 0, 0, 0.6)"
backdropFilter="blur(10px)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
>
<Icon as={FaCube} color="purple.300" />
<Text color="white" fontWeight="bold" fontSize="sm">
3D 概念星图
</Text>
<Badge colorScheme="purple" variant="solid" borderRadius="full">
{graphData.nodes.length} 节点
</Badge>
</HStack>
{/* 悬停节点信息 */}
{hoveredNode && hoveredNode.level !== 'root' && (
<Box
bg="rgba(0, 0, 0, 0.8)"
backdropFilter="blur(10px)"
px={4}
py={3}
borderRadius="xl"
border="1px solid"
borderColor={hoveredNode.color}
maxW="300px"
>
<VStack align="start" spacing={1}>
<HStack>
<Badge
bg={hoveredNode.baseColor}
color="white"
borderRadius="full"
px={2}
fontSize="xs"
>
{hoveredNode.level === 'lv1' ? '一级' :
hoveredNode.level === 'lv2' ? '二级' :
hoveredNode.level === 'lv3' ? '三级' : '概念'}
</Badge>
{hoveredNode.changePct !== undefined && (
<Badge
bg={hoveredNode.changePct > 0 ? 'red.500' : hoveredNode.changePct < 0 ? 'green.500' : 'gray.500'}
color="white"
borderRadius="full"
px={2}
fontSize="xs"
>
{formatChangePercent(hoveredNode.changePct)}
</Badge>
)}
</HStack>
<Text color="white" fontWeight="bold" fontSize="md">
{hoveredNode.name}
</Text>
{hoveredNode.stockCount && (
<Text color="whiteAlpha.700" fontSize="xs">
{hoveredNode.stockCount} 只股票
{hoveredNode.conceptCount ? ` · ${hoveredNode.conceptCount} 个概念` : ''}
</Text>
)}
</VStack>
</Box>
)}
</VStack>
{/* 右侧控制按钮 */}
<VStack spacing={2} pointerEvents="auto">
<HStack spacing={2}>
{priceLoading && <Spinner size="sm" color="purple.300" />}
<Tooltip label="刷新数据" placement="left">
<IconButton
size="sm"
icon={<FaSync />}
onClick={handleRefresh}
isLoading={priceLoading}
bg="rgba(0, 0, 0, 0.6)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="刷新"
/>
</Tooltip>
<Tooltip label="重置视角" placement="left">
<IconButton
size="sm"
icon={<FaHome />}
onClick={handleResetView}
bg="rgba(0, 0, 0, 0.6)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label="重置视角"
/>
</Tooltip>
<Tooltip label={isRotating ? '暂停旋转' : '开始旋转'} placement="left">
<IconButton
size="sm"
icon={isRotating ? <FaPause /> : <FaPlay />}
onClick={() => setIsRotating(!isRotating)}
bg="rgba(0, 0, 0, 0.6)"
color={isRotating ? 'green.300' : 'white'}
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label={isRotating ? '暂停旋转' : '开始旋转'}
/>
</Tooltip>
<Tooltip label="设置" placement="left">
<IconButton
size="sm"
icon={isSettingsOpen ? <FaChevronUp /> : <FaCog />}
onClick={onSettingsToggle}
bg={isSettingsOpen ? 'purple.500' : 'rgba(0, 0, 0, 0.6)'}
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: isSettingsOpen ? 'purple.400' : 'whiteAlpha.200' }}
aria-label="设置"
/>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'} placement="left">
<IconButton
size="sm"
icon={isFullscreen ? <FaCompress /> : <FaExpand />}
onClick={toggleFullscreen}
bg="rgba(0, 0, 0, 0.6)"
color="white"
border="1px solid"
borderColor="whiteAlpha.200"
_hover={{ bg: 'whiteAlpha.200' }}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
/>
</Tooltip>
</HStack>
{/* 设置面板 */}
<Collapse in={isSettingsOpen}>
<Box
bg="rgba(0, 0, 0, 0.8)"
backdropFilter="blur(10px)"
p={4}
borderRadius="xl"
border="1px solid"
borderColor="whiteAlpha.200"
w="250px"
>
<VStack spacing={4} align="stretch">
<FormControl>
<FormLabel color="whiteAlpha.800" fontSize="sm">
显示层级
</FormLabel>
<Select
value={displayLevel}
onChange={(e) => setDisplayLevel(e.target.value)}
size="sm"
bg="whiteAlpha.100"
color="white"
borderColor="whiteAlpha.300"
_hover={{ borderColor: 'purple.400' }}
>
<option value="all" style={{ background: '#1a1a2e' }}>全部层级</option>
<option value="lv1" style={{ background: '#1a1a2e' }}>仅一级</option>
<option value="lv2" style={{ background: '#1a1a2e' }}>仅二级</option>
<option value="lv3" style={{ background: '#1a1a2e' }}>仅三级</option>
<option value="concept" style={{ background: '#1a1a2e' }}>仅概念</option>
</Select>
</FormControl>
<FormControl>
<FormLabel color="whiteAlpha.800" fontSize="sm">
节点大小: {nodeSize.toFixed(1)}x
</FormLabel>
<Slider
value={nodeSize}
onChange={setNodeSize}
min={0.5}
max={2}
step={0.1}
colorScheme="purple"
>
<SliderTrack bg="whiteAlpha.200">
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<FormLabel color="whiteAlpha.800" fontSize="sm">
连线透明度: {(linkOpacity * 100).toFixed(0)}%
</FormLabel>
<Slider
value={linkOpacity}
onChange={setLinkOpacity}
min={0.1}
max={1}
step={0.1}
colorScheme="purple"
>
<SliderTrack bg="whiteAlpha.200">
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel color="whiteAlpha.800" fontSize="sm" mb={0}>
显示标签
</FormLabel>
<Switch
isChecked={showLabels}
onChange={(e) => setShowLabels(e.target.checked)}
colorScheme="purple"
/>
</FormControl>
</VStack>
</Box>
</Collapse>
</VStack>
</Flex>
{/* 图例 */}
<Flex
position="absolute"
bottom={4}
left={4}
zIndex={10}
gap={3}
flexWrap="wrap"
pointerEvents="none"
>
<HStack
bg="rgba(0, 0, 0, 0.6)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={2}
>
<Box w={3} h={3} borderRadius="full" bg="#EF4444" />
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
<HStack
bg="rgba(0, 0, 0, 0.6)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={2}
>
<Box w={3} h={3} borderRadius="full" bg="#22C55E" />
<Text color="whiteAlpha.800" fontSize="xs"></Text>
</HStack>
<HStack
bg="rgba(0, 0, 0, 0.6)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={2}
>
<Box w={3} h={3} borderRadius="full" bg="#64748B" />
<Text color="whiteAlpha.800" fontSize="xs">/无数据</Text>
</HStack>
</Flex>
{/* 操作提示 */}
<Box
position="absolute"
bottom={4}
right={4}
zIndex={10}
pointerEvents="none"
>
<HStack
bg="rgba(0, 0, 0, 0.6)"
backdropFilter="blur(10px)"
px={3}
py={2}
borderRadius="full"
border="1px solid"
borderColor="whiteAlpha.200"
spacing={3}
>
<Text color="whiteAlpha.600" fontSize="xs">
🖱 左键旋转 · 右键平移 · 滚轮缩放 · 点击聚焦
</Text>
</HStack>
</Box>
{/* 3D Force Graph */}
<ForceGraph3D
ref={fgRef}
graphData={graphData}
height={isFullscreen ? window.innerHeight : (isMobile ? 500 : 700)}
width={containerRef.current?.clientWidth || window.innerWidth}
backgroundColor="rgba(15, 23, 42, 0)"
nodeThreeObject={nodeThreeObject}
nodeLabel={showLabels ? (node) => `
<div style="
background: rgba(0,0,0,0.8);
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
color: white;
border: 1px solid ${node.color};
">
<div style="font-weight: bold; margin-bottom: 4px;">${node.name}</div>
${node.changePct !== undefined ? `
<div style="color: ${node.changePct > 0 ? '#FCA5A5' : node.changePct < 0 ? '#86EFAC' : '#94A3B8'}">
${node.changePct > 0 ? '↑' : node.changePct < 0 ? '↓' : ''}
${formatChangePercent(node.changePct)}
</div>
` : ''}
${node.stockCount ? `<div style="color: #94A3B8; font-size: 11px;">${node.stockCount} 只股票</div>` : ''}
</div>
` : ''}
linkColor={(link) => link.color}
linkOpacity={linkOpacity}
linkWidth={1}
onNodeClick={handleNodeClick}
onNodeHover={(node) => setHoveredNode(node)}
enableNodeDrag={true}
enableNavigationControls={true}
showNavInfo={false}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
warmupTicks={100}
cooldownTicks={200}
/>
</Box>
);
};
export default ForceGraphView;

View File

@@ -81,12 +81,13 @@ import {
useBreakpointValue,
} from '@chakra-ui/react';
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock, FaSitemap, FaLayerGroup } from 'react-icons/fa';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock, FaSitemap, FaLayerGroup, FaCube, FaProjectDiagram } from 'react-icons/fa';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel';
import HierarchyView from './components/HierarchyView';
import ForceGraphView from './components/ForceGraphView';
import BreadcrumbNav from './components/BreadcrumbNav';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
@@ -1759,6 +1760,25 @@ const ConceptCenter = () => {
</HStack>
<ButtonGroup size="sm" isAttached variant="outline">
<Tooltip label="3D星图" placement="top">
<IconButton
icon={<FaCube />}
onClick={() => {
if (viewMode !== 'force3d') {
trackViewModeChanged('force3d', viewMode);
setViewMode('force3d');
}
}}
bg={viewMode === 'force3d' ? 'purple.500' : 'transparent'}
color={viewMode === 'force3d' ? 'white' : 'whiteAlpha.700'}
borderColor="whiteAlpha.300"
_hover={{
bg: viewMode === 'force3d' ? 'purple.400' : 'whiteAlpha.100',
boxShadow: viewMode === 'force3d' ? '0 0 10px rgba(139, 92, 246, 0.4)' : 'none',
}}
aria-label="3D星图"
/>
</Tooltip>
<Tooltip label="层级图" placement="top">
<IconButton
icon={<FaSitemap />}
@@ -1829,7 +1849,7 @@ const ConceptCenter = () => {
isDarkMode={true}
/>
{selectedDate && viewMode !== 'hierarchy' && (
{selectedDate && viewMode !== 'hierarchy' && viewMode !== 'force3d' && (
<Box mb={4} p={3} bg="rgba(59, 130, 246, 0.2)" borderRadius="xl" borderLeft="4px solid" borderColor="blue.400">
<HStack>
<Icon as={InfoIcon} color="blue.300" />
@@ -1841,8 +1861,15 @@ const ConceptCenter = () => {
</Box>
)}
{/* 层级图视图 */}
{viewMode === 'hierarchy' ? (
{/* 3D 力导向图视图 */}
{viewMode === 'force3d' ? (
<ForceGraphView
apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect}
selectedDate={selectedDate}
/>
) : /* 层级图视图 */
viewMode === 'hierarchy' ? (
<HierarchyView
apiBaseUrl={API_BASE_URL}
onSelectCategory={handleHierarchySelect}
@@ -1956,7 +1983,7 @@ const ConceptCenter = () => {
</HStack>
</Center>
</>
) : viewMode !== 'hierarchy' ? (
) : viewMode !== 'hierarchy' && viewMode !== 'force3d' ? (
<Center h="400px">
<VStack spacing={6}>
<Icon as={FaTags} boxSize={20} color="whiteAlpha.300" />