|
|
|
|
<template>
|
|
|
|
|
<div class="p-2">
|
|
|
|
|
<el-row :gutter="20">
|
|
|
|
|
<!-- 左侧:虚拟化组织机构树 -->
|
|
|
|
|
<el-col :lg="6" :xs="24">
|
|
|
|
|
<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"
|
|
|
|
|
: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>
|
|
|
|
|
</el-col>
|
|
|
|
|
|
|
|
|
|
<!-- 右侧:用户表格 -->
|
|
|
|
|
<el-col :lg="18" :xs="24">
|
|
|
|
|
<el-card shadow="hover" class="mb-2">
|
|
|
|
|
<el-form :inline="true" :model="queryParams" ref="queryFormRef">
|
|
|
|
|
<el-form-item label="姓名" prop="fullname">
|
|
|
|
|
<el-input v-model="queryParams.fullname" placeholder="请输入姓名" clearable @keyup.enter="handleQuery" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="用户名" prop="username">
|
|
|
|
|
<el-input v-model="queryParams.username" placeholder="请输入用户名" clearable @keyup.enter="handleQuery" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item label="手机号" prop="mobilenumber">
|
|
|
|
|
<el-input v-model="queryParams.mobilenumber" placeholder="请输入手机号" clearable @keyup.enter="handleQuery" />
|
|
|
|
|
</el-form-item>
|
|
|
|
|
<el-form-item>
|
|
|
|
|
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
|
|
|
|
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
|
|
|
|
</el-form-item>
|
|
|
|
|
</el-form>
|
|
|
|
|
</el-card>
|
|
|
|
|
|
|
|
|
|
<el-card shadow="hover">
|
|
|
|
|
<template #header>
|
|
|
|
|
<span v-if="selectedDeptName" class="font-bold">{{ selectedDeptName }} - 人员列表</span>
|
|
|
|
|
<span v-else class="text-gray-400">请选择左侧单位查看人员</span>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<el-table v-loading="loading" border :data="userList" empty-text="暂无数据">
|
|
|
|
|
<el-table-column label="姓名" prop="fullname" min-width="100" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="用户名" prop="username" min-width="120" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="手机号" prop="mobilenumber" min-width="130" />
|
|
|
|
|
<el-table-column label="性别" prop="gender" width="70" align="center" />
|
|
|
|
|
<el-table-column label="职务" prop="positionname" min-width="120" show-overflow-tooltip />
|
|
|
|
|
<el-table-column label="人员状态" prop="ryzt" width="100" align="center" />
|
|
|
|
|
<el-table-column label="邮箱" prop="email" min-width="160" show-overflow-tooltip />
|
|
|
|
|
</el-table>
|
|
|
|
|
|
|
|
|
|
<pagination
|
|
|
|
|
v-show="total > 0"
|
|
|
|
|
v-model:page="queryParams.pageNum"
|
|
|
|
|
v-model:limit="queryParams.pageSize"
|
|
|
|
|
:total="total"
|
|
|
|
|
@pagination="getUserList"
|
|
|
|
|
/>
|
|
|
|
|
</el-card>
|
|
|
|
|
</el-col>
|
|
|
|
|
</el-row>
|
|
|
|
|
</div>
|
|
|
|
|
</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';
|
|
|
|
|
|
|
|
|
|
interface TreeNode {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
children?: TreeNode[];
|
|
|
|
|
zzlevel?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filterText = ref('');
|
|
|
|
|
const treeRef = ref();
|
|
|
|
|
const treeData = ref<TreeNode[]>([]);
|
|
|
|
|
const defaultExpandedKeys = ref<string[]>([]);
|
|
|
|
|
const treeHeight = ref(600);
|
|
|
|
|
const selectedDeptName = ref('');
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
const userList = ref<SrzUserVo[]>([]);
|
|
|
|
|
const total = ref(0);
|
|
|
|
|
const queryFormRef = ref<ElFormInstance>();
|
|
|
|
|
|
|
|
|
|
const treeProps = {
|
|
|
|
|
value: 'id',
|
|
|
|
|
label: 'label',
|
|
|
|
|
children: 'children'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const queryParams = reactive<SrzUserQuery>({
|
|
|
|
|
pageNum: 1,
|
|
|
|
|
pageSize: 20,
|
|
|
|
|
organizationid: undefined,
|
|
|
|
|
fullname: undefined,
|
|
|
|
|
username: undefined,
|
|
|
|
|
mobilenumber: undefined
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 保存扁平列表用于计算可见节点
|
|
|
|
|
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, 2);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 节点点击事件 */
|
|
|
|
|
const handleNodeClick = (data: TreeNode) => {
|
|
|
|
|
selectedDeptName.value = data.label;
|
|
|
|
|
queryParams.organizationid = data.id;
|
|
|
|
|
queryParams.pageNum = 1;
|
|
|
|
|
getUserList();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 获取用户列表 */
|
|
|
|
|
const getUserList = async () => {
|
|
|
|
|
if (!queryParams.organizationid) {
|
|
|
|
|
userList.value = [];
|
|
|
|
|
total.value = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
loading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const res = await listSrzUser(queryParams);
|
|
|
|
|
userList.value = res.rows;
|
|
|
|
|
total.value = res.total;
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 搜索按钮 */
|
|
|
|
|
const handleQuery = () => {
|
|
|
|
|
queryParams.pageNum = 1;
|
|
|
|
|
getUserList();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 重置按钮 */
|
|
|
|
|
const resetQuery = () => {
|
|
|
|
|
queryFormRef.value?.resetFields();
|
|
|
|
|
queryParams.fullname = undefined;
|
|
|
|
|
queryParams.username = undefined;
|
|
|
|
|
queryParams.mobilenumber = undefined;
|
|
|
|
|
queryParams.pageNum = 1;
|
|
|
|
|
getUserList();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 计算树高度自适应视口 */
|
|
|
|
|
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>
|