国外得组织架构图

665 阅读13分钟

image.png

image.png 不常见得组织架构图,用了G6和自己一些写法再里面。此篇为自己得记录篇-----------------------------

let data = [
  {
    "id": 10009070,
    "shortNameEn": "MAINT",
    "shortNameZh": null,
    "shortNameAr": null,
    "nameEn": "Maintenance",
    "nameZh": "Maintenance",
    "nameAr": "Maintenance",
    "sectionCount": 0,
    "localCount": 38,
    "expatCount": 68,
    "mixedCount": 0,
    "headcount": 106,
    "employeeCount": 0,
    "leaderList": null,
    "positionList": [
      {
        "id": 10074621,
        "unicode": "AOSIMTNSPOS111",
        "enterpriseId": 10000504,
        "buId": 10009070,
        "name": "Metering Engineer",
        "shortName": "Metering Engineer",
        "reportTo": 10074531,
        "reportBuId": 10009153,
        "personalType": 0,
        "positionType": 1,
        "workPatternId": 10000042,
        "workPatternName": "14/14",
        "headCount": 2,
        "sort": 1,
        "employeeCount": 1,
        "employeeList": [{
            "id": 10087744,
            "unicode": null,
            "fullName": "HASAN LAZIM MOHSIN",
            "workPatternId": 10000042,
            "workPatternName": "14/14",
          }
        ],
        "children": null
      }
    ],
    "children": [
      {
        "id": 10009109,
        "tenantId": 10000000,
        "appId": 10000000,
        "unicode": "AOSIMAINTSEC003",
        "enterpriseId": 10000504,
        "parentId": 10009070,
        "unitTypeId": 10001520,
        "shortNameEn": "Diesel.",
        "shortNameZh": null,
        "shortNameAr": null,
        "nameEn": "Diesel Workshop",
        "nameZh": "Diesel Workshop",
        "nameAr": "Diesel Workshop",
        "sectionCount": 0,
        "localCount": 0,
        "expatCount": 4,
        "mixedCount": 0,
        "headcount": 4,
        "employeeCount": 0,
        "unitTypeIcon": "section",
        "leaderList": null,
        "positionList": [{
            "id": 10076160,
            "uuid": "a858b0a8b57e47f7adbaeda46e67bbd9",
            "deleted": 0,
            "createTime": "2024-12-30 11:07:25",
            "updateTime": "2024-12-30 11:07:25",
            "createBy": "levi.liang@itforce-tech.com",
            "updateBy": "levi.liang@itforce-tech.com",
            "tenantId": 10000000,
            "appId": 10000000,
            "unicode": "AOSIDIESEL.POS002",
            "enterpriseId": 10000504,
            "buId": 10009109,
            "name": "WPB",
            "shortName": "WPB",
            "reportTo": 10074592,
            "reportBuId": 10009161,
            "personalType": 1,
            "positionType": 0,
            "headCount": 1,
            "sort": 1,
            "employeeCount": 2,
            "employeeList": [{
                "id": 10087898,
                "unicode": null,
                "fullName": "WALEED KAMIL JASIM",
                "workPatternId": 10000042,
                "workPatternName": "14/14",
              },
            ],
            "children": null
          },
        ],
        "children": null
      }
    ]
  }
]

这是组织树需要得数据结构 view-org-chart.vue 父级

<script setup>
import { h } from 'vue';
import { CloseOutlined } from '@ant-design/icons-vue';
import { useLanguage } from '@/hooks/index';
import { GetBusinessUnitTree } from '@/api/organization/index';
const { isCn, isArabic } = useLanguage();
const TreeChart = defineAsyncComponent(() =>
	import('./components/TreeChart.vue'),
);
const route = useRoute();
const router = useRouter();
const TreeChartRef = ref(null);
// 使用watch来监听对象
const stringTree = ref([]);
const convertValuesToString = (tree) => {
	return tree.map((node) => {
		const convertedNode = { ...node };
		if (convertedNode.children) {
			convertedNode.children = convertValuesToString(convertedNode.children);
		}
		convertedNode.id = String(convertedNode.id);
		convertedNode.nameZh =
			isCn && convertedNode.nameZh
				? convertedNode.nameZh
				: convertedNode.nameEn;
		convertedNode.nameAr =
			isArabic && convertedNode.nameAr
				? convertedNode.nameAr
				: convertedNode.nameEn;
		return convertedNode;
	});
};

// 我给了每个层级加了标识好区分
const addLevels = (nodes, level2 = 0) => {
	return nodes.map((node) => {
		node.level2 = level2; // 添加层级标记
		if (node.children && node.children.length > 0) {
			node.children = addLevels(node.children, level2 + 1);
		}
		return node;
	});
};
// 这个是树形访问接口,可以替换
const getSelectTree = async () => {
	await GetBusinessUnitTree({
		id: route.query.buId,
	}).then((res) => {
		stringTree.value = addLevels(convertValuesToString(res.data));
	});
};
onMounted(() => {
	getSelectTree();
});

const loading = ref(false);
const loading2 = ref(false);
// 下载svg 图片
const handleDownIamges = debounce(() => {
	loading.value = true;
	TreeChartRef.value.downImage();
	loading.value = false;
}, 1000);
// 下载PDF
const handleDownPdf = debounce(() => {
	loading2.value = true;
	TreeChartRef.value.downPdf();
	loading2.value = false;
}, 1000);
const onClose = () => {
	router.back();
};
</script>
<template>
	<div style="background-color: #fff; border-radius: 6px">
		<a-row style="padding: 16px 16px 0 16px">
			<a-col :span="24" style="display: flex; justify-content: flex-end">
				<a-button
					style="margin-right: 8px"
					:loading="loading"
					@click="handleDownIamges"
				>
					<i class="fa fa-download" />
					<span style="margin-left: 4px; margin-right: 4px">SVG</span>
				</a-button>
				<a-button
					style="margin-right: 8px"
					:loading="loading2"
					@click="handleDownPdf"
				>
					<i class="fa fa-download" />
					<span style="margin-left: 4px; margin-right: 4px">PDF</span>
				</a-button>
				<a-button :icon="h(CloseOutlined)" @click="onClose"></a-button>
			</a-col>
		</a-row>

		<TreeChart
			v-if="stringTree.length > 0"
			ref="TreeChartRef"
			:tree-data="stringTree"
		/>
	</div>
</template>

TreeChart.vue 子级 渲染图

<script setup>
import G6 from '@antv/g6';
import { useLanguage } from '@/hooks/index';
import jsPDF from 'jspdf';
import { Session } from '@/utils/storage';
const router = useRouter();
const { isCn, isArabic } = useLanguage();

const props = defineProps({
	treeData: { type: Array },
});

const graphContainer = ref(null);
let graph = null;

const isCategory = (cfg) => {
	if (cfg.personalType === 0) {
		return `<div class="square-style-right-top positionGreen">
      ${cfg.name}
    </div>`;
	} else if (cfg.personalType === 1) {
		return `<div class="square-style-right-top positionOrange">
      ${cfg.name}
    </div>`;
	} else {
		return `<div class="square-style-right-top gradualChange">
      ${cfg.name}
    </div>`;
	}
};
const generateSonHtml = (children, hasOuterLine = false) => {
	if (!children) return '';

	let level = 0;

	let html = '';

	function generaHtml(kids, level) {
		if (!kids) return '';
		level++;

		let kidHtml = '';
		kids.forEach((cfg, idx) => {
			kidHtml += `
              <div class="outermost">
                ${
									level === 1 && hasOuterLine
										? `
                    <div class="outermost-line">
                      <div class="top-line"></div>
                      ${
												idx === kids.length - 1
													? ''
													: `<div class="bottom-line"></div>`
											}
                    </div>
                  `
										: ''
								}
                <li class="hierarchy" data-level='${level}' last-index='${
				kids.length - 1
			}' index='${idx}'>
                  <div class="square-wrap">
                    ${
											level > 1
												? `
                        <div class="line">
                          <div class="top-line"></div>
                          ${
														idx === kids.length - 1
															? ''
															: `<div class="bottom-line"></div>`
													}
                        </div>
                      `
												: ''
										}
                    <div class="node square-style">
                      <div class="square-style-left">
                        <div class="square-style-left-top"> ${
													cfg.employeeCount || 0
												} / ${
				(cfg.employeeList && cfg.employeeList.length) || 0
			}</div>
                        <div class="square-style-left-bottom"> ${
													cfg.workPatternName || '--'
												}</div>
                      </div>
                      <div class="square-style-right">
                        ${isCategory(cfg)}
                        ${
													(cfg.employeeList &&
														cfg.employeeList
															.map((item) => {
																if (item.fullName == 'TBC') {
																	return `<div class="square-style-right-bottom" style="color: #409eff">${item.fullName}</div>`;
																} else {
																	return `<div class="square-style-right-bottom" data-id='${
																		item.id
																	}'>${item.fullName}(${
																		item.workPatternName || '--'
																	})</div>`;
																}
															})
															.join('')) ||
													`<div class="square-style-right-bottom">--</div>`
												}
                      </div>
                    </div>
                  </div>
                  ${
										cfg.children && cfg.children.length
											? `<ul class="nodes">${generaHtml(
													cfg.children,
													level,
											  )}</ul>`
											: ''
									}
                </li>
              </div>
      `;
		});

		return kidHtml;
	}

	html += `
      <ul class="nodes">
        ${generaHtml(children, level)}
      </ul>
    `;

	return html;
};

