1、VbenTree新增是否全选、展开折叠功能;
2、解决当点击子节点label文字区域,而非checkbox时,关联父组件不能选中问题;
3、优化子节点选中时关联父节点选中功能:删除VbenTree中processParentSelection方法,改为在onSelect中实现,原因:processParentSelection在每次模型值更新时都会被调用,且计算复杂度为O(n^2),onSelect只在交互时触发,复杂度为O(n);
4、新增中间层tree组件,处理无数据时显示场景(显示图标Inbox和国际化comom.noData文本);
5、为防止父组件传值子组件boolean类型默认false问题,新增treePropsDefaults方法,为TreeProps赋默认值,Tree组件和VbenTree组件统一使用;
6、优化VbenTree组件整体样式(优化padding、margin、gap值,优化type为button时outline左右空白区域不对称问题),优化内部header、footer插槽样式。
This commit is contained in:
zouawen 2025-09-11 16:41:41 +08:00
parent 6a85b3ab84
commit 39820c783c
8 changed files with 198 additions and 112 deletions

View File

@ -32,6 +32,7 @@ export {
Grip, Grip,
GripVertical, GripVertical,
Menu as IconDefault, Menu as IconDefault,
Inbox,
Info, Info,
InspectionPanel, InspectionPanel,
Languages, Languages,

View File

@ -1,2 +1,4 @@
export { default as VbenTree } from './tree.vue'; export { default as VbenTree } from './tree.vue';
export type { TreeProps } from './types';
export { treePropsDefaults } from './types';
export type { FlattenedItem } from 'radix-vue'; export type { FlattenedItem } from 'radix-vue';

View File

@ -14,25 +14,9 @@ import { cn, get } from '@vben-core/shared/utils';
import { TreeItem, TreeRoot } from 'radix-vue'; import { TreeItem, TreeRoot } from 'radix-vue';
import { Checkbox } from '../checkbox'; import { Checkbox } from '../checkbox';
import { treePropsDefaults } from './types';
const props = withDefaults(defineProps<TreeProps>(), { const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
allowClear: false,
autoCheckParent: true,
bordered: false,
checkStrictly: false,
defaultExpandedKeys: () => [],
defaultExpandedLevel: 0,
disabled: false,
disabledField: 'disabled',
expanded: () => [],
iconField: 'icon',
labelField: 'label',
multiple: false,
showIcon: true,
transition: true,
valueField: 'value',
childrenField: 'children',
});
const emits = defineEmits<{ const emits = defineEmits<{
expand: [value: FlattenedItem<Recordable<any>>]; expand: [value: FlattenedItem<Recordable<any>>];
@ -41,7 +25,9 @@ const emits = defineEmits<{
interface InnerFlattenItem<T = Recordable<any>, P = number | string> { interface InnerFlattenItem<T = Recordable<any>, P = number | string> {
hasChildren: boolean; hasChildren: boolean;
id: P;
level: number; level: number;
parentId: null | P;
parents: P[]; parents: P[];
value: T; value: T;
} }
@ -50,24 +36,25 @@ function flatten<T = Recordable<any>, P = number | string>(
items: T[], items: T[],
childrenField: string = 'children', childrenField: string = 'children',
level = 0, level = 0,
parentId: null | P = null,
parents: P[] = [], parents: P[] = [],
): InnerFlattenItem<T, P>[] { ): InnerFlattenItem<T, P>[] {
const result: InnerFlattenItem<T, P>[] = []; const result: InnerFlattenItem<T, P>[] = [];
items.forEach((item) => { items.forEach((item) => {
const children = get(item, childrenField) as Array<T>; const children = get(item, childrenField) as Array<T>;
const val = { const id = get(item, props.valueField) as P;
const val: InnerFlattenItem<T, P> = {
hasChildren: Array.isArray(children) && children.length > 0, hasChildren: Array.isArray(children) && children.length > 0,
id,
level, level,
parentId,
parents: [...parents], parents: [...parents],
value: item, value: item,
}; };
result.push(val); result.push(val);
if (val.hasChildren) if (val.hasChildren)
result.push( result.push(
...flatten(children, childrenField, level + 1, [ ...flatten(children, childrenField, level + 1, id, [...parents, id]),
...parents,
get(item, props.valueField),
]),
); );
}); });
return result; return result;
@ -103,15 +90,10 @@ function updateTreeValue() {
treeValue.value = undefined; treeValue.value = undefined;
} else { } else {
if (Array.isArray(val)) { if (Array.isArray(val)) {
let filteredValues = val.filter((v) => { const filteredValues = val.filter((v) => {
const item = getItemByValue(v); const item = getItemByValue(v);
return item && !get(item, props.disabledField); return item && !get(item, props.disabledField);
}); });
if (!props.checkStrictly && props.autoCheckParent) {
filteredValues = processParentSelection(filteredValues);
}
treeValue.value = filteredValues.map((v) => getItemByValue(v)); treeValue.value = filteredValues.map((v) => getItemByValue(v));
if (filteredValues.length !== val.length) { if (filteredValues.length !== val.length) {
@ -128,35 +110,7 @@ function updateTreeValue() {
} }
} }
} }
function processParentSelection(
selectedValues: Array<number | string>,
): Array<number | string> {
if (props.checkStrictly) return selectedValues;
const result = [...selectedValues];
for (let i = result.length - 1; i >= 0; i--) {
const currentValue = result[i];
if (currentValue === undefined) continue;
const currentItem = getItemByValue(currentValue);
if (!currentItem) continue;
const children = get(currentItem, props.childrenField);
if (Array.isArray(children) && children.length > 0) {
const hasSelectedChildren = children.some((child) => {
const childValue = get(child, props.valueField);
return result.includes(childValue);
});
if (!hasSelectedChildren) {
result.splice(i, 1);
}
}
}
return result;
}
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));
@ -204,6 +158,22 @@ function collapseAll() {
expanded.value = []; expanded.value = [];
} }
function checkAll() {
if (props.multiple) {
modelValue.value = flattenData.value.map((item) =>
get(item.value, props.valueField),
);
updateTreeValue();
}
}
function unCheckAll() {
if (props.multiple) {
modelValue.value = [];
updateTreeValue();
}
}
function isNodeDisabled(item: FlattenedItem<Recordable<any>>) { function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
return props.disabled || get(item.value, props.disabledField); return props.disabled || get(item.value, props.disabledField);
} }
@ -234,6 +204,43 @@ function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
} }
}); });
} }
if (
!props.checkStrictly &&
props.multiple &&
props.autoCheckParent &&
!isSelected
) {
flattenData.value
.find((i) => {
return (
get(i.value, props.valueField) === get(item.value, props.valueField)
);
})
?.parents?.reverse()
.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)) {
const hasSelectedChild = children.some((child) =>
(modelValue.value as unknown[]).includes(
get(child.value, props.valueField),
),
);
if (!hasSelectedChild) {
const index = modelValue.value.indexOf(p);
if (index !== -1) {
modelValue.value.splice(index, 1);
}
}
}
});
}
updateTreeValue(); updateTreeValue();
emits('select', item); emits('select', item);
} }
@ -243,6 +250,8 @@ defineExpose({
collapseNodes, collapseNodes,
expandAll, expandAll,
expandNodes, expandNodes,
checkAll,
unCheckAll,
expandToLevel, expandToLevel,
getItemByValue, getItemByValue,
}); });
@ -263,15 +272,41 @@ defineExpose({
v-slot="{ flattenItems }" v-slot="{ flattenItems }"
:class=" :class="
cn( cn(
'text-blackA11 container select-none list-none rounded-lg p-2 text-sm font-medium', 'text-blackA11 container select-none list-none rounded-lg text-sm font-medium',
$attrs.class as unknown as ClassType, $attrs.class as unknown as ClassType,
bordered ? 'border' : '', bordered ? 'border' : '',
) )
" "
> >
<div class="w-full" v-if="$slots.header"> <div
:class="
cn('my-0.5 flex w-full items-center p-1', bordered ? 'border-b' : '')
"
v-if="$slots.header"
>
<slot name="header"> </slot> <slot name="header"> </slot>
</div> </div>
<div
:class="
cn('my-0.5 flex w-full items-center p-1', bordered ? 'border-b' : '')
"
v-if="treeData.length > 0"
>
<div
class="flex size-5 flex-1 cursor-pointer items-center"
@click="() => (expanded?.length > 0 ? collapseAll() : expandAll())"
>
<ChevronRight
:class="{ 'rotate-90': expanded?.length > 0 }"
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
/>
<Checkbox
v-if="multiple"
@click.stop
@update:checked="(checked) => (checked ? checkAll() : unCheckAll())"
/>
</div>
</div>
<TransitionGroup :name="transition ? 'fade' : ''"> <TransitionGroup :name="transition ? 'fade' : ''">
<TreeItem <TreeItem
v-for="item in flattenItems" v-for="item in flattenItems"
@ -283,7 +318,7 @@ defineExpose({
handleToggle, handleToggle,
}" }"
:key="item._id" :key="item._id"
:style="{ 'padding-left': `${item.level - 0.5}rem` }" :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,
@ -317,7 +352,7 @@ defineExpose({
!isNodeDisabled(item) && onToggle(item); !isNodeDisabled(item) && onToggle(item);
} }
" "
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2" class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-none focus:ring-2"
> >
<ChevronRight <ChevronRight
v-if=" v-if="
@ -325,7 +360,7 @@ defineExpose({
Array.isArray(item.value[childrenField]) && Array.isArray(item.value[childrenField]) &&
item.value[childrenField].length > 0 item.value[childrenField].length > 0
" "
class="size-4 cursor-pointer transition" class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }" :class="{ 'rotate-90': isExpanded }"
@click.stop=" @click.stop="
() => { () => {
@ -334,9 +369,8 @@ defineExpose({
} }
" "
/> />
<div v-else class="h-4 w-4"> <div v-else class="h-4 w-4"></div>
<!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> --> <div class="flex items-center gap-1">
</div>
<Checkbox <Checkbox
v-if="multiple" v-if="multiple"
:checked="isSelected && !isNodeDisabled(item)" :checked="isSelected && !isNodeDisabled(item)"
@ -354,7 +388,7 @@ defineExpose({
" "
/> />
<div <div
class="flex items-center gap-1 pl-2" class="flex items-center gap-1"
@click=" @click="
(event: MouseEvent) => { (event: MouseEvent) => {
if (isNodeDisabled(item)) { if (isNodeDisabled(item)) {
@ -362,8 +396,6 @@ defineExpose({
event.stopPropagation(); event.stopPropagation();
return; return;
} }
event.stopPropagation();
event.preventDefault();
handleSelect(); handleSelect();
} }
" "
@ -377,9 +409,16 @@ defineExpose({
{{ get(item.value, labelField) }} {{ get(item.value, labelField) }}
</slot> </slot>
</div> </div>
</div>
<div class="h-4 w-4"></div>
</TreeItem> </TreeItem>
</TransitionGroup> </TransitionGroup>
<div class="w-full" v-if="$slots.footer"> <div
:class="
cn('my-0.5 flex w-full items-center p-1', bordered ? 'border-t' : '')
"
v-if="$slots.footer"
>
<slot name="footer"> </slot> <slot name="footer"> </slot>
</div> </div>
</TreeRoot> </TreeRoot>

View File

@ -40,3 +40,24 @@ export interface TreeProps {
/** 值字段 */ /** 值字段 */
valueField?: string; valueField?: string;
} }
export function treePropsDefaults() {
return {
allowClear: false,
autoCheckParent: true,
bordered: false,
checkStrictly: false,
defaultExpandedKeys: () => [],
defaultExpandedLevel: 0,
disabled: false,
disabledField: 'disabled',
expanded: () => [],
iconField: 'icon',
labelField: 'label',
multiple: false,
showIcon: true,
transition: true,
valueField: 'value',
childrenField: 'children',
};
}

View File

@ -9,6 +9,7 @@ export * from './loading';
export * from './page'; export * from './page';
export * from './resize'; export * from './resize';
export * from './tippy'; export * from './tippy';
export * from './tree';
export * from '@vben-core/form-ui'; export * from '@vben-core/form-ui';
export * from '@vben-core/popup-ui'; export * from '@vben-core/popup-ui';
@ -27,7 +28,6 @@ export {
VbenPinInput, VbenPinInput,
VbenSelect, VbenSelect,
VbenSpinner, VbenSpinner,
VbenTree,
} from '@vben-core/shadcn-ui'; } from '@vben-core/shadcn-ui';
export type { FlattenedItem } from '@vben-core/shadcn-ui'; export type { FlattenedItem } from '@vben-core/shadcn-ui';

View File

@ -0,0 +1 @@
export { default as Tree } from './tree.vue';

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { TreeProps } from '@vben-core/shadcn-ui';
import { Inbox } from '@vben/icons';
import { $t } from '@vben/locales';
import { treePropsDefaults, VbenTree } from '@vben-core/shadcn-ui';
const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
</script>
<template>
<VbenTree v-if="props.treeData?.length > 0" v-bind="props">
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps"> </slot>
</template>
</VbenTree>
<div
v-else
class="flex-col-center text-muted-foreground cursor-pointer rounded-lg border p-10 text-sm font-medium"
>
<Inbox class="size-10" />
<div class="mt-1">{{ $t('common.noData') }}</div>
</div>
</template>

View File

@ -7,7 +7,7 @@ import type { SystemRoleApi } from '#/api/system/role';
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { useVbenDrawer, VbenTree } from '@vben/common-ui'; import { Tree, useVbenDrawer } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { Spin } from 'ant-design-vue'; import { Spin } from 'ant-design-vue';
@ -92,9 +92,6 @@ function getNodeClass(node: Recordable<any>) {
const classes: string[] = []; const classes: string[] = [];
if (node.value?.type === 'button') { if (node.value?.type === 'button') {
classes.push('inline-flex'); classes.push('inline-flex');
if (node.index % 3 >= 1) {
classes.push('!pl-0');
}
} }
return classes.join(' '); return classes.join(' ');
@ -105,7 +102,7 @@ function getNodeClass(node: Recordable<any>) {
<Form> <Form>
<template #permissions="slotProps"> <template #permissions="slotProps">
<Spin :spinning="loadingPermissions" wrapper-class-name="w-full"> <Spin :spinning="loadingPermissions" wrapper-class-name="w-full">
<VbenTree <Tree
:tree-data="permissions" :tree-data="permissions"
multiple multiple
bordered bordered
@ -120,7 +117,7 @@ function getNodeClass(node: Recordable<any>) {
<IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" /> <IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
{{ $t(value.meta.title) }} {{ $t(value.meta.title) }}
</template> </template>
</VbenTree> </Tree>
</Spin> </Spin>
</template> </template>
</Form> </Form>