不常见得组织架构图,用了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()。如果大家有更好得处理方式可以优化。美少女写这个头发掉了一大把。后面产品这块需要改版,
大概是这样子。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>
把代码都贴出来了。仅仅作为个人记录篇。本人比较菜,这样详细记录防止我以后看不懂。