const getTopHtml = (cfg) => {
	return `
    <div class="top-box-html">
      <div class="diamond-style">
        <div class="diamond-top">${cfg.employeeCount || 0} / ${
		cfg.headcount || 0
	}</div>
        <div class="diamond-bottom">${cfg.workPatternName || '--'}</div>
      </div>
      <div class="tox-box-diamond">
        <div class="diamond-title">
          ${
						isCn.value && cfg.nameZh
							? cfg.nameZh
							: isArabic.value && cfg.nameAr
							? cfg.nameAr
							: cfg.nameEn
					}
        </div>
        ${
					(cfg.leaderList &&
						cfg.leaderList
							.map((item) => {
								return `<div class="diamond-name">${item.fullName.replace(
									/(.{19})/g,
									'$1\n',
								)}(${item.workPatternName || '--'})</div>`;
							})
							.join('')) ||
					`<div class="diamond-name">--</div>`
				}
      </div>
    </div>`;
};
const generateSonTopHtml = (children, hasOuterLine = false) => {
	if (!children) return '';

	let level = 0;

	let html = '';

	function generaHtml(kids, level) {
		if (!kids) return '';
		level++;

		let kidHtml = '';
		kids.forEach((cfg, idx) => {
			kidHtml += `
              <div class="outermost">
                ${
									level === 1 && hasOuterLine
										? `
                    <div class="outermost-line">
                      ${
												idx === 0
													? '<div class="top-line0"></div>'
													: `<div class="top-line"></div>`
											}
                      <div class="bottom-line"></div>
                    </div>
                  `
										: ''
								}
                <li class="hierarchy" data-level='${level}' last-index='${
				kids.length - 1
			}' index='${idx}'>
                  <div class="square-wrap">
                    ${
											level > 1
												? `
                        <div class="line">
                          <div class="top-line"></div>
                          ${
														idx === kids.length - 1
															? ''
															: `<div class="bottom-line"></div>`
													}
                        </div>
                      `
												: ''
										}
                    <div class="node square-style">
                      <div class="square-style-left">
                        <div class="square-style-left-top">${
													cfg.employeeCount || 0
												} / ${
				(cfg.employeeList && cfg.employeeList.length) || 0
			}</div>
                        <div class="square-style-left-bottom"> ${
													cfg.workPatternName || '--'
												}</div>
                      </div>
                      <div class="square-style-right">
                        ${isCategory(cfg)}
                        ${
													(cfg.employeeList &&
														cfg.employeeList
															.map((item) => {
																if (item.fullName == 'TBC') {
																	return `<div class="square-style-right-bottom" style="color: #409eff">${item.fullName}</div>`;
																} else {
																	return `<div class="square-style-right-bottom" data-id="${
																		item.id
																	}">${item.fullName}(${
																		item.workPatternName || '--'
																	})</div>`;
																}
															})
															.join('')) ||
													`<div class="square-style-right-bottom">--</div>`
												}
                      </div>
                    </div>
                  </div>
                  ${
										cfg.children && cfg.children.length
											? `<ul class="nodes">${generaHtml(
													cfg.children,
													level,
											  )}</ul>`
											: ''
									}
                </li>
              </div>
      `;
		});

		return kidHtml;
	}

	html += `
      <ul class="nodes">
        ${generaHtml(children, level)}
      </ul>
    `;

	return html;
};

function calculateMaxChildren(data) {
	let maxChildrenCount = 0;

	for (let node of data) {
		let childrenCount = 0;

		// 递归计算子节点的数量
		if (node.children) {
			childrenCount += calculateMaxChildren(node.children);
		}

		// 计算当前节点及其子节点的子节点数量
		childrenCount += node.children ? node.children.length : 0;

		// 更新最大子节点数量
		maxChildrenCount = Math.max(maxChildrenCount, childrenCount);
	}

	return maxChildrenCount;
}
const methodChart = () => {
	G6.registerNode(
		'dom-node',
		{
			draw(cfg, group) {
				const container = document.createElement('div');
				if (cfg.level2 === 0) {
					container.innerHTML = `
              <div class="boxChartTop" id="${cfg.id}">
                ${getTopHtml(cfg)}
								${
									cfg.positionList && cfg.positionList.length > 0
										? `<div class="center-line"></div>`
										: ''
								}
                ${generateSonTopHtml(cfg.positionList, true)}
              </div>`;
				} else {
					container.innerHTML = `
              <div class="boxChart" id="${cfg.id}">
                <div class="circle-wrap">
                  <div class="outermost-line">
                    <div class="bottom-line"></div>
                  </div>
                  <div class="circle-style">${
										isCn.value && cfg.nameZh
											? cfg.nameZh
											: isArabic.value && cfg.nameAr
											? cfg.nameAr
											: cfg.nameEn
									}</div>
                </div>
                ${generateSonHtml(cfg.positionList, true)}
              </div>
            `;
				}
				// 将container插入隐藏容器以计算宽高
				const hiddenContainer = document.getElementById('hiddenContainer');
				hiddenContainer.appendChild(container);
				const width = container.clientWidth;
				const height = container.clientHeight;
				hiddenContainer.removeChild(container);

				const shape = group.addShape('dom', {
					attrs: {
						width,
						height: height + 10,
						x: 0,
						y: 0,
						html: container.innerHTML,
						size: [width, height],
					},
					draggable: true,
				});

				cfg.width = width;
				cfg.height = height;

				return shape;
			},
		},
		'dom',
	);

	G6.registerEdge('flow-line', {
		draw(cfg, group) {
			let startPoint = cfg.startPoint;
			let endPoint = cfg.endPoint;

			// 计算源节点底部中间位置
			const sourceModel = graph.findById(cfg.source).getModel();
			const sourceHeight = sourceModel.height;

			// 目标节点 这块可以优化,看看那位大佬帮忙优化一下
			const targetModel = graph.findById(cfg.target).getModel();
			const targetHeight = targetModel.height;
			const list =
				sourceModel.positionList && sourceModel.positionList.length > 0
					? calculateMaxChildren(sourceModel.positionList)
					: 0;
			console.log('计算', list);
			// 计算父节点底部中间位置
			let sourceX = startPoint.x;
			if (
				sourceModel.level2 === 0 &&
				sourceModel.positionList?.length > 0 &&
				list < 1
			) {
				console.log('1');
				sourceX = startPoint.x - 115;
			} else if (
				sourceModel.level2 === 0 &&
				sourceModel.positionList?.length <= 3 &&
				list <= 3
			) {
				console.log('2');
				sourceX = startPoint.x - 150;
			} else if (
				sourceModel.level2 === 0 &&
				sourceModel.positionList?.length > 3 &&
				list < 3
			) {
				console.log('3');
				sourceX = startPoint.x - 180;
			} else if (
				sourceModel.level2 === 0 &&
				sourceModel.positionList?.length < 4 &&
				list > 3
			) {
				console.log('4');
				sourceX = startPoint.x - list * 35;
			} else if (
				sourceModel.level2 === 0 &&
				sourceModel.positionList?.length === 4 &&
				list === 3
			) {
				console.log('5');
				sourceX = startPoint.x - list * 50;
			} else if (
				sourceModel.level2 === 0 &&
				sourceModel.positionList?.length <= 4 &&
				list >= 4
			) {
				console.log('6');
				sourceX = startPoint.x - list * 50;
			}

			const sourceY = startPoint.y + sourceHeight / 2;

			// 计算目标节点顶部中间位置
			const targetX = endPoint.x;
			const targetY = endPoint.y - targetHeight / 2;

			const isSafari = /^((?!chrome|android).)*safari/i.test(
				navigator.userAgent,
			);

			// 控制连接线的路径,使其从父节点底部中间连接到子节点顶部中间
			const path = [
				['M', sourceX, sourceY - 17],
				['L', sourceX, (sourceY + targetY) / 2],
				['L', targetX, (sourceY + targetY) / 2],
				['L', targetX, targetY],
			];

			// 绘制连接线
			const shape = group.addShape('path', {
				attrs: {
					path: path,
					stroke: '#e07572',
					lineWidth: 1.5,
				},
			});

			return shape;
		},
	});
};

