|
|
|
@ -4,7 +4,9 @@ import type { FlattenedItem } from 'radix-vue'; |
|
|
|
|
|
|
|
import type { ClassType, Recordable } from '@vben-core/typings'; |
|
|
|
|
|
|
|
import { onMounted, ref, watch, watchEffect } from 'vue'; |
|
|
|
import type { TreeProps } from './types'; |
|
|
|
|
|
|
|
import { onMounted, ref, watchEffect } from 'vue'; |
|
|
|
|
|
|
|
import { ChevronRight, IconifyIcon } from '@vben-core/icons'; |
|
|
|
import { cn, get } from '@vben-core/shared/utils'; |
|
|
|
@ -14,46 +16,13 @@ import { TreeItem, TreeRoot } from 'radix-vue'; |
|
|
|
|
|
|
|
import { Checkbox } from '../checkbox'; |
|
|
|
|
|
|
|
interface TreeProps { |
|
|
|
/** 单选时允许取消已有选项 */ |
|
|
|
allowClear?: boolean; |
|
|
|
/** 显示边框 */ |
|
|
|
bordered?: boolean; |
|
|
|
/** 取消父子关联选择 */ |
|
|
|
checkStrictly?: boolean; |
|
|
|
/** 子级字段名 */ |
|
|
|
childrenField?: string; |
|
|
|
/** 默认展开的键 */ |
|
|
|
defaultExpandedKeys?: Array<number | string>; |
|
|
|
/** 默认展开的级别(优先级高于defaultExpandedKeys) */ |
|
|
|
defaultExpandedLevel?: number; |
|
|
|
/** 默认值 */ |
|
|
|
defaultValue?: Arrayable<number | string>; |
|
|
|
/** 禁用 */ |
|
|
|
disabled?: boolean; |
|
|
|
/** 自定义节点类名 */ |
|
|
|
getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string; |
|
|
|
iconField?: string; |
|
|
|
/** label字段 */ |
|
|
|
labelField?: string; |
|
|
|
/** 当前值 */ |
|
|
|
modelValue?: Arrayable<number | string>; |
|
|
|
/** 是否多选 */ |
|
|
|
multiple?: boolean; |
|
|
|
/** 显示由iconField指定的图标 */ |
|
|
|
showIcon?: boolean; |
|
|
|
/** 启用展开收缩动画 */ |
|
|
|
transition?: boolean; |
|
|
|
/** 树数据 */ |
|
|
|
treeData: Recordable<any>[]; |
|
|
|
/** 值字段 */ |
|
|
|
valueField?: string; |
|
|
|
} |
|
|
|
const props = withDefaults(defineProps<TreeProps>(), { |
|
|
|
allowClear: false, |
|
|
|
autoCheckParent: true, |
|
|
|
bordered: false, |
|
|
|
checkStrictly: false, |
|
|
|
defaultExpandedKeys: () => [], |
|
|
|
defaultExpandedLevel: 0, |
|
|
|
disabled: false, |
|
|
|
expanded: () => [], |
|
|
|
iconField: 'icon', |
|
|
|
@ -61,7 +30,7 @@ const props = withDefaults(defineProps<TreeProps>(), { |
|
|
|
modelValue: () => [], |
|
|
|
multiple: false, |
|
|
|
showIcon: true, |
|
|
|
transition: false, |
|
|
|
transition: true, |
|
|
|
valueField: 'value', |
|
|
|
childrenField: 'children', |
|
|
|
}); |
|
|
|
@ -72,28 +41,36 @@ const emits = defineEmits<{ |
|
|
|
'update:modelValue': [value: Arrayable<Recordable<any>>]; |
|
|
|
}>(); |
|
|
|
|
|
|
|
interface InnerFlattenItem<T = Recordable<any>> { |
|
|
|
interface InnerFlattenItem<T = Recordable<any>, P = number | string> { |
|
|
|
hasChildren: boolean; |
|
|
|
level: number; |
|
|
|
parents: P[]; |
|
|
|
value: T; |
|
|
|
} |
|
|
|
|
|
|
|
function flatten<T = Recordable<any>>( |
|
|
|
function flatten<T = Recordable<any>, P = number | string>( |
|
|
|
items: T[], |
|
|
|
childrenField: string = 'children', |
|
|
|
level = 0, |
|
|
|
): InnerFlattenItem<T>[] { |
|
|
|
const result: InnerFlattenItem<T>[] = []; |
|
|
|
parents: P[] = [], |
|
|
|
): InnerFlattenItem<T, P>[] { |
|
|
|
const result: InnerFlattenItem<T, P>[] = []; |
|
|
|
items.forEach((item) => { |
|
|
|
const children = get(item, childrenField) as Array<T>; |
|
|
|
const val = { |
|
|
|
hasChildren: Array.isArray(children) && children.length > 0, |
|
|
|
level, |
|
|
|
parents: [...parents], |
|
|
|
value: item, |
|
|
|
}; |
|
|
|
result.push(val); |
|
|
|
if (val.hasChildren) |
|
|
|
result.push(...flatten(children, childrenField, level + 1)); |
|
|
|
result.push( |
|
|
|
...flatten(children, childrenField, level + 1, [ |
|
|
|
...parents, |
|
|
|
get(item, props.valueField), |
|
|
|
]), |
|
|
|
); |
|
|
|
}); |
|
|
|
return result; |
|
|
|
} |
|
|
|
@ -133,14 +110,6 @@ function updateTreeValue() { |
|
|
|
: getItemByValue(val); |
|
|
|
} |
|
|
|
|
|
|
|
watch( |
|
|
|
modelValue, |
|
|
|
() => { |
|
|
|
updateTreeValue(); |
|
|
|
}, |
|
|
|
{ deep: true, immediate: true }, |
|
|
|
); |
|
|
|
|
|
|
|
function updateModelValue(val: Arrayable<Recordable<any>>) { |
|
|
|
modelValue.value = Array.isArray(val) |
|
|
|
? val.map((v) => get(v, props.valueField)) |
|
|
|
@ -186,7 +155,33 @@ function collapseAll() { |
|
|
|
function onToggle(item: FlattenedItem<Recordable<any>>) { |
|
|
|
emits('expand', item); |
|
|
|
} |
|
|
|
function onSelect(item: FlattenedItem<Recordable<any>>) { |
|
|
|
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?.forEach((p) => { |
|
|
|
if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) { |
|
|
|
modelValue.value.push(p); |
|
|
|
} |
|
|
|
}); |
|
|
|
} else { |
|
|
|
if (Array.isArray(modelValue.value)) { |
|
|
|
const index = modelValue.value.indexOf(get(item.value, props.valueField)); |
|
|
|
if (index !== -1) { |
|
|
|
modelValue.value.splice(index, 1); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
updateTreeValue(); |
|
|
|
emits('select', item); |
|
|
|
} |
|
|
|
|
|
|
|
@ -224,78 +219,130 @@ defineExpose({ |
|
|
|
<div class="w-full" v-if="$slots.header"> |
|
|
|
<slot name="header"> </slot> |
|
|
|
</div> |
|
|
|
<TreeItem |
|
|
|
v-for="item in flattenItems" |
|
|
|
v-slot="{ |
|
|
|
isExpanded, |
|
|
|
isSelected, |
|
|
|
isIndeterminate, |
|
|
|
handleSelect, |
|
|
|
handleToggle, |
|
|
|
}" |
|
|
|
:key="item._id" |
|
|
|
:style="{ 'padding-left': `${item.level - 0.5}rem` }" |
|
|
|
:class=" |
|
|
|
cn('cursor-pointer', getNodeClass?.(item), { |
|
|
|
'data-[selected]:bg-accent': !multiple, |
|
|
|
}) |
|
|
|
" |
|
|
|
v-bind="item.bind" |
|
|
|
@select=" |
|
|
|
(event) => { |
|
|
|
if (event.detail.originalEvent.type === 'click') { |
|
|
|
// event.preventDefault(); |
|
|
|
} |
|
|
|
onSelect(item); |
|
|
|
} |
|
|
|
" |
|
|
|
@toggle=" |
|
|
|
(event) => { |
|
|
|
if (event.detail.originalEvent.type === 'click') { |
|
|
|
event.preventDefault(); |
|
|
|
} |
|
|
|
onToggle(item); |
|
|
|
} |
|
|
|
" |
|
|
|
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2" |
|
|
|
<TransitionGroup |
|
|
|
:name="transition ? 'fade' : ''" |
|
|
|
mode="out-in" |
|
|
|
class="container" |
|
|
|
> |
|
|
|
<ChevronRight |
|
|
|
v-if="item.hasChildren" |
|
|
|
class="size-4 cursor-pointer transition" |
|
|
|
:class="{ 'rotate-90': isExpanded }" |
|
|
|
@click.stop="handleToggle" |
|
|
|
/> |
|
|
|
<div v-else class="h-4 w-4"> |
|
|
|
<!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> --> |
|
|
|
</div> |
|
|
|
<Checkbox |
|
|
|
v-if="multiple" |
|
|
|
:checked="isSelected" |
|
|
|
:indeterminate="isIndeterminate" |
|
|
|
@click.stop="handleSelect" |
|
|
|
/> |
|
|
|
<div |
|
|
|
class="flex items-center gap-1 pl-2" |
|
|
|
@click=" |
|
|
|
($event) => { |
|
|
|
$event.stopPropagation(); |
|
|
|
$event.preventDefault(); |
|
|
|
handleSelect(); |
|
|
|
<TreeItem |
|
|
|
v-for="item in flattenItems" |
|
|
|
v-slot="{ |
|
|
|
isExpanded, |
|
|
|
isSelected, |
|
|
|
isIndeterminate, |
|
|
|
handleSelect, |
|
|
|
handleToggle, |
|
|
|
}" |
|
|
|
:key="item._id" |
|
|
|
:style="{ 'padding-left': `${item.level - 0.5}rem` }" |
|
|
|
:class=" |
|
|
|
cn('cursor-pointer', getNodeClass?.(item), { |
|
|
|
'data-[selected]:bg-accent': !multiple, |
|
|
|
}) |
|
|
|
" |
|
|
|
v-bind="item.bind" |
|
|
|
@select=" |
|
|
|
(event) => { |
|
|
|
if (event.detail.originalEvent.type === 'click') { |
|
|
|
// event.preventDefault(); |
|
|
|
} |
|
|
|
onSelect(item, event.detail.isSelected); |
|
|
|
} |
|
|
|
" |
|
|
|
@toggle=" |
|
|
|
(event) => { |
|
|
|
if (event.detail.originalEvent.type === 'click') { |
|
|
|
event.preventDefault(); |
|
|
|
} |
|
|
|
onToggle(item); |
|
|
|
} |
|
|
|
" |
|
|
|
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2" |
|
|
|
> |
|
|
|
<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> |
|
|
|
</TreeItem> |
|
|
|
<ChevronRight |
|
|
|
v-if="item.hasChildren" |
|
|
|
class="size-4 cursor-pointer transition" |
|
|
|
:class="{ 'rotate-90': isExpanded }" |
|
|
|
@click.stop=" |
|
|
|
() => { |
|
|
|
handleToggle(); |
|
|
|
onToggle(item); |
|
|
|
} |
|
|
|
" |
|
|
|
/> |
|
|
|
<div v-else class="h-4 w-4"> |
|
|
|
<!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> --> |
|
|
|
</div> |
|
|
|
<Checkbox |
|
|
|
v-if="multiple" |
|
|
|
:checked="isSelected" |
|
|
|
:indeterminate="isIndeterminate" |
|
|
|
@click=" |
|
|
|
() => { |
|
|
|
handleSelect(); |
|
|
|
// onSelect(item, !isSelected); |
|
|
|
} |
|
|
|
" |
|
|
|
/> |
|
|
|
<div |
|
|
|
class="flex items-center gap-1 pl-2" |
|
|
|
@click=" |
|
|
|
(_event) => { |
|
|
|
// $event.stopPropagation(); |
|
|
|
// $event.preventDefault(); |
|
|
|
handleSelect(); |
|
|
|
// onSelect(item, !isSelected); |
|
|
|
} |
|
|
|
" |
|
|
|
> |
|
|
|
<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> |
|
|
|
</TreeItem> |
|
|
|
</TransitionGroup> |
|
|
|
<div class="w-full" v-if="$slots.footer"> |
|
|
|
<slot name="footer"> </slot> |
|
|
|
</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; |
|
|
|
} |
|
|
|
|
|
|
|
/* 1. 声明过渡效果 */ |
|
|
|
.fade-move, |
|
|
|
.fade-enter-active, |
|
|
|
.fade-leave-active { |
|
|
|
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1); |
|
|
|
} |
|
|
|
|
|
|
|
/* 2. 声明进入和离开的状态 */ |
|
|
|
.fade-enter-from, |
|
|
|
.fade-leave-to { |
|
|
|
opacity: 0; |
|
|
|
transform: scaleY(0.01) translate(30px, 0); |
|
|
|
} |
|
|
|
|
|
|
|
/* 3. 确保离开的项目被移除出了布局流 |
|
|
|
以便正确地计算移动时的动画效果。 */ |
|
|
|
.fade-leave-active { |
|
|
|
position: absolute; |
|
|
|
} |
|
|
|
</style> |
|
|
|
|