Files
DianZhanDemo/app/components/PowerStation/ModelTreePanel.vue

392 lines
11 KiB
Vue
Raw Normal View History

2025-12-11 01:01:11 +08:00
<!--
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>