const treeChart = () => {
	methodChart();
	const width = graphContainer.value.scrollWidth || 1740;
	const height = graphContainer.value.scrollHeight || 700;
	graph = new G6.TreeGraph({
		container: graphContainer.value,
		width,
		height,
		renderer: 'svg',
		fitView: true,
		linkCenter: true,
		modes: {
			default: ['drag-canvas', 'zoom-canvas'],
		},
		defaultNode: {
			type: 'dom-node',
		},
		defaultEdge: {
			type: 'flow-line',
			style: {
				stroke: '#e07572',
				lineWidth: 1,
			},
		},
		layout: {
			type: 'compactBox',
			direction: 'TB',
			getId: function getId(d) {
				return d.id;
			},
			getHeight: function getHeight(d) {
				return d.height || 200;
			},
			getWidth: function getWidth(d) {
				return d.width || 16;
			},
			// 每个节点的垂直间隙
			getVGap: function getVGap() {
				return 50;
			},
			// 每个节点的水平间隙
			getHGap: function getHGap() {
				return 70;
			},
		},
	});

	graph.read(props.treeData[0]);
	graph.clear();

	// 在首次渲染后获取节点实际宽高并重新布局
	setTimeout(() => {
		graph.read(props.treeData[0]);
		graph.fitView();
	}, 650);

	if (typeof window !== 'undefined')
		window.onresize = () => {
			if (!graph || graph.get('destroyed')) return;
			if (
				!graphContainer.value ||
				!graphContainer.value.scrollWidth ||
				!graphContainer.value.scrollHeight
			)
				return;
			graph.changeSize(
				graphContainer.value.scrollWidth,
				graphContainer.value.scrollHeight,
			);
		};
};
const context = ref();
const downImage = async () => {
	graph.fitView(); // 确保视图包含所有节点和边
	setTimeout(() => {
		domtoimage
			.toSvg(context.value)
			.then(function (dataUrl) {
				const link = document.createElement('a');
				link.href = dataUrl;
				link.download = `Org Chart-${props.treeData[0].nameEn}`;
				document.body.appendChild(link);
				link.click();
				document.body.removeChild(link);
			})
			.catch(function (error) {
				console.error('oops, something went wrong!', error);
			});
	}, 200);
};

const downPdf = () => {
	graph.fitView(); // 确保视图包含所有节点和边
	const target = context.value;
	console.log('target', target);

	let contentWidth = target.clientWidth; // 获得该容器的宽
	let contentHeight = target.clientHeight; // 获得该容器的高

	target.ownerDocument.defaultView.devicePixelRatio = 1.25;
	target.ownerDocument.defaultView.innerWidth = contentWidth;
	target.ownerDocument.defaultView.innerHeight = contentHeight;

	let opts = {
		scale: 4,
		width: contentWidth,
		height: contentHeight,
		useCORS: true,
		bgcolor: '#fff',
	};
	domtoimage
		.toPng(target, opts)
		.then(function (dataUrl) {
			var img = new Image();
			img.src = dataUrl;
			//一页pdf显示html页面生成的canvas高度;
			var pageHeight = (contentWidth / 592.28) * 841.89;
			//未生成pdf的html页面高度
			var leftHeight = contentHeight;
			//页面偏移
			var position = 0;
			//a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
			var imgWidth = 595.28;
			var imgHeight = (592.28 / contentWidth) * contentHeight;

			var pdf = new jsPDF('', 'pt', 'a4');

			//有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
			//当内容未超过pdf一页显示的范围,无需分页
			if (leftHeight < pageHeight) {
				pdf.addImage(dataUrl, 'PNG', 0, 0, imgWidth, imgHeight);
			} else {
				while (leftHeight > 0) {
					pdf.addImage(dataUrl, 'PNG', 0, position, imgWidth, imgHeight);
					leftHeight -= pageHeight;
					position -= 841.89;
					//避免添加空白页
					if (leftHeight > 0) {
						pdf.addPage();
					}
				}
			}

			pdf.save(`Org Chart-${props.treeData[0].nameEn}.pdf`);
		})
		.catch(function (error) {
			console.error('oops, something went wrong!', error);
		});
};
onUnmounted(() => {
	// 销毁图实例
	if (graph) {
		graph.clear();
		graph.destroy();
	}
});

const closeChart = () => {
	if (graph) {
		graph.clear();
		graph.destroy();
	}
};

onMounted(() => {
	Session.remove('employeeID');
	Session.remove('employeeParams');
	if (props.treeData && props.treeData.length) {
		treeChart();
	}
});
const clickHandler = (event) => {
	if (event.target.classList.contains('square-style-right-bottom')) {
		var dataId = event.target.getAttribute('data-id');
		if (dataId) {
			Session.set('employeeID', dataId);
			let params = {
				form: 'organization',
				employeeId: dataId,
				to: 'workInfo',
			};
			Session.set('employeeParams', params);
			router.push({ name: 'employDetails' });
		}
	}
};

document.body.addEventListener('click', clickHandler);
onBeforeUnmount(() => {
	document.body.removeEventListener('click', clickHandler);
});

defineExpose({
	treeChart,
	downImage,
	downPdf,
	closeChart,
});
</script>
<template>
	<div id="context" ref="context">
		<div class="use-cases">
			<div class="total-table">
				<div class="lattice">
					<div class="box">{{ $t('employee.organization.expat') }}</div>
					<div class="box">{{ props.treeData[0].expatCount }}</div>
				</div>
				<div class="lattice">
					<div class="box">{{ $t('employee.organization.local') }}</div>
					<div class="box">{{ props.treeData[0].localCount }}</div>
				</div>
				<div class="lattice">
					<div class="box">{{ $t('employee.organization.mixed') }}</div>
					<div class="box">{{ props.treeData[0].mixedCount }}</div>
				</div>
				<div class="lattice total">
					<div class="box">{{ $t('employee.organization.total') }}</div>
					<div class="box">{{ props.treeData[0].headcount }}</div>
				</div>
			</div>
			<div class="org-text">
				<div class="box positionGreen"></div>
				{{ $t('employee.organization.local') }}
				<div class="box positionOrange"></div>
				{{ $t('employee.organization.expat') }}
				<div class="box gradualChange"></div>
				{{ $t('employee.organization.mixed') }}
			</div>
		</div>
		<div id="graphContainer" ref="graphContainer"></div>
		<div
			id="hiddenContainer"
			style="visibility: hidden; position: absolute"
		></div>
	</div>
