|
|
@ -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; |
|
|
|