Files
DianZhanDemo/app/components/PowerStation/ModelTreePanel.vue
ch197511161 908b4361ed init3
2025-12-11 01:01:11 +08:00

392 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
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>