</template>
<style lang="scss">
.total-table {
	border: 1px solid #dae0e6;
	border-radius: 4px;
	.lattice {
		display: flex;
		color: #66809e;
		border-bottom: 1px solid #dae0e6;
		&:last-child {
			border-bottom: none;
		}
		.box {
			text-align: center;
			padding: 8px 16px;
			width: 80px;
			border-right: 1px solid #dae0e6;
			&:last-child {
				border-right: none;
			}
		}
	}
	.total {
		background-color: #f2f4f7;
	}
}
.use-cases {
	display: flex;
	justify-content: space-between;
	padding: 24px 24px 0px 24px;
	.org-text {
		display: flex;
		align-items: center;
		column-gap: 8px;
		margin-right: 8px;
		color: #66809e;
		.box {
			width: 20px;
			height: 20px;
			border-radius: 4px;
		}
		.positionGreen {
			background-color: #6fc9c2;
		}
		.positionOrange {
			background-color: #f2f09f;
		}
		.gradualChange {
			background: linear-gradient(to right, #f3ef9f, #6ecac1);
		}
	}
}

.boxChart {
	display: flex;
	flex-direction: column;
	align-items: center;
	user-select: none;
	height: 100%;

	.circle-wrap {
		display: flex;
		width: 100%;
		margin-bottom: -10px;
	}

	.circle-style {
		width: 100%;
		height: 80px;
		padding: 8px;
		border-radius: 48%;
		border: 1px solid #242424;
		background-color: #fff2cc;
		text-align: center;
		word-wrap: break-word;
		font-size: 20px;
		font-weight: bold;
		display: flex;
		justify-content: center;
		align-items: center;
	}

	.diamond-style {
		display: flex;
		flex-direction: column;

		.diamond-top {
			border: 1px solid #242424;
			border-right: none;
			width: 50px;
			height: 28px;
			line-height: 28px;
			text-align: center;
		}

		.diamond-bottom {
			border: 1px solid #242424;
			border-right: none;
			width: 50px;
			height: 26px;
			line-height: 28px;
			text-align: center;
		}
	}

	.nodes {
		list-style: none;

		.outermost {
			display: flex;
			overflow: hidden;

			.outermost-line {
				.top-line {
					width: 10px;
					height: 35px;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}

				.bottom-line {
					width: 0;
					height: 5000%;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}
			}
		}

		.hierarchy {
			padding-bottom: 20px;
			overflow: hidden;

			.square-wrap {
				display: flex;
				padding-top: 20px;

				&:last-child {
					margin-bottom: 0;
				}
			}
		}

		.square-wrap {
			.line {
				margin-top: -21px;

				.top-line {
					width: 10px;
					height: 35px;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}

				.bottom-line {
					width: 0;
					height: 5000%;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}
			}
		}

		.nodes {
			padding-left: 60px;
		}
	}

	.square-style {
		display: flex;
		background-color: #fff;

		&-left {
			display: flex;
			flex-direction: column;

			&-top {
				border: 1px solid #242424;
				border-right: none;
				width: 50px;
				padding: 8px;
				text-align: center;
			}

			&-bottom {
				border: 1px solid #242424;
				border-right: none;
				border-top: none;
				width: 50px;
				padding: 8px;
				text-align: center;
			}
		}

		&-right {
			width: 200px;
			height: auto;
			text-align: center;

			&-top {
				border: 1px solid #242424;
				padding: 8px;
			}

			&-bottom {
				border: 1px solid #242424;
				border-top: none;
				padding: 8px 0;
				cursor: pointer;
			}
		}
	}
}

.boxChartTop {
	display: flex;
	flex-direction: column;
	align-items: center;
	user-select: none;
	padding-right: 32px;

	.circle-wrap {
		display: flex;
		width: 100%;
		margin-left: -40px;
	}

	.center-top {
		width: 100%;
		display: flex;
		align-items: center;
	}

	.top-vertical {
		flex: 1;
		width: 0;
		height: 100%;
		border-color: rgba(217, 83, 79, 0.8);
		border-style: solid;
		border-width: 0 0 2px 2px;
		margin-left: 42.8%;
	}

	.diamond-style {
		display: flex;
		flex-direction: column;

		.diamond-top {
			border: 1px solid #242424;
			border-right: none;
			width: 50px;
			height: 28px;
			line-height: 28px;
			text-align: center;
		}

		.diamond-bottom {
			border: 1px solid #242424;
			border-right: none;
			border-top: none;
			width: 50px;
			height: 28px;
			line-height: 28px;
			text-align: center;
		}
	}

	.nodes {
		list-style: none;
		padding-left: 60px !important;

		.outermost {
			display: flex;
			overflow: hidden;

			.outermost-line {
				.top-line {
					width: 10px;
					height: 35px;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}

				.top-line0 {
					width: 10px;
					height: 35px;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 0;
				}

				.bottom-line {
					width: 0;
					height: 5000%;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}
			}
		}

		.hierarchy {
			padding-bottom: 21px;
			overflow: hidden;

			.square-wrap {
				display: flex;
				padding-top: 20px;

				&:last-child {
					margin-bottom: 0;
				}
			}
		}

		.square-wrap {
			.line {
				margin-top: -21px;

				.top-line {
					width: 10px;
					height: 35px;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}

				.bottom-line {
					width: 0;
					height: 5000%;
					border-color: rgba(217, 83, 79, 0.8);
					border-style: solid;
					border-width: 0 0 2px 2px;
				}
			}
		}

		.nodes {
			padding-left: 60px;
			// display: flex;
		}
	}

	.center-line {
		width: 0;
		height: 20px;
		border-color: rgba(217, 83, 79, 0.8);
		border-style: solid;
		border-width: 0 0 2px 2px;
		margin-bottom: -20px;
	}

	.square-style {
		display: flex;
		background-color: #fff;

		&-left {
			display: flex;
			flex-direction: column;

			&-top {
				border: 1px solid #242424;
				border-right: none;
				width: 50px;
				padding: 8px;
				text-align: center;
			}

			&-bottom {
				border: 1px solid #242424;
				border-right: none;
				border-top: none;
				width: 50px;
				padding: 8px;
				text-align: center;
			}
		}

		&-right {
			width: 200px;
			height: auto;
			text-align: center;

			&-top {
				border: 1px solid #242424;
				padding: 8px;
			}

			&-bottom {
				border: 1px solid #242424;
				border-top: none;
				padding: 8px 0;
				cursor: pointer;
			}
		}
	}

	.top-right {
		margin-left: -41px;
	}
}

.top-box-html {
	display: flex;
	width: 100%;
	margin-left: 135px;
	.tox-box-diamond {
		width: 200px;
		height: auto;
		text-align: center;

		.diamond-title {
			border: 1px solid #242424;
			background-color: #4f5b72;
			color: #fff;
			padding: 4px;
			min-height: 28px;
		}

		.diamond-name {
			border: 1px solid #242424;
			border-top: none;
			padding: 4px 0;
			min-height: 28px;
			background-color: #fff;
		}
	}
}

.positionGreen {
	background-color: #6fc9c2;
}

.positionOrange {
	background-color: #f2f09f;
}

.gradualChange {
	background: linear-gradient(to right, #f3ef9f, #6ecac1);
}
</style>

G6不能使用绝对定位,所以处理起来很麻烦。代码写的很繁琐,我已经尽力了。我项目使用了多语言,所以有$t()。如果大家有更好得处理方式可以优化。美少女写这个头发掉了一大把。后面产品这块需要改版,

image.png

大概是这样子。G6不能使用绝对定位就很头大。所以我换了一个插件用法。这个很像思维导图,所以用了这个插件wanglin2.github.io/mind-map-do… 这个是官网链接。写法就简单很多了。

父级 view-org-chart.vue

<script setup lang="ts">
import { Component, h, render } from 'vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import { useLanguage } from '@/hooks/index';

import MindMap from 'simple-mind-map';
import Export from 'simple-mind-map/src/plugins/Export.js';
import ExportPDF from 'simple-mind-map/src/plugins/ExportPDF.js';
import MiniMap from 'simple-mind-map/src/plugins/MiniMap.js';

import { CountColumn, ViewTree, TopNode, SecondNode } from './components';
import { $mitt } from 'itf-common';

import { GetBusinessUnitTree } from '@/api/organization/index';
import { ViewEnum } from './types';

const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const { isCn, isArabic } = useLanguage();
// 使用watch来监听对象
const stringTree: any = ref([]);
const convertValuesToString = (tree: any) => {
	return tree.map((node: any) => {
		const convertedNode = { ...node };
		if (convertedNode.children) {
			convertedNode.children = convertValuesToString(convertedNode.children);
		}
		convertedNode.data = {
			text:
				isCn && convertedNode.nameZh
					? convertedNode.nameZh
					: isArabic && convertedNode.nameAr
					? convertedNode.nameAr
					: convertedNode.nameEn,
			...convertedNode,
		};
		return convertedNode;
	});
};
const addLevels = (nodes: any, level = 0) => {
	return nodes.map((node: any) => {
		node.level = level; // 添加层级标记
		if (node.children && node.children.length > 0) {
			node.children = addLevels(node.children, level + 1);
		}
		return node;
	});
};
const countData: any = ref([]);
const getSelectTree = async () => {
	await GetBusinessUnitTree({
		includeLeader: true,
		includePosition: true,
		id: route.query.buId,
		positionType: route.query.value, // 0名义岗位1实际岗位
	}).then((res) => {
		stringTree.value = addLevels(convertValuesToString(res.data));
		countData.value = [
			{
				color: '#5dc264',
				title: $t('employee.organization.expat'),
				count: res.data[0]?.expatCount,
			},
			{
				color: '#fbbb26',
				title: $t('employee.organization.local'),
				count: res.data[0]?.localCount,
			},
			{
				color: '#ff7a00',
				title: $t('employee.organization.mixed'),
				count: res.data[0]?.mixedCount,
			},
		];
	});
};

let mindMap: any = null;
const mindMapContainerRef = ref();

MindMap.usePlugin(Export);
MindMap.usePlugin(MiniMap);
MindMap.usePlugin(ExportPDF);
MindMap.defineTheme('redSpirit', {
	backgroundColor: '#fff',
	lineColor: '#9ea4b3',
	lineWidth: 1,
	lineMarkerDir: 'end',
	root: {
		fillColor: 'rgb(255, 255, 255)',
		color: 'rgb(255, 233, 157)',
		borderColor: '',
		borderWidth: 0,
		hoverRectColor: 'rgba(211, 58, 21,0)',
		hoverRectRadius: 0,
		paddingY: 0,
	},
	// 二级节点样式
	second: {
		color: 'rgb(211, 58, 21)',
		borderColor: '',
		borderWidth: 2,
		hoverRectColor: 'rgba(211, 58, 21,0)',
		hoverRectRadius: 0,
	},
	// 三级及以下节点样式
	node: {
		borderColor: '#dce0e6',
		color: 'rgb(144, 71, 43)',
		marginY: -25,
		hoverRectColor: 'rgba(211, 58, 21,0)',
		hoverRectRadius: 0,
		marginX: -50,
	},
});

const closeViews = ref<string[]>([]);
const changeView = (views: string[]) => {
	closeViews.value = views;

	mindMap.setData(stringTree.value[0]);
};

const isView = (keys: string[] | string): boolean => {
	if (typeof keys === 'string') {
		return !closeViews.value.includes(keys);
	}
	return keys.every((key) => !closeViews.value.includes(key));
};

let count = 0;
const SecondNodes: any = [];

const initLoading = ref(true);

const init = async () => {
	mindMap = new MindMap({
		el: document.getElementById('mindMapContainer'),
		layout: 'catalogOrganization',
		mousewheelAction: 'zoom',
		mousewheelMoveStep: 50,
		minZoomRatio: 10,
		isUseCustomNodeContent: true,
		isShowExpandNum: false,
		notShowExpandBtn: true,
		alwaysShowExpandBtn: true,
		readonly: true,
		fit: true,
		/**
		 * 性能模式
		 * 即只渲染画布可视区域内的节点,超出的节点不渲染,这样会大幅提高渲染速度,当然同时也会带来一些其他问题,比如:1.当拖动或是缩放画布时会实时计算并渲染未节
		 * 点的节点,所以会带来一定卡顿;2.导出图片、svg、pdf时需要先渲染全部节点,所以会比较慢;3.其他目前未发现的问题
		 */
		// openPerformance: true,
		customCreateNodeContent: (node: any) => {
			let el = document.createElement('div');

			const layerIndex = node?.layerIndex;

			if (layerIndex >= 1) {
				SecondNodes.push(node);
				// 第五层开始,每层增加149px宽度
				if (layerIndex >= 4) {
					el.style.width = `${786 + (149 * layerIndex - 1)}px`;
				} else {
					el.style.width = '786px';
				}
				const isShow = node?.nodeData?.data?.isShow ?? true;
				const positionList = node?.nodeData?.data?.positionList;
				console.log('positionList:', positionList);
				el.style.height = !isShow
					? layerIndex > 1
						? positionList
							? '105px'
							: '65px'
						: '65px'
					: '';
			}
			const ComponentToRender = layerIndex == 0 ? TopNode : SecondNode;

			if (!ComponentToRender) {
				console.warn(`No component found for layerIndex: ${layerIndex}`);
				return el;
			}

			const app = createApp(ComponentToRender, {
				nodeData: node.nodeData.data,
				node: node,
				isViewHandler: isView,
				renderMindMap: renderMindMap,
			});

			app.mount(el);

			return el;
		},
		addContentToHeader: () => {
			const el = document.createElement('div');
			el.className = 'header';
			render(
				h(CountColumn, {
					stringTree: stringTree.value,
					countData: countData.value,
				}),
				el,
			);
			return {
				el,
				cssText: `
				.header{
				padding-top:12px;
				padding-left:12px	
			}
				`,
				height: !isView(ViewEnum.positionEstablishment) ? 0 : 180,
			};
		},
	} as any);

	mindMap.setTheme('redSpirit');

	mindMap.setData(stringTree.value[0]);

	mindMap.on('node_tree_render_end', () => {
		if (mindMapContainerRef.value) {
			// 选中 .smm-node 但仅包含 .node 子元素的
			const matchingNodes = Array.from(
				mindMapContainerRef.value.querySelectorAll('.smm-node'),
			).filter((node: any) => {
				const secondNode = node.querySelector('.secondNode');
				return (
					secondNode && Number(secondNode.getAttribute('data-layerindex')) > 2
				);
			});

			if (matchingNodes && matchingNodes.length) {
				matchingNodes.forEach((ele: any) => {
					const transformValue = ele.getAttribute('transform');
					const match = transformValue.match(
						/matrix\(([^,]+),([^,]+),([^,]+),([^,]+),([^,]+),([^,]+)\)/,
					);
					if (match) {
						let x = parseFloat(match[5]); // 获取 x 坐标
						let y = parseFloat(match[6]); // 获取 y 坐标
						const layerIndex = ele
							.querySelector('.secondNode')
							?.getAttribute('data-layerindex');

						x -= 393 * (layerIndex - 2);
						ele.setAttribute('transform', `matrix(1,0,0,1,${x},${y})`);
					}
				});
			}
		}

		if (count == 0) {
			mindMap.view.fit();
			mindMap.view.translateY(25);

			initLoading.value = false;
		}
		if (count == 0) {
			count++;
		}

		SecondNodes.filter(
			(node: any) => node?.children && node?.children.length,
		).forEach((node: any) => {
			node?.removeLine();
		});
	});

	return mindMap;
};

onMounted(async () => {
	await getSelectTree();
	await init();
});

onUnmounted(() => {
	$mitt.off('node:data-change');
});

const renderMindMap = () => {
	if (mindMap) mindMap?.render();
};

const downloading = ref(false);
const downloadDataUrl = (dataUrl: string, filename: string) => {
	const link = document.createElement('a');
	link.href = dataUrl;
	link.setAttribute('download', filename);
	document.body.appendChild(link);
	link.click();
	document.body.removeChild(link);
};

const exportFile = async (type: 'pdf' | 'svg') => {
	if (mindMap) {
		try {
			downloading.value = true;

			let result: any = null;
			if (type === 'pdf') {
				result = await mindMap.doExport.pdf();
			} else {
				result = await mindMap.doExport.svg('', false, ``);
			}
			if (result) {
				downloadDataUrl(
					result,
					`Org Chart-${stringTree.value[0].nameEn}.${type}`,
				);
			}
		} catch (e: any) {
			console.log('Error:', e);
		} finally {
			downloading.value = false;
		}
	}
};
</script>

<template>
	<div class="org-Map">
		<div v-if="initLoading" class="loading">
			<a-spin :spinning="initLoading"></a-spin>
		</div>

		<div class="operation">
			<div class="other">
				<div
					v-if="isView(ViewEnum.positionEstablishment)"
					class="count-column"
					:class="isArabic ? 'arab' : 'general'"
				>
					<div class="row">
						<div class="key">
							<div class="box">
								<div class="block green"></div>
								{{ $t('employee.organization.expat') }}
							</div>
						</div>
						<div class="value">{{ stringTree[0]?.expatCount }}</div>
					</div>
					<div class="row">
						<div class="key">
							<div class="box">
								<div class="block yellow"></div>
								{{ $t('employee.organization.local') }}
							</div>
						</div>
						<div class="value">{{ stringTree[0]?.localCount }}</div>
					</div>
					<div class="row">
						<div class="key">
							<div class="box">
								<div class="block orange"></div>
								{{ $t('employee.organization.mixed') }}
							</div>
						</div>
						<div class="value">{{ stringTree[0]?.mixedCount }}</div>
					</div>

					<div class="row total">
						<div class="key">{{ $t('employee.organization.total') }}</div>
						<div class="value">{{ stringTree[0]?.headcount }}</div>
					</div>
				</div>

				<view-tree @changeView="changeView"></view-tree>
			</div>
			<div class="export">
				<a-button
					:icon="h(DownloadOutlined)"
					@click="exportFile('svg')"
					:loading="downloading"
					>SVG</a-button
				>
				<a-button
					:icon="h(DownloadOutlined)"
					@click="exportFile('pdf')"
					:loading="downloading"
					>PDF</a-button
				>
			</div>
		</div>
		<div id="mindMapContainer" ref="mindMapContainerRef"></div>
	</div>
</template>

<style scoped lang="scss">
.org-Map {
	margin-top: 28px;
	padding: 0;
	width: 100%;
	height: calc(100% - 48px);
	display: flex;
	flex-direction: column;
	position: relative;
	background: #fff;
	.loading {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		z-index: 3;
	}
	.general {
		position: absolute;
		left: 24px;
	}
	.arab {
		right: 24px;
	}
	.count-column {
		user-select: none;
		top: 24px;
		border-radius: 2px;
		border: 1px solid var(--Neutral-700, #dae0e6);
		margin-inline-end: 24px;
		height: fit-content;
		.row {
			width: 236px;
			height: 40px;
			display: flex;
			border-bottom: 1px solid var(--Neutral-700, #dae0e6);
			color: #66809e;
			font-family: Inter;
			font-size: 12px;
			font-weight: 500;
			background: #fff;
			&:last-child {
				border-bottom: none;
			}
			.key {
				flex: 0.5;
				display: flex;
				align-items: center;
				justify-content: center;
				.box {
					width: 100%;
					display: flex;
					padding-inline-start: 25%;
				}
				.block {
					width: 16px;
					height: 16px;
					border-radius: 4px;
					margin-inline-end: 8px;
					&.green {
						background: #5dc264;
					}
					&.yellow {
						background: #fbbb26;
					}
					&.orange {
						background: #ff7a00;
					}
				}
			}
			.value {
				flex: 0.5;
				text-align: center;
				// line-height: 40px;
				padding: 12px 0;
				border-inline-start: 1px solid var(--Neutral-700, #dae0e6);
			}
		}
		.total {
			background: #f2f4f7;
		}
	}
	.operation {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		display: flex;
		justify-content: space-between;
		padding: 24px;
		pointer-events: none;
		.other {
			margin-left: 256px;
			pointer-events: all;
			display: flex;
		}
		.export {
			pointer-events: all;
			::v-deep {
				.ant-btn {
					border: 1px solid var(--Neutral-700, #dae0e6);
					background: #fafbfc;
					color: #66809e;
					font-family: Inter;
					font-size: 12px;
					font-weight: 700;
					margin-left: 20px;
				}
			}
		}
	}
	#mindMapContainer {
		flex: 1;
	}
	#mindMapContainer * {
		margin: 0;
		padding: 0;
	}
}
</style>

type.js

export enum ViewEnum {
	all = '0-0',
	position = '0-0-0',
	positionName = '0-0-0-0',
	positionEstablishment = '0-0-0-1',
	headcount = '0-0-0-2',
	employeeCount = '0-0-0-3',
	employeeWorkPattern1 = '0-0-0-4',
	vacantPosition = '0-0-0-5',
	employee = '0-0-1',
	employeeName = '0-0-1-0',
	employeeWorkPattern2 = '0-0-1-1',
}

TopNode.vue

<script setup lang="ts">
import { $mitt } from 'itf-common';
import { ViewEnum } from '../types';
import { EstablishmentEnum } from '@/types/recruitment.ts';

interface IProps {
	nodeData: any;
	node: any;
	isViewHandler: Function;
}

const props = withDefaults(defineProps<IProps>(), {});

const isView = (keys: string[] | string): boolean => {
	return props.isViewHandler(keys);
};

const hasChildren = ref(true);

onMounted(() => {
	requestAnimationFrame(() => {
		hasChildren.value = props.node.children && props.node.children.length;
	});
});

const isExpand = ref(props?.nodeData?.isShow ?? true);
const toggleExpand = () => {
	isExpand.value = !isExpand.value;
	props.node?.setData({
		isShow: isExpand.value,
	});
	const childNodes = props.node?.children ?? [];
	childNodes.forEach((node: any) => {
		node.setData({
			isShow: isExpand.value,
		});
	});

	if (!isExpand.value) {
		props.node?.removeLine();
	} else {
		props.node?.renderLine();
	}

	$mitt.emit('node:data-change');
};

const getIfDisplayColor = (workTerms: any) => {
	if (!isView(ViewEnum.positionEstablishment)) {
		return '#66809e';
	}
	const colors = {
		[EstablishmentEnum.local]: '#5dc264',
		[EstablishmentEnum.expat]: '#fbbb26',
		[EstablishmentEnum.mixed]: '#ff7a00',
	};

	return colors[workTerms] || '#66809e';
};

const filteredEmployeeList = (employeeList: any) => {
	const list = !isView(ViewEnum.vacantPosition)
		? employeeList.filter((employee) => employee.fullName !== 'TBC')
		: employeeList;
	return list;
};
</script>

<template>
	<div
		class="topNode"
		style="user-select: none; padding-top: 20px"
		:style="{
			'padding-bottom': nodeData?.positionList?.length ? '40px' : '0px',
			'margin-left': nodeData?.positionList?.length ? '253px' : '0px',
			width: !nodeData?.positionList?.length && hasChildren ? '358px' : 'auto',
		}"
	>
		<div class="top" style="display: flex">
			<div
				style="
					width: 72px;
					height: 32px;
					border: 1px solid #dae0e6;
					background: #ffffff;
					font-size: 12px;
					font-weight: 500;
					color: #66809e;
					border-radius: 8px 0px 0px 8px;
					line-height: 32px;
					text-align: center;
				"
			>
				<span>{{ nodeData.employeeCount }}</span
				>/<span>{{ nodeData.headcount }}</span>
			</div>
			<div style="display: flex; flex-direction: column">
				<div
					style="
						width: 212px;
						height: 56px;
						background: #313f51;
						font-size: 14px;
						font-weight: 700;
						color: #fff;
						border-radius: 0px 8px 4px 8px;
						text-align: center;
						display: flex;
						align-items: center;
						justify-content: center;
						position: relative;
						line-height: 20px;
					"
				>
					{{ nodeData.text }}
				</div>
				<div
					v-if="hasChildren || nodeData.positionList?.length"
					style="
						width: 100%;
						display: flex;
						justify-content: center;
						margin-top: -9px;
						z-index: 2;
					"
					:style="{
						'margin-left': nodeData.positionList?.length ? '-39px' : '',
					}"
				>
					<div
						style="
							width: 18px;
							height: 18px;
							border-radius: 50%;
							border: 1px solid #dae0e6;
							color: #6f7f9c;
							background: #fff;
							display: flex;
							align-items: center;
							justify-content: center;
							cursor: pointer;
						"
						@click="toggleExpand"
					>
						{{ isExpand ? '-' : '+' }}
					</div>
				</div>
			</div>
		</div>
		<div
			v-if="
				nodeData.positionList && nodeData.positionList.length > 0 && isExpand
			"
			class="position"
			style="display: flex; flex-direction: column"
		>
			<div
				v-for="item in nodeData.positionList"
				:key="item"
				style="display: flex"
			>
				<div
					style="
						width: 75px;
						border-left: 1px solid #9ea4b3;
						margin-left: 138.5px;
						margin-bottom: -40px;
					"
				>
					<div style="border-top: 1px solid #9ea4b3; margin-top: 70px"></div>
				</div>

				<div
					class="node"
					style="display: flex; user-select: none; margin-top: 40px"
				>
					<div
						style="
							border-top: 1px solid #dae0e6;
							border-bottom: 1px solid #dae0e6;
							border-left: 1px solid #dae0e6;
							background: #ffffff;
							font-family: Inter;
							font-size: 12px;
							font-weight: 500;
							color: #66809e;
							border-radius: 8px 0px 0px 8px;
							display: flex;
							flex-direction: column;
							width: 68px;
							height: fit-content;
						"
						v-if="
							isView([ViewEnum.employeeCount, ViewEnum.employee]) ||
							isView(ViewEnum.employeeWorkPattern1)
						"
					>
						<div
							v-if="
								isView([ViewEnum.employeeCount, ViewEnum.employee]) &&
								item.employeeList.length > 0
							"
							style="
								border-bottom: 1px solid #dae0e6;
								text-align: center;
								line-height: 30px;
								height: 30px;
							"
							:style="{
								'border-right':
									!isView(ViewEnum.positionName) && !isView(ViewEnum.employee)
										? '1px solid #dae0e6'
										: 'none',
							}"
						>
							{{ item.employeeCount || 0 }} /
							{{ (item.employeeList && item.employeeList.length) || 0 }}
						</div>
						<div
							v-if="isView(ViewEnum.employeeWorkPattern1)"
							style="text-align: center; line-height: 30px; height: 30px"
							:style="{
								'border-right':
									!isView(ViewEnum.employee) ||
									!isView(ViewEnum.employeeCount) ||
									!item.employeeList.length
										? '1px solid #dae0e6'
										: 'none',
							}"
						>
							{{ item.workPatternName || '--' }}
						</div>
					</div>
					<div
						style="
							width: 250px;
							font-family: Inter;
							font-size: 14px;
							font-weight: 700;
							color: #fff;
							border-radius: 0px 8px 8px 8px;
						"
					>
						<div
							v-if="isView(ViewEnum.positionName)"
							style="
								width: 250px;
								padding: 8px 18px 8px 18px;
								gap: 10px;
								border-radius: 0px 8px 0px 0px;
								opacity: 0px;
								background: #f3f4f7;
								display: flex;
								align-items: center;
								border: 1px solid var(--Neutral-700, #dae0e6);
							"
							:style="{
								color: getIfDisplayColor(item.workTerms),
							}"
						>
							<div
								style="height: 16px; width: 4px; border-radius: 14px"
								:style="{
									background: getIfDisplayColor(item.workTerms),
								}"
							></div>
							<span> {{ item.name }} </span>
						</div>
						<div
							v-if="isView(ViewEnum.employee) && item.employeeList.length > 0"
							style="
								background: #fff;
								border-radius: 0 0 8px 8px;
								padding: 0px 18px;
								font-weight: 400;
								border-left: 1px solid #dae0e6;
								border-right: 1px solid #dae0e6;
								border-bottom: 1px solid #dae0e6;
							"
							:style="{
								'border-top':
									!isView(ViewEnum.position) || !isView(ViewEnum.positionName)
										? '1px solid #dae0e6'
										: 'none',
							}"
						>
							<div style="color: #313f51; padding-bottom: 6px">
								<div
									class="employee-item"
									v-for="(employee, index) in filteredEmployeeList(
										item.employeeList,
									)"
									:key="index"
									style="
										border-bottom: 1px solid var(--Neutral-700, #dae0e6);
										padding: 12px 0px;
										box-sizing: content-box;
									"
									:style="{
										borderBottom:
											index + 1 ==
											filteredEmployeeList(item.employeeList).length
												? 'none'
												: '1px solid var(--Neutral-700, #dae0e6)',
									}"
								>
									<template v-if="isView(ViewEnum.employeeName)">
										<span
											:style="{
												color: employee.fullName === 'TBC' ? '#a7b1c2' : '',
											}"
											>{{ employee.fullName }}</span
										>
									</template>
									<template
										v-if="
											isView(ViewEnum.employeeWorkPattern2) &&
											employee.fullName !== 'TBC'
										"
										>({{ employee.workPatternName || '--' }})</template
									>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>
		</div>
	</div>
