chore: 🤖 修改了一些打包配置
This commit is contained in:
@@ -1,219 +0,0 @@
|
|||||||
/**
|
|
||||||
* EcoTree 组件样式 - React版本
|
|
||||||
* 从Vue版本转换而来,移除了Vue特有的样式语法
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Tree View Container Styles */
|
|
||||||
.tree-view-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: stretch;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-scroll-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow: auto;
|
|
||||||
/* Enable both horizontal and vertical scrolling */
|
|
||||||
position: relative;
|
|
||||||
min-height: 0;
|
|
||||||
/* 防止 flex 项目溢出其容器 */
|
|
||||||
display: flex;
|
|
||||||
/* 使用flex布局使子元素能够正确填充 */
|
|
||||||
flex-direction: column;
|
|
||||||
/* 垂直排列 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-tree-chart {
|
|
||||||
min-width: 100%;
|
|
||||||
height: auto !important;
|
|
||||||
/* 强制使用自动高度 */
|
|
||||||
min-height: 100%;
|
|
||||||
/* 确保至少占满容器 */
|
|
||||||
flex: 1 0 auto;
|
|
||||||
/* 允许伸展但不允许收缩 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-tree-chart svg {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
/* 确保SVG不会超出容器 */
|
|
||||||
height: auto !important;
|
|
||||||
/* 强制高度自适应 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Node and Link Styles */
|
|
||||||
.tree-view-link {
|
|
||||||
fill: none;
|
|
||||||
stroke: #ccc;
|
|
||||||
stroke-width: 1.2px;
|
|
||||||
transition: stroke 0.3s, stroke-width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-node-marker {
|
|
||||||
transition: filter 0.2s ease, r 0.2s ease;
|
|
||||||
stroke: #fff;
|
|
||||||
stroke-width: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-left-marker {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-label-bg {
|
|
||||||
transition: fill 0.2s ease, stroke 0.2s ease, filter 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-node:hover .tree-view-label-bg {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-node-label {
|
|
||||||
font-family: "Alibaba-PuHuiTi-Regular", sans-serif;
|
|
||||||
pointer-events: none;
|
|
||||||
fill: #333;
|
|
||||||
transition: fill 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-toggle-bg {
|
|
||||||
transition: filter 0.2s ease, r 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
stroke: #fff;
|
|
||||||
stroke-width: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-toggle-bg:hover {
|
|
||||||
filter: brightness(1.2);
|
|
||||||
r: 13;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-toggle-icon-fo svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-color-block {
|
|
||||||
transition: filter 0.2s ease;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
rx: 3;
|
|
||||||
ry: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-node-icon {
|
|
||||||
overflow: visible;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-view-node:hover .tree-view-node-icon img {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.depth-0 .tree-view-node-label {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.depth-1 .tree-view-node-label {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading State Styles */
|
|
||||||
.chart-loading {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #ffffff;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border: 5px solid #f3f3f3;
|
|
||||||
border-top: 5px solid #0275f2;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-text {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #0275f2;
|
|
||||||
font-family: "Alibaba-PuHuiTi-SemiBold", sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error State Styles */
|
|
||||||
.chart-error {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background-color: rgba(255, 255, 255, 0.9);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #ff6b6b;
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-family: "Alibaba-PuHuiTi-SemiBold", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-button {
|
|
||||||
padding: 8px 20px;
|
|
||||||
background-color: #0275f2;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-button:hover {
|
|
||||||
background-color: #0262d3;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,693 +0,0 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
|
||||||
import * as d3 from 'd3';
|
|
||||||
import './EcoTree.css';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EcoTree React组件 - 从Vue版本转换而来
|
|
||||||
* 使用D3.js绘制可交互的树形图
|
|
||||||
*/
|
|
||||||
const EcoTree = ({
|
|
||||||
// Tree data in hierarchical format
|
|
||||||
treeData,
|
|
||||||
// Custom icons for nodes at different levels
|
|
||||||
nodeIcons = {},
|
|
||||||
// Initial expanded nodes
|
|
||||||
initialExpandedNodes = [],
|
|
||||||
// Whether to expand all nodes initially
|
|
||||||
expandAll = false,
|
|
||||||
// Whether to allow clicking on nodes to expand/collapse
|
|
||||||
expandOnClickNode = false,
|
|
||||||
// Custom node colors based on depth
|
|
||||||
nodeColors = {},
|
|
||||||
// Custom node sizes based on depth
|
|
||||||
nodeSizes = {},
|
|
||||||
// Custom node text colors based on depth
|
|
||||||
nodeTextColors = {},
|
|
||||||
// Custom node text sizes based on depth
|
|
||||||
nodeTextSizes = {},
|
|
||||||
// Allow customizing background colors
|
|
||||||
backgroundColor = '#f5f6f9',
|
|
||||||
// Whether the component is in loading state
|
|
||||||
isLoading = false,
|
|
||||||
// Loading state text
|
|
||||||
loadingText = '正在加载数据...',
|
|
||||||
// Error state
|
|
||||||
loadingError = false,
|
|
||||||
// Error text
|
|
||||||
errorText = '加载失败,请稍后重试',
|
|
||||||
// 控制节点之间的水平间距
|
|
||||||
horizontalNodeSpace = 320,
|
|
||||||
// 控制根节点到第一级节点的间距
|
|
||||||
rootLevelNodeSpace = 320,
|
|
||||||
// 是否显示第一级节点的颜色块
|
|
||||||
showLevel1ColorBlock = true,
|
|
||||||
// 控制叶子节点到其父节点的间距
|
|
||||||
leafNodeSpace = null,
|
|
||||||
// 控制不同层级节点之间的间距配置
|
|
||||||
levelNodeSpaces = {},
|
|
||||||
// 事件回调
|
|
||||||
onNodeClick,
|
|
||||||
onNodeToggle,
|
|
||||||
onRetry,
|
|
||||||
onUpdateExpandedNodes
|
|
||||||
}) => {
|
|
||||||
const treeContainer = useRef(null);
|
|
||||||
const canvasContextRef = useRef(null);
|
|
||||||
const currentHierarchyRootRef = useRef(null);
|
|
||||||
const nodeStatesRef = useRef(new Map());
|
|
||||||
const resizeObserverRef = useRef(null);
|
|
||||||
|
|
||||||
// Helper Functions
|
|
||||||
const getNodeColor = useCallback((depth) => {
|
|
||||||
if (nodeColors && nodeColors[depth] !== undefined) {
|
|
||||||
return nodeColors[depth];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default colors
|
|
||||||
switch (depth) {
|
|
||||||
case 0: return '#cfedff'; // Root - Light Blue
|
|
||||||
case 1: return '#43a047'; // Level 1 - Green
|
|
||||||
case 2: return '#fb8c00'; // Level 2 - Orange
|
|
||||||
case 3: return '#8e24aa'; // Level 3 - Purple
|
|
||||||
default: return '#a0a0a0'; // Level 4+ - Gray
|
|
||||||
}
|
|
||||||
}, [nodeColors]);
|
|
||||||
|
|
||||||
const getNodeSize = useCallback((depth) => {
|
|
||||||
if (nodeSizes && nodeSizes[depth] !== undefined) {
|
|
||||||
return nodeSizes[depth];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default sizes
|
|
||||||
switch (depth) {
|
|
||||||
case 0: return 8;
|
|
||||||
case 1: return 7;
|
|
||||||
case 2: return 6;
|
|
||||||
default: return 5;
|
|
||||||
}
|
|
||||||
}, [nodeSizes]);
|
|
||||||
|
|
||||||
const getNodeTextColor = useCallback((depth) => {
|
|
||||||
if (nodeTextColors && nodeTextColors[depth] !== undefined) {
|
|
||||||
return nodeTextColors[depth];
|
|
||||||
}
|
|
||||||
return depth > 0 ? '#ffffff' : '#4c5767';
|
|
||||||
}, [nodeTextColors]);
|
|
||||||
|
|
||||||
const getNodeTextSize = useCallback((depth) => {
|
|
||||||
if (nodeTextSizes && nodeTextSizes[depth] !== undefined) {
|
|
||||||
return nodeTextSizes[depth];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth === 0) {
|
|
||||||
return '20px';
|
|
||||||
}
|
|
||||||
return ['19px', '18px', '16px'][depth - 1] || '16px';
|
|
||||||
}, [nodeTextSizes]);
|
|
||||||
|
|
||||||
const safeNumber = useCallback((value, fallback = 0) => {
|
|
||||||
return (value === undefined || isNaN(value)) ? fallback : value;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Text Processing Functions
|
|
||||||
const getTextWidth = useCallback((text, fontStyle) => {
|
|
||||||
if (!canvasContextRef.current) {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvasContextRef.current = canvas.getContext('2d');
|
|
||||||
}
|
|
||||||
canvasContextRef.current.font = fontStyle;
|
|
||||||
return canvasContextRef.current.measureText(text || '').width;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const truncateText = useCallback((text, maxWidth, fontStyle) => {
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
if (!canvasContextRef.current) {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvasContextRef.current = canvas.getContext('2d');
|
|
||||||
}
|
|
||||||
|
|
||||||
canvasContextRef.current.font = fontStyle;
|
|
||||||
const ellipsis = '...';
|
|
||||||
const ellipsisWidth = canvasContextRef.current.measureText(ellipsis).width;
|
|
||||||
|
|
||||||
if (canvasContextRef.current.measureText(text).width <= maxWidth) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetWidth = maxWidth - ellipsisWidth;
|
|
||||||
let truncatedText = text;
|
|
||||||
|
|
||||||
while (truncatedText.length > 0) {
|
|
||||||
truncatedText = truncatedText.slice(0, -1);
|
|
||||||
if (canvasContextRef.current.measureText(truncatedText).width <= targetWidth) {
|
|
||||||
return truncatedText + ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ellipsis;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Main render function
|
|
||||||
const renderTree = useCallback(() => {
|
|
||||||
if (!treeContainer.current || !currentHierarchyRootRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Clear existing visualization
|
|
||||||
d3.select(treeContainer.current).html('');
|
|
||||||
|
|
||||||
// Constants for layout
|
|
||||||
const rectHeight = 36;
|
|
||||||
const textPaddingLeft = 44;
|
|
||||||
const textPaddingRight = 28;
|
|
||||||
|
|
||||||
const containerWidth = treeContainer.current.clientWidth;
|
|
||||||
|
|
||||||
// Icon dimensions
|
|
||||||
const iconWidth = 30;
|
|
||||||
const iconHeight = 30;
|
|
||||||
|
|
||||||
// Toggle button dimensions
|
|
||||||
const toggleButtonRadius = 12;
|
|
||||||
const toggleButtonGap = 10;
|
|
||||||
|
|
||||||
// Calculate node widths and store in Map
|
|
||||||
const nodeVisualWidths = new Map();
|
|
||||||
|
|
||||||
// Apply expanded/collapsed state
|
|
||||||
currentHierarchyRootRef.current.descendants().forEach(node => {
|
|
||||||
const nodeId = node.id || node.data.code;
|
|
||||||
const state = nodeId ? nodeStatesRef.current.get(nodeId) : undefined;
|
|
||||||
const shouldBeExpanded = state !== undefined
|
|
||||||
? state.expanded
|
|
||||||
: (node.depth <= 1 || expandAll);
|
|
||||||
|
|
||||||
if (node.depth > 0) {
|
|
||||||
if (!shouldBeExpanded) {
|
|
||||||
if (node.children) {
|
|
||||||
node._children = node.children;
|
|
||||||
node.children = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (node._children) {
|
|
||||||
node.children = node._children;
|
|
||||||
node._children = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate tree layout
|
|
||||||
const treeLayout = d3.tree().nodeSize([60, horizontalNodeSpace]);
|
|
||||||
treeLayout(currentHierarchyRootRef.current);
|
|
||||||
|
|
||||||
// Adjust node positions
|
|
||||||
currentHierarchyRootRef.current.descendants().forEach(node => {
|
|
||||||
if (node.parent) {
|
|
||||||
node.originalY = node.y;
|
|
||||||
node.y += 20;
|
|
||||||
|
|
||||||
// Handle level spacing
|
|
||||||
let levelSpacingApplied = false;
|
|
||||||
if (levelNodeSpaces && Object.keys(levelNodeSpaces).length > 0) {
|
|
||||||
const levelKey = `${node.parent.depth}-${node.depth}`;
|
|
||||||
if (levelNodeSpaces[levelKey] !== undefined) {
|
|
||||||
if (node.parent.depth === 0) {
|
|
||||||
const rootOuterSize = Math.max(95 * (safeNumber(getNodeSize(0), 8) / 8), 75);
|
|
||||||
node.y = rootOuterSize + levelNodeSpaces[levelKey];
|
|
||||||
levelSpacingApplied = true;
|
|
||||||
} else {
|
|
||||||
const parentY = node.parent.y;
|
|
||||||
const parentId = node.parent.id || node.parent.data.code;
|
|
||||||
const parentWidth = nodeVisualWidths.get(parentId) || node.parent._calculatedRectWidth || 0;
|
|
||||||
const spacingStartY = parentY + parentWidth;
|
|
||||||
node.y = spacingStartY + levelNodeSpaces[levelKey];
|
|
||||||
levelSpacingApplied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!levelSpacingApplied) {
|
|
||||||
if (node.parent.depth === 0 && node.depth === 1) {
|
|
||||||
const rootOuterSize = Math.max(95 * (safeNumber(getNodeSize(0), 8) / 8), 75);
|
|
||||||
node.y = Math.max(rootLevelNodeSpace, rootOuterSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle leaf nodes
|
|
||||||
const isLeafNode = !node.children && !node._children;
|
|
||||||
if (leafNodeSpace !== null && isLeafNode) {
|
|
||||||
if (node.parent) {
|
|
||||||
const parentY = node.parent.y;
|
|
||||||
const parentId = node.parent.id || node.parent.data.code;
|
|
||||||
const parentWidth = nodeVisualWidths.get(parentId) || node.parent._calculatedRectWidth || 0;
|
|
||||||
const minSpacing = 30;
|
|
||||||
const spacing = Math.max(leafNodeSpace, minSpacing);
|
|
||||||
node.y = parentY + parentWidth + spacing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter visible nodes
|
|
||||||
const visibleNodes = currentHierarchyRootRef.current.descendants().filter(node => {
|
|
||||||
let current = node;
|
|
||||||
while (current.parent) {
|
|
||||||
if (current.parent.children?.indexOf(current) === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
current = current.parent;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate node widths
|
|
||||||
visibleNodes.forEach(node => {
|
|
||||||
const fontSize = getNodeTextSize(node.depth);
|
|
||||||
const fontWeight = node.depth < 2 ? 'bold' : 'normal';
|
|
||||||
const fontFamily = fontWeight === 'bold'
|
|
||||||
? 'Alibaba-PuHuiTi-SemiBold, sans-serif'
|
|
||||||
: 'Alibaba-PuHuiTi-Regular, sans-serif';
|
|
||||||
const fontStyle = `${fontWeight} ${fontSize} ${fontFamily}`;
|
|
||||||
|
|
||||||
const actualTextWidth = getTextWidth(node.data.name || '', fontStyle);
|
|
||||||
const hasColorBlock = node.depth === 1;
|
|
||||||
const colorBlockWidth = hasColorBlock ? 10 : 0;
|
|
||||||
const colorBlockPadding = hasColorBlock ? 5 : 0;
|
|
||||||
|
|
||||||
const calculatedWidth = textPaddingLeft + actualTextWidth + textPaddingRight + colorBlockWidth + colorBlockPadding;
|
|
||||||
|
|
||||||
node._calculatedRectWidth = calculatedWidth;
|
|
||||||
node._textAvailableWidth = calculatedWidth - textPaddingLeft - (node.depth === 1 ? 10 + 5 : 0) - textPaddingRight + 10;
|
|
||||||
|
|
||||||
const nodeId = node.id || node.data.code;
|
|
||||||
if (nodeId) {
|
|
||||||
nodeVisualWidths.set(nodeId, calculatedWidth);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate boundaries
|
|
||||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
||||||
|
|
||||||
visibleNodes.forEach(node => {
|
|
||||||
minX = Math.min(minX, node.x);
|
|
||||||
maxX = Math.max(maxX, node.x);
|
|
||||||
minY = Math.min(minY, node.y);
|
|
||||||
maxY = Math.max(maxY, node.y);
|
|
||||||
maxY = Math.max(maxY, node.y + (nodeVisualWidths.get(node.id || node.data.code) || node._calculatedRectWidth || 0) + 30);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add padding
|
|
||||||
const padding = { top: 100, right: 20, bottom: 100, left: 120 };
|
|
||||||
|
|
||||||
// Calculate SVG dimensions
|
|
||||||
const realWidth = maxY - minY + padding.left + padding.right;
|
|
||||||
const realHeight = maxX - minX + padding.top + padding.bottom;
|
|
||||||
|
|
||||||
const containerHeight = treeContainer.current.clientHeight;
|
|
||||||
const containerAvailableWidth = containerWidth - 20;
|
|
||||||
let scaleFactor = 1;
|
|
||||||
let needsScaling = false;
|
|
||||||
|
|
||||||
if (realWidth > containerAvailableWidth) {
|
|
||||||
scaleFactor = containerAvailableWidth / realWidth;
|
|
||||||
needsScaling = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalHeight = realHeight * (needsScaling ? scaleFactor : 1);
|
|
||||||
const useFullHeight = finalHeight < containerHeight;
|
|
||||||
|
|
||||||
// Create SVG
|
|
||||||
const svg = d3.select(treeContainer.current)
|
|
||||||
.append('svg')
|
|
||||||
.attr('width', '100%')
|
|
||||||
.attr('height', useFullHeight ? '100%' : finalHeight)
|
|
||||||
.attr('viewBox', `0 0 ${realWidth} ${realHeight}`)
|
|
||||||
.attr('preserveAspectRatio', 'xMinYMid meet')
|
|
||||||
.style('display', 'block')
|
|
||||||
.style('max-height', '100%');
|
|
||||||
|
|
||||||
// Add gradients
|
|
||||||
const defs = svg.append('defs');
|
|
||||||
|
|
||||||
const incrementGradient = defs.append('linearGradient')
|
|
||||||
.attr('id', 'increment-gradient')
|
|
||||||
.attr('x1', '0%').attr('y1', '0%')
|
|
||||||
.attr('x2', '100%').attr('y2', '0%');
|
|
||||||
incrementGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
|
||||||
incrementGradient.append('stop').attr('offset', '100%').attr('stop-color', '#FF8989');
|
|
||||||
|
|
||||||
const stockGradient = defs.append('linearGradient')
|
|
||||||
.attr('id', 'stock-gradient')
|
|
||||||
.attr('x1', '0%').attr('y1', '0%')
|
|
||||||
.attr('x2', '100%').attr('y2', '0%');
|
|
||||||
stockGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
|
||||||
stockGradient.append('stop').attr('offset', '100%').attr('stop-color', '#7ABAFF');
|
|
||||||
|
|
||||||
const declineGradient = defs.append('linearGradient')
|
|
||||||
.attr('id', 'decline-gradient')
|
|
||||||
.attr('x1', '0%').attr('y1', '0%')
|
|
||||||
.attr('x2', '100%').attr('y2', '0%');
|
|
||||||
declineGradient.append('stop').attr('offset', '0%').attr('stop-color', '#E3F5FF');
|
|
||||||
declineGradient.append('stop').attr('offset', '100%').attr('stop-color', '#FFBD7F');
|
|
||||||
|
|
||||||
// Main group
|
|
||||||
const mainGroup = svg.append('g')
|
|
||||||
.attr('transform', `translate(${padding.left - minY}, ${padding.top - minX})`);
|
|
||||||
|
|
||||||
// Draw links
|
|
||||||
mainGroup.selectAll('.tree-view-link')
|
|
||||||
.data(visibleNodes.filter(d => d.parent))
|
|
||||||
.enter().append('path')
|
|
||||||
.attr('class', 'tree-view-link')
|
|
||||||
.attr('d', d => {
|
|
||||||
const parent = d.parent;
|
|
||||||
const sourceX = parent.x;
|
|
||||||
const targetX = d.x;
|
|
||||||
const targetY = d.y;
|
|
||||||
let sourceY;
|
|
||||||
|
|
||||||
if (parent.depth === 0) {
|
|
||||||
sourceY = parent.y + 95;
|
|
||||||
} else {
|
|
||||||
const parentId = parent.id || parent.data.code;
|
|
||||||
const parentWidth = safeNumber(nodeVisualWidths.get(parentId) || parent._calculatedRectWidth || 0, 0);
|
|
||||||
sourceY = parent.y + parentWidth + toggleButtonGap + toggleButtonRadius;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `M ${sourceY},${sourceX}
|
|
||||||
C ${(sourceY + targetY) / 2},${sourceX}
|
|
||||||
${(sourceY + targetY) / 2},${targetX}
|
|
||||||
${targetY},${targetX}`;
|
|
||||||
})
|
|
||||||
.attr('stroke', '#ccc')
|
|
||||||
.attr('stroke-width', '1.2px')
|
|
||||||
.attr('fill', 'none');
|
|
||||||
|
|
||||||
// Draw nodes
|
|
||||||
const node = mainGroup.selectAll('.tree-view-node')
|
|
||||||
.data(visibleNodes, d => d.id || d.data.code || `${d.depth}-${d.data.name}`)
|
|
||||||
.enter().append('g')
|
|
||||||
.attr('class', d => `tree-view-node depth-${d.depth}`)
|
|
||||||
.attr('transform', d => `translate(${d.y}, ${d.x})`);
|
|
||||||
|
|
||||||
// Root nodes
|
|
||||||
const rootNodeGroup = node.filter(d => d.depth === 0);
|
|
||||||
|
|
||||||
// Root outer circle
|
|
||||||
rootNodeGroup.append('circle')
|
|
||||||
.attr('class', 'tree-view-root-outer')
|
|
||||||
.attr('r', d => {
|
|
||||||
const baseSize = 95;
|
|
||||||
const configSize = safeNumber(getNodeSize(0), 8);
|
|
||||||
return Math.max(baseSize * (configSize / 8), 75);
|
|
||||||
})
|
|
||||||
.attr('fill', backgroundColor)
|
|
||||||
.attr('cx', 0)
|
|
||||||
.attr('cy', 0);
|
|
||||||
|
|
||||||
// Root inner circle
|
|
||||||
rootNodeGroup.append('circle')
|
|
||||||
.attr('class', 'tree-view-root-inner')
|
|
||||||
.attr('r', d => {
|
|
||||||
const baseSize = 75;
|
|
||||||
const configSize = safeNumber(getNodeSize(0), 8);
|
|
||||||
return Math.max(baseSize * (configSize / 8), 60);
|
|
||||||
})
|
|
||||||
.attr('fill', d => getNodeColor(0))
|
|
||||||
.attr('cx', 0)
|
|
||||||
.attr('cy', 0);
|
|
||||||
|
|
||||||
// Root node text
|
|
||||||
rootNodeGroup.append('foreignObject')
|
|
||||||
.attr('x', -60)
|
|
||||||
.attr('y', -25)
|
|
||||||
.attr('width', 120)
|
|
||||||
.attr('height', 50)
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.html(d => {
|
|
||||||
const name = d.data.name || '';
|
|
||||||
const textColor = getNodeTextColor(d.depth);
|
|
||||||
const fontSize = getNodeTextSize(d.depth);
|
|
||||||
return `<div style="display: flex; align-items: center; justify-content: center; height: 100%; text-align: center;
|
|
||||||
font-size: ${fontSize}; font-weight: bold; color: ${textColor}; font-family: 'Alibaba-PuHuiTi-SemiBold', sans-serif;
|
|
||||||
overflow-wrap: break-word; line-height: 1.2;">
|
|
||||||
${name}
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Non-root nodes
|
|
||||||
const nonRootNodeGroups = node.filter(d => d.depth > 0);
|
|
||||||
|
|
||||||
// Left marker circle
|
|
||||||
nonRootNodeGroups.append('circle')
|
|
||||||
.attr('class', 'tree-view-node-marker tree-view-left-marker')
|
|
||||||
.attr('r', d => getNodeSize(d.depth))
|
|
||||||
.attr('cx', rectHeight / 2)
|
|
||||||
.attr('cy', 0)
|
|
||||||
.attr('fill', d => getNodeColor(d.depth))
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5);
|
|
||||||
|
|
||||||
// Node background
|
|
||||||
nonRootNodeGroups.append('path')
|
|
||||||
.attr('class', 'tree-view-label-bg')
|
|
||||||
.attr('d', d => {
|
|
||||||
const w = nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0;
|
|
||||||
const h = rectHeight;
|
|
||||||
const r = h / 2;
|
|
||||||
const rr = 10;
|
|
||||||
|
|
||||||
return `M ${w - rr},${-h / 2}
|
|
||||||
L ${r},${-h / 2}
|
|
||||||
A ${r},${r} 0 0 0 ${r},${h / 2}
|
|
||||||
L ${w - rr},${h / 2}
|
|
||||||
A ${rr},${rr} 0 0 0 ${w},${h / 2 - rr}
|
|
||||||
L ${w},${-h / 2 + rr}
|
|
||||||
A ${rr},${rr} 0 0 0 ${w - rr},${-h / 2}
|
|
||||||
Z`;
|
|
||||||
})
|
|
||||||
.attr('fill', d => {
|
|
||||||
if (d.depth === 1 && d.data.tag) {
|
|
||||||
const gradientMap = {
|
|
||||||
'增量行业': 'url(#increment-gradient)',
|
|
||||||
'存量行业': 'url(#stock-gradient)',
|
|
||||||
'衰落行业': 'url(#decline-gradient)'
|
|
||||||
};
|
|
||||||
return gradientMap[d.data.tag] || backgroundColor;
|
|
||||||
}
|
|
||||||
return backgroundColor;
|
|
||||||
})
|
|
||||||
.style('cursor', 'pointer')
|
|
||||||
.on('click', (event, d) => {
|
|
||||||
if (onNodeClick) {
|
|
||||||
onNodeClick({
|
|
||||||
node: d.data,
|
|
||||||
level: d.depth + 1,
|
|
||||||
event
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expandOnClickNode && (d.children || d._children)) {
|
|
||||||
event.stopPropagation();
|
|
||||||
handleToggleNode(d);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Node icon
|
|
||||||
nonRootNodeGroups.append('foreignObject')
|
|
||||||
.attr('class', 'tree-view-node-icon')
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', -(iconHeight / 2))
|
|
||||||
.attr('width', iconWidth)
|
|
||||||
.attr('height', iconHeight)
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
.html(d => {
|
|
||||||
let icon = '';
|
|
||||||
if (nodeIcons && nodeIcons[d.depth]) {
|
|
||||||
icon = nodeIcons[d.depth];
|
|
||||||
}
|
|
||||||
return icon ? `<img src="${icon}" width="${iconWidth}" height="${iconHeight}" style="display: block;" />` : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Node label text
|
|
||||||
nonRootNodeGroups.append('text')
|
|
||||||
.attr('class', 'tree-view-node-label')
|
|
||||||
.attr('dy', '.35em')
|
|
||||||
.attr('y', 0)
|
|
||||||
.attr('x', d => textPaddingLeft + 3)
|
|
||||||
.attr('text-anchor', 'start')
|
|
||||||
.text(d => {
|
|
||||||
const fontSize = getNodeTextSize(d.depth);
|
|
||||||
const fontWeight = d.depth < 2 ? 'bold' : 'normal';
|
|
||||||
const fontFamily = fontWeight === 'bold'
|
|
||||||
? 'Alibaba-PuHuiTi-SemiBold, sans-serif'
|
|
||||||
: 'Alibaba-PuHuiTi-Regular, sans-serif';
|
|
||||||
const fontStyle = `${fontWeight} ${fontSize} ${fontFamily}`;
|
|
||||||
return truncateText(d.data.name, d._textAvailableWidth, fontStyle);
|
|
||||||
})
|
|
||||||
.style('font-size', d => getNodeTextSize(d.depth))
|
|
||||||
.style('font-weight', d => d.depth < 2 ? 'bold' : 'normal')
|
|
||||||
.attr('fill', d => getNodeTextColor(d.depth))
|
|
||||||
.style('pointer-events', 'none');
|
|
||||||
|
|
||||||
// Node tooltips
|
|
||||||
nonRootNodeGroups.append('title')
|
|
||||||
.text(d => d.data.name);
|
|
||||||
|
|
||||||
// Toggle buttons
|
|
||||||
const toggleNodes = nonRootNodeGroups.filter(d => d.children || d._children);
|
|
||||||
|
|
||||||
toggleNodes.append('circle')
|
|
||||||
.attr('class', 'tree-view-toggle-bg')
|
|
||||||
.attr('r', toggleButtonRadius)
|
|
||||||
.attr('cx', d => safeNumber((nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0) + toggleButtonGap + toggleButtonRadius, toggleButtonGap + toggleButtonRadius))
|
|
||||||
.attr('cy', 0)
|
|
||||||
.attr('fill', d => getNodeColor(d.depth))
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.style('cursor', 'pointer')
|
|
||||||
.on('click', (event, d) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
handleToggleNode(d);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle icons
|
|
||||||
toggleNodes.append('foreignObject')
|
|
||||||
.attr('class', 'tree-view-toggle-icon-fo')
|
|
||||||
.attr('x', d => safeNumber((nodeVisualWidths.get(d.id || d.data.code) || d._calculatedRectWidth || 0) + toggleButtonGap + toggleButtonRadius - 8, toggleButtonGap + toggleButtonRadius - 8))
|
|
||||||
.attr('y', -8)
|
|
||||||
.attr('width', 16)
|
|
||||||
.attr('height', 16)
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
.html(d => {
|
|
||||||
const nodeId = d.id || d.data.code;
|
|
||||||
if (!nodeId) return '';
|
|
||||||
|
|
||||||
const currentState = nodeStatesRef.current.get(nodeId);
|
|
||||||
const isExpanded = currentState !== undefined ? currentState.expanded : (d.children != null);
|
|
||||||
|
|
||||||
return isExpanded ?
|
|
||||||
`<svg viewBox="0 0 24 24" fill="white" width="16" height="16"><path d="M19 13H5v-2h14v2z"/></svg>` :
|
|
||||||
`<svg viewBox="0 0 24 24" fill="white" width="16" height="16"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Error handled silently
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
backgroundColor, nodeColors, nodeSizes, nodeTextColors, nodeTextSizes,
|
|
||||||
expandAll, nodeIcons, horizontalNodeSpace, rootLevelNodeSpace,
|
|
||||||
leafNodeSpace, levelNodeSpaces, expandOnClickNode, onNodeClick,
|
|
||||||
getNodeColor, getNodeSize, getNodeTextColor, getNodeTextSize,
|
|
||||||
safeNumber, getTextWidth, truncateText
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Handle toggle node
|
|
||||||
const handleToggleNode = useCallback((d) => {
|
|
||||||
const nodeId = d.id || d.data.code;
|
|
||||||
if (!nodeId) return;
|
|
||||||
|
|
||||||
const currentState = nodeStatesRef.current.get(nodeId);
|
|
||||||
const currentlyExpanded = currentState !== undefined ? currentState.expanded : (d.children != null);
|
|
||||||
|
|
||||||
const newState = { expanded: !currentlyExpanded };
|
|
||||||
nodeStatesRef.current.set(nodeId, newState);
|
|
||||||
|
|
||||||
if (onUpdateExpandedNodes) {
|
|
||||||
const expandedNodesList = [];
|
|
||||||
nodeStatesRef.current.forEach((state, id) => {
|
|
||||||
if (state.expanded) {
|
|
||||||
expandedNodesList.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
onUpdateExpandedNodes(expandedNodesList);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onNodeToggle) {
|
|
||||||
onNodeToggle({
|
|
||||||
node: d.data,
|
|
||||||
expanded: !currentlyExpanded
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTree();
|
|
||||||
}, [onUpdateExpandedNodes, onNodeToggle, renderTree]);
|
|
||||||
|
|
||||||
// Initialize component
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialExpandedNodes && initialExpandedNodes.length > 0) {
|
|
||||||
initialExpandedNodes.forEach(nodeId => {
|
|
||||||
nodeStatesRef.current.set(nodeId, { expanded: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (treeData) {
|
|
||||||
currentHierarchyRootRef.current = d3.hierarchy(treeData, d => d.children);
|
|
||||||
renderTree();
|
|
||||||
}
|
|
||||||
}, [treeData, initialExpandedNodes, renderTree]);
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (treeContainer.current) {
|
|
||||||
renderTree();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
if (typeof ResizeObserver !== 'undefined' && treeContainer.current) {
|
|
||||||
resizeObserverRef.current = new ResizeObserver(() => {
|
|
||||||
renderTree();
|
|
||||||
});
|
|
||||||
resizeObserverRef.current.observe(treeContainer.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
if (resizeObserverRef.current) {
|
|
||||||
resizeObserverRef.current.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [renderTree]);
|
|
||||||
|
|
||||||
// Watch for prop changes that require re-render
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentHierarchyRootRef.current && treeContainer.current) {
|
|
||||||
renderTree();
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
backgroundColor, nodeColors, nodeSizes, nodeTextColors, nodeTextSizes,
|
|
||||||
expandAll, nodeIcons, horizontalNodeSpace, rootLevelNodeSpace,
|
|
||||||
leafNodeSpace, levelNodeSpaces, renderTree
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tree-view-container">
|
|
||||||
<div className="tree-view-scroll-wrapper">
|
|
||||||
<div ref={treeContainer} className="tree-view-tree-chart"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="chart-loading">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<div className="loading-text">{loadingText}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{loadingError && (
|
|
||||||
<div className="chart-error">
|
|
||||||
<div className="error-icon">!</div>
|
|
||||||
<div className="error-text">{errorText}</div>
|
|
||||||
<button className="retry-button" onClick={onRetry}>重新加载</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EcoTree;
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
// ... existing code ...
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
// 生产环境基础路径配置 - Vercel部署使用根路径
|
// 生产环境基础路径配置
|
||||||
base: "/",
|
base: "/",
|
||||||
// 构建配置
|
// 构建配置
|
||||||
build: {
|
build: {
|
||||||
@@ -13,25 +14,41 @@ export default defineConfig({
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
// 资源内联限制
|
// 资源内联限制
|
||||||
assetsInlineLimit: 4096,
|
assetsInlineLimit: 4096,
|
||||||
|
// 代码分割配置
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// 分割代码
|
||||||
|
manualChunks: {
|
||||||
|
// 分割第三方库
|
||||||
|
vendor: ["react", "react-dom", "react-router-dom"],
|
||||||
|
// 分割大型组件
|
||||||
|
components: ["@/components/CoursesVideoPlayer"],
|
||||||
|
// 分割工具函数
|
||||||
|
utils: ["@/utils/LinePathGenerator", "@/utils/request"],
|
||||||
|
},
|
||||||
|
// 配置chunk文件名格式
|
||||||
|
chunkFileNames: "assets/js/[name]-[hash].js",
|
||||||
|
// 配置入口文件名格式
|
||||||
|
entryFileNames: "assets/js/[name]-[hash].js",
|
||||||
|
// 配置静态资源文件名格式
|
||||||
|
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// 开发服务器配置
|
// 开发服务器配置
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
port: 3000,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
// API代理配置
|
// API代理配置
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:3000", // 本地开发用 localhost
|
target: "http://localhost:3000",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 预览服务器配置
|
|
||||||
preview: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: 4173,
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": "/src",
|
"@": "/src",
|
||||||
|
|||||||
Reference in New Issue
Block a user