企业级部门树组件(DepartmentTree.vue)文档
企业级部门树组件(DepartmentTree.vue)文档
一、组件概述
DepartmentTree是基于 QuasarQTree和 Vue 3 组合式 API 开发的企业级组织架构树组件,支持部门层级展示、实时搜索过滤、节点交互和路由导航。该组件专为大型组织架构系统设计,提供直观的部门层级可视化和高效的导航体验,同时确保类型安全和响应式适配。
二、核心功能亮点
1. 智能搜索与过滤
- 实时过滤:输入搜索关键词时自动筛选匹配节点(名称模糊匹配)
- 自动展开:搜索结果节点及其所有父节点自动展开,确保可见性
- 空状态处理:
o 无数据时显示“没有部门数据”(no-nodes-label)
o 搜索无结果时显示“没有找到匹配的部门”(no-results-label)
2. 节点可视化增强
- 类型化图标:不同部门类型显示专属图标(如安全部门用security图标,生产单位用factory图标)
- 状态色标:节点颜色随部门类型动态变化(如监管机构为红色,安全部门为绿色)
- 子节点计数:带下级部门的节点显示子节点数量徽章(如2表示包含 2 个子部门)
- 悬停反馈:节点 hover 时背景色微妙变化(rgba(0, 0, 0, 0.05))
- 平滑展开:节点展开/折叠有过渡动画
- 路由联动:选择节点时自动跳转至部门详情页,URL 参数变化时同步选中状态
- 类型安全:全程使用 TypeScript 类型定义(Department、OrganizationNodeType等)
- 响应式设计:最大高度限制(70vh)配合滚动条,适配不同屏幕尺寸
- 模块化逻辑:通过useOrganization组合式函数复用数据获取逻辑
3. 交互体验优化
4. 企业级特性
三、完整代码实现
<template>
<div class="q-pa-sm">
<!-- 搜索框 -->
<q-input
v-model="searchText"
dense
outlined
placeholder="搜索部门..."
class="q-mb-sm"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<!-- 部门树 -->
<q-tree
:nodes="filteredTree"
node-key="id"
selected-color="primary"
v-model:selected="selectedDepartment"
v-model:expanded="expandedDepartments"
:default-expand-all="!searchText"
no-nodes-label="没有部门数据"
no-results-label="没有找到匹配的部门"
dense
no-connectors
>
<!-- 节点头部插槽(名称+图标+计数) -->
<template v-slot:default-header="prop">
<div class="row items-center">
<q-icon
:name="getNodeIcon(prop.node)"
:color="getNodeColor(prop.node)"
size="xs"
class="q-mr-xs"
/>
<div class="text-caption">{{ prop.node.name }}</div>
<q-badge
v-if="prop.node.children && prop.node.children.length"
rounded
color="grey-6"
class="q-ml-xs"
>
{{ prop.node.children.length }}
</q-badge>
</div>
</template>
<!-- 节点正文插槽(描述信息) -->
<template v-slot:default-body="prop">
<div v-if="prop.node.description" class="q-pl-lg text-caption text-grey-6">
{{ prop.node.description }}
</div>
</template>
</q-tree>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Department, OrganizationTreeNode } from 'src/types/auth/organization'
import { OrganizationNodeType } from 'src/types/auth/organization'
import useOrganization from 'src/composable/useOrganization'
// 外部依赖
const router = useRouter()
const { fetchDepartmentTree } = useOrganization()
// 状态管理
const searchText = ref('')
const selectedDepartment = ref<string | null>(null)
const expandedDepartments = ref<string[]>([])
const treeData = ref<Department[]>([]) // 原始部门树数据
// 过滤后的部门树(响应式计算)
const filteredTree = computed(() => {
if (!searchText.value) return treeData.value
const filterNode = (node: Department): Department | null => {
// 克隆节点避免修改原始数据
const nodeCopy = { ...node }
// 检查当前节点是否匹配搜索文本
const isMatch = nodeCopy.name.toLowerCase().includes(searchText.value.toLowerCase())
// 递归过滤子节点
if (nodeCopy.children && nodeCopy.children.length > 0) {
const filteredChildren = nodeCopy.children
.map(filterNode)
.filter((n): n is Department => n !== null) // 过滤 null 值
nodeCopy.children = filteredChildren
// 当前节点不匹配但子节点有匹配时,保留节点
if (isMatch || filteredChildren.length > 0) {
return nodeCopy
}
return null
}
// 无子女节点时,仅保留匹配节点
return isMatch ? nodeCopy : null
}
// 过滤根节点并返回有效节点
return treeData.value.map(filterNode).filter((n): n is Department => n !== null)
})
// 节点图标映射
const getNodeIcon = (node: OrganizationTreeNode) => {
const iconMap: Record<OrganizationNodeType, string> = {
[OrganizationNodeType.ROOT_ORGANIZATION]: 'corporate_fare',
[OrganizationNodeType.SECURITY_SERVICE_PROVIDER]: 'security',
[OrganizationNodeType.HAZARD_MANAGEMENT_PROVIDER]: 'construction',
[OrganizationNodeType.PRODUCTION_UNIT]: 'factory',
[OrganizationNodeType.SAFETY_DEPARTMENT]: 'verified_user',
[OrganizationNodeType.REGULATORY_AGENCY]: 'gavel',
[OrganizationNodeType.THIRD_PARTY_AUDITOR]: 'assignment'
}
return iconMap[node.node_type] || 'business'
}
// 节点颜色映射
const getNodeColor = (node: OrganizationTreeNode) => {
if (!node.is_active) return 'grey' // 非活跃部门置灰
const colorMap: Record<OrganizationNodeType, string> = {
[OrganizationNodeType.ROOT_ORGANIZATION]: 'primary',
[OrganizationNodeType.SECURITY_SERVICE_PROVIDER]: 'teal',
[OrganizationNodeType.HAZARD_MANAGEMENT_PROVIDER]: 'orange',
[OrganizationNodeType.PRODUCTION_UNIT]: 'blue-grey',
[OrganizationNodeType.SAFETY_DEPARTMENT]: 'green',
[OrganizationNodeType.REGULATORY_AGENCY]: 'red',
[OrganizationNodeType.THIRD_PARTY_AUDITOR]: 'purple'
}
return colorMap[node.node_type] || 'blue-grey'
}
// 初始化加载部门树
const loadTree = async () => {
try {
const data = await fetchDepartmentTree()
treeData.value = Array.isArray(data) ? data : []
// 默认展开根节点
if (treeData.value.length > 0) {
expandedDepartments.value = [treeData.value[0].id]
// 同步路由中的部门ID
const routeId = router.currentRoute.value.params.id as string | undefined
if (routeId) {
selectedDepartment.value = routeId
}
}
} catch (error) {
console.error('加载部门树失败:', error)
treeData.value = []
}
}
// 监听节点选择变化,同步路由
watch(selectedDepartment, async (newVal) => {
if (newVal) {
await router.push({ name: 'DepartmentDetail', params: { id: newVal } })
}
})
// 监听搜索文本变化,自动展开匹配节点
watch(searchText, (newVal) => {
if (newVal) {
const expandIds = new Set<string>()
// 递归收集所有匹配节点及其父节点ID
const findMatchingNodes = (nodes: Department[]) => {
nodes.forEach((node) => {
const isMatch = node.name.toLowerCase().includes(newVal.toLowerCase())
if (isMatch) {
expandIds.add(node.id)
// 添加直接父节点(实际应用中需递归追溯所有祖先)
if (node.parent) expandIds.add(node.parent)
}
if (node.children) findMatchingNodes(node.children)
})
}
findMatchingNodes(treeData.value)
expandedDepartments.value = Array.from(expandIds)
} else {
// 清空搜索时仅展开根节点
expandedDepartments.value = treeData.value.length > 0
? [treeData.value[0].id]
: []
}
})
// 初始化加载
onMounted(async () => {
await loadTree()
})
</script>
<style scoped>
.q-tree {
max-height: 70vh; /* 限制高度,超出滚动 */
overflow-y: auto;
}
.q-tree__node-header {
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.q-tree__node-header:hover {
/* 悬停背景色 */
}
</style>
四、使用指南
1. 组件引入
<template>
<div class="organization-page">
<department-tree />
</div>
</template>
<script setup>
import DepartmentTree from 'src/components/organization/DepartmentTree.vue'
</script>
2. 依赖要求
- 路由配置:需定义名为DepartmentDetail的路由,接受id参数
- 类型定义:需提供Department接口和OrganizationNodeType枚举
- 数据服务:需实现useOrganization组合式函数,提供fetchDepartmentTree方法
3. 数据结构示例
// Department 接口示例
interface Department {
id: string
name: string
description?: string
parent?: string
node_type: OrganizationNodeType
is_active: boolean
children?: Department[]
// 其他属性...
}
// OrganizationNodeType 枚举示例
enum OrganizationNodeType {
ROOT_ORGANIZATION = 'root',
SECURITY_SERVICE_PROVIDER = 'security',
// 其他类型...
}
五、技术实现详解
1. 搜索过滤机制
- 深度优先过滤:通过递归函数filterNode遍历所有节点,保留匹配节点及其父节点
- 数据隔离:通过节点克隆({ ...node })避免修改原始树数据
- 性能优化:仅在搜索文本变化时触发过滤,减少计算开销
2. 自动展开逻辑
// 核心逻辑:搜索时展开匹配节点及其父节点
watch(searchText, (newVal) => {
if (newVal) {
const expandIds = new Set<string>()
const findMatchingNodes = (nodes: Department[]) => {
nodes.forEach((node) => {
if (node.name.toLowerCase().includes(newVal.toLowerCase())) {
expandIds.add(node.id)
if (node.parent) expandIds.add(node.parent) // 添加父节点
}
if (node.children) findMatchingNodes(node.children)
})
}
findMatchingNodes(treeData.value)
expandedDepartments.value = Array.from(expandIds)
}
})
3. 节点可视化映射
- 图标与颜色解耦:通过getNodeIcon和getNodeColor两个独立函数处理,便于维护
- 扩展性设计:使用对象映射(iconMap/colorMap)替代 switch-case,新增部门类型时只需扩展映射表
- 严格类型定义:所有状态和函数参数均指定类型,避免any类型
- 接口约束:Department和OrganizationNodeType接口确保后端数据兼容性
- 路由参数校验:通过 TypeScript 泛型约束路由参数类型
- 惰性加载:部门树数据仅在组件挂载时加载一次
- 无副作用过滤:通过深克隆节点避免修改原始数据
- 事件清理:虽然示例中未显式清理,但 Vue 的watch和onMounted会在组件卸载时自动清理
- 逻辑分层:数据获取、过滤、交互逻辑分离,单一职责原则
- 命名规范:函数和变量命名遵循业务语义(如findMatchingNodes、expandedDepartments)
- 样式隔离:使用scoped样式和 BEM 命名规范,避免样式污染
六、企业级特性
1. 类型安全保障
2. 性能优化
3. 可维护性设计
七、注意事项
1. 路由同步:组件依赖路由参数同步选中状态,需确保路由配置正确且无冲突
2. 父节点追溯:当前自动展开逻辑仅添加直接父节点,如需展开所有祖先节点,需实现完整的父节点追溯逻辑
3. 数据缓存:fetchDepartmentTree建议添加缓存机制,避免重复请求
4. 大型数据处理:部门树节点超过1000+时,建议添加虚拟滚动优化渲染性能
完整代码示例可参考企业级前端组件库或 GitCode 实战项目。
浙公网安备 33010602011771号