392 lines
11 KiB
Vue
392 lines
11 KiB
Vue
|
|
<!--
|
|||
|
|
ModelTreePanel.vue
|
|||
|
|
模型结构树面板组件
|
|||
|
|
显示3D模型的层级结构,支持搜索、节点选择和聚焦
|
|||
|
|
-->
|
|||
|
|
<template>
|
|||
|
|
<div class="relative">
|
|||
|
|
<div
|
|||
|
|
class="bg-[#0b1120] h-full border-r border-cyan-500/20 flex flex-col transition-all duration-300"
|
|||
|
|
>
|
|||
|
|
<!-- ============================================================
|
|||
|
|
搜索头部
|
|||
|
|
============================================================ -->
|
|||
|
|
<div class="p-4 bg-[#0f172a] border-b border-cyan-500/20">
|
|||
|
|
<div class="flex items-center justify-between mb-3">
|
|||
|
|
<h2 class="text-base font-bold text-cyan-400 flex items-center font-mono tracking-wide">
|
|||
|
|
<el-icon class="mr-2"><Files /></el-icon>
|
|||
|
|
模型结构
|
|||
|
|
</h2>
|
|||
|
|
</div>
|
|||
|
|
<el-input
|
|||
|
|
v-model="filterText"
|
|||
|
|
placeholder="搜索节点..."
|
|||
|
|
prefix-icon="Search"
|
|||
|
|
clearable
|
|||
|
|
class="tech-input"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- ============================================================
|
|||
|
|
树内容
|
|||
|
|
============================================================ -->
|
|||
|
|
<div
|
|||
|
|
ref="treeContainer"
|
|||
|
|
class="flex-1 overflow-y-auto overflow-x-hidden py-2 custom-scrollbar min-h-0"
|
|||
|
|
>
|
|||
|
|
<el-tree
|
|||
|
|
ref="treeRef"
|
|||
|
|
:data="store.modelHierarchy"
|
|||
|
|
:props="defaultProps"
|
|||
|
|
node-key="id"
|
|||
|
|
:filter-node-method="filterNodeMethod"
|
|||
|
|
highlight-current
|
|||
|
|
default-expand-all
|
|||
|
|
class="tech-tree"
|
|||
|
|
@node-click="handleNodeClick"
|
|||
|
|
@node-expand="handleNodeExpand"
|
|||
|
|
@node-collapse="handleNodeCollapse"
|
|||
|
|
>
|
|||
|
|
<template #default="{ node }">
|
|||
|
|
<div
|
|||
|
|
:id="'tree-node-' + node.data.id"
|
|||
|
|
class="flex items-center gap-2 text-sm w-full pr-2 group select-none py-1"
|
|||
|
|
@dblclick="handleNodeDblClick(node.data)"
|
|||
|
|
@mousedown.prevent="$event.preventDefault()"
|
|||
|
|
@selectstart.prevent="$event.preventDefault()"
|
|||
|
|
>
|
|||
|
|
<!-- 节点图标 -->
|
|||
|
|
<el-icon v-if="!node.isLeaf" class="text-cyan-500"><Folder /></el-icon>
|
|||
|
|
<el-icon v-else class="text-blue-400"><Document /></el-icon>
|
|||
|
|
|
|||
|
|
<!-- 节点标签 -->
|
|||
|
|
<span
|
|||
|
|
class="truncate flex-1 font-mono text-gray-400 group-hover:text-cyan-300 transition-colors"
|
|||
|
|
:title="node.label"
|
|||
|
|
>
|
|||
|
|
{{ node.label }}
|
|||
|
|
</span>
|
|||
|
|
|
|||
|
|
<!-- 聚焦按钮 -->
|
|||
|
|
<el-tooltip content="聚焦视图" placement="right" :show-after="500" effect="dark">
|
|||
|
|
<el-icon
|
|||
|
|
class="text-cyan-500/50 hover:text-cyan-400 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
|||
|
|
@click.stop="handleZoom(node.data)"
|
|||
|
|
>
|
|||
|
|
<ZoomIn />
|
|||
|
|
</el-icon>
|
|||
|
|
</el-tooltip>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</el-tree>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
/**
|
|||
|
|
* ModelTreePanel 组件
|
|||
|
|
* 显示模型的层级结构树,支持搜索、选择和聚焦功能
|
|||
|
|
*/
|
|||
|
|
import { Document, Files, Folder, ZoomIn } from '@element-plus/icons-vue'
|
|||
|
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
|||
|
|
import { useRoute } from 'vue-router'
|
|||
|
|
import { usePowerStationStore } from '~/stores/powerStation'
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 状态和引用
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
const store = usePowerStationStore()
|
|||
|
|
const route = useRoute()
|
|||
|
|
|
|||
|
|
/** 过滤文本 */
|
|||
|
|
const filterText = ref('')
|
|||
|
|
/** 树组件引用 */
|
|||
|
|
const treeRef = ref()
|
|||
|
|
/** 树容器引用 */
|
|||
|
|
const treeContainer = ref<HTMLElement>()
|
|||
|
|
/** 是否折叠 */
|
|||
|
|
const isCollapsed = ref(false)
|
|||
|
|
/** 是否为内部选择(来自树本身的选择) */
|
|||
|
|
const isInternalSelection = ref(false)
|
|||
|
|
/** 已展开的节点ID集合 */
|
|||
|
|
const expandedKeys = ref<Set<string>>(new Set())
|
|||
|
|
|
|||
|
|
/** 树组件属性配置 */
|
|||
|
|
const defaultProps = {
|
|||
|
|
value: 'id',
|
|||
|
|
label: 'label',
|
|||
|
|
children: 'children',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 响应式布局
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新布局状态
|
|||
|
|
* 根据窗口宽度自动折叠
|
|||
|
|
*/
|
|||
|
|
const updateLayout = () => {
|
|||
|
|
if (window.innerWidth < 1200) {
|
|||
|
|
isCollapsed.value = true
|
|||
|
|
} else {
|
|||
|
|
isCollapsed.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 切换折叠状态
|
|||
|
|
*/
|
|||
|
|
const toggleCollapse = () => {
|
|||
|
|
isCollapsed.value = !isCollapsed.value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 过滤功能
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
// 监听过滤文本变化
|
|||
|
|
watch(filterText, val => {
|
|||
|
|
treeRef.value?.filter(val)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 节点过滤方法
|
|||
|
|
* @param value - 过滤值
|
|||
|
|
* @param data - 节点数据
|
|||
|
|
* @returns 是否显示该节点
|
|||
|
|
*/
|
|||
|
|
const filterNodeMethod = (value: string, data: any) => {
|
|||
|
|
if (!value) return true
|
|||
|
|
const searchValue = value.toLowerCase()
|
|||
|
|
const label = data.label?.toLowerCase() || ''
|
|||
|
|
return label.includes(searchValue)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 节点操作
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理节点点击
|
|||
|
|
* @param data - 节点数据
|
|||
|
|
*/
|
|||
|
|
const handleNodeClick = (data: any) => {
|
|||
|
|
// 更新store选择状态
|
|||
|
|
store.selectNode(data)
|
|||
|
|
// 标记为内部选择
|
|||
|
|
isInternalSelection.value = true
|
|||
|
|
|
|||
|
|
// 移动设备上自动折叠
|
|||
|
|
if (window.innerWidth < 1024) {
|
|||
|
|
isCollapsed.value = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理缩放/聚焦操作
|
|||
|
|
* @param data - 节点数据
|
|||
|
|
*/
|
|||
|
|
const handleZoom = (data: any) => {
|
|||
|
|
store.selectNode(data)
|
|||
|
|
store.triggerFocus(data.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理节点双击
|
|||
|
|
* @param data - 节点数据
|
|||
|
|
*/
|
|||
|
|
const handleNodeDblClick = async (data: any) => {
|
|||
|
|
await navigateTo({
|
|||
|
|
query: {
|
|||
|
|
...route.query,
|
|||
|
|
currentNodeId: data.id,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理节点展开
|
|||
|
|
* @param data - 节点数据
|
|||
|
|
*/
|
|||
|
|
const handleNodeExpand = (data: any) => {
|
|||
|
|
expandedKeys.value.add(data.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理节点折叠
|
|||
|
|
* @param data - 节点数据
|
|||
|
|
*/
|
|||
|
|
const handleNodeCollapse = (data: any) => {
|
|||
|
|
expandedKeys.value.delete(data.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 辅助函数
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 查找节点路径
|
|||
|
|
* @param nodes - 节点数组
|
|||
|
|
* @param targetId - 目标ID
|
|||
|
|
* @param path - 当前路径
|
|||
|
|
* @returns 路径数组或null
|
|||
|
|
*/
|
|||
|
|
const findPath = (nodes: any[], targetId: string, path: string[] = []): string[] | null => {
|
|||
|
|
for (const node of nodes) {
|
|||
|
|
if (node.id === targetId) {
|
|||
|
|
return path
|
|||
|
|
}
|
|||
|
|
if (node.children && node.children.length > 0) {
|
|||
|
|
const result = findPath(node.children, targetId, [...path, node.id])
|
|||
|
|
if (result) return result
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 监听器
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
// 监听store选择变化(3D视图 -> 树)
|
|||
|
|
watch(
|
|||
|
|
() => store.currentNodeId,
|
|||
|
|
newId => {
|
|||
|
|
if (!treeRef.value) return
|
|||
|
|
|
|||
|
|
// 如果是内部选择,不需要滚动/展开
|
|||
|
|
if (isInternalSelection.value) {
|
|||
|
|
isInternalSelection.value = false
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!newId) {
|
|||
|
|
treeRef.value.setCurrentKey(null)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 1. 高亮节点
|
|||
|
|
try {
|
|||
|
|
treeRef.value.setCurrentKey(newId)
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('设置当前键失败:', e)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 查找路径并展开/滚动
|
|||
|
|
const path = findPath(store.modelHierarchy, newId)
|
|||
|
|
if (path) {
|
|||
|
|
// 更新展开状态
|
|||
|
|
path.forEach(id => expandedKeys.value.add(id))
|
|||
|
|
|
|||
|
|
// 同步树展开状态
|
|||
|
|
path.forEach(id => {
|
|||
|
|
const node = treeRef.value.getNode(id)
|
|||
|
|
if (node) {
|
|||
|
|
node.expanded = true
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 延迟滚动,等待DOM更新
|
|||
|
|
setTimeout(() => {
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
try {
|
|||
|
|
const element = document.getElementById('tree-node-' + newId)
|
|||
|
|
if (element) {
|
|||
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('树滚动错误:', e)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}, 300)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// 生命周期
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
updateLayout()
|
|||
|
|
window.addEventListener('resize', updateLayout)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
window.removeEventListener('resize', updateLayout)
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* ============================================================
|
|||
|
|
输入框样式
|
|||
|
|
============================================================ */
|
|||
|
|
|
|||
|
|
:deep(.tech-input .el-input__wrapper) {
|
|||
|
|
background-color: rgba(15, 23, 42, 0.5);
|
|||
|
|
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.2) inset;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.tech-input .el-input__inner) {
|
|||
|
|
color: #e2e8f0;
|
|||
|
|
font-family: monospace;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.tech-input .el-input__wrapper.is-focus) {
|
|||
|
|
box-shadow: 0 0 0 1px #06b6d4 inset;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
树样式
|
|||
|
|
============================================================ */
|
|||
|
|
|
|||
|
|
.tech-tree {
|
|||
|
|
background: transparent;
|
|||
|
|
color: #94a3b8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.el-tree-node__content) {
|
|||
|
|
background-color: transparent !important;
|
|||
|
|
height: 32px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.el-tree-node__content:hover) {
|
|||
|
|
background-color: rgba(6, 182, 212, 0.1) !important;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.el-tree-node.is-current > .el-tree-node__content) {
|
|||
|
|
background-color: rgba(6, 182, 212, 0.15) !important;
|
|||
|
|
border-right: 2px solid #22d3ee;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.el-tree-node.is-current > .el-tree-node__content .truncate) {
|
|||
|
|
color: #22d3ee !important;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
滚动条样式
|
|||
|
|
============================================================ */
|
|||
|
|
|
|||
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|||
|
|
width: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|||
|
|
background: rgba(15, 23, 42, 0.5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|||
|
|
background: rgba(34, 211, 238, 0.2);
|
|||
|
|
border-radius: 3px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|||
|
|
background: rgba(34, 211, 238, 0.4);
|
|||
|
|
}
|
|||
|
|
</style>
|