
代码中使用到的组件和icon 是arco.design ,项目中遇到一个这样的需求,我使用了gojs实现了这个交互,但是看到有人使用div自己写交互还是值得借鉴,也值得收藏以方便自己以后学习、巩固
原始数据
export default [
{ id: '1', parentId: null, name: '叶子节点', remark: '' },
{ id: '1_1', parentId: '1', name: '一级1', remark: '' },
{ id: '1_1_1', parentId: '1_1', name: '三级1', remark: '' },
{ id: '1_1_2', parentId: '1_1', name: '三级2', remark: '' },
{ id: '1_2', parentId: '1', name: '一级2', remark: '' },
{ id: '1_2_1', parentId: '1_2', name: '三级3', remark: '' },
{ id: '1_2_2', parentId: '1_2', name: '三级4', remark: '' },
]
父组件中使用时,调用组件,里面有多余编辑,删除,查看,新增弹窗的代码就没有贴出来了
const getDefaultNode = () => {
return {
name: '',
remark: '',
caliber: '',
unit: '',
parentId: ''
}
}
class Test extends React.Component<any, State> {
constructor(props: any, context: any) {
super(props);
this.state = {
loading: true,
loadingTip: '数据加载中...',
drawerLoading: false,
drawerLoadingTip: '',
treeMap:{},
expandMap: {},
editType: null,
drawerVisible: false,
drawerTitle: '',
currentNode: null,
modelId: null,
initialValues: {}
};
this.baseInfoFormRef = React.createRef();
this.formRef = React.createRef();
this.seniorRef = React.createRef();
}
getNodeMap = () => {
}
addNode = (node) => {
const n = getDefaultNode()
this.setState(() => {
return {
currentParentNode: node,
currentNode: n,
drawerTitle: '新增',
drawerVisible: true,
editType: 'add',
initialValues: n
}
}, () => {
this.formRef?.current?.clearFields();
this.formRef?.current?.setFieldsValue(n);
})
}
editNode = (node) => {
let dataUsage = [];
if (node.dataUsage && typeof node.dataUsage === 'string') {
dataUsage = node.dataUsage.split(',').filter(val => {
return this.state.dataUsageLeafRelaMap[val];
}).map(val => {
return this.state.dataUsageLeafRelaMap[val]
})
}
let n = {...node, dataUsage};
this.setState(() => {
return {
modelId: node.id,
currentNode: node,
drawerTitle: '编辑',
drawerVisible: true,
editType: 'edit',
initialValues: {...n}
}
}, () => {
this.formRef?.current?.clearFields();
this.formRef?.current?.setFieldsValue({...n});
})
}
viewNode = (node) => {
console.log('node',node)
}
deleteNode = (node) => {
const {
treeMap
} = this.state;
this.setState({
loading: true,
loadingTip: '删除中...'
})
deleteNode(node.id).then(res => {
if (res.data && res.data.success) {
const children = treeMap[node.parentId].children;
const index = children.indexOf(node.id)
children.splice(index, 1);
treeMap[node.parentId].children = children;
delete treeMap[node.id];
this.setState({
loading: false,
treeMap
})
Message.success('删除成功!');
return;
}
throw 'err';
}).catch(err => {
Message.success('删除失败!');
})
}
handleSaveBaseInfo = () => {
const {
treeMap,
editType,
currentNode,
currentParentNode
} = this.state;
if (editType === 'add') {
this.setState({
drawerLoading: true,
drawerLoadingTip: '新建中...',
});
let node = {
...getDefaultNode(),
...this.baseInfoFormRef.current.getValue(),
parentId: currentParentNode.id,
modelId: this.props.rootId
}
node.dataUsage = Array.isArray(node.dataUsage) ? node.dataUsage.map(arr => arr[arr.length - 1]).join(',') : '';
createNode(node).then(res => {
if (res.data && res.data.success) {
const n = res.data.data;
n.children = [];
treeMap[n.id] = n;
treeMap[n.parentId].children.push(n.id);
this.setState({
drawerLoading: false,
drawerVisible: false,
treeMap
})
Message.success('新建成功!');
return;
}
throw 'err';
}).catch(err => {
Message.success('新建失败!');
this.setState({
drawerLoading: false
})
})
} else if (editType === 'edit') {
const node = {
...currentNode,
...this.baseInfoFormRef.current.getValue()
};
updateNodeInfo(node).then(res => {
if (res.data && res.data.success) {
treeMap[node.id] = node;
this.setState({
drawerLoading: false,
drawerVisible: false,
treeMap
})
Message.success('更新成功!');
return;
}
throw 'err';
}).catch(err => {
Message.success('更新失败!');
this.setState({
drawerLoading: false
})
})
}
}
handleSave = async () => {
const valid = await this.baseInfoFormRef.current.validate();
if (!valid) {
return;
}
const {
modelId,
editType
} = this.state;
this.setState({
drawerLoading: true,
drawerLoadingTip: '更新中...',
});
let v = true;
if (modelId === '320885092937818118' && editType === 'edit') {
await updateCPI(this.seniorRef.current.getValue()).then(res => {
if (res?.data?.success) {
return;
}
throw 'err';
}).catch(err => {
v = false;
Message.success('更新失败!');
this.setState({
drawerLoading: false
})
})
}
if (modelId === '320885092937818115' && editType === 'edit') {
await updateGI(this.seniorRef.current.getValue()).then(res => {
if (res?.data?.success) {
return;
}
throw 'err';
}).catch(err => {
v = false;
Message.success('更新失败!');
this.setState({
drawerLoading: false
})
})
}
if (v) {
this.handleSaveBaseInfo();
}
return;
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.rootId !== this.props.rootId && this.props.rootId) {
this.setState(() => {
return {
loading: false,
treeMap: this.props.treeMap
}
}, () => {
})
}
}
componentDidMount() {
console.log('this.props.',this.props)
let { mockTreeData ,rootId}=this.props;
let treeMap_ = {};
mockTreeData.forEach(n => {
if (!n.parentId) {
rootId = n.id;
}
treeMap_[n.id] = {
...n,
children: []
}
});
mockTreeData.forEach(n => {
if (n.parentId && treeMap_[n.parentId]) {
treeMap_[n.parentId].children.push(n.id);
}
})
this.setState({
treeMap:treeMap_,
rootId:rootId,
})
}
render() {
const {
loading,
loadingTip,
drawerLoading,
drawerLoadingTip,
treeMap,
expandMap,
currentNode,
drawerTitle,
drawerVisible,
editType,
initialValues,
modelId
} = this.state;
const {
rootId
} = this.props;
return ( <div
className="valuation-model-diagram">
{rootId && Object.keys(treeMap).length > 0 && (
<DiagramNode
node={treeMap[rootId]}
lvl={0}
hasNext={false}
lines={[]}
diagram={this}
hidden={expandMap[rootId] === false}
/>
)}
</div>
)
}
}
export default connect(state => ({
rootId: '1',
mockTreeData:[
{ id: '1', parentId: null, name: '叶子节点', remark: '' },
{ id: '1_1', parentId: '1', name: '一级1', remark: '' },
{ id: '1_1_1', parentId: '1_1', name: '三级1', remark: '' },
{ id: '1_1_2', parentId: '1_1', name: '三级2', remark: '' },
{ id: '1_2', parentId: '1', name: '一级2', remark: '' },
{ id: '1_2_1', parentId: '1_2', name: '三级3', remark: '' },
{ id: '1_2_2', parentId: '1_2', name: '三级4', remark: '' },
],
}))(Test);
定义组件DiagramNode.tsx
import React, { useEffect } from 'react';
import { Popconfirm, } from '@arco-design/web-react';
import { IconEye, IconDelete, IconAddObject, } from 'icon';//自定义库的icon
import modelPng from './imgs/model.png';//自定义库的png
import './style/index.scoped.less';
class DiagramNode extends React.Component<any, State> {
constructor(props: any, context: any) {
super(props);
this.state = {
};
}
render() {
const {node, lvl, hasNext, lines, diagram, hidden} = this.props;
const expanded = !(diagram.state.expandMap[node.id] === false);
const canDelete = diagram.props.rootId !== node.id && node.children.length === 0 && node.canDel !== 0;
let realCanDelete = canDelete;
let deleteTip = '您确认要删除?';
let nodeObj = diagram.state.treeMap[node.id];
if (nodeObj.children.length > 0) {
deleteTip = '请先删除!';
realCanDelete = false;
}
if (!nodeObj.parentId) {
deleteTip = '不允许删除!';
realCanDelete = false;
}
const Node = (
<>
<div
className={[
"tree-node",
`lvl-${lvl}`,
hidden ? 'hidden' : '',
Array.isArray(node.children) && node.children.length > 0 ? 'has-children' : ''
].join(' ')}>
{lvl > 1 && <div
className="indent-block-group">
{Array(lvl - 1).fill(null).map((item, index) => {
return (
<div
className={[
"indent-block",
(lines[index] === '1' || index === lvl - 2) ? 'pre-line' : ''
].join(" ")}>
</div>
)
})}
</div>}
<div
className={[
"cost-model-card",
lvl === 0 ? 'root' : '',
].join(' ')}>
<div className="title-container">
{lvl === 0 && <div
className="icon">
<img src={modelPng}/>
</div>}
<div
className="text">
{node.name}
</div>
</div>
<div className="oper-group-container">
<div className="oper-group-content">
<div
className="oper-left">
<IconEye
onClick={() => {
diagram.viewNode(node);
}}/>
<IconEye
onClick={() => {
diagram.editNode(node);
}}/>
{canDelete && (
<Popconfirm
focusLock
title={deleteTip}
onOk={() => {
if (realCanDelete) {
diagram.deleteNode(node);
}
}}
onCancel={() => {
}}>
<IconDelete
style={{
color: 'var(--color-red-6)'
}}/>
</Popconfirm>
)}
</div>
<div
className="split">
</div>
<div
className="oper-right">
<IconAddObject
style={{
color: 'var(--color-primary-6)'
}}
onClick={() => {
diagram.addNode(node);
}}/>
</div>
</div>
</div>
{Array.isArray(node.children) && node.children.length > 0 && (
<div
className="expand-btn"
onClick={() => {
let expandObj = {};
expandObj[node.id] = !expanded;
diagram.setState({
expandMap: {
...diagram.state.expandMap,
...expandObj
}
})
}}>
{expanded ? (
<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" viewBox="0 0 7 7" fill="none">
<path d="M0 3.5 L 7 3.5" stroke-width="1.5" stroke="#4D5E7D"/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="7" height="7" viewBox="0 0 7 7" fill="none">
<path d="M3.5 0 L 3.5 7" stroke-width="1.5" stroke="#4D5E7D"/>
<path d="M0 3.5 L 7 3.5" stroke-width="1.5" stroke="#4D5E7D"/>
</svg>
)}
</div>
)}
</div>
</div>
</>
)
const NodeChildren = (
<>
{Array.isArray(node.children) && node.children.map((id, index) => {
if (lvl > 1) {
lines.push(hasNext ? '1' : '0')
}
const n = diagram.state.treeMap[id]
return (
<DiagramNode
node={n}
lvl={lvl + 1}
hasNext={index < node.children.length - 1}
lines={[...lines]}
diagram={diagram}
hidden={!expanded || hidden}
/>
)
})}
</>
);
if (lvl > 1) {
return (
<>
{Node}
{NodeChildren}
</>
);
}
let left = false;
let right = false;
let center = false;
if (this.props.lvl === 1) {
const children = this.props.diagram.state.treeMap[this.props.diagram.props.rootId].children;
const index = children.indexOf(this.props.node.id);
let centerIndex = Math.floor(children.length / 2);
if (children.length % 2) {
centerIndex += 1;
if (index + 1 < centerIndex) {
left = true
}
if (index + 1 == centerIndex) {
center = true
}
if (index + 1 > centerIndex) {
right = true
}
} else {
if (index + 1 <= centerIndex) {
left = true
}
if (index + 1 > centerIndex) {
right = true
}
}
}
return (
<div
className={`node-group-container ${left ? 'left' : ''} ${right ? 'right' : ''} ${center ? 'center' : ''}`}>
{Node}
<div
className={[
"tree-node-children",
`lvl-${lvl + 1}-group`,
!expanded || hidden ? 'hidden' : ''
].join(' ')}>
{NodeChildren}
</div>
</div>
)
}
}
export default DiagramNode;
组件样式
/deep/.arco-spin {
width: 100%;
height: 100%;
.arco-spin-children {
width: 100%;
height: 100%;
}
.arco-spin-children::after {
z-index: 2;
}
}
.valuation-model-diagram {
width: 100%;
height: 100%;
padding: 30px 16px 10px 16px;
overflow: auto;
box-sizing: border-box;
&::-webkit-scrollbar {
width: 0px;
height: 10px;
}
* {
box-sizing: border-box;
}
.node-group-container {
width: max-content;
margin: 0px auto;
}
.tree-node {
display: flex;
transition: all .2s;
&.lvl-0 {
display: block;
width: max-content;
margin: 0px auto;
&>.cost-model-card {
margin: 0px auto 20px;
display: block;
width: max-content;
margin-bottom: 90px;
position: relative;
z-index: 2;
&:before {
content: '';
position: absolute;
top: 84px;
left: calc(50% - 10px);
width: 10px;
height: 29px;
border: 1px solid var(--color-border-2);
border-top: 0px;
border-left: 0px;
border-radius: 0px 0px 8px 0px;
z-index: 1;
background: transparent;
}
&:after {
content: '';
position: absolute;
top: 84px;
left: 50%;
width: 10px;
height: 29px;
border: 1px solid var(--color-border-2);
border-top: 0px;
border-right: 0px;
border-radius: 0px 0px 0px 8px;
z-index: 1;
background: transparent;
}
.expand-btn {
position: absolute;
top: 72px;
left: calc(50% - 6px);
width: 12px;
height: 12px;
border-radius: 2px;
background: var(--color-fill-2);
color: var(--color-text-2);
text-align: center;
line-height: 12px;
font-size: 6px;
svg {
vertical-align: top;
margin-top: 2.5px;
}
}
}
}
.indent-block-group {
display: flex;
.indent-block {
height: 68px;
width: 30px;
}
}
&:not(.lvl-0) {
.expand-btn {
position: absolute;
top: calc(50% - 6px);
left: -20px;
width: 12px;
height: 12px;
border-radius: 2px;
background: var(--color-fill-2);
color: var(--color-text-2);
text-align: center;
line-height: 12px;
font-size: 6px;
svg {
vertical-align: top;
margin-top: 2.5px;
}
}
}
}
.lvl-1-group {
display: flex;
column-gap: 40px;
margin-left: 0px;
position: relative;
z-index: 2;
&:before {
content: '';
position: absolute;
top: -44px;
left: 80px;
width: calc(50% - 80px - 10px);
height: 45px;
border: 1px solid var(--color-border-2);
border-bottom: 0px;
border-right: 0px;
border-radius: 8px 0px 0px 0px;
z-index: 1;
}
&:after {
content: '';
position: absolute;
top: -44px;
right: 80px;
width: calc(50% - 80px - 10px);
height: 45px;
border: 1px solid var(--color-border-2);
border-bottom: 0px;
border-left: 0px;
border-radius: 0px 8px 0px 0px;
z-index: 1;
}
&>.node-group-container {
&:not(:first-child):not(:last-child)>.tree-node>.cost-model-card:after {
content: "";
position: absolute;
top: -45px;
height: 42px;
width: 10px;
border-top: 1px solid var(--color-border-2);
border-right: 1px solid var(--color-border-2);
border-radius: 0px 8px 0px 0px;
left: calc(50% + 10px);
}
&.left:not(:first-child):not(:last-child)>.tree-node>.cost-model-card:after {
left: calc(50% - 10px);
border-top: 1px solid var(--color-border-2);
border-left: 1px solid var(--color-border-2);
border-right: 0px;
border-radius: 8px 0px 0px 0px;
}
&.center:not(:first-child):not(:last-child)>.tree-node>.cost-model-card:after {
width: 0px;
border-top: 0px;
}
&.right:not(:first-child):not(:last-child)>.tree-node>.cost-model-card:after {
}
}
}
.indent-block {
position: relative;
transition: all .2s;
}
.indent-block.pre-line:before {
content: '';
position: absolute;
width: 1px;
height: 90px;
top: -58px;
left: -13.5px;
border-left: 1px solid var(--color-border-2);
}
.indent-block.pre-line:last-child:before {
height: 84px;
}
.indent-block.pre-line:last-child:after {
content: '';
position: absolute;
top: 24px;
left: -13.5px;
border-bottom: 1px solid var(--color-border-2);
border-left: 1px solid var(--color-border-2);
width: 44px;
height: 10px;
border-radius: 0px 0px 0px 8px;
}
.has-children>.indent-block-group>.indent-block.pre-line:last-child:after {
width: 34px;
}
.cost-model-card {
height: 68px;
opacity: 1;
transition: all .2s;
}
.tree-node.hidden:not(.lvl-0) {
&>.cost-model-card {
width: 0px;
height: 0px;
opacity: 0;
margin-bottom: 0px;
border: 0px;
overflow: hidden;
}
}
.tree-node.hidden:not(.lvl-0)>.indent-block-group {
.indent-block {
width: 0px;
height: 0px;
}
.indent-block:before {
display: none;
}
.indent-block:after {
display: none;
}
}
.tree-node.hidden.lvl-0 {
&>.cost-model-card {
&:before {
display: none;
}
&:after {
display: none;
}
}
}
.tree-node-children.lvl-1-group.hidden {
&:before {
display: none;
}
&:after {
display: none;
}
}
.cost-model-card {
display: inline-block;
min-width: 200px;
border: 1px solid var(--color-border-2);
background: var(--color-white);
border-radius: 4px;
margin-bottom: 20px;
position: relative;
z-index: 2;
cursor: pointer;
&:before {
content: "";
border-radius: 4px;
border-top: 2px solid var(--color-primary-6);
background: var(--color-bg-2);
position: absolute;
top: -2px;
height: 10px;
width: 100%;
}
&:hover {
box-shadow: 0px 2px 5px 0px rgba(36, 46, 67, 0.10);
}
&.root {
.title-container {
display: block;
text-align: center;
.icon, .text {
display: inline-block;
line-height: 20px;
}
.icon {
height: 14px;
vertical-align: middle;
}
.text {
vertical-align: middle;
color: var(--color-text-1);
font-size: 14px;
}
}
}
.title-container {
display: flex;
height: 36px;
padding: 8px 16px;
.icon {
margin-right: 8px;
img {
width: 12px;
}
}
.text {
color: var(--color-text-2);
flex: 1;
font-weight: 600;
}
.oper {
margin-left: 8px;
}
}
.oper-group-container {
height: 31px;
line-height: 30px;
color: var(--color-text-2);
border-top: 1px solid var(--color-border-2);
.oper-group-content {
display: flex;
flex-direction: row;
width: max-content;
margin: 0px auto;
}
.split {
width: 1px;
height: 10px;
background: var(--color-border-2);
margin-right: 6px;
margin-top: 10px;
}
svg {
margin-right: 6px;
}
}
}
.cost-model-card.root .title-container {
position: relative;
}
.cost-model-card.root .title-container:before {
content: "";
border-radius: 4px;
border-top: 2px solid var(--color-primary-6);
background: var(--color-bg-2);
position: absolute;
top: -2px;
height: 10px;
width: 100%;
left: 0px;
}
}