Browse Source

feat(tree): 树组件能力优化,增加includeIndeterminate控制参数,支持modelValue仅返回全选节点 & 全选节点与半选节点同时返回; (#8290)

* 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>
pull/8310/head
xueyitt 1 month ago
committed by GitHub
parent
commit
251cde42e5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 512
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue
  2. 3
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts

512
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<T = Recordable<any>, P = number | string>(
const flattenData = ref<Array<InnerFlattenItem>>([]);
const modelValue = defineModel<Arrayable<number | string>>();
const expanded = ref<Array<number | string>>(props.defaultExpandedKeys ?? []);
const expanded = ref<Array<number | string>>([]);
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<number | string, Recordable<any>>();
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<any>[] = [];
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<Recordable<any>>) {
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<Recordable<any>>) {
@ -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<Recordable<any>>, 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<number | string, number>();
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<number | string> {
const result = new Set<number | string>();
if (
!props.multiple ||
selectedIds.length === 0 ||
flattenData.value.length === 0
) {
return result;
}
const selectedSet = new Set(selectedIds);
const totalDescendants = new Map<number | string, number>();
const selectedDescendants = new Map<number | string, number>();
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<number | string>();
}
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({
</script>
<template>
<TreeRoot
:get-key="(item) => get(item, valueField)"
:get-children="(item) => get(item, childrenField)"
:get-key="(item: any) => get(item, valueField)"
:get-children="(item: any) => get(item, childrenField)"
:items="treeData"
:model-value="treeValue"
v-model:expanded="expanded as string[]"
@ -367,19 +535,17 @@ defineExpose({
handleToggle,
}"
:key="item._id"
:style="{ 'margin-left': `${item.level - 1}rem` }"
:class="
cn('cursor-pointer', getNodeClass?.(item), {
'data-[selected]:bg-accent': !multiple,
'text-foreground/50 cursor-not-allowed': isNodeDisabled(item),
})
"
v-bind="
Object.assign(item.bind, {
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
disabled: isNodeDisabled(item),
})
"
v-bind="{
...item.bind,
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
disabled: isNodeDisabled(item),
}"
@select="
(event: any) => {
if (isNodeDisabled(item)) {
@ -401,67 +567,78 @@ defineExpose({
!isNodeDisabled(item) && onToggle(item);
}
"
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-hidden"
class="tree-node focus:ring-grass8 my-0.5 rounded outline-hidden"
>
<!-- class="hover:ring-2" 鼠标移动上去时2px的圆环边框 -->
<ChevronRight
v-if="
item.hasChildren &&
Array.isArray(item.value[childrenField]) &&
item.value[childrenField].length > 0
"
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }"
@click.stop="
() => {
handleToggle();
onToggle(item);
}
"
/>
<div v-else class="h-4 w-4"></div>
<div class="flex items-center gap-1 item-checkbox">
<Checkbox
v-if="multiple"
:model-value="isSelected && !isNodeDisabled(item)"
:disabled="isNodeDisabled(item)"
:indeterminate="isIndeterminate && !isNodeDisabled(item)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
<!-- 内容行通过 paddingLeft 控制缩进,不受 TreeItem 自身的渲染行为影响 -->
<div
class="flex items-center w-full p-1"
:style="{ paddingLeft: `${item.level - 1 + 0.25}rem` }"
>
<!-- 固定宽度展开区,有子节点时内部渲染箭头,无子节点时为空但宽度不变 -->
<div class="flex shrink-0 items-center justify-center w-5">
<ChevronRight
v-if="
item.hasChildren &&
Array.isArray(item.value[childrenField]) &&
item.value[childrenField].length > 0
"
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }"
@click.stop="
() => {
handleToggle();
onToggle(item);
}
handleSelect();
}
"
/>
<div
class="flex items-center gap-1 item-checkbox"
:title="get(item.value, labelField)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
"
/>
</div>
<div class="flex items-center gap-1 item-checkbox">
<Checkbox
v-if="multiple"
:model-value="isSelected && !isNodeDisabled(item)"
:disabled="isNodeDisabled(item)"
:indeterminate="
(isIndeterminate ||
indeterminateKeys.has(get(item.value, valueField))) &&
!isNodeDisabled(item)
"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
}
handleSelect();
}
"
>
<slot name="node" v-bind="item">
<IconifyIcon
class="size-4"
v-if="showIcon && get(item.value, iconField)"
:icon="get(item.value, iconField)"
/>
{{ get(item.value, labelField) }}
</slot>
"
/>
<div
class="flex items-center gap-1 item-checkbox"
:title="get(item.value, labelField)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
}
"
>
<slot name="node" v-bind="item">
<IconifyIcon
class="size-4"
v-if="showIcon && get(item.value, iconField)"
:icon="get(item.value, iconField)"
/>
{{ get(item.value, labelField) }}
</slot>
</div>
</div>
<div class="h-4 w-4 shrink-0"></div>
</div>
<div class="h-4 w-4"></div>
</TreeItem>
</TransitionGroup>
<div
@ -474,21 +651,8 @@ defineExpose({
</div>
</TreeRoot>
</template>
<style lang="scss" scoped>
.container {
position: relative;
padding: 0;
list-style-type: none;
}
.item {
box-sizing: border-box;
width: 100%;
height: 30px;
background-color: #f3f3f3;
border: 1px solid #666;
}
<style lang="scss" scoped>
.item-checkbox {
width: 100%;
overflow: hidden;

3
packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts

@ -27,6 +27,8 @@ export interface TreeProps {
/** 自定义节点类名 */
getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
iconField?: string;
/** 返回值是否包含半选节点(默认 false,仅返回全选节点;true 则返回全选 ∪ 半选) */
includeIndeterminate?: boolean;
/** 取消勾选子节点时保持父节点的选中状态,不级联取消父节点(默认 false:级联取消) */
keepParentOnUncheck?: boolean;
/** label字段 */
@ -60,6 +62,7 @@ export function treePropsDefaults() {
multiple: false,
showIcon: true,
transition: true,
includeIndeterminate: false,
keepParentOnUncheck: false,
valueField: 'value',
childrenField: 'children',

Loading…
Cancel
Save