From 251cde42e5c9add280532cd401235d9e7ad0c55d Mon Sep 17 00:00:00 2001 From: xueyitt <1455668754@qq.com> Date: Thu, 27 Aug 2026 16:47:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(tree):=20=E6=A0=91=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E4=BC=98=E5=8C=96=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?includeIndeterminate=E6=8E=A7=E5=88=B6=E5=8F=82=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81modelValue=E4=BB=85=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=85=A8=E9=80=89=E8=8A=82=E7=82=B9=20&=20=E5=85=A8?= =?UTF-8?q?=E9=80=89=E8=8A=82=E7=82=B9=E4=B8=8E=E5=8D=8A=E9=80=89=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=90=8C=E6=97=B6=E8=BF=94=E5=9B=9E=EF=BC=9B=20(#8290?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add includeIndeterminate option to tree types * Refactor tree component for improved state management * fix(tree): 修复 1、声明后移动初始化块。 当 有初始值时,调用 之前的调用被初始化。这会抛弃并阻止组件渲染。modelValueupdateTreeValue()getItemByValue()const valueMapReferenceError: Cannot access 'valueMap' before initialization 2、不要用 来对选择分支进行门控。 现在警卫要求整个街区都有。该分支执行 。如果消费者设置 ,祖先不再被添加在选择中。 在 中仅影响 uncheck 的级联移除。!props.keepParentOnUncheckisSelectedautoCheckParentkeepParentOnUncheck: truekeepParentOnUnchecktypes.ts 3、避免将不确定的祖先ID排除在Reka UI内部选择之外 启用时,将每个不确定祖先添加到 ,并按选中方式传递给 Reka UI 2.10.3。复选框依然会渲染,因为优先级。然而,点击该祖先后,它被视为被选中,因此移除了祖先及其所有后代,而不是选择后代。将不确定的身份从中移除,只将它们添加到发射的 。includeIndeterminatemergeIndeterminatemodelValueupdateTreeValueMinusindeterminateTreeRootpropagateSelecttreeValuemodelValue --------- Co-authored-by: Jin Mao <50581550+jinmao88@users.noreply.github.com> --- .../ui-kit/shadcn-ui/src/ui/tree/tree.vue | 512 ++++++++++++------ .../ui-kit/shadcn-ui/src/ui/tree/types.ts | 3 + 2 files changed, 341 insertions(+), 174 deletions(-) diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue index 149332b16..f11c0e540 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue @@ -6,7 +6,7 @@ import type { ClassType, Recordable } from '@vben-core/typings'; import type { TreeProps } from './types'; -import { computed, onMounted, ref, watchEffect } from 'vue'; +import { computed, ref, watch } from 'vue'; import { ChevronRight, IconifyIcon } from '@vben-core/icons'; import { cn, get } from '@vben-core/shared/utils'; @@ -62,36 +62,21 @@ function flatten, P = number | string>( const flattenData = ref>([]); const modelValue = defineModel>(); -const expanded = ref>(props.defaultExpandedKeys ?? []); +const expanded = ref>([]); const treeValue = ref(); -let lastTreeData: any = null; -onMounted(() => { - watchEffect(() => { - flattenData.value = flatten(props.treeData, props.childrenField); - if (flattenData.value.length > 0) { - updateTreeValue(); - } - - // 只在 treeData 变化时执行展开 - const currentTreeData = JSON.stringify(props.treeData); - if (lastTreeData !== currentTreeData) { - lastTreeData = currentTreeData; - if ( - props.defaultExpandedLevel !== undefined && - props.defaultExpandedLevel > 0 - ) { - expandToLevel(props.defaultExpandedLevel); - } - } - }); +/** ID → 原始数据映射,O(1) 查找替代 O(n) find */ +const valueMap = computed(() => { + const map = new Map>(); + for (const item of flattenData.value) { + map.set(get(item.value, props.valueField) as number | string, item.value); + } + return map; }); function getItemByValue(value: number | string) { - return flattenData.value.find( - (item) => get(item.value, props.valueField) === value, - )?.value; + return valueMap.value.get(value); } function updateTreeValue() { @@ -102,14 +87,30 @@ function updateTreeValue() { if (val.length === 0) { treeValue.value = []; } else { - const filteredValues = val.filter((v) => { + // 单次遍历完成过滤 + 映射,避免重复 getItemByValue + const enabledItems: Recordable[] = []; + const enabledIds: (number | string)[] = []; + for (const v of val) { const item = getItemByValue(v); - return item && !get(item, props.disabledField); - }); - treeValue.value = filteredValues.map((v) => getItemByValue(v)); + if (item && !get(item, props.disabledField)) { + enabledItems.push(item); + enabledIds.push(v); + } + } - if (filteredValues.length !== val.length) { - modelValue.value = filteredValues; + // 半选节点不传给 Reka UI 内部选择:Reka UI 会把 model-value 中的节点当作已选中, + // 点击半选祖先时会被视为取消选择,误移除祖先及其所有后代。半选节点仅保留在 modelValue 返回值中。 + if (props.includeIndeterminate && props.multiple) { + const excludedKeys = computeIndeterminateKeysInSelection(enabledIds); + treeValue.value = enabledItems.filter( + (item) => !excludedKeys.has(get(item.value, props.valueField)), + ); + } else { + treeValue.value = enabledItems; + } + + if (enabledIds.length !== val.length) { + modelValue.value = mergeIndeterminate(enabledIds); } } } else { @@ -123,22 +124,82 @@ function updateTreeValue() { } } +// 初始化:构建 flattenData 并同步 treeValue +flattenData.value = flatten(props.treeData, props.childrenField); +if (flattenData.value.length > 0) { + updateTreeValue(); +} + +// 初始化:展开 — defaultExpandedLevel 优先,否则使用 defaultExpandedKeys +if ( + props.defaultExpandedLevel !== undefined && + props.defaultExpandedLevel > 0 +) { + expandToLevel(props.defaultExpandedLevel); +} else { + expanded.value = props.defaultExpandedKeys ?? []; +} + +// 仅监听 treeData 及相关字段变更,重建 flattenData +let lastTreeData: any = JSON.stringify(props.treeData); +watch( + () => ({ + treeData: props.treeData, + childrenField: props.childrenField, + valueField: props.valueField, + }), + () => { + flattenData.value = flatten(props.treeData, props.childrenField); + if (flattenData.value.length > 0) { + updateTreeValue(); + } + + // 仅在 treeData 内容实质变化时才重新展开(避免过度展开) + const currentTreeData = JSON.stringify(props.treeData); + if (lastTreeData !== currentTreeData) { + lastTreeData = currentTreeData; + if ( + props.defaultExpandedLevel !== undefined && + props.defaultExpandedLevel > 0 + ) { + expandToLevel(props.defaultExpandedLevel); + } + } + }, + { deep: true }, +); + +// 仅监听 modelValue 变更,同步 treeValue(不重建 flattenData,避免响应式回环) +// flush: 'sync' 确保赋值后立即同步,配合 checkAll/unCheckAll 移除手动 updateTreeValue 调用 +watch( + () => modelValue.value, + () => { + if (flattenData.value.length > 0) { + updateTreeValue(); + } + }, + { flush: 'sync' }, +); + function updateModelValue(val: Arrayable>) { if (Array.isArray(val)) { const filteredVal = val.filter((v) => !get(v, props.disabledField)); - modelValue.value = filteredVal.map((v) => get(v, props.valueField)); + const selectedIds = filteredVal.map((v) => get(v, props.valueField)); + modelValue.value = mergeIndeterminate(selectedIds); } else { - if (val && !get(val, props.disabledField)) { - modelValue.value = get(val, props.valueField); - } + // 单选模式下取消选择时 val 为 null/undefined,需要同步清空 + modelValue.value = + val && !get(val, props.disabledField) + ? get(val, props.valueField) + : undefined; } } function expandToLevel(level: number) { - const keys: string[] = []; + const keys: (number | string)[] = []; flattenData.value.forEach((item) => { if (item.level <= level - 1) { - keys.push(get(item.value, props.valueField)); + keys.push(get(item.value, props.valueField) as number | string); } }); expanded.value = keys; @@ -172,20 +233,19 @@ function collapseAll() { function checkAll() { if (!props.multiple) return; - modelValue.value = [ + const selectedIds = [ ...new Set( flattenData.value .filter((item) => !get(item.value, props.disabledField)) .map((item) => get(item.value, props.valueField)), ), ]; - updateTreeValue(); + modelValue.value = mergeIndeterminate(selectedIds); } function unCheckAll() { if (!props.multiple) return; modelValue.value = []; - updateTreeValue(); } function isNodeDisabled(item: FlattenedItem>) { @@ -201,9 +261,8 @@ const selectAllStatus = computed<'indeterminate' | boolean>(() => { .filter((item) => !get(item.value, props.disabledField)) .map((item) => get(item.value, props.valueField)); - const selectedCount = allValues.filter((v) => - (modelValue.value as (number | string)[]).includes(v), - ).length; + const selectedSet = new Set(modelValue.value as (number | string)[]); + const selectedCount = allValues.filter((v) => selectedSet.has(v)).length; if (selectedCount === 0) return false; if (selectedCount === allValues.length) return true; @@ -226,68 +285,177 @@ function onSelect(item: FlattenedItem>, isSelected: boolean) { return; } - if ( - props.checkStrictly && - props.multiple && - props.autoCheckParent && - isSelected - ) { - flattenData.value - .find((i) => { - return ( - get(i.value, props.valueField) === get(item.value, props.valueField) - ); - }) - ?.parents?.filter((item) => !get(item, props.disabledField)) - ?.forEach((p) => { - if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) { - modelValue.value.push(p); - } - }); - } - if ( - props.checkStrictly && - props.multiple && - props.autoCheckParent && - !props.keepParentOnUncheck && - !isSelected - ) { - flattenData.value - .find((i) => { - return ( - get(i.value, props.valueField) === get(item.value, props.valueField) - ); - }) - ?.parents?.filter((item) => !get(item, props.disabledField)) - ?.toReversed() - .forEach((p) => { - const children = flattenData.value.filter((i) => { - return ( - i.parents.length > 0 && - i.parents.includes(p) && - i.id !== item._id && - i.parentId === p - ); + if (props.checkStrictly && props.multiple && props.autoCheckParent) { + // 获取当前节点在 flattenData 中的记录以读取 parents + const flatItem = flattenData.value.find( + (i) => + get(i.value, props.valueField) === get(item.value, props.valueField), + ); + + if (isSelected) { + // 选中时:将所有已启用的祖先追加到 modelValue(keepParentOnUncheck 不影响选中时的祖先追加) + flatItem?.parents + ?.filter((p) => { + const parentItem = getItemByValue(p); + return parentItem && !get(parentItem, props.disabledField); + }) + ?.forEach((p) => { + if ( + Array.isArray(modelValue.value) && + !modelValue.value.includes(p) + ) { + modelValue.value.push(p); + } }); - if (Array.isArray(modelValue.value)) { - const hasSelectedChild = children.some((child) => - (modelValue.value as unknown[]).includes( - get(child.value, props.valueField), - ), + } else if (!props.keepParentOnUncheck) { + // 取消选中时:移除不再有已选子节点的祖先;keepParentOnUncheck 为 true 时保留父节点选中状态 + flatItem?.parents + ?.filter((p) => { + const parentItem = getItemByValue(p); + return parentItem && !get(parentItem, props.disabledField); + }) + ?.toReversed() + ?.forEach((p) => { + const hasSelectedChild = flattenData.value.some( + (i) => + i.parentId === p && + i.id !== item._id && + Array.isArray(modelValue.value) && + (modelValue.value as (number | string)[]).includes( + get(i.value, props.valueField) as number | string, + ), ); if (!hasSelectedChild) { - const index = modelValue.value.indexOf(p); + const index = (modelValue.value as (number | string)[]).indexOf(p); if (index !== -1) { - modelValue.value.splice(index, 1); + (modelValue.value as (number | string)[]).splice(index, 1); } } - } - }); + }); + } + } + // 确保 watcher 同步 treeValue:checkStrictly 模式需要新引用(祖先 push/pop 已原位修改数组), + // 或 includeIndeterminate 需合并半选节点时创建新引用 + if (props.multiple && Array.isArray(modelValue.value)) { + if (props.includeIndeterminate) { + modelValue.value = mergeIndeterminate( + modelValue.value as (number | string)[], + ); + } else if (props.checkStrictly) { + modelValue.value = [...modelValue.value]; + } } - updateTreeValue(); emits('select', item); } +/** 计算给定选中列表中,处于半选(indeterminate)状态的节点 key */ +function computeIndeterminateKeys( + selectedIds: (number | string)[], +): (number | string)[] { + if ( + !props.multiple || + selectedIds.length === 0 || + flattenData.value.length === 0 + ) + return []; + + const selectedSet = new Set(selectedIds); + // 统计每个祖先节点下有多少个选中后代 + const ancestorCounts = new Map(); + + for (const node of flattenData.value) { + const nodeId = get(node.value, props.valueField) as number | string; + if (!selectedSet.has(nodeId)) continue; + + // 为当前选中节点的所有祖先累加计数 + for (const parentId of node.parents) { + ancestorCounts.set(parentId, (ancestorCounts.get(parentId) ?? 0) + 1); + } + } + + // 过滤出自身未选中的祖先即为半选节点 + const result: (number | string)[] = []; + for (const [nodeId] of ancestorCounts) { + if (!selectedSet.has(nodeId)) { + result.push(nodeId); + } + } + + return result; +} + +/** + * 计算选中列表中"半选祖先节点"的 key:节点自身已在选中列表,但后代仅部分被选中。 + * 与 computeIndeterminateKeys 不同,它基于后代选中数量统计,适用于 modelValue 已合并半选节点的场景, + * 用于从传给 Reka UI 的内部选择中剔除半选节点,并用于补充复选框的半选状态展示。 + */ +function computeIndeterminateKeysInSelection( + selectedIds: (number | string)[], +): Set { + const result = new Set(); + if ( + !props.multiple || + selectedIds.length === 0 || + flattenData.value.length === 0 + ) { + return result; + } + + const selectedSet = new Set(selectedIds); + const totalDescendants = new Map(); + const selectedDescendants = new Map(); + + for (const node of flattenData.value) { + const nodeId = get(node.value, props.valueField) as number | string; + for (const parentId of node.parents) { + totalDescendants.set(parentId, (totalDescendants.get(parentId) ?? 0) + 1); + if (selectedSet.has(nodeId)) { + selectedDescendants.set( + parentId, + (selectedDescendants.get(parentId) ?? 0) + 1, + ); + } + } + } + + for (const id of selectedIds) { + const total = totalDescendants.get(id) ?? 0; + const selected = selectedDescendants.get(id) ?? 0; + if (total > 0 && selected > 0 && selected < total) { + result.add(id); + } + } + + return result; +} + +/** 半选节点 key 集合,用于 Reka UI 不计算半选状态的场景(如 checkStrictly)下补充复选框展示 */ +const indeterminateKeys = computed(() => { + if (!props.multiple || !props.includeIndeterminate) { + return new Set(); + } + const ids = Array.isArray(modelValue.value) + ? (modelValue.value as (number | string)[]) + : []; + return computeIndeterminateKeysInSelection(ids); +}); + +/** 根据 includeIndeterminate 配置,将半选节点合并到选中列表 */ +function mergeIndeterminate( + selectedIds: (number | string)[], +): (number | string)[] { + if ( + !props.includeIndeterminate || + !props.multiple || + selectedIds.length === 0 + ) { + return selectedIds; + } + const indeterminate = computeIndeterminateKeys(selectedIds); + if (indeterminate.length === 0) return selectedIds; + return [...new Set([...selectedIds, ...indeterminate])]; +} + defineExpose({ collapseAll, collapseNodes, @@ -301,8 +469,8 @@ defineExpose({ -