</template>

<style scoped lang="scss"></style>

SecondNode.vue

<script setup lang="ts">
import { $mitt } from 'itf-common';
import { ViewEnum } from '../types';
import { EstablishmentEnum } from '@/types/recruitment.ts';

interface IProps {
	nodeData: any;
	node: any;
	isViewHandler: Function;
	renderMindMap: Function;
}

const props = withDefaults(defineProps<IProps>(), {});

const isView = (keys: string[] | string): boolean => {
	return props.isViewHandler(keys);
};

const hasChildren = ref(true);
const isLastChild = ref(false);
const parentNodeIsLastChild = ref(false);

onMounted(() => {
	requestAnimationFrame(() => {
		hasChildren.value = props.node.children && props.node.children.length;

		const index = props.node?.getIndexInBrothers() + 1;
		const parentChildLength = props.node?.parent?.children?.length;
		isLastChild.value = index == parentChildLength;

		const parentNodeIndex = props.node?.parent?.getIndexInBrothers() + 1;
		if (parentNodeIndex) {
			const grandpaChildLength = props.node?.parent?.parent?.children?.length;
			parentNodeIsLastChild.value = parentNodeIndex == grandpaChildLength;
		}
	});
});

const isExpand = ref(props?.nodeData?.isShow ?? true);
const toggleExpand = () => {
	isExpand.value = !isExpand.value;
	props.node?.setData({
		isShow: isExpand.value,
	});
	const childNodes = props.node?.children ?? [];
	childNodes.forEach((node: any) => {
		node.setData({
			isShow: isExpand.value,
		});
	});

	$mitt.emit('node:data-change');
	props?.renderMindMap();
};

