229 lines
5.0 KiB
Vue
229 lines
5.0 KiB
Vue
<template>
|
||
<div class="tree-node" ref="nodeRef">
|
||
<div
|
||
class="node-label"
|
||
:class="{
|
||
'has-children': hasChildren,
|
||
'mesh-node': node.type === 'mesh',
|
||
'selected': isSelected
|
||
}"
|
||
@click="toggleExpand"
|
||
>
|
||
<i
|
||
v-if="hasChildren"
|
||
class="fas expand-icon"
|
||
:class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"
|
||
></i>
|
||
<i class="fas node-icon" :class="getIcon()"></i>
|
||
<span class="node-name">{{ node.name }}</span>
|
||
<span v-if="node.type === 'mesh'" class="mesh-info">
|
||
({{ node.triangleCount?.toLocaleString() }} 面)
|
||
</span>
|
||
</div>
|
||
<div v-if="expanded && hasChildren" class="node-children">
|
||
<ModelTreeNode
|
||
v-for="(child, index) in node.children"
|
||
:key="index"
|
||
:node="child"
|
||
:selected-mesh-id="selectedMeshId"
|
||
@mesh-click="(meshId) => emit('mesh-click', meshId)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
|
||
const props = defineProps({
|
||
node: {
|
||
type: Object,
|
||
required: true
|
||
},
|
||
selectedMeshId: {
|
||
type: Object,
|
||
default: null
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['mesh-click'])
|
||
|
||
const expanded = ref(props.node.expanded || false)
|
||
const nodeRef = ref(null)
|
||
|
||
const hasChildren = computed(() => {
|
||
return props.node.children && props.node.children.length > 0
|
||
})
|
||
|
||
const toggleExpand = () => {
|
||
// 如果是mesh节点,触发点击事件
|
||
if (props.node.type === 'mesh') {
|
||
const meshId = {
|
||
nodeId: props.node.nodeId,
|
||
meshIndex: props.node.meshIndex
|
||
}
|
||
emit('mesh-click', meshId)
|
||
return
|
||
}
|
||
|
||
// 否则切换展开状态
|
||
if (hasChildren.value) {
|
||
expanded.value = !expanded.value
|
||
}
|
||
}
|
||
|
||
// 判断当前mesh节点是否被选中
|
||
const isSelected = computed(() => {
|
||
if (props.node.type !== 'mesh' || !props.selectedMeshId) return false
|
||
const result = props.node.nodeId === props.selectedMeshId.nodeId &&
|
||
props.node.meshIndex === props.selectedMeshId.meshIndex
|
||
if (result) {
|
||
console.log('节点匹配成功:', {
|
||
nodeName: props.node.name,
|
||
nodeId: props.node.nodeId,
|
||
meshIndex: props.node.meshIndex,
|
||
selectedNodeId: props.selectedMeshId.nodeId,
|
||
selectedMeshIndex: props.selectedMeshId.meshIndex
|
||
})
|
||
}
|
||
return result
|
||
})
|
||
|
||
const getIcon = () => {
|
||
if (props.node.type === 'mesh') {
|
||
return 'fa-cube'
|
||
}
|
||
return expanded.value ? 'fa-folder-open' : 'fa-folder'
|
||
}
|
||
|
||
// 检查子节点中是否有被选中的mesh
|
||
const hasSelectedChild = () => {
|
||
if (!props.node.children || !props.selectedMeshId) return false
|
||
|
||
const checkNode = (node) => {
|
||
if (node.type === 'mesh') {
|
||
return node.nodeId === props.selectedMeshId.nodeId &&
|
||
node.meshIndex === props.selectedMeshId.meshIndex
|
||
}
|
||
if (node.children) {
|
||
return node.children.some(checkNode)
|
||
}
|
||
return false
|
||
}
|
||
|
||
return props.node.children.some(checkNode)
|
||
}
|
||
|
||
// 监听选中状态变化,自动展开到选中节点
|
||
watch(() => props.selectedMeshId, () => {
|
||
if (hasSelectedChild()) {
|
||
expanded.value = true
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// 当节点被选中时,滚动到可视区域
|
||
watch(isSelected, async (newVal) => {
|
||
if (newVal && nodeRef.value) {
|
||
await nextTick()
|
||
nodeRef.value.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'nearest',
|
||
inline: 'nearest'
|
||
})
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tree-node {
|
||
margin: 2px 0;
|
||
}
|
||
|
||
.node-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 8px;
|
||
border-radius: var(--size-border-radius);
|
||
cursor: pointer;
|
||
transition: var(--transition-all);
|
||
color: var(--color-text-primary);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.node-label:hover {
|
||
background: var(--color-bg-hover);
|
||
}
|
||
|
||
.node-label.has-children {
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
.node-label.mesh-node {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.node-label.mesh-node:hover {
|
||
background: var(--color-white-rgb-1);
|
||
}
|
||
|
||
.node-label.selected {
|
||
background: var(--color-primary-rgb-2);
|
||
border-left: 4px solid var(--color-primary);
|
||
padding-left: 4px;
|
||
font-weight: var(--font-weight-semibold);
|
||
color: var(--color-primary);
|
||
box-shadow: 0 2px 8px var(--color-primary-rgb-2);
|
||
animation: highlight 0.3s ease-in-out;
|
||
}
|
||
|
||
@keyframes highlight {
|
||
0% {
|
||
transform: translateX(0);
|
||
opacity: 0.7;
|
||
}
|
||
50% {
|
||
transform: translateX(2px);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.expand-icon {
|
||
width: 12px;
|
||
font-size: 10px;
|
||
color: var(--color-text-tertiary);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.node-icon {
|
||
font-size: 14px;
|
||
color: var(--color-primary);
|
||
width: 16px;
|
||
text-align: center;
|
||
}
|
||
|
||
.node-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.mesh-info {
|
||
color: var(--color-text-tertiary);
|
||
font-size: var(--font-size-xs);
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.node-children {
|
||
margin-left: 20px;
|
||
border-left: 1px solid var(--color-border-primary);
|
||
padding-left: 4px;
|
||
}
|
||
</style>
|