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 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 { ChevronRight, IconifyIcon } from '@vben-core/icons';
import { cn, get } from '@vben-core/shared/utils'; 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 flattenData = ref<Array<InnerFlattenItem>>([]);
const modelValue = defineModel<Arrayable<number | string>>(); const modelValue = defineModel<Arrayable<number | string>>();
const expanded = ref<Array<number | string>>(props.defaultExpandedKeys ?? []); const expanded = ref<Array<number | string>>([]);
const treeValue = ref(); const treeValue = ref();
let lastTreeData: any = null;
onMounted(() => { /** ID → 原始数据映射,O(1) 查找替代 O(n) find */
watchEffect(() => { const valueMap = computed(() => {
flattenData.value = flatten(props.treeData, props.childrenField); const map = new Map<number | string, Recordable<any>>();
if (flattenData.value.length > 0) { for (const item of flattenData.value) {
updateTreeValue(); map.set(get(item.value, props.valueField) as number | string, item.value);
} }
return map;
// 只在 treeData 变化时执行展开
const currentTreeData = JSON.stringify(props.treeData);
if (lastTreeData !== currentTreeData) {
lastTreeData = currentTreeData;
if (
props.defaultExpandedLevel !== undefined &&
props.defaultExpandedLevel > 0
) {
expandToLevel(props.defaultExpandedLevel);
}
}
});
}); });
function getItemByValue(value: number | string) { function getItemByValue(value: number | string) {
return flattenData.value.find( return valueMap.value.get(value);
(item) => get(item.value, props.valueField) === value,
)?.value;
} }
function updateTreeValue() { function updateTreeValue() {
@ -102,14 +87,30 @@ function updateTreeValue() {
if (val.length === 0) { if (val.length === 0) {
treeValue.value = []; treeValue.value = [];
} else { } else {
const filteredValues = val.filter((v) => { // 单次遍历完成过滤 + 映射,避免重复 getItemByValue
const enabledItems: Recordable<any>[] = [];
const enabledIds: (number | string)[] = [];
for (const v of val) {
const item = getItemByValue(v); const item = getItemByValue(v);
return item && !get(item, props.disabledField); if (item && !get(item, props.disabledField)) {
}); enabledItems.push(item);
treeValue.value = filteredValues.map((v) => getItemByValue(v)); enabledIds.push(v);
}
}
if (filteredValues.length !== val.length) { // 半选节点不传给 Reka UI 内部选择:Reka UI 会把 model-value 中的节点当作已选中,
modelValue.value = filteredValues; // 点击半选祖先时会被视为取消选择,误移除祖先及其所有后代。半选节点仅保留在 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 { } 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>>) { function updateModelValue(val: Arrayable<Recordable<any>>) {
if (Array.isArray(val)) { if (Array.isArray(val)) {
const filteredVal = val.filter((v) => !get(v, props.disabledField)); 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 { } else {
if (val && !get(val, props.disabledField)) { // 单选模式下取消选择时 val 为 null/undefined,需要同步清空
modelValue.value = get(val, props.valueField); modelValue.value =
} val && !get(val, props.disabledField)
? get(val, props.valueField)
: undefined;
} }
} }
function expandToLevel(level: number) { function expandToLevel(level: number) {
const keys: string[] = []; const keys: (number | string)[] = [];
flattenData.value.forEach((item) => { flattenData.value.forEach((item) => {
if (item.level <= level - 1) { 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; expanded.value = keys;
@ -172,20 +233,19 @@ function collapseAll() {
function checkAll() { function checkAll() {
if (!props.multiple) return; if (!props.multiple) return;
modelValue.value = [ const selectedIds = [
...new Set( ...new Set(
flattenData.value flattenData.value
.filter((item) => !get(item.value, props.disabledField)) .filter((item) => !get(item.value, props.disabledField))
.map((item) => get(item.value, props.valueField)), .map((item) => get(item.value, props.valueField)),
), ),
]; ];
updateTreeValue(); modelValue.value = mergeIndeterminate(selectedIds);
} }
function unCheckAll() { function unCheckAll() {
if (!props.multiple) return; if (!props.multiple) return;
modelValue.value = []; modelValue.value = [];
updateTreeValue();
} }
function isNodeDisabled(item: FlattenedItem<Recordable<any>>) { function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
@ -201,9 +261,8 @@ const selectAllStatus = computed<'indeterminate' | boolean>(() => {
.filter((item) => !get(item.value, props.disabledField)) .filter((item) => !get(item.value, props.disabledField))
.map((item) => get(item.value, props.valueField)); .map((item) => get(item.value, props.valueField));
const selectedCount = allValues.filter((v) => const selectedSet = new Set(modelValue.value as (number | string)[]);
(modelValue.value as (number | string)[]).includes(v), const selectedCount = allValues.filter((v) => selectedSet.has(v)).length;
).length;
if (selectedCount === 0) return false; if (selectedCount === 0) return false;
if (selectedCount === allValues.length) return true; if (selectedCount === allValues.length) return true;
@ -226,68 +285,177 @@ function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
return; return;
} }
if ( if (props.checkStrictly && props.multiple && props.autoCheckParent) {
props.checkStrictly && // 获取当前节点在 flattenData 中的记录以读取 parents
props.multiple && const flatItem = flattenData.value.find(
props.autoCheckParent && (i) =>
isSelected get(i.value, props.valueField) === get(item.value, props.valueField),
) { );
flattenData.value
.find((i) => { if (isSelected) {
return ( // 选中时:将所有已启用的祖先追加到 modelValue(keepParentOnUncheck 不影响选中时的祖先追加)
get(i.value, props.valueField) === get(item.value, props.valueField) flatItem?.parents
); ?.filter((p) => {
}) const parentItem = getItemByValue(p);
?.parents?.filter((item) => !get(item, props.disabledField)) return parentItem && !get(parentItem, props.disabledField);
?.forEach((p) => { })
if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) { ?.forEach((p) => {
modelValue.value.push(p); if (
} Array.isArray(modelValue.value) &&
}); !modelValue.value.includes(p)
} ) {
if ( modelValue.value.push(p);
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 (Array.isArray(modelValue.value)) { } else if (!props.keepParentOnUncheck) {
const hasSelectedChild = children.some((child) => // 取消选中时:移除不再有已选子节点的祖先;keepParentOnUncheck 为 true 时保留父节点选中状态
(modelValue.value as unknown[]).includes( flatItem?.parents
get(child.value, props.valueField), ?.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) { if (!hasSelectedChild) {
const index = modelValue.value.indexOf(p); const index = (modelValue.value as (number | string)[]).indexOf(p);
if (index !== -1) { 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); 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({ defineExpose({
collapseAll, collapseAll,
collapseNodes, collapseNodes,
@ -301,8 +469,8 @@ defineExpose({
</script> </script>
<template> <template>
<TreeRoot <TreeRoot
:get-key="(item) => get(item, valueField)" :get-key="(item: any) => get(item, valueField)"
:get-children="(item) => get(item, childrenField)" :get-children="(item: any) => get(item, childrenField)"
:items="treeData" :items="treeData"
:model-value="treeValue" :model-value="treeValue"
v-model:expanded="expanded as string[]" v-model:expanded="expanded as string[]"
@ -367,19 +535,17 @@ defineExpose({
handleToggle, handleToggle,
}" }"
:key="item._id" :key="item._id"
:style="{ 'margin-left': `${item.level - 1}rem` }"
:class=" :class="
cn('cursor-pointer', getNodeClass?.(item), { cn('cursor-pointer', getNodeClass?.(item), {
'data-[selected]:bg-accent': !multiple, 'data-[selected]:bg-accent': !multiple,
'text-foreground/50 cursor-not-allowed': isNodeDisabled(item), 'text-foreground/50 cursor-not-allowed': isNodeDisabled(item),
}) })
" "
v-bind=" v-bind="{
Object.assign(item.bind, { ...item.bind,
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined, onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
disabled: isNodeDisabled(item), disabled: isNodeDisabled(item),
}) }"
"
@select=" @select="
(event: any) => { (event: any) => {
if (isNodeDisabled(item)) { if (isNodeDisabled(item)) {
@ -401,67 +567,78 @@ defineExpose({
!isNodeDisabled(item) && onToggle(item); !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的圆环边框 --> <!-- 内容行通过 paddingLeft 控制缩进,不受 TreeItem 自身的渲染行为影响 -->
<ChevronRight <div
v-if=" class="flex items-center w-full p-1"
item.hasChildren && :style="{ paddingLeft: `${item.level - 1 + 0.25}rem` }"
Array.isArray(item.value[childrenField]) && >
item.value[childrenField].length > 0 <!-- 固定宽度展开区,有子节点时内部渲染箭头,无子节点时为空但宽度不变 -->
" <div class="flex shrink-0 items-center justify-center w-5">
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition" <ChevronRight
:class="{ 'rotate-90': isExpanded }" v-if="
@click.stop=" item.hasChildren &&
() => { Array.isArray(item.value[childrenField]) &&
handleToggle(); item.value[childrenField].length > 0
onToggle(item); "
} class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
" :class="{ 'rotate-90': isExpanded }"
/> @click.stop="
<div v-else class="h-4 w-4"></div> () => {
<div class="flex items-center gap-1 item-checkbox"> handleToggle();
<Checkbox onToggle(item);
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;
} }
handleSelect(); "
} />
" </div>
/> <div class="flex items-center gap-1 item-checkbox">
<div <Checkbox
class="flex items-center gap-1 item-checkbox" v-if="multiple"
:title="get(item.value, labelField)" :model-value="isSelected && !isNodeDisabled(item)"
@click=" :disabled="isNodeDisabled(item)"
(event: MouseEvent) => { :indeterminate="
if (isNodeDisabled(item)) { (isIndeterminate ||
event.preventDefault(); indeterminateKeys.has(get(item.value, valueField))) &&
event.stopPropagation(); !isNodeDisabled(item)
return; "
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
} }
handleSelect(); "
} />
" <div
> class="flex items-center gap-1 item-checkbox"
<slot name="node" v-bind="item"> :title="get(item.value, labelField)"
<IconifyIcon @click="
class="size-4" (event: MouseEvent) => {
v-if="showIcon && get(item.value, iconField)" if (isNodeDisabled(item)) {
:icon="get(item.value, iconField)" event.preventDefault();
/> event.stopPropagation();
{{ get(item.value, labelField) }} return;
</slot> }
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>
<div class="h-4 w-4 shrink-0"></div>
</div> </div>
<div class="h-4 w-4"></div>
</TreeItem> </TreeItem>
</TransitionGroup> </TransitionGroup>
<div <div
@ -474,21 +651,8 @@ defineExpose({
</div> </div>
</TreeRoot> </TreeRoot>
</template> </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 { .item-checkbox {
width: 100%; width: 100%;
overflow: hidden; 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; getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
iconField?: string; iconField?: string;
/** 返回值是否包含半选节点(默认 false,仅返回全选节点;true 则返回全选 ∪ 半选) */
includeIndeterminate?: boolean;
/** 取消勾选子节点时保持父节点的选中状态,不级联取消父节点(默认 false:级联取消) */ /** 取消勾选子节点时保持父节点的选中状态,不级联取消父节点(默认 false:级联取消) */
keepParentOnUncheck?: boolean; keepParentOnUncheck?: boolean;
/** label字段 */ /** label字段 */
@ -60,6 +62,7 @@ export function treePropsDefaults() {
multiple: false, multiple: false,
showIcon: true, showIcon: true,
transition: true, transition: true,
includeIndeterminate: false,
keepParentOnUncheck: false, keepParentOnUncheck: false,
valueField: 'value', valueField: 'value',
childrenField: 'children', childrenField: 'children',

Loading…
Cancel
Save