447 lines
16 KiB
Vue
447 lines
16 KiB
Vue
<template>
|
||
<Modal
|
||
v-model="modal"
|
||
title="选择用户"
|
||
width="720px"
|
||
:mask-closable="false"
|
||
:closable="false"
|
||
>
|
||
<Tabs
|
||
v-if="sources.length > 1"
|
||
:key="tabKey"
|
||
type="card"
|
||
:value="String(tabIndex)"
|
||
@on-click="tabChange"
|
||
>
|
||
<TabPane v-for="(item, index) in sources" :key="index" :label="item.name" :name="index + ''"></TabPane>
|
||
</Tabs>
|
||
<AutoComplete
|
||
@on-search="handleSearch"
|
||
@on-select="handleSelect"
|
||
placeholder="输入名字搜索"
|
||
v-model="searchTreeName"
|
||
:clearable="true"
|
||
class="auto-box"
|
||
>
|
||
<Option v-for="item in searchResult" :key="item.id" :value="item.name">
|
||
<span>{{ item.name }}</span>
|
||
<span class="fr info">{{ item.group }}</span>
|
||
</Option>
|
||
</AutoComplete>
|
||
<div class="tree-wrap fix">
|
||
<div class="tree-panel fl">
|
||
<div class="tree-box">
|
||
<div :id="ztreeDomId" class="ztree"></div>
|
||
</div>
|
||
</div>
|
||
<div class="arrow-box fl">
|
||
<svg
|
||
t="1626916477711"
|
||
class="icon"
|
||
viewBox="0 0 1024 1024"
|
||
version="1.1"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
width="36"
|
||
height="36"
|
||
>
|
||
<path
|
||
d="M138.935 256.078v511.844h63.42V256.078h-63.42z m126.842 0v511.844h127.215V256.078H265.777zM885.064 512L583.254 64.323v191.755H456.414v511.844h126.842v191.755L885.064 512z"
|
||
fill="#2d8cf0"
|
||
></path>
|
||
</svg>
|
||
</div>
|
||
<div class="user-panel fl">
|
||
<div class="title-box fix">
|
||
<Checkbox @on-change="checkAllUser" v-model="isCheckUserAll">全选</Checkbox>
|
||
<div class="fr">{{ tempUserList.length }}</div>
|
||
<a
|
||
href="javascript:;"
|
||
@click="delCheckUser"
|
||
v-if="tempUserList.findIndex((x: any) => x.isCheck) > -1"
|
||
>删除选中</a
|
||
>
|
||
</div>
|
||
<ul class="user-box">
|
||
<li
|
||
v-for="item in tempUserList"
|
||
:key="item.id"
|
||
:class="{ active: item.isCheck }"
|
||
@click="checkUserOne(item)"
|
||
>
|
||
{{ item.name }}
|
||
<span class="info">{{ item.group }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button type="primary" @click="submit">提交</Button>
|
||
<Button @click="modal = false" style="margin-left: 8px">取消</Button>
|
||
</template>
|
||
</Modal>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
/*
|
||
* 选择用户弹窗(自旧前端 oldfront/src/components/userCheck.vue 适配 Vue3)。
|
||
* 左侧为带复选框的组织树(部门→人员,叶子=人员),勾选/取消按父子级联;
|
||
* 右侧为已选人员列表,支持全选/删除选中;提交返回去重后的用户 id 数组与用户对象数组。
|
||
*/
|
||
import { Message } from "view-ui-plus";
|
||
import { requireImg } from "@/utils/hook/useImg";
|
||
|
||
interface SelectSource {
|
||
name: string;
|
||
loader: () => Promise<any[]>;
|
||
}
|
||
|
||
export default {
|
||
name: "UserCheckModal",
|
||
props: {
|
||
/** 数据源 tab:{ name, loader },loader 返回 ztree 树数据(children 嵌套,叶子=人员) */
|
||
sources: {
|
||
type: Array as () => SelectSource[],
|
||
default: () => [],
|
||
},
|
||
/** 已选用户 id(打开时自动勾选) */
|
||
userids: {
|
||
type: Array as () => number[],
|
||
default: () => [],
|
||
},
|
||
isDisabled: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
},
|
||
emits: ["update:userids", "on-submit"],
|
||
data() {
|
||
return {
|
||
ztreeDomId: "user_check_tree_" + Math.floor(Math.random() * 1e8),
|
||
modal: false,
|
||
tabIndex: 0,
|
||
/** 每次打开弹窗自增,强制重建 Tabs,避免上次 tab 高亮残留与数据错位 */
|
||
tabKey: 0,
|
||
/** 树加载序号:tab 快速切换时只渲染最后一次请求的结果 */
|
||
treeSeq: 0,
|
||
isCheckUserAll: false,
|
||
cacheMap: new Map(),
|
||
currentTree: [] as any[],
|
||
searchTreeName: "",
|
||
searchResult: [] as any[],
|
||
tempUserList: [] as any[],
|
||
};
|
||
},
|
||
computed: {
|
||
/** 当前树扁平化(含父级名称,供搜索展示) */
|
||
currentTreeFlatten(): any[] {
|
||
const flatten = (data: any[], pid: number, pname: string): any[] =>
|
||
data.reduce(
|
||
(arr: any[], { id, name, children = [], type }: any) =>
|
||
arr.concat([{ id, name, pid, type, group: pname }], flatten(children, id, name)),
|
||
[]
|
||
);
|
||
return flatten(this.currentTree, 0, "");
|
||
},
|
||
},
|
||
methods: {
|
||
show() {
|
||
this.modal = true;
|
||
// 重置到第一个 tab:显式赋值 + key 重建 Tabs,保证高亮与数据一致
|
||
this.tabIndex = 0;
|
||
this.tabKey++;
|
||
this.changeEmpty();
|
||
this.initTree();
|
||
},
|
||
tabChange(index: string) {
|
||
const idx = Number(index);
|
||
// 严格比较:v-model 场景下 iview 会把值写成字符串 name,
|
||
// 宽松相等("1" == 1)会误判“未切换”导致 tab 永远不生效
|
||
if (idx !== 0 && this.tabIndex === idx) return;
|
||
this.tabIndex = idx;
|
||
this.changeEmpty();
|
||
this.initTree();
|
||
},
|
||
handleSearch(query: string) {
|
||
let arr: any[] = [];
|
||
const searchArr = this.currentTreeFlatten.filter(
|
||
(x: any) => x.type == 2 && x.name.indexOf(query) != -1
|
||
);
|
||
for (const item of searchArr) {
|
||
!arr
|
||
.map((x) => this.getSplitId(x.id))
|
||
.includes(this.getSplitId(item.id)) && arr.push(item);
|
||
}
|
||
this.searchResult = arr;
|
||
},
|
||
handleSelect(query: string) {
|
||
if (query.indexOf(",") != -1) query = query.split(",")[0];
|
||
var treeObj = ($.fn as any).zTree.getZTreeObj(this.ztreeDomId);
|
||
var node = treeObj.getNodeByParam("name", query, null);
|
||
if (!!node) treeObj.checkNode(node, true, true, true);
|
||
},
|
||
/**
|
||
* ztree 数据预处理:拼接 pid 保证同人多组时 id 可区分;叶子标记 type=2。
|
||
* 组/用户判定优先用接口 isParent(组=文件夹),缺失时按有无子节点兜底;
|
||
* 并设置组/用户区分图标。
|
||
*/
|
||
formatTree(tree: any[], level = 0, pid: number = 0) {
|
||
tree.forEach((element: any) => {
|
||
element.open = level == 0;
|
||
element.pid = pid;
|
||
element.id = element.id + "," + pid;
|
||
element.checked = false;
|
||
if (typeof element.isParent !== "undefined") {
|
||
element.type = element.isParent ? 1 : 2;
|
||
} else {
|
||
element.type = element.children && element.children.length > 0 ? 1 : 2;
|
||
}
|
||
element.icon = requireImg(
|
||
element.type == 1 ? "tree-icon-group.png" : "tree-icon-item.png",
|
||
"image"
|
||
);
|
||
if (typeof element.children !== "undefined" && element.children.length > 0) {
|
||
element.children = this.formatTree(element.children, level + 1, this.getSplitId(element.id));
|
||
}
|
||
});
|
||
return tree;
|
||
},
|
||
initTree() {
|
||
const _this = this;
|
||
// 竞态守卫:慢请求返回时若已切换 tab,丢弃结果
|
||
const seq = ++this.treeSeq;
|
||
const cacheKey = this.tabIndex;
|
||
const source = this.sources[this.tabIndex];
|
||
if (!source) return;
|
||
const cached = this.cacheMap.get(cacheKey);
|
||
if (cached) {
|
||
this.createTree(cached);
|
||
return;
|
||
}
|
||
source
|
||
.loader()
|
||
.then((data: any[]) => {
|
||
if (seq !== _this.treeSeq) return;
|
||
_this.cacheMap.set(cacheKey, data || []);
|
||
_this.createTree(data || []);
|
||
})
|
||
.catch((e: any) => {
|
||
if (seq === _this.treeSeq) Message.error(e?.message || "加载组织数据失败");
|
||
});
|
||
},
|
||
createTree(rawData: any[]) {
|
||
const _this = this;
|
||
var jtree = JSON.parse(JSON.stringify(rawData));
|
||
var ftree = _this.formatTree(jtree);
|
||
_this.currentTree = ftree;
|
||
|
||
let treeSetting: any = {
|
||
view: {
|
||
dblClickExpand: false,
|
||
showIcon: true,
|
||
},
|
||
check: {
|
||
enable: true,
|
||
chkboxType: { Y: "ps", N: "ps" },
|
||
},
|
||
callback: {},
|
||
};
|
||
/** 勾选变化后同步右侧已选列表:收集勾选节点子树内的所有叶子 */
|
||
function formatUser(treeNode: any, checked: boolean) {
|
||
const recursionCheckIds = (tree: any): any[] => {
|
||
let arrIds: any[] = [];
|
||
for (const key in tree) {
|
||
const element = tree[key];
|
||
if (element.type == 2 && !element.chkDisabled) {
|
||
arrIds.push({ id: element.id, tid: element.tId });
|
||
}
|
||
if (typeof element.children !== "undefined" && element.children) {
|
||
arrIds = [...arrIds, ...recursionCheckIds(element.children)];
|
||
}
|
||
}
|
||
return arrIds;
|
||
};
|
||
const checkIds = recursionCheckIds([treeNode]);
|
||
const checkUserList = _this.currentTreeFlatten.filter((x: any) =>
|
||
checkIds.map((x) => x.id).includes(x.id)
|
||
);
|
||
const checkUserArr: any[] = [];
|
||
for (const element of checkUserList) {
|
||
var findObj = checkIds.find((x) => x.id == element.id);
|
||
if (
|
||
!!findObj &&
|
||
checkUserArr.findIndex((x) => x.id == element.id) === -1 &&
|
||
_this.tempUserList.findIndex((x) => x.id == element.id) === -1
|
||
) {
|
||
checkUserArr.push({ ...element, isCheck: false, tid: findObj.tid });
|
||
}
|
||
}
|
||
if (checked) {
|
||
_this.tempUserList = _this.tempUserList.concat(checkUserArr);
|
||
} else {
|
||
var ids = checkUserList.map((x) => x.id);
|
||
_this.tempUserList = _this.tempUserList.filter((x) => !ids.includes(x.id));
|
||
}
|
||
}
|
||
treeSetting.callback.onClick = (event: any, treeId: string, treeNode: any) => {
|
||
let check = treeNode.checked;
|
||
let treeObj = ($.fn as any).zTree.getZTreeObj(treeId);
|
||
treeObj.checkNode(treeNode, !check, true);
|
||
formatUser(treeNode, treeNode.checked);
|
||
};
|
||
treeSetting.callback.onCheck = (event: any, treeId: string, treeNode: any) => {
|
||
formatUser(treeNode, treeNode.checked);
|
||
};
|
||
var treeObj = ($.fn as any).zTree.init($("#" + _this.ztreeDomId), treeSetting, ftree);
|
||
// 回显:已选用户打勾(callbackFlag=true 会触发 onCheck,同步右侧列表)
|
||
var nodes = treeObj.getNodesByFilter((x: any) =>
|
||
_this.userids.includes(_this.getSplitId(x.id))
|
||
);
|
||
var arr: any[] = [];
|
||
nodes.forEach((element: any) => {
|
||
var splitId = _this.getSplitId(element.id); //重复id只选择第一个
|
||
if (!arr.includes(splitId)) treeObj.checkNode(element, true, true, true);
|
||
if (_this.isDisabled) treeObj.setChkDisabled(element, true);
|
||
arr.push(splitId);
|
||
});
|
||
},
|
||
checkAllUser(check: boolean) {
|
||
this.tempUserList.forEach((element: any) => {
|
||
element.isCheck = check;
|
||
});
|
||
},
|
||
checkUserOne(item: any) {
|
||
item.isCheck = !item.isCheck;
|
||
const tlist = this.tempUserList;
|
||
this.isCheckUserAll =
|
||
tlist.length > 0 && tlist.filter((x) => x.isCheck).length == tlist.length;
|
||
},
|
||
delCheckUser() {
|
||
const _this = this;
|
||
const tlist = _this.tempUserList;
|
||
const delArr = tlist.filter((item: any) => item.isCheck);
|
||
var treeObj = ($.fn as any).zTree.getZTreeObj(_this.ztreeDomId);
|
||
delArr.forEach((element: any) => {
|
||
var node = treeObj.getNodeByTId(element.tid);
|
||
treeObj.checkNode(node, false, true, true);
|
||
});
|
||
_this.isCheckUserAll = false;
|
||
},
|
||
changeEmpty() {
|
||
this.tempUserList = [];
|
||
this.searchTreeName = "";
|
||
this.searchResult = [];
|
||
},
|
||
submit() {
|
||
const _this = this;
|
||
let idsArr = this.tempUserList.map((x: any) => _this.getSplitId(x.id));
|
||
idsArr = Array.from(new Set(idsArr));
|
||
this.$emit("update:userids", idsArr);
|
||
function delKey(obj: any, ...args: string[]) {
|
||
args.forEach((v) => delete obj[v]);
|
||
return obj;
|
||
}
|
||
const objArr: any[] = [];
|
||
idsArr.forEach((element) => {
|
||
let findObj = _this.tempUserList.find((x: any) => _this.getSplitId(x.id) == element);
|
||
findObj = delKey({ ...findObj }, "id", "isCheck", "tid", "pid", "type");
|
||
objArr.push({ ...findObj, id: element });
|
||
});
|
||
this.$emit("on-submit", objArr);
|
||
this.modal = false;
|
||
},
|
||
/** 树节点 id 形如 "1,301"(原id,父id),取真实 id */
|
||
getSplitId(strId: any) {
|
||
if (typeof strId === "string" && strId.indexOf(",") != -1) return parseInt(strId.split(",")[0]);
|
||
return strId;
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.tree-wrap {
|
||
height: 430px;
|
||
position: relative;
|
||
}
|
||
.tree-panel {
|
||
border: 1px solid #eee;
|
||
width: 280px;
|
||
height: 430px;
|
||
overflow: hidden;
|
||
}
|
||
.tree-box {
|
||
padding: 4px 0;
|
||
height: 428px;
|
||
width: auto;
|
||
overflow-y: auto;
|
||
border: none;
|
||
}
|
||
.user-panel {
|
||
border: 1px solid #eee;
|
||
width: 280px;
|
||
.title-box {
|
||
background: #f7f7f7;
|
||
height: 34px;
|
||
line-height: 34px;
|
||
padding: 0 12px;
|
||
a {
|
||
text-decoration: none;
|
||
float: right;
|
||
margin-right: 20px;
|
||
outline: none;
|
||
}
|
||
}
|
||
.user-box {
|
||
height: 396px;
|
||
overflow-y: auto;
|
||
li {
|
||
line-height: 32px;
|
||
padding-left: 12px;
|
||
cursor: pointer;
|
||
.info {
|
||
float: right;
|
||
margin-right: 12px;
|
||
font-size: 12px;
|
||
color: gray;
|
||
}
|
||
}
|
||
li:hover {
|
||
background: #f3f3f3;
|
||
}
|
||
li.active {
|
||
background: #e4edf6;
|
||
}
|
||
}
|
||
}
|
||
.arrow-box {
|
||
width: 128px;
|
||
text-align: center;
|
||
position: relative;
|
||
top: 50%;
|
||
margin-top: -17px;
|
||
}
|
||
.auto-box {
|
||
width: 200px;
|
||
margin-bottom: 10px;
|
||
:deep(.ivu-select-item .info) {
|
||
font-size: 12px;
|
||
color: gray;
|
||
position: relative;
|
||
top: 2px;
|
||
}
|
||
}
|
||
:deep(.ztree li) {
|
||
overflow: hidden;
|
||
}
|
||
:deep(.ztree li a) {
|
||
width: 100%;
|
||
}
|
||
:deep(.ztree li a:hover) {
|
||
text-decoration: none;
|
||
}
|
||
:deep(.ivu-auto-complete .ivu-select-dropdown-list) {
|
||
max-height: 310px;
|
||
overflow-y: auto;
|
||
}
|
||
</style>
|