|
|
|
@ -3,14 +3,33 @@ |
|
|
|
<el-row :gutter="20"> |
|
|
|
<!-- 左侧:虚拟化组织机构树 --> |
|
|
|
<el-col :lg="6" :xs="24"> |
|
|
|
<el-card shadow="hover" class="dept-tree-card"> |
|
|
|
<el-input |
|
|
|
v-model="filterText" |
|
|
|
placeholder="搜索单位名称,支持层级搜索如:南山/民政" |
|
|
|
prefix-icon="Search" |
|
|
|
clearable |
|
|
|
class="mb-2" |
|
|
|
/> |
|
|
|
<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="320" 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" |
|
|
|
@ -79,6 +98,7 @@ |
|
|
|
</template> |
|
|
|
|
|
|
|
<script setup lang="ts" name="SrzDeptUser"> |
|
|
|
import { QuestionFilled } from '@element-plus/icons-vue'; |
|
|
|
import { getSrzDeptTreeList, listSrzUser } from '@/api/efcode/srz'; |
|
|
|
import type { SrzDeptMinVo, SrzUserQuery, SrzUserVo } from '@/api/efcode/srz/types'; |
|
|
|
|
|
|
|
@ -120,9 +140,28 @@ 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) { |
|
|
|
@ -130,7 +169,13 @@ const computeVisibleIds = (query: string) => { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 构建 parentId -> childIds 映射 和 id -> parentId 映射 |
|
|
|
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>(); |
|
|
|
@ -146,52 +191,48 @@ const computeVisibleIds = (query: string) => { |
|
|
|
// 获取某节点的所有子孙ID |
|
|
|
const getAllDescendantIds = (id: string): string[] => { |
|
|
|
const result: string[] = []; |
|
|
|
const children = childrenMap.get(id) || []; |
|
|
|
for (const childId of children) { |
|
|
|
result.push(childId); |
|
|
|
result.push(...getAllDescendantIds(childId)); |
|
|
|
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 segments = query.split('/').map(s => s.trim()).filter(s => s.length > 0); |
|
|
|
if (segments.length === 0) { |
|
|
|
visibleIds.value = new Set(); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 逐层搜索 |
|
|
|
// candidateIds: 当前层搜索的候选范围(null表示全量) |
|
|
|
let candidateIds: Set<string> | null = null; |
|
|
|
// 逐层搜索,记录每层匹配结果 |
|
|
|
const levelMatches: string[][] = []; |
|
|
|
let scopeIds: Set<string> | null = null; // null = 全量 |
|
|
|
|
|
|
|
for (const segment of segments) { |
|
|
|
const matchedInThisLevel: string[] = []; |
|
|
|
const matched: string[] = []; |
|
|
|
|
|
|
|
if (candidateIds === null) { |
|
|
|
// 第一层:在全量中搜索 |
|
|
|
if (scopeIds === null) { |
|
|
|
for (const item of flatList.value) { |
|
|
|
if (item.name?.includes(segment)) { |
|
|
|
matchedInThisLevel.push(item.organizationid); |
|
|
|
if (item.name?.includes(segment.keyword)) { |
|
|
|
matched.push(item.organizationid); |
|
|
|
} |
|
|
|
} |
|
|
|
} else { |
|
|
|
// 后续层:在上一层匹配节点的子孙中搜索 |
|
|
|
for (const id of candidateIds) { |
|
|
|
for (const id of scopeIds) { |
|
|
|
const descendants = getAllDescendantIds(id); |
|
|
|
for (const descId of descendants) { |
|
|
|
if (nameMap.get(descId)?.includes(segment)) { |
|
|
|
matchedInThisLevel.push(descId); |
|
|
|
if (nameMap.get(descId)?.includes(segment.keyword)) { |
|
|
|
matched.push(descId); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 当前层的匹配结果作为下一层的候选父节点 |
|
|
|
candidateIds = new Set(matchedInThisLevel); |
|
|
|
levelMatches.push(matched); |
|
|
|
// 下一层的搜索范围 = 当前层匹配节点(作为父节点向下搜索) |
|
|
|
scopeIds = new Set(matched); |
|
|
|
} |
|
|
|
|
|
|
|
// 最终结果:最后一层匹配的节点 + 子孙 + 祖先 |
|
|
|
// 构建可见集合 |
|
|
|
const ids = new Set<string>(); |
|
|
|
|
|
|
|
const addDescendants = (id: string) => { |
|
|
|
@ -210,11 +251,30 @@ const computeVisibleIds = (query: string) => { |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
if (candidateIds) { |
|
|
|
for (const id of candidateIds) { |
|
|
|
addDescendants(id); |
|
|
|
addAncestors(id); |
|
|
|
// 每层的贡献 |
|
|
|
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; |
|
|
|
@ -226,14 +286,14 @@ const filterMethod = (query: string, node: TreeNode) => { |
|
|
|
return visibleIds.value.has(node.id); |
|
|
|
}; |
|
|
|
|
|
|
|
/** 高亮匹配文本(高亮所有搜索段) */ |
|
|
|
/** 高亮匹配文本(高亮所有搜索段的关键词,去除+后缀) */ |
|
|
|
const highlightText = (text: string) => { |
|
|
|
if (!filterText.value || !text) return text; |
|
|
|
const segments = filterText.value.split('/').map(s => s.trim()).filter(s => s.length > 0); |
|
|
|
const segments = parseSegments(filterText.value); |
|
|
|
if (segments.length === 0) return text; |
|
|
|
let result = text; |
|
|
|
for (const segment of segments) { |
|
|
|
const escaped = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
|
|
const escaped = segment.keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
|
|
const regex = new RegExp(`(${escaped})`, 'gi'); |
|
|
|
result = result.replace(regex, '<span class="tree-highlight">$1</span>'); |
|
|
|
} |
|
|
|
@ -371,8 +431,49 @@ onUnmounted(() => { |
|
|
|
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> |
|
|
|
|