|
@@ -0,0 +1,1074 @@
|
|
|
+<template>
|
|
|
+ <div class="task-select-tree" :style="{ width: componentWidth }">
|
|
|
+ <el-popover
|
|
|
+ ref="popover"
|
|
|
+ placement="bottom-start"
|
|
|
+ :width="popoverComputedWidth"
|
|
|
+ trigger="click"
|
|
|
+ v-model="popoverVisible"
|
|
|
+ popper-class="task-select-tree-popper"
|
|
|
+ :disabled="disabled"
|
|
|
+ @show="onPopoverShow"
|
|
|
+ >
|
|
|
+ <div class="popover-content-wrapper">
|
|
|
+ <el-date-picker v-model="dateRange"
|
|
|
+ size="small"
|
|
|
+ style="width: 220px"
|
|
|
+ value-format="yyyy-MM-dd"
|
|
|
+ type="daterange"
|
|
|
+ range-separator="-"
|
|
|
+ start-placeholder="营期开始日期"
|
|
|
+ end-placeholder="营期结束日期"
|
|
|
+ @change="dateRangeChange"/>
|
|
|
+ <button type="primary" @click="dateRangeChange(dateRange)">查询</button>
|
|
|
+ <br/>
|
|
|
+ <br/>
|
|
|
+ <el-input
|
|
|
+ v-if="filterable"
|
|
|
+ placeholder="输入关键字过滤"
|
|
|
+ v-model="filterText"
|
|
|
+ size="mini"
|
|
|
+ clearable
|
|
|
+ class="filter-input"
|
|
|
+ @input="handleFilterDebounced"
|
|
|
+ >
|
|
|
+ </el-input>
|
|
|
+
|
|
|
+ <div class="tree-container" :style="{ height: treeHeight + 'px' }">
|
|
|
+ <div
|
|
|
+ ref="virtualList"
|
|
|
+ class="virtual-tree-list"
|
|
|
+ @scroll="handleScroll"
|
|
|
+ :style="{ height: treeHeight + 'px', overflow: 'auto' }"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ class="virtual-content"
|
|
|
+ :style="{
|
|
|
+ height: totalHeight + 'px',
|
|
|
+ position: 'relative',
|
|
|
+ paddingTop: offsetY + 'px'
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ v-for="(item, index) in visibleItems"
|
|
|
+ :key="`${item.key}-${item.level}-${index}`"
|
|
|
+ :style="{
|
|
|
+ height: itemHeight + 'px',
|
|
|
+ paddingLeft: (item.level * indentSize) + 'px',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ position: 'relative'
|
|
|
+ }"
|
|
|
+ :class="[
|
|
|
+ 'virtual-tree-node',
|
|
|
+ {
|
|
|
+ 'is-leaf': item.isLeaf,
|
|
|
+ 'is-checked': checkedKeysSet.has(item.key) && (item.isLeaf || parentSelectable || !checkStrictly),
|
|
|
+ 'is-disabled': item.nodeDisabled || checkboxDisabled(item) // Combined disabled state for row styling
|
|
|
+ }
|
|
|
+ ]"
|
|
|
+ @click="handleNodeClick(item)"
|
|
|
+ >
|
|
|
+ <div class="node-expand-container">
|
|
|
+ <i
|
|
|
+ v-if="!item.isLeaf"
|
|
|
+ :class="[
|
|
|
+ 'node-expand-icon',
|
|
|
+ item.expanded ? 'el-icon-caret-bottom' : 'el-icon-caret-right'
|
|
|
+ ]"
|
|
|
+ @click.stop="toggleNodeExpand(item)"
|
|
|
+ ></i>
|
|
|
+ <span v-else class="node-expand-placeholder"></span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-checkbox
|
|
|
+ :value="checkedKeysSet.has(item.key)"
|
|
|
+ :disabled="checkboxDisabled(item)"
|
|
|
+ @change="handleCheckboxChange(item, $event)"
|
|
|
+ @click.stop
|
|
|
+ class="node-checkbox"
|
|
|
+ ></el-checkbox>
|
|
|
+
|
|
|
+ <span class="node-label" :title="item.label">{{ item.label }}</span>
|
|
|
+
|
|
|
+ <span v-if="item.isLeaf && showNodeDetail" class="node-detail-text">
|
|
|
+ (ID: {{ item.originalData.qwUserId || item.originalData.id }}{{ item.originalData.taskExecDate ? ' ' + item.originalData.taskExecDate : '' }})
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="popover-footer">
|
|
|
+ <span class="selected-count">已选择: {{ displaySelectedCount }}</span>
|
|
|
+ <el-button size="mini" @click="handleClearInPopover">清空</el-button>
|
|
|
+ <el-button size="mini" type="primary" @click="handleSelectAllToggle">{{ isCurrentSelectionAll ? '取消全选' : '全选' }}</el-button>
|
|
|
+ <el-button type="primary" size="mini" @click="handleConfirm">确定</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ slot="reference"
|
|
|
+ :class="['select-trigger-wrapper', { 'is-disabled': disabled, 'is-active': popoverVisible }]"
|
|
|
+ @mouseenter="inputHovering = true"
|
|
|
+ @mouseleave="inputHovering = false"
|
|
|
+ >
|
|
|
+ <div class="tags-or-value-container">
|
|
|
+ <template v-if="multiple && displaySelectedNodes.length > 0">
|
|
|
+ <el-tag
|
|
|
+ v-for="node in displaySelectedNodes"
|
|
|
+ :key="node.key"
|
|
|
+ type="info"
|
|
|
+ size="small"
|
|
|
+ closable
|
|
|
+ :disable-transitions="true"
|
|
|
+ @close.stop="removeTag(node)"
|
|
|
+ class="trigger-tag"
|
|
|
+ >
|
|
|
+ {{ node.label }}
|
|
|
+ </el-tag>
|
|
|
+ <el-tag
|
|
|
+ v-if="displaySelectedCount > maxDisplayTags && displaySelectedNodes.length === maxDisplayTags"
|
|
|
+ type="info"
|
|
|
+ size="small"
|
|
|
+ class="trigger-tag"
|
|
|
+ >
|
|
|
+ + {{ displaySelectedCount - maxDisplayTags }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ <span v-else-if="!multiple && displaySelectedCount > 0" class="single-value-display">
|
|
|
+ {{ singleSelectedLabel }}
|
|
|
+ </span>
|
|
|
+ <span v-else class="placeholder-text">{{ placeholder }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <span class="icons-container">
|
|
|
+ <i
|
|
|
+ v-if="showClearIcon"
|
|
|
+ class="el-icon-circle-close clear-icon"
|
|
|
+ @click.stop="handleClearOnTrigger"
|
|
|
+ ></i>
|
|
|
+ <i :class="['el-icon-arrow-up', 'arrow-icon', { 'is-reverse': !popoverVisible }]"></i>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </el-popover>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+// 防抖函数
|
|
|
+function debounce(func, wait) {
|
|
|
+ let timeout;
|
|
|
+ return function executedFunction(...args) {
|
|
|
+ const later = () => {
|
|
|
+ clearTimeout(timeout);
|
|
|
+ func(...args);
|
|
|
+ };
|
|
|
+ clearTimeout(timeout);
|
|
|
+ timeout = setTimeout(later, wait);
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'OptimizedSelectTree',
|
|
|
+ props: {
|
|
|
+ value: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
+ },
|
|
|
+ rawData: {
|
|
|
+ type: Array,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ placeholder: {
|
|
|
+ type: String,
|
|
|
+ default: '请选择'
|
|
|
+ },
|
|
|
+ multiple: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ disabled: { // Global disabled for the component
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ checkStrictly: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false // Important for parent-child linkage
|
|
|
+ },
|
|
|
+ filterable: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ dateRange: {
|
|
|
+ type: Array,
|
|
|
+ default: null
|
|
|
+ },
|
|
|
+ maxDisplayTags: {
|
|
|
+ type: Number,
|
|
|
+ default: 2
|
|
|
+ },
|
|
|
+ componentWidth: {
|
|
|
+ type: String,
|
|
|
+ default: '100%'
|
|
|
+ },
|
|
|
+ popoverWidth: {
|
|
|
+ type: [String, Number],
|
|
|
+ default: 'auto'
|
|
|
+ },
|
|
|
+ treeProps: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({
|
|
|
+ children: 'children',
|
|
|
+ label: 'label',
|
|
|
+ isLeaf: 'isLeaf',
|
|
|
+ disabled: 'disabled' // Key for disabled state in rawData
|
|
|
+ })
|
|
|
+ },
|
|
|
+ nodeKey: {
|
|
|
+ type: String,
|
|
|
+ default: 'id'
|
|
|
+ },
|
|
|
+ defaultExpandAll: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ showNodeDetail: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ treeHeight: {
|
|
|
+ type: Number,
|
|
|
+ default: 280
|
|
|
+ },
|
|
|
+ returnLeafOnly: {
|
|
|
+ // This prop's effect on output is now overridden.
|
|
|
+ // It might still be used by consumers for other logic if they rely on its original meaning.
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ parentSelectable: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ popoverVisible: false,
|
|
|
+ filterText: '',
|
|
|
+ inputHovering: false,
|
|
|
+ triggerElRect: null,
|
|
|
+ itemHeight: 36,
|
|
|
+ indentSize: 24,
|
|
|
+ startIndex: 0,
|
|
|
+ endIndex: 0,
|
|
|
+ scrollTop: 0,
|
|
|
+ visibleItemCount: 0,
|
|
|
+ flattenedNodes: [],
|
|
|
+ nodeMap: new Map(),
|
|
|
+ checkedKeysSet: new Set(),
|
|
|
+ expandedKeysSet: new Set(),
|
|
|
+ updateTimer: null
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ targetLeafNodes() {
|
|
|
+ if (this.filterText.trim()) {
|
|
|
+ return this.currentResultLeafNodes;
|
|
|
+ }
|
|
|
+ return this.allSelectableLeafNodes;
|
|
|
+ },
|
|
|
+
|
|
|
+ isCurrentSelectionAll() {
|
|
|
+ const nodes = this.targetLeafNodes;
|
|
|
+ if (nodes.length === 0) return false;
|
|
|
+ return nodes.every(node => this.checkedKeysSet.has(node.key));
|
|
|
+ },
|
|
|
+ popoverComputedWidth() {
|
|
|
+ if (this.popoverWidth === 'auto') {
|
|
|
+ return this.triggerElRect ? this.triggerElRect.width : 300;
|
|
|
+ }
|
|
|
+ return parseInt(this.popoverWidth, 10);
|
|
|
+ },
|
|
|
+ totalHeight() {
|
|
|
+ return this.visibleNodes.length * this.itemHeight;
|
|
|
+ },
|
|
|
+ offsetY() {
|
|
|
+ return this.startIndex * this.itemHeight;
|
|
|
+ },
|
|
|
+ visibleNodes() {
|
|
|
+ let nodes = this.flattenedNodes;
|
|
|
+ if (this.filterText.trim()) {
|
|
|
+ nodes = this.applyFilter(this.flattenedNodes);
|
|
|
+ }
|
|
|
+
|
|
|
+ const visible = [];
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (this.isNodeCurrentlyVisible(node)) {
|
|
|
+ visible.push(node);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return visible;
|
|
|
+ },
|
|
|
+ visibleItems() {
|
|
|
+ const items = this.visibleNodes.slice(this.startIndex, this.endIndex);
|
|
|
+ return items.map(node => ({
|
|
|
+ ...node,
|
|
|
+ expanded: this.expandedKeysSet.has(node.key)
|
|
|
+ }));
|
|
|
+ },
|
|
|
+ // Actual count of selected leaf items
|
|
|
+ displaySelectedCount() {
|
|
|
+ let leafCount = 0;
|
|
|
+ this.checkedKeysSet.forEach(key => {
|
|
|
+ const node = this.nodeMap.get(key);
|
|
|
+ if (node && node.isLeaf) {
|
|
|
+ leafCount++;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return leafCount;
|
|
|
+ },
|
|
|
+ displaySelectedNodes() {
|
|
|
+ if (!this.multiple) return [];
|
|
|
+ const resultNodes = [];
|
|
|
+ Array.from(this.checkedKeysSet)
|
|
|
+ .map(key => this.nodeMap.get(key))
|
|
|
+ .filter(Boolean)
|
|
|
+ .forEach(node => {
|
|
|
+ // Always display tags for leaf nodes that are selected.
|
|
|
+ if (node.isLeaf) {
|
|
|
+ resultNodes.push(node);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return resultNodes.slice(0, this.maxDisplayTags);
|
|
|
+ },
|
|
|
+ singleSelectedLabel() {
|
|
|
+ if (this.multiple || this.value.length === 0) return '';
|
|
|
+ // 'value' prop should only contain leaf keys due to emitChange modification.
|
|
|
+ // For single select, value should have at most one key.
|
|
|
+ const selectedKey = String(this.value[0]);
|
|
|
+ const node = this.nodeMap.get(selectedKey);
|
|
|
+ // We expect 'value' to contain a leaf key.
|
|
|
+ return node ? node.label : '';
|
|
|
+ },
|
|
|
+ showClearIcon() {
|
|
|
+ return (
|
|
|
+ !this.disabled &&
|
|
|
+ this.inputHovering &&
|
|
|
+ this.displaySelectedCount > 0 && // Based on leaf count now
|
|
|
+ !this.multiple
|
|
|
+ );
|
|
|
+ },
|
|
|
+ // 计算当前搜索结果中的所有叶子节点
|
|
|
+ currentResultLeafNodes() {
|
|
|
+ return this.visibleNodes.filter(node => node.isLeaf && !this.checkboxDisabled(node));
|
|
|
+ },
|
|
|
+ // 判断当前搜索结果是否全部选中
|
|
|
+ isAllCurrentResultsSelected() {
|
|
|
+ const leafNodes = this.currentResultLeafNodes;
|
|
|
+ if (leafNodes.length === 0) return false;
|
|
|
+ return leafNodes.every(node => this.checkedKeysSet.has(node.key));
|
|
|
+ }, // 新增:计算所有可选叶子节点
|
|
|
+ allSelectableLeafNodes() {
|
|
|
+ return this.flattenedNodes.filter(node => node.isLeaf && !this.checkboxDisabled(node));
|
|
|
+ },
|
|
|
+
|
|
|
+ // 新增:判断是否所有数据都被选中
|
|
|
+ isAllDataSelected() {
|
|
|
+ const allLeafNodes = this.allSelectableLeafNodes;
|
|
|
+ if (allLeafNodes.length === 0) return false;
|
|
|
+ return allLeafNodes.every(node => this.checkedKeysSet.has(node.key));
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ rawData: {
|
|
|
+ handler(newData) {
|
|
|
+ this.processRawData(newData);
|
|
|
+ this.syncCheckedKeysFromValue();
|
|
|
+ },
|
|
|
+ immediate: true
|
|
|
+ },
|
|
|
+ value: {
|
|
|
+ handler() {
|
|
|
+ this.syncCheckedKeysFromValue();
|
|
|
+ },
|
|
|
+ deep: true,
|
|
|
+ },
|
|
|
+ filterText: {
|
|
|
+ handler() {
|
|
|
+ this.resetScroll();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ popoverVisible(isVisible) {
|
|
|
+ if (isVisible) {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.calculateVisibleRange();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.handleFilterDebounced = debounce(this.filterTextChange, 300);
|
|
|
+ this.visibleItemCount = Math.ceil(this.treeHeight / this.itemHeight) + 5;
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ dateRangeChange(e){
|
|
|
+ this.$emit("dateRangeChange",e)
|
|
|
+ },
|
|
|
+ filterTextChange() {
|
|
|
+ this.resetScroll();
|
|
|
+ },
|
|
|
+ processRawData(data) {
|
|
|
+ if (!Array.isArray(data)) {
|
|
|
+ this.flattenedNodes = [];
|
|
|
+ this.nodeMap.clear();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const flattened = [];
|
|
|
+ const nodeMap = new Map();
|
|
|
+ const expandedKeys = new Set();
|
|
|
+
|
|
|
+ const processNode = (node, level = 0, parentKey = null) => {
|
|
|
+ const children = node[this.treeProps.children] || [];
|
|
|
+ const hasChildren = children.length > 0;
|
|
|
+ const key = node[this.nodeKey];
|
|
|
+
|
|
|
+ if (key === undefined || key === null) {
|
|
|
+ console.warn('Node lacks a unique key:', node);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const strKey = String(key);
|
|
|
+
|
|
|
+ let label = node[this.treeProps.label];
|
|
|
+ if (typeof label !== 'string' || label.trim() === '') {
|
|
|
+ label = node.name || node.title || node.text || `Node-${strKey}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const nodeDisabledByData = !!node[this.treeProps.disabled];
|
|
|
+
|
|
|
+ const processedNode = {
|
|
|
+ key: strKey,
|
|
|
+ label: String(label).trim(),
|
|
|
+ level,
|
|
|
+ isLeaf: !hasChildren,
|
|
|
+ nodeDisabled: nodeDisabledByData,
|
|
|
+ parentKey: parentKey ? String(parentKey) : null,
|
|
|
+ childrenKeys: hasChildren ? children.map(child => String(child[this.nodeKey])).filter(k => k !== undefined && k !== null) : [],
|
|
|
+ originalData: node,
|
|
|
+ hasChildren
|
|
|
+ };
|
|
|
+
|
|
|
+ flattened.push(processedNode);
|
|
|
+ nodeMap.set(processedNode.key, processedNode);
|
|
|
+
|
|
|
+ if (this.defaultExpandAll && hasChildren && !nodeDisabledByData) {
|
|
|
+ expandedKeys.add(processedNode.key);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (hasChildren) {
|
|
|
+ children.forEach(child => processNode(child, level + 1, strKey));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ data.forEach(node => processNode(node));
|
|
|
+
|
|
|
+ this.flattenedNodes = flattened;
|
|
|
+ this.nodeMap = nodeMap;
|
|
|
+ this.expandedKeysSet = new Set(expandedKeys);
|
|
|
+ },
|
|
|
+
|
|
|
+ isNodeCurrentlyVisible(node, currentExpandedSet = this.expandedKeysSet) {
|
|
|
+ if (node.level === 0) return true;
|
|
|
+ let current = node;
|
|
|
+ while (current.parentKey) {
|
|
|
+ const parent = this.nodeMap.get(current.parentKey);
|
|
|
+ if (!parent || !currentExpandedSet.has(parent.key)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ current = parent;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ },
|
|
|
+
|
|
|
+ applyFilter(nodesToFilter) {
|
|
|
+ const searchText = this.filterText.toLowerCase().trim();
|
|
|
+ if (!searchText) return nodesToFilter;
|
|
|
+
|
|
|
+ const matchedNodeKeys = new Set();
|
|
|
+ const newExpandedKeys = new Set(this.expandedKeysSet);
|
|
|
+
|
|
|
+ nodesToFilter.forEach(node => {
|
|
|
+ if (node.label.toLowerCase().includes(searchText)) {
|
|
|
+ matchedNodeKeys.add(node.key);
|
|
|
+ let parentKey = node.parentKey;
|
|
|
+ while (parentKey) {
|
|
|
+ const parentNode = this.nodeMap.get(parentKey);
|
|
|
+ if (parentNode) {
|
|
|
+ newExpandedKeys.add(parentKey);
|
|
|
+ parentKey = parentNode.parentKey;
|
|
|
+ } else {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const filteredResult = nodesToFilter.filter(node => {
|
|
|
+ return this.hasMatchInSubtreeOrSelf(node, searchText);
|
|
|
+ });
|
|
|
+
|
|
|
+ this.expandedKeysSet = newExpandedKeys;
|
|
|
+ return filteredResult;
|
|
|
+ },
|
|
|
+ hasMatchInSubtreeOrSelf(node, searchText) {
|
|
|
+ if (node.label.toLowerCase().includes(searchText)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (!node.isLeaf) {
|
|
|
+ for (const childKey of node.childrenKeys) {
|
|
|
+ const childNode = this.nodeMap.get(childKey);
|
|
|
+ if (childNode && this.hasMatchInSubtreeOrSelf(childNode, searchText)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ },
|
|
|
+ checkboxDisabled(item) {
|
|
|
+ if (item.nodeDisabled) return true;
|
|
|
+ if (this.checkStrictly && !item.isLeaf && !this.parentSelectable) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ },
|
|
|
+
|
|
|
+ calculateVisibleRange() {
|
|
|
+ const containerHeight = this.treeHeight;
|
|
|
+ const totalItems = this.visibleNodes.length;
|
|
|
+
|
|
|
+ const newStartIndex = Math.floor(this.scrollTop / this.itemHeight);
|
|
|
+ const visibleCountInViewport = Math.ceil(containerHeight / this.itemHeight);
|
|
|
+
|
|
|
+ const buffer = 5;
|
|
|
+ this.startIndex = Math.max(0, newStartIndex - buffer);
|
|
|
+ this.endIndex = Math.min(totalItems, newStartIndex + visibleCountInViewport + buffer);
|
|
|
+ },
|
|
|
+ handleScroll(e) {
|
|
|
+ const newScrollTop = e.target.scrollTop;
|
|
|
+ if (newScrollTop !== this.scrollTop) {
|
|
|
+ this.scrollTop = newScrollTop;
|
|
|
+ if (!this.scrollRAF) {
|
|
|
+ this.scrollRAF = requestAnimationFrame(() => {
|
|
|
+ this.calculateVisibleRange();
|
|
|
+ this.scrollRAF = null;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ resetScroll() {
|
|
|
+ this.scrollTop = 0;
|
|
|
+ if (this.$refs.virtualList) {
|
|
|
+ this.$refs.virtualList.scrollTop = 0;
|
|
|
+ }
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.calculateVisibleRange();
|
|
|
+ });
|
|
|
+ },
|
|
|
+ toggleNodeExpand(node) {
|
|
|
+ if (node.isLeaf || node.nodeDisabled) return;
|
|
|
+ const key = node.key;
|
|
|
+ if (this.expandedKeysSet.has(key)) {
|
|
|
+ this.expandedKeysSet.delete(key);
|
|
|
+ } else {
|
|
|
+ this.expandedKeysSet.add(key);
|
|
|
+ }
|
|
|
+ this.expandedKeysSet = new Set(this.expandedKeysSet);
|
|
|
+ this.$nextTick(() => this.calculateVisibleRange());
|
|
|
+ },
|
|
|
+ handleNodeClick(node) {
|
|
|
+ if (node.nodeDisabled) return;
|
|
|
+
|
|
|
+ if (!node.isLeaf && !this.parentSelectable) {
|
|
|
+ this.toggleNodeExpand(node);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!node.isLeaf) {
|
|
|
+ this.toggleNodeExpand(node);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.checkboxDisabled(node)) {
|
|
|
+ const isCurrentlyChecked = this.checkedKeysSet.has(node.key);
|
|
|
+ this.handleCheckboxChange(node, !isCurrentlyChecked);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ handleCheckboxChange(node, checked) {
|
|
|
+ if (this.checkboxDisabled(node)) return;
|
|
|
+
|
|
|
+ const newCheckedKeys = new Set(this.checkedKeysSet);
|
|
|
+ const canNodeItselfBeInSet = node.isLeaf || this.parentSelectable || !this.checkStrictly;
|
|
|
+
|
|
|
+ if (checked) {
|
|
|
+ if (!this.multiple) {
|
|
|
+ newCheckedKeys.clear();
|
|
|
+ }
|
|
|
+ if (canNodeItselfBeInSet) {
|
|
|
+ newCheckedKeys.add(node.key);
|
|
|
+ }
|
|
|
+ if (!this.checkStrictly) {
|
|
|
+ this.selectAllChildren(node, newCheckedKeys);
|
|
|
+ this.checkParentSelection(node, newCheckedKeys);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (canNodeItselfBeInSet) {
|
|
|
+ newCheckedKeys.delete(node.key);
|
|
|
+ }
|
|
|
+ if (!this.checkStrictly) {
|
|
|
+ this.unselectAllChildren(node, newCheckedKeys);
|
|
|
+ this.uncheckParentSelection(node, newCheckedKeys);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.checkedKeysSet = newCheckedKeys;
|
|
|
+ this.emitChange();
|
|
|
+ },
|
|
|
+ selectAllChildren(node, checkedKeys) {
|
|
|
+ if (node.childrenKeys && node.childrenKeys.length > 0) {
|
|
|
+ node.childrenKeys.forEach(childKey => {
|
|
|
+ const childNode = this.nodeMap.get(childKey);
|
|
|
+ if (childNode && !childNode.nodeDisabled) {
|
|
|
+ const canChildBeInSet = childNode.isLeaf || this.parentSelectable || !this.checkStrictly;
|
|
|
+ if(canChildBeInSet){
|
|
|
+ checkedKeys.add(childKey);
|
|
|
+ }
|
|
|
+ if (!childNode.isLeaf) {
|
|
|
+ this.selectAllChildren(childNode, checkedKeys);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ unselectAllChildren(node, checkedKeys) {
|
|
|
+ if (node.childrenKeys && node.childrenKeys.length > 0) {
|
|
|
+ node.childrenKeys.forEach(childKey => {
|
|
|
+ const childNode = this.nodeMap.get(childKey);
|
|
|
+ if (childNode) {
|
|
|
+ const canChildBeInSet = childNode.isLeaf || this.parentSelectable || !this.checkStrictly;
|
|
|
+ if(canChildBeInSet){
|
|
|
+ checkedKeys.delete(childKey);
|
|
|
+ }
|
|
|
+ if (!childNode.isLeaf) {
|
|
|
+ this.unselectAllChildren(childNode, checkedKeys);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ checkParentSelection(node, checkedKeys) {
|
|
|
+ if (!node.parentKey || this.checkStrictly) return;
|
|
|
+
|
|
|
+ const parentNode = this.nodeMap.get(node.parentKey);
|
|
|
+ if (parentNode && !parentNode.nodeDisabled && (this.parentSelectable || !this.checkStrictly)) {
|
|
|
+ const allChildrenChecked = parentNode.childrenKeys.every(childKey => {
|
|
|
+ const child = this.nodeMap.get(childKey);
|
|
|
+ return !child || child.nodeDisabled || checkedKeys.has(childKey);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (allChildrenChecked) {
|
|
|
+ checkedKeys.add(parentNode.key);
|
|
|
+ this.checkParentSelection(parentNode, checkedKeys);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ uncheckParentSelection(node, checkedKeys) {
|
|
|
+ if (!node.parentKey || this.checkStrictly) return;
|
|
|
+ const parentNode = this.nodeMap.get(node.parentKey);
|
|
|
+ if (parentNode && (this.parentSelectable || !this.checkStrictly)) {
|
|
|
+ if (checkedKeys.has(parentNode.key)) {
|
|
|
+ checkedKeys.delete(parentNode.key);
|
|
|
+ this.uncheckParentSelection(parentNode, checkedKeys);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ emitChange() {
|
|
|
+ let finalKeys = [];
|
|
|
+ const currentInternalCheckedKeys = Array.from(this.checkedKeysSet);
|
|
|
+
|
|
|
+ // ALWAYS return only leaf nodes in the emitted value.
|
|
|
+ currentInternalCheckedKeys.forEach(key => {
|
|
|
+ const node = this.nodeMap.get(key);
|
|
|
+ if (node && node.isLeaf) {
|
|
|
+ finalKeys.push(key);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ finalKeys = [...new Set(finalKeys)]; // Deduplicate
|
|
|
+ const finalNodes = finalKeys.map(k => this.nodeMap.get(k)).filter(Boolean);
|
|
|
+
|
|
|
+ this.$emit('input', finalKeys);
|
|
|
+ this.$emit('change', finalKeys, finalNodes);
|
|
|
+ },
|
|
|
+ syncCheckedKeysFromValue() {
|
|
|
+ const newCheckedKeys = new Set();
|
|
|
+ if (Array.isArray(this.value)) {
|
|
|
+ this.value.forEach(key => {
|
|
|
+ const strKey = String(key);
|
|
|
+ const node = this.nodeMap.get(strKey);
|
|
|
+ if (node) {
|
|
|
+ newCheckedKeys.add(strKey);
|
|
|
+ if (!node.isLeaf && !this.checkStrictly) {
|
|
|
+ this.selectAllChildren(node, newCheckedKeys);
|
|
|
+ this.checkParentSelection(node, newCheckedKeys);
|
|
|
+ } else if (node.isLeaf && !this.checkStrictly) {
|
|
|
+ this.checkParentSelection(node, newCheckedKeys);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.checkedKeysSet = newCheckedKeys;
|
|
|
+
|
|
|
+ if(this.popoverVisible) {
|
|
|
+ this.$nextTick(() => this.calculateVisibleRange());
|
|
|
+ }
|
|
|
+ },
|
|
|
+ handleConfirm() {
|
|
|
+ this.emitChange();
|
|
|
+ this.popoverVisible = false;
|
|
|
+ },
|
|
|
+ handleClearInPopover() {
|
|
|
+ this.checkedKeysSet.clear();
|
|
|
+ this.emitChange();
|
|
|
+ },
|
|
|
+ handleClearOnTrigger() {
|
|
|
+ if (this.disabled) return;
|
|
|
+ this.checkedKeysSet.clear();
|
|
|
+ this.emitChange();
|
|
|
+ this.popoverVisible = false;
|
|
|
+ },
|
|
|
+ // 新增:处理全选/取消全选功能
|
|
|
+ handleSelectAllToggle() {
|
|
|
+ if (this.disabled) return;
|
|
|
+
|
|
|
+ const leafNodes = this.targetLeafNodes;
|
|
|
+ if (leafNodes.length === 0) return;
|
|
|
+
|
|
|
+ // 根据当前状态决定是全选还是取消全选
|
|
|
+ const shouldSelect = !this.isCurrentSelectionAll;
|
|
|
+
|
|
|
+ if (!this.multiple && shouldSelect) {
|
|
|
+ // 单选模式下,只选择第一个叶子节点
|
|
|
+ const firstLeaf = leafNodes[0];
|
|
|
+ if (firstLeaf) {
|
|
|
+ this.handleCheckboxChange(firstLeaf, true);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const newCheckedKeys = new Set(this.checkedKeysSet);
|
|
|
+
|
|
|
+ leafNodes.forEach(node => {
|
|
|
+ if (this.checkboxDisabled(node)) return;
|
|
|
+
|
|
|
+ if (!this.checkStrictly) {
|
|
|
+ // 如果不是严格模式,需要处理父子节点关系
|
|
|
+ this.handleCheckboxChange(node, shouldSelect);
|
|
|
+ } else {
|
|
|
+ // 严格模式下直接添加或删除
|
|
|
+ if (shouldSelect) {
|
|
|
+ newCheckedKeys.add(node.key);
|
|
|
+ } else {
|
|
|
+ newCheckedKeys.delete(node.key);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (this.checkStrictly) {
|
|
|
+ this.checkedKeysSet = newCheckedKeys;
|
|
|
+ this.emitChange();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 新增:处理下拉菜单选择事件
|
|
|
+ handleSelectAllCommand(command) {
|
|
|
+ this.handleSelectAllToggle(command);
|
|
|
+ },
|
|
|
+ removeTag(nodeToRemove) {
|
|
|
+ if (this.disabled || this.checkboxDisabled(nodeToRemove)) return;
|
|
|
+ // nodeToRemove here will be a leaf node because displaySelectedNodes only gives leaf nodes
|
|
|
+ this.handleCheckboxChange(nodeToRemove, false);
|
|
|
+ },
|
|
|
+ onPopoverShow() {
|
|
|
+ if (this.$refs.popover && this.$refs.popover.$refs.reference) {
|
|
|
+ this.triggerElRect = this.$refs.popover.$refs.reference.getBoundingClientRect();
|
|
|
+ }
|
|
|
+ this.$nextTick(() => {
|
|
|
+ this.resetScroll();
|
|
|
+ if (this.filterable && this.$refs.popover && this.$refs.popover.$el) {
|
|
|
+ const inputEl = this.$refs.popover.$el.querySelector('.filter-input input');
|
|
|
+ if (inputEl) inputEl.focus();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ if (this.updateTimer) clearTimeout(this.updateTimer);
|
|
|
+ if (this.scrollRAF) cancelAnimationFrame(this.scrollRAF);
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.task-select-tree {
|
|
|
+ display: inline-block;
|
|
|
+ position: relative;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.select-trigger-wrapper {
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ box-sizing: border-box;
|
|
|
+ color: #606266;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 32px; /* Element UI default input height */
|
|
|
+ padding: 0 30px 0 10px;
|
|
|
+ position: relative;
|
|
|
+ transition: border-color .2s cubic-bezier(.645,.045,.355,1);
|
|
|
+ width: 100%;
|
|
|
+ cursor: pointer;
|
|
|
+ overflow: hidden; /* To contain tags */
|
|
|
+}
|
|
|
+
|
|
|
+.select-trigger-wrapper.is-disabled {
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ border-color: #e4e7ed;
|
|
|
+ color: #c0c4cc;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.select-trigger-wrapper:hover:not(.is-disabled) {
|
|
|
+ border-color: #c0c4cc;
|
|
|
+}
|
|
|
+
|
|
|
+.select-trigger-wrapper.is-active:not(.is-disabled) {
|
|
|
+ border-color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.tags-or-value-container {
|
|
|
+ flex-grow: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap; /* Allow tags to wrap */
|
|
|
+ gap: 4px; /* Space between tags */
|
|
|
+ align-items: center;
|
|
|
+ overflow: hidden; /* Hide overflowed tags/text */
|
|
|
+ padding: 2px 0; /* Minimal padding for tags */
|
|
|
+ min-height: 28px; /* Ensure container has some height */
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.single-value-display,
|
|
|
+.placeholder-text {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ line-height: 28px; /* Align with tags/input field */
|
|
|
+}
|
|
|
+
|
|
|
+.single-value-display {
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.placeholder-text {
|
|
|
+ color: #c0c4cc;
|
|
|
+}
|
|
|
+
|
|
|
+.icons-container {
|
|
|
+ position: absolute;
|
|
|
+ right: 8px;
|
|
|
+ top: 50%;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ color: #c0c4cc;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.clear-icon {
|
|
|
+ cursor: pointer;
|
|
|
+ margin-right: 5px;
|
|
|
+ display: none; /* Hidden by default */
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.select-trigger-wrapper:hover .clear-icon {
|
|
|
+ display: inline-block; /* Show on hover */
|
|
|
+}
|
|
|
+.select-trigger-wrapper.is-disabled .clear-icon {
|
|
|
+ display: none !important; /* Never show if component is disabled */
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.arrow-icon {
|
|
|
+ transition: transform .3s;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.arrow-icon.is-reverse {
|
|
|
+ transform: rotate(180deg);
|
|
|
+}
|
|
|
+
|
|
|
+.tree-container {
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
+ border-radius: 4px;
|
|
|
+ background-color: #fff;
|
|
|
+ margin-top: 8px; /* If filter input is present */
|
|
|
+}
|
|
|
+
|
|
|
+.virtual-tree-list {
|
|
|
+ position: relative; /* For absolute positioning of virtual-content if needed */
|
|
|
+}
|
|
|
+
|
|
|
+.virtual-content {
|
|
|
+ width: 100%; /* Ensure full width */
|
|
|
+}
|
|
|
+
|
|
|
+.virtual-tree-node {
|
|
|
+ box-sizing: border-box;
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+ border-bottom: 1px solid #f5f7fa; /* Lighter border */
|
|
|
+ transition: background-color 0.2s;
|
|
|
+ min-height: 36px; /* Ensure consistency with itemHeight */
|
|
|
+}
|
|
|
+.virtual-tree-node:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+
|
|
|
+.virtual-tree-node:hover:not(.is-disabled) {
|
|
|
+ background-color: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.virtual-tree-node.is-checked:not(.is-disabled) {
|
|
|
+ /* color: #409eff; */
|
|
|
+}
|
|
|
+.virtual-tree-node.is-checked .node-label {
|
|
|
+ /* font-weight: bold; */
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.virtual-tree-node.is-disabled {
|
|
|
+ color: #c0c4cc;
|
|
|
+ cursor: not-allowed;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+}
|
|
|
+.virtual-tree-node.is-disabled .node-label {
|
|
|
+ color: #c0c4cc;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.node-expand-container {
|
|
|
+ width: 20px;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.node-expand-icon {
|
|
|
+ cursor: pointer;
|
|
|
+ color: #c0c4cc;
|
|
|
+ transition: transform 0.2s, color 0.2s;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.node-expand-icon:hover {
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.node-expand-placeholder {
|
|
|
+ width: 12px;
|
|
|
+ height: 12px;
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.node-checkbox {
|
|
|
+ margin: 0 8px 0 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.node-label {
|
|
|
+ flex: 1;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ line-height: 1.4;
|
|
|
+ font-weight: normal;
|
|
|
+ padding-right: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.node-detail-text {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-left: 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.popover-content-wrapper {
|
|
|
+ max-width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.popover-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding-top: 10px;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+ margin-top: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.selected-count {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.filter-input {
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.trigger-tag {
|
|
|
+ margin: 1px 0;
|
|
|
+ max-width: 150px;
|
|
|
+}
|
|
|
+
|
|
|
+.trigger-tag >>> .el-tag__content {
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style>
|
|
|
+/* Global styles for popper, if needed */
|
|
|
+.task-select-tree-popper.el-popover {
|
|
|
+ padding: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-select-tree-popper .el-checkbox__input.is-disabled .el-checkbox__inner {
|
|
|
+ background-color: #edf2fc;
|
|
|
+ border-color: #dcdfe6;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.task-select-tree-popper .el-checkbox__input.is-checked .el-checkbox__inner {
|
|
|
+ background-color: #409eff;
|
|
|
+ border-color: #409eff;
|
|
|
+}
|
|
|
+.task-select-tree-popper .el-checkbox__input.is-disabled.is-checked .el-checkbox__inner {
|
|
|
+ background-color: #f2f6fc;
|
|
|
+ border-color: #dcdfe6;
|
|
|
+}
|
|
|
+</style>
|