6 changed files with 411 additions and 378 deletions
@ -0,0 +1,380 @@ |
|||
<template> |
|||
<el-card shadow="hover" class="dept-tree-card"> |
|||
<div class="search-row"> |
|||
<el-input |
|||
v-model="filterText" |
|||
placeholder="搜索单位名称" |
|||
prefix-icon="Search" |
|||
clearable |
|||
/> |
|||
<el-popover placement="bottom-end" :width="360" trigger="hover"> |
|||
<template #reference> |
|||
<el-button :icon="QuestionFilled" link class="help-btn" /> |
|||
</template> |
|||
<div class="search-help"> |
|||
<p class="help-title">搜索语法说明</p> |
|||
<el-divider style="margin: 8px 0" /> |
|||
<ul class="help-list"> |
|||
<li><code>福田</code> 查找含"福田"的节点</li> |
|||
<li><code>福田+</code> 查找含"福田"的节点,并显示其所有子孙</li> |
|||
<li><code>福田/民政</code> 先找"福田",再在其下级中找"民政"</li> |
|||
<li><code>福田+/民政</code> 找"福田"并展开子孙,再在其中找"民政"</li> |
|||
<li><code>福田+/民政+</code> 同上,且"民政"也展开子孙</li> |
|||
</ul> |
|||
<el-divider style="margin: 8px 0" /> |
|||
<p class="help-note"><b>/</b> 分隔层级,<b>+</b> 后缀展开子孙节点</p> |
|||
</div> |
|||
</el-popover> |
|||
</div> |
|||
<el-tree-v2 |
|||
ref="treeRef" |
|||
:data="treeData" |
|||
:props="treeProps" |
|||
:height="treeHeight" |
|||
:expand-on-click-node="false" |
|||
:default-expanded-keys="defaultExpandedKeys" |
|||
:filter-method="filterMethod" |
|||
highlight-current |
|||
@node-click="handleNodeClick" |
|||
> |
|||
<template #default="{ data }"> |
|||
<span v-html="highlightText(data.label)"></span> |
|||
</template> |
|||
</el-tree-v2> |
|||
</el-card> |
|||
</template> |
|||
|
|||
<script setup lang="ts" name="SrzDeptFullLoadTree"> |
|||
import { QuestionFilled } from '@element-plus/icons-vue'; |
|||
import { getSrzDeptTreeList } from '@/api/efcode/srz'; |
|||
import type { SrzDeptMinVo } from '@/api/efcode/srz/types'; |
|||
|
|||
interface TreeNode { |
|||
id: string; |
|||
label: string; |
|||
children?: TreeNode[]; |
|||
zzlevel?: string; |
|||
} |
|||
|
|||
const emit = defineEmits<{ |
|||
(e: 'node-click', data: { id: string; label: string }): void; |
|||
}>(); |
|||
|
|||
const filterText = ref(''); |
|||
const treeRef = ref(); |
|||
const treeData = ref<TreeNode[]>([]); |
|||
const defaultExpandedKeys = ref<string[]>([]); |
|||
const treeHeight = ref(600); |
|||
|
|||
const treeProps = { |
|||
value: 'id', |
|||
label: 'label', |
|||
children: 'children' |
|||
}; |
|||
|
|||
// 保存扁平列表用于计算可见节点 |
|||
const flatList = ref<SrzDeptMinVo[]>([]); |
|||
// 存储过滤后应该显示的节点ID集合 |
|||
const visibleIds = ref<Set<string>>(new Set()); |
|||
|
|||
/** 解析搜索段:提取关键词和是否带 + 后缀 */ |
|||
interface SearchSegment { |
|||
keyword: string; |
|||
showDescendants: boolean; |
|||
} |
|||
|
|||
const parseSegments = (query: string): SearchSegment[] => { |
|||
return query.split('/').map(s => s.trim()).filter(s => s.length > 0).map(s => { |
|||
if (s.endsWith('+')) { |
|||
return { keyword: s.slice(0, -1), showDescendants: true }; |
|||
} |
|||
return { keyword: s, showDescendants: false }; |
|||
}).filter(s => s.keyword.length > 0); |
|||
}; |
|||
|
|||
/** |
|||
* 计算可见节点集合,支持层级搜索(/ 分隔)和子孙展开(+ 后缀) |
|||
* - 福田:仅显示匹配节点 + 祖先 |
|||
* - 福田+:显示匹配节点 + 所有子孙 + 祖先 |
|||
* - 福田/民政:在福田子孙中找民政,显示民政匹配 + 祖先 |
|||
* - 福田+/民政:福田全部子孙可见,在其中找民政匹配 + 祖先 |
|||
* - 福田+/民政+:福田全部子孙可见,民政匹配 + 民政子孙 + 祖先 |
|||
*/ |
|||
const computeVisibleIds = (query: string) => { |
|||
if (!query) { |
|||
visibleIds.value = new Set(); |
|||
return; |
|||
} |
|||
|
|||
const segments = parseSegments(query); |
|||
if (segments.length === 0) { |
|||
visibleIds.value = new Set(); |
|||
return; |
|||
} |
|||
|
|||
// 构建映射 |
|||
const childrenMap = new Map<string, string[]>(); |
|||
const parentMap = new Map<string, string>(); |
|||
const nameMap = new Map<string, string>(); |
|||
for (const item of flatList.value) { |
|||
parentMap.set(item.organizationid, item.parentid); |
|||
nameMap.set(item.organizationid, item.name || ''); |
|||
if (!childrenMap.has(item.parentid)) { |
|||
childrenMap.set(item.parentid, []); |
|||
} |
|||
childrenMap.get(item.parentid)!.push(item.organizationid); |
|||
} |
|||
|
|||
// 获取某节点的所有子孙ID |
|||
const getAllDescendantIds = (id: string): string[] => { |
|||
const result: string[] = []; |
|||
const stack = [id]; |
|||
while (stack.length) { |
|||
const current = stack.pop()!; |
|||
const children = childrenMap.get(current) || []; |
|||
for (const childId of children) { |
|||
result.push(childId); |
|||
stack.push(childId); |
|||
} |
|||
} |
|||
return result; |
|||
}; |
|||
|
|||
// 逐层搜索,记录每层匹配结果 |
|||
const levelMatches: string[][] = []; |
|||
let scopeIds: Set<string> | null = null; // null = 全量 |
|||
|
|||
for (const segment of segments) { |
|||
const matched: string[] = []; |
|||
|
|||
if (scopeIds === null) { |
|||
for (const item of flatList.value) { |
|||
if (item.name?.includes(segment.keyword)) { |
|||
matched.push(item.organizationid); |
|||
} |
|||
} |
|||
} else { |
|||
for (const id of scopeIds) { |
|||
const descendants = getAllDescendantIds(id); |
|||
for (const descId of descendants) { |
|||
if (nameMap.get(descId)?.includes(segment.keyword)) { |
|||
matched.push(descId); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
levelMatches.push(matched); |
|||
// 下一层的搜索范围 = 当前层匹配节点(作为父节点向下搜索) |
|||
scopeIds = new Set(matched); |
|||
} |
|||
|
|||
// 构建可见集合 |
|||
const ids = new Set<string>(); |
|||
|
|||
const addDescendants = (id: string) => { |
|||
ids.add(id); |
|||
const children = childrenMap.get(id) || []; |
|||
for (const childId of children) { |
|||
addDescendants(childId); |
|||
} |
|||
}; |
|||
|
|||
const addAncestors = (id: string) => { |
|||
const pid = parentMap.get(id); |
|||
if (pid && !ids.has(pid)) { |
|||
ids.add(pid); |
|||
addAncestors(pid); |
|||
} |
|||
}; |
|||
|
|||
// 每层的贡献 |
|||
for (let i = 0; i < segments.length; i++) { |
|||
const segment = segments[i]; |
|||
const matched = levelMatches[i]; |
|||
const isLast = i === segments.length - 1; |
|||
|
|||
if (segment.showDescendants) { |
|||
// 带 + :匹配节点 + 所有子孙 |
|||
for (const id of matched) { |
|||
addDescendants(id); |
|||
} |
|||
} else if (isLast) { |
|||
// 最后一段无 + :仅匹配节点 |
|||
for (const id of matched) { |
|||
ids.add(id); |
|||
} |
|||
} |
|||
// 中间段无 + :不直接贡献可见节点(会作为后续匹配的祖先自动出现) |
|||
} |
|||
|
|||
// 补充祖先链路保持树结构完整 |
|||
const currentIds = [...ids]; |
|||
for (const id of currentIds) { |
|||
addAncestors(id); |
|||
} |
|||
|
|||
visibleIds.value = ids; |
|||
}; |
|||
|
|||
/** 树节点过滤方法 */ |
|||
const filterMethod = (query: string, node: TreeNode) => { |
|||
if (!query) return true; |
|||
return visibleIds.value.has(node.id); |
|||
}; |
|||
|
|||
/** 高亮匹配文本(高亮所有搜索段的关键词,去除+后缀) */ |
|||
const highlightText = (text: string) => { |
|||
if (!filterText.value || !text) return text; |
|||
const segments = parseSegments(filterText.value); |
|||
if (segments.length === 0) return text; |
|||
let result = text; |
|||
for (const segment of segments) { |
|||
const escaped = segment.keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|||
const regex = new RegExp(`(${escaped})`, 'gi'); |
|||
result = result.replace(regex, '<span class="tree-highlight">$1</span>'); |
|||
} |
|||
return result; |
|||
}; |
|||
|
|||
/** 监听搜索框变化触发过滤 */ |
|||
watch(filterText, (val) => { |
|||
computeVisibleIds(val); |
|||
treeRef.value?.filter(val); |
|||
}); |
|||
|
|||
/** 将扁平列表构建为树结构 */ |
|||
const buildTree = (list: SrzDeptMinVo[]): TreeNode[] => { |
|||
const map = new Map<string, TreeNode>(); |
|||
const roots: TreeNode[] = []; |
|||
|
|||
// 先创建所有节点 |
|||
for (const item of list) { |
|||
map.set(item.organizationid, { |
|||
id: item.organizationid, |
|||
label: item.name || '', |
|||
zzlevel: item.zzlevel, |
|||
children: [] |
|||
}); |
|||
} |
|||
|
|||
// 建立父子关系 |
|||
for (const item of list) { |
|||
const node = map.get(item.organizationid)!; |
|||
const parent = map.get(item.parentid); |
|||
if (parent) { |
|||
parent.children!.push(node); |
|||
} else { |
|||
roots.push(node); |
|||
} |
|||
} |
|||
|
|||
// 移除空的children数组(叶子节点) |
|||
const removeEmptyChildren = (nodes: TreeNode[]) => { |
|||
for (const node of nodes) { |
|||
if (node.children && node.children.length === 0) { |
|||
delete node.children; |
|||
} else if (node.children) { |
|||
removeEmptyChildren(node.children); |
|||
} |
|||
} |
|||
}; |
|||
removeEmptyChildren(roots); |
|||
|
|||
return roots; |
|||
}; |
|||
|
|||
/** 收集前N层节点的id,用于默认展开 */ |
|||
const collectExpandKeys = (nodes: TreeNode[], currentLevel: number, maxLevel: number) => { |
|||
if (currentLevel > maxLevel) return; |
|||
for (const node of nodes) { |
|||
defaultExpandedKeys.value.push(node.id); |
|||
if (node.children?.length) { |
|||
collectExpandKeys(node.children, currentLevel + 1, maxLevel); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
/** 获取部门树数据 */ |
|||
const getDeptTree = async () => { |
|||
const res = await getSrzDeptTreeList(); |
|||
flatList.value = res.data; |
|||
const tree = buildTree(res.data); |
|||
treeData.value = tree; |
|||
defaultExpandedKeys.value = []; |
|||
collectExpandKeys(tree, 1, 1); |
|||
}; |
|||
|
|||
/** 节点点击事件 - 向外分发 */ |
|||
const handleNodeClick = (data: TreeNode) => { |
|||
emit('node-click', { id: data.id, label: data.label }); |
|||
}; |
|||
|
|||
/** 计算树高度自适应视口 */ |
|||
const calcTreeHeight = () => { |
|||
treeHeight.value = window.innerHeight - 180; |
|||
}; |
|||
|
|||
onMounted(() => { |
|||
calcTreeHeight(); |
|||
window.addEventListener('resize', calcTreeHeight); |
|||
getDeptTree(); |
|||
}); |
|||
|
|||
onUnmounted(() => { |
|||
window.removeEventListener('resize', calcTreeHeight); |
|||
}); |
|||
</script> |
|||
|
|||
<style scoped lang="scss"> |
|||
.dept-tree-card { |
|||
position: sticky; |
|||
top: 0; |
|||
} |
|||
|
|||
.search-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.help-btn { |
|||
font-size: 18px; |
|||
color: var(--el-text-color-secondary); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
:deep(.tree-highlight) { |
|||
color: var(--el-color-primary); |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.search-help { |
|||
.help-title { |
|||
font-weight: bold; |
|||
font-size: 14px; |
|||
margin: 0; |
|||
} |
|||
.help-list { |
|||
margin: 0; |
|||
padding-left: 16px; |
|||
li { |
|||
margin-bottom: 6px; |
|||
font-size: 13px; |
|||
code { |
|||
background: var(--el-fill-color-light); |
|||
padding: 1px 6px; |
|||
border-radius: 3px; |
|||
font-family: monospace; |
|||
color: var(--el-color-primary); |
|||
} |
|||
} |
|||
} |
|||
.help-note { |
|||
margin: 0; |
|||
font-size: 12px; |
|||
color: var(--el-text-color-secondary); |
|||
} |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue