mirror of
https://github.com/vbenjs/vue-vben-admin.git
synced 2026-10-06 14:43:54 +08:00
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>
This commit is contained in:
@@ -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
|
|
||||||
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="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();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
<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>
|
<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-checkbox {
|
.item-checkbox {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user