const parentNodeExpand = ref(
	props.node?.parent?.nodeData?.data?.isShow ?? true,
);
$mitt.on('node:data-change', () => {
	const parentNode = props.node?.parent;
	if (parentNode) {
		const parentNodeData = parentNode?.getData();
		const { isShow = true } = parentNodeData;
		parentNodeExpand.value = isShow;
	}
});

const getIfDisplayColor = (workTerms: any) => {
	if (!isView(ViewEnum.positionEstablishment)) {
		return '#66809e';
	}
	const colors = {
		[EstablishmentEnum.local]: '#5dc264',
		[EstablishmentEnum.expat]: '#fbbb26',
		[EstablishmentEnum.mixed]: '#ff7a00',
	};

	return colors[workTerms] || '#66809e';
};
const filteredEmployeeList = (employeeList: any) => {
	const list = !isView(ViewEnum.vacantPosition)
		? employeeList.filter((employee) => employee.fullName !== 'TBC')
		: employeeList;
	return list;
};
</script>

<template>
	<div style="display: flex">
		<template v-if="node.layerIndex > 2">
			<div
				:style="{
					width: `${148.5 * (node.layerIndex - 2)}px`,
					'border-left': parentNodeIsLastChild
						? '1px solid transparent'
						: '1px solid #9ea4b3',
				}"
			></div>
		</template>
		<div
			v-if="parentNodeExpand"
			class="secondNode"
			:data-layerIndex="node.layerIndex"
			style="user-select: none; padding-bottom: 40px"
			:style="{
				'margin-left': node.layerIndex == 1 ? '245px' : '0px',
				'border-left':
					!isLastChild && node.layerIndex > 1 ? '1px solid #9ea4b3' : 'none',
				'padding-bottom': nodeData.positionList ? '40px' : '0',
			}"
		>
			<div class="top" style="display: flex">
				<div
					v-if="isView(ViewEnum.headcount)"
					style="
						width: 36px;
						height: 32px;
						border: 1px solid #dae0e6;
						background: #ffffff;
						font-family: Inter;
						font-size: 12px;
						font-weight: 500;
						color: #66809e;
						border-radius: 8px 0px 0px 8px;
						display: flex;
						align-items: center;
						justify-content: center;
					"
				>
					--
				</div>
				<div style="display: flex; flex-direction: column">
					<div
						style="
							width: 211px;
							height: 56px;
							background: #788ca3;
							font-family: Inter;
							font-size: 14px;
							font-weight: 700;
							color: #fff;
							border-radius: 0px 8px 8px 8px;
							text-align: center;
							display: flex;
							align-items: center;
							justify-content: center;
							line-height: 20px;
						"
					>
						{{ nodeData.text }}
					</div>
					<div
						v-if="hasChildren || nodeData.positionList?.length"
						style="
							width: 100%;
							display: flex;
							flex-direction: column;
							align-items: center;
							justify-content: center;
							margin-top: -9px;
							margin-left: 7px;
							z-index: 2;
						"
					>
						<div
							style="
								width: 18px;
								height: 18px;
								border-radius: 50%;
								border: 1px solid #dae0e6;
								color: #6f7f9c;
								background: #fff;
								display: flex;
								align-items: center;
								justify-content: center;
								cursor: pointer;
							"
							@click="toggleExpand"
						>
							{{ isExpand ? '-' : '+' }}
						</div>
						<div
							v-if="!nodeData.positionList"
							style="border-left: 1px solid #9ea4b3; height: 40px"
						></div>
					</div>
				</div>
			</div>
			<div
				v-if="
					nodeData.positionList && nodeData.positionList.length > 0 && isExpand
				"
				class="position"
				style="display: flex; flex-direction: column"
			>
				<div
					v-for="(item, index) in nodeData.positionList"
					:key="item"
					style="display: flex"
				>
					<div
						style="width: 75px; margin-left: 148px; margin-bottom: -40px"
						:style="{
							'border-left':
								nodeData.positionList.length == index + 1 && !hasChildren
									? 'none'
									: '1px solid #9ea4b3',
						}"
					>
						<div
							style="border-bottom: 1px solid #9ea4b3; height: 70px"
							:style="{
								'border-left':
									(nodeData.positionList.length == index + 1 &&
										node.layerIndex > 1) ||
									(index + 1 == nodeData.positionList.length && !hasChildren)
										? '1px solid #9ea4b3'
										: 'none',
							}"
						></div>
					</div>

					<div
						class="node"
						style="display: flex; user-select: none; margin-top: 40px"
					>
						<div
							style="
								border-top: 1px solid #dae0e6;
								border-bottom: 1px solid #dae0e6;
								border-left: 1px solid #dae0e6;
								background: #ffffff;
								font-family: Inter;
								font-size: 12px;
								font-weight: 500;
								color: #66809e;
								border-radius: 8px 0px 0px 8px;
								display: flex;
								flex-direction: column;
								width: 68px;
								height: fit-content;
							"
							v-if="
								isView([ViewEnum.employeeCount, ViewEnum.employee]) ||
								isView(ViewEnum.employeeWorkPattern1)
							"
						>
							<div
								v-if="
									isView([ViewEnum.employeeCount, ViewEnum.employee]) &&
									item.employeeList.length > 0
								"
								style="
									border-bottom: 1px solid #dae0e6;
									text-align: center;
									line-height: 30px;
									height: 30px;
								"
								:style="{
									'border-right':
										!isView(ViewEnum.positionName) && !isView(ViewEnum.employee)
											? '1px solid #dae0e6'
											: 'none',
								}"
							>
								{{ item.employeeCount || 0 }} /
								{{ (item.employeeList && item.employeeList.length) || 0 }}
							</div>
							<div
								v-if="isView(ViewEnum.employeeWorkPattern1)"
								style="text-align: center; line-height: 30px; height: 30px"
								:style="{
									'border-right':
										!isView(ViewEnum.employee) ||
										!isView(ViewEnum.employeeCount) ||
										!item.employeeList.length
											? '1px solid #dae0e6'
											: 'none',
								}"
							>
								{{ item.workPatternName || '--' }}
							</div>
						</div>
						<div
							style="
								width: 250px;
								font-family: Inter;
								font-size: 14px;
								font-weight: 700;
								color: #fff;
								border-radius: 0px 8px 8px 8px;
							"
						>
							<div
								v-if="isView(ViewEnum.positionName)"
								style="
									width: 250px;
									padding: 8px 18px 8px 18px;
									gap: 10px;
									border-radius: 0px 8px 0px 0px;
									opacity: 0px;
									background: #f3f4f7;
									display: flex;
									align-items: center;
									border: 1px solid #dae0e6;
									box-sizing: border-box;
								"
								:style="{
									color: getIfDisplayColor(item.workTerms),
								}"
							>
								<div
									style="height: 16px; width: 4px; border-radius: 14px"
									:style="{
										background: getIfDisplayColor(item.workTerms),
									}"
								></div>
								<span> {{ item.name }} </span>
							</div>
							<div
								v-if="isView(ViewEnum.employee) && item.employeeList.length > 0"
								style="
									background: #fff;
									border-radius: 0 0 8px 8px;
									padding: 0px 18px;
									font-weight: 400;
									border-left: 1px solid #dae0e6;
									border-right: 1px solid #dae0e6;
									border-bottom: 1px solid #dae0e6;
								"
								:style="{
									'border-top':
										!isView(ViewEnum.position) || !isView(ViewEnum.positionName)
											? '1px solid #dae0e6'
											: 'none',
								}"
							>
								<div style="color: #313f51; padding-bottom: 6px">
									<div
										class="employee-item"
										v-for="(employee, index) in filteredEmployeeList(
											item.employeeList,
										)"
										:key="index"
										style="
											border-bottom: 1px solid var(--Neutral-700, #dae0e6);
											padding: 12px 0px;
											box-sizing: content-box;
										"
										:style="{
											borderBottom:
												index + 1 ===
												filteredEmployeeList(item.employeeList).length
													? 'none'
													: '1px solid var(--Neutral-700, #dae0e6)',
										}"
									>
										<template v-if="isView(ViewEnum.employeeName)">
											<span
												:style="{
													color: employee.fullName === 'TBC' ? '#a7b1c2' : '',
												}"
												>{{ employee.fullName }}</span
											>
										</template>
										<template
											v-if="
												isView(ViewEnum.employeeWorkPattern2) &&
												employee.fullName !== 'TBC'
											"
											>({{ employee.workPatternName || '--' }})</template
										>
									</div>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>
		</div>
	</div>
