<template>
|
<div
|
class="tree-node"
|
:class="{
|
'is-root': isRoot,
|
'is-expanded': expanded,
|
'is-highlighted': highlighted,
|
'is-active': active,
|
'is-animating': animating
|
}"
|
:style="{ '--delay': delay + 'ms', '--speed': animationSpeed }"
|
>
|
<div class="node-content" @click="handleClick">
|
<div class="node-icon">
|
<el-icon v-if="hasChildren"><FolderOpened v-if="expanded" /><Folder v-else /></el-icon>
|
<el-icon v-else><Document /></el-icon>
|
</div>
|
|
<div class="node-info">
|
<div class="node-name">{{ node.productName || '未命名产品' }}</div>
|
<div class="node-model" v-if="node.model">{{ node.model }}</div>
|
<div class="node-meta">
|
<span class="node-quantity">
|
<el-icon><Goods /></el-icon>
|
{{ node.unitQuantity || 1 }} {{ node.unit || '' }}
|
</span>
|
<span class="node-process" v-if="node.processName && !isRoot">
|
<el-icon><Setting /></el-icon>
|
{{ node.processName }}
|
</span>
|
</div>
|
</div>
|
|
<div class="node-expand-btn" v-if="hasChildren" @click.stop="toggleExpand">
|
<el-icon>
|
<ArrowDown v-if="expanded" />
|
<ArrowRight v-else />
|
</el-icon>
|
</div>
|
</div>
|
|
<div class="node-children" v-if="hasChildren && expanded">
|
<div class="children-container">
|
<svg class="connection-lines" v-if="showLines">
|
<path
|
v-for="(child, index) in node.children"
|
:key="child.tempId || child.id || index"
|
:d="getLinePath(index)"
|
class="connection-line"
|
:class="{ 'is-animating': animating }"
|
:style="{ '--line-delay': (delay + index * 100) + 'ms' }"
|
/>
|
</svg>
|
|
<TreeNode
|
v-for="(child, index) in node.children"
|
:key="child.tempId || child.id || index"
|
:node="child"
|
:level="level + 1"
|
:delay="delay + 200 + index * 100"
|
:animation-speed="animationSpeed"
|
:auto-expand="autoExpand"
|
:show-lines="showLines"
|
:highlight-ids="highlightIds"
|
:active-id="activeId"
|
:animating="animating"
|
@node-click="(n) => emit('node-click', n)"
|
@expand="(n, exp) => emit('expand', n, exp)"
|
/>
|
</div>
|
</div>
|
|
<div class="material-flow-ball" v-if="animating && !hasChildren">
|
<div class="ball"></div>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
import {
|
Folder,
|
FolderOpened,
|
Document,
|
ArrowDown,
|
ArrowRight,
|
Goods,
|
Setting
|
} from '@element-plus/icons-vue'
|
|
const props = defineProps({
|
node: {
|
type: Object,
|
required: true
|
},
|
level: {
|
type: Number,
|
default: 0
|
},
|
delay: {
|
type: Number,
|
default: 0
|
},
|
animationSpeed: {
|
type: Number,
|
default: 1
|
},
|
autoExpand: {
|
type: Boolean,
|
default: false
|
},
|
showLines: {
|
type: Boolean,
|
default: true
|
},
|
highlightIds: {
|
type: Array,
|
default: () => []
|
},
|
activeId: {
|
type: [String, Number],
|
default: null
|
},
|
animating: {
|
type: Boolean,
|
default: false
|
}
|
})
|
|
const emit = defineEmits(['node-click', 'expand'])
|
|
const expanded = ref(false)
|
const nodeWidth = ref(200)
|
const nodeHeight = ref(60)
|
|
const isRoot = computed(() => props.level === 0)
|
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
|
|
const highlighted = computed(() => {
|
const nodeId = props.node.tempId || props.node.id
|
return props.highlightIds.includes(nodeId)
|
})
|
|
const active = computed(() => {
|
const nodeId = props.node.tempId || props.node.id
|
return props.activeId === nodeId
|
})
|
|
const toggleExpand = () => {
|
expanded.value = !expanded.value
|
emit('expand', props.node, expanded.value)
|
}
|
|
const handleClick = () => {
|
emit('node-click', props.node)
|
}
|
|
const getLinePath = (index) => {
|
const startX = 20
|
const startY = 0
|
const endX = 20
|
const endY = 30 + index * 70
|
return `M ${startX} ${startY} L ${startX} ${endY}`
|
}
|
|
watch(() => props.autoExpand, (val) => {
|
if (val && hasChildren.value) {
|
setTimeout(() => {
|
expanded.value = true
|
}, props.delay)
|
}
|
})
|
|
onMounted(() => {
|
if (props.autoExpand && isRoot.value && hasChildren.value) {
|
expanded.value = true
|
}
|
})
|
</script>
|
|
<style scoped lang="scss">
|
.tree-node {
|
position: relative;
|
margin-left: 20px;
|
animation: nodeAppear calc(var(--delay) * var(--speed)) ease-out both;
|
|
&.is-root {
|
margin-left: 0;
|
|
.node-content {
|
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
|
color: #fff;
|
|
.node-name {
|
color: #fff;
|
}
|
|
.node-model {
|
color: rgba(255, 255, 255, 0.85);
|
}
|
|
.node-meta {
|
color: rgba(255, 255, 255, 0.9);
|
|
.node-quantity,
|
.node-process {
|
background: rgba(255, 255, 255, 0.2);
|
padding: 2px 8px;
|
border-radius: 4px;
|
}
|
}
|
}
|
}
|
|
&.is-highlighted {
|
.node-content {
|
border-color: #e6a23c;
|
box-shadow: 0 0 12px rgba(230, 162, 60, 0.4);
|
}
|
}
|
|
&.is-active {
|
.node-content {
|
border-color: #409eff;
|
box-shadow: 0 0 16px rgba(64, 158, 255, 0.5);
|
animation: activePulse 1.5s ease-in-out infinite;
|
}
|
}
|
|
&.is-animating {
|
.node-content {
|
animation: nodeFlow calc(2s * var(--speed)) ease-in-out infinite;
|
}
|
}
|
|
.node-content {
|
display: flex;
|
align-items: center;
|
padding: 12px 16px;
|
background: #fff;
|
border-radius: 12px;
|
border: 2px solid #e4e7ed;
|
min-width: 200px;
|
cursor: pointer;
|
transition: all 0.3s ease;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
&:hover {
|
transform: translateY(-2px);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
border-color: #409eff;
|
}
|
|
.node-icon {
|
width: 32px;
|
height: 32px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: #f5f7fa;
|
border-radius: 8px;
|
margin-right: 12px;
|
|
.el-icon {
|
color: #409eff;
|
font-size: 18px;
|
}
|
}
|
|
.node-info {
|
flex: 1;
|
|
.node-name {
|
font-weight: 600;
|
color: #303133;
|
font-size: 14px;
|
}
|
|
.node-model {
|
color: #909399;
|
font-size: 12px;
|
margin-top: 2px;
|
}
|
|
.node-meta {
|
display: flex;
|
gap: 8px;
|
margin-top: 6px;
|
font-size: 12px;
|
color: #606266;
|
|
.node-quantity,
|
.node-process {
|
display: flex;
|
align-items: center;
|
gap: 4px;
|
}
|
}
|
}
|
|
.node-expand-btn {
|
width: 24px;
|
height: 24px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: #f5f7fa;
|
border-radius: 4px;
|
transition: background 0.2s;
|
|
&:hover {
|
background: #409eff;
|
.el-icon {
|
color: #fff;
|
}
|
}
|
|
.el-icon {
|
font-size: 14px;
|
color: #909399;
|
}
|
}
|
}
|
|
.node-children {
|
margin-top: 8px;
|
|
.children-container {
|
position: relative;
|
padding-left: 20px;
|
|
.connection-lines {
|
position: absolute;
|
top: 0;
|
left: 0;
|
width: 40px;
|
height: 100%;
|
overflow: visible;
|
|
.connection-line {
|
stroke: #c0c4cc;
|
stroke-width: 2;
|
fill: none;
|
stroke-dasharray: 100;
|
stroke-dashoffset: 100;
|
transition: stroke-dashoffset calc(var(--line-delay) * var(--speed)) ease-out;
|
|
&.is-animating {
|
stroke-dashoffset: 0;
|
stroke: #409eff;
|
animation: lineFlow 1s ease-in-out infinite;
|
}
|
}
|
}
|
}
|
}
|
|
.material-flow-ball {
|
position: absolute;
|
right: -20px;
|
top: 50%;
|
transform: translateY(-50%);
|
|
.ball {
|
width: 12px;
|
height: 12px;
|
background: #67c23a;
|
border-radius: 50%;
|
box-shadow: 0 0 8px rgba(103, 194, 58, 0.6);
|
animation: ballPulse 0.8s ease-in-out infinite;
|
}
|
}
|
}
|
|
@keyframes nodeAppear {
|
from {
|
opacity: 0;
|
transform: translateY(-20px) scale(0.9);
|
}
|
to {
|
opacity: 1;
|
transform: translateY(0) scale(1);
|
}
|
}
|
|
@keyframes activePulse {
|
0%, 100% {
|
box-shadow: 0 0 16px rgba(64, 158, 255, 0.5);
|
}
|
50% {
|
box-shadow: 0 0 24px rgba(64, 158, 255, 0.8);
|
}
|
}
|
|
@keyframes nodeFlow {
|
0%, 100% {
|
transform: scale(1);
|
}
|
50% {
|
transform: scale(1.02);
|
}
|
}
|
|
@keyframes lineFlow {
|
0% {
|
stroke-dashoffset: 100;
|
}
|
100% {
|
stroke-dashoffset: 0;
|
}
|
}
|
|
@keyframes ballPulse {
|
0%, 100% {
|
transform: scale(1);
|
opacity: 1;
|
}
|
50% {
|
transform: scale(1.3);
|
opacity: 0.8;
|
}
|
}
|
</style>
|