</template>

<style scoped lang="scss"></style>

CountColumn.vue

<script setup lang="ts">
interface IProps {
	stringTree: any;
	countData: any;
}

const props = withDefaults(defineProps<IProps>(), {});

const stringTree = computed(() => {
	return props?.stringTree ?? 0;
});
const countData = computed(() => {
	return props?.countData;
});
</script>

<template>
	<div
		style="
			width: 236px;
			height: 163px;
			border-radius: 2px;
			border: 1px solid #dae0e6;
		"
	>
		<div
			style="
				width: 236px;
				height: 40px;
				display: flex;
				color: #66809e;
				font-family: Inter;
				font-size: 12px;
				font-weight: 500;
				background: #fff;
				border-bottom: 1px solid #dae0e6;
			"
			v-for="(item, index) in countData"
			:key="index"
		>
			<div
				style="
					flex: 0.5;
					display: flex;
					align-items: center;
					justify-content: center;
				"
			>
				<div style="width: 50%; display: flex">
					<div
						style="
							width: 16px;
							height: 16px;
							border-radius: 4px;
							margin-inline-end: 8px;
						"
						:style="{ background: item.color }"
					></div>
					{{ item.title }}
				</div>
			</div>
			<div
				style="
					flex: 0.5;
					text-align: center;
					padding: 12px 0;
					border-left: 1px solid #dae0e6;
				"
			>
				{{ item.count }}
			</div>
		</div>
		<div
			style="
				width: 236px;
				height: 40px;
				display: flex;
				color: #66809e;
				font-family: Inter;
				font-size: 12px;
				font-weight: 500;
				background: #fff;
				background: #f2f4f7;
			"
		>
			<div
				style="
					flex: 0.5;
					display: flex;
					align-items: center;
					justify-content: center;
				"
			>
				Total
			</div>
			<div
				style="
					flex: 0.5;
					text-align: center;
					padding: 12px 0;
					border-left: 1px solid #dae0e6;
				"
			>
				{{ stringTree[0]?.headcount }}
			</div>
		</div>
	</div>
</template>

<style scoped lang="scss"></style>

把代码都贴出来了。仅仅作为个人记录篇。本人比较菜,这样详细记录防止我以后看不懂。