fix(system): 修复菜单树半选父节点权限丢失

- 回显时还原完整选中节点,提交时补齐祖先菜单
- 去重 Tree 级联选择产生的重复节点
- 对齐五端租户套餐菜单接口与详情回填
- 修复五端租户套餐及角色菜单授权
- 补全租户套餐详情 API 返回类型
- 增加 Tree 工具和组件回归测试
This commit is contained in:
YunaiV
2026-07-11 19:07:37 +08:00
parent c96cf0ead9
commit b5029a0b67
19 changed files with 532 additions and 47 deletions

View File

@@ -27,7 +27,9 @@ export function getTenantPackagePage(params: PageParam) {
/** 查询租户套餐详情 */
export function getTenantPackage(id: number) {
return requestClient.get(`/system/tenant-package/get?id=${id}`);
return requestClient.get<SystemTenantPackageApi.TenantPackage>(
`/system/tenant-package/get?id=${id}`,
);
}
/** 新增租户套餐 */

View File

@@ -8,7 +8,11 @@ import { nextTick, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { Checkbox, message, Spin } from 'ant-design-vue';
@@ -52,7 +56,11 @@ const [Modal, modalApi] = useVbenModal({
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
menuIds: getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
),
});
// 关闭并提示
await modalApi.close();
@@ -76,7 +84,10 @@ const [Modal, modalApi] = useVbenModal({
try {
// 加载角色菜单
const menuIds = await getRoleMenuList(data.id);
await formApi.setFieldValue('menuIds', menuIds);
await formApi.setFieldValue(
'menuIds',
getTreeCheckedValues(menuTree.value, menuIds, (menu) => menu.id),
);
await formApi.setValues(data);
} finally {

View File

@@ -5,12 +5,16 @@ import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { computed, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { Checkbox, message, Spin } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { getSimpleMenusList } from '#/api/system/menu';
import {
createTenantPackage,
getTenantPackage,
@@ -56,6 +60,11 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data =
(await formApi.getValues()) as SystemTenantPackageApi.TenantPackage;
data.menuIds = getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
);
try {
await (formData.value
? updateTenantPackage(data)
@@ -82,8 +91,16 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
formData.value = await getTenantPackage(data.id);
await formApi.setValues(data);
const detail = await getTenantPackage(data.id);
formData.value = detail;
await formApi.setValues({
...detail,
menuIds: getTreeCheckedValues(
menuTree.value,
detail.menuIds ?? [],
(menu) => menu.id,
),
});
} finally {
modalApi.unlock();
}
@@ -94,7 +111,7 @@ const [Modal, modalApi] = useVbenModal({
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
const data = await getSimpleMenusList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;

View File

@@ -27,7 +27,9 @@ export function getTenantPackagePage(params: PageParam) {
/** 查询租户套餐详情 */
export function getTenantPackage(id: number) {
return requestClient.get(`/system/tenant-package/get?id=${id}`);
return requestClient.get<SystemTenantPackageApi.TenantPackage>(
`/system/tenant-package/get?id=${id}`,
);
}
/** 新增租户套餐 */

View File

@@ -8,7 +8,11 @@ import { nextTick, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { Checkbox, message, Spin } from 'antdv-next';
@@ -52,7 +56,11 @@ const [Modal, modalApi] = useVbenModal({
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
menuIds: getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
),
});
// 关闭并提示
await modalApi.close();
@@ -76,7 +84,10 @@ const [Modal, modalApi] = useVbenModal({
try {
// 加载角色菜单
const menuIds = await getRoleMenuList(data.id);
await formApi.setFieldValue('menuIds', menuIds);
await formApi.setFieldValue(
'menuIds',
getTreeCheckedValues(menuTree.value, menuIds, (menu) => menu.id),
);
await formApi.setValues(data);
} finally {

View File

@@ -5,12 +5,16 @@ import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { computed, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { Checkbox, message, Spin } from 'antdv-next';
import { useVbenForm } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { getSimpleMenusList } from '#/api/system/menu';
import {
createTenantPackage,
getTenantPackage,
@@ -56,6 +60,11 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data =
(await formApi.getValues()) as SystemTenantPackageApi.TenantPackage;
data.menuIds = getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
);
try {
await (formData.value
? updateTenantPackage(data)
@@ -82,8 +91,16 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
formData.value = await getTenantPackage(data.id);
await formApi.setValues(data);
const detail = await getTenantPackage(data.id);
formData.value = detail;
await formApi.setValues({
...detail,
menuIds: getTreeCheckedValues(
menuTree.value,
detail.menuIds ?? [],
(menu) => menu.id,
),
});
} finally {
modalApi.unlock();
}
@@ -94,7 +111,7 @@ const [Modal, modalApi] = useVbenModal({
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
const data = await getSimpleMenusList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;

View File

@@ -27,7 +27,9 @@ export function getTenantPackagePage(params: PageParam) {
/** 查询租户套餐详情 */
export function getTenantPackage(id: number) {
return requestClient.get(`/system/tenant-package/get?id=${id}`);
return requestClient.get<SystemTenantPackageApi.TenantPackage>(
`/system/tenant-package/get?id=${id}`,
);
}
/** 新增租户套餐 */

View File

@@ -8,7 +8,11 @@ import { nextTick, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { ElCheckbox, ElMessage } from 'element-plus';
@@ -52,7 +56,11 @@ const [Modal, modalApi] = useVbenModal({
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
menuIds: getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
),
});
// 关闭并提示
await modalApi.close();
@@ -76,7 +84,10 @@ const [Modal, modalApi] = useVbenModal({
try {
// 加载角色菜单
const menuIds = await getRoleMenuList(data.id);
await formApi.setFieldValue('menuIds', menuIds);
await formApi.setFieldValue(
'menuIds',
getTreeCheckedValues(menuTree.value, menuIds, (menu) => menu.id),
);
await formApi.setValues(data);
} finally {

View File

@@ -5,12 +5,16 @@ import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { computed, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { ElCheckbox, ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { getMenuList } from '#/api/system/menu';
import { getSimpleMenusList } from '#/api/system/menu';
import {
createTenantPackage,
getTenantPackage,
@@ -56,6 +60,11 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data =
(await formApi.getValues()) as SystemTenantPackageApi.TenantPackage;
data.menuIds = getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
);
try {
await (formData.value
? updateTenantPackage(data)
@@ -82,8 +91,16 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
formData.value = await getTenantPackage(data.id);
await formApi.setValues(data);
const detail = await getTenantPackage(data.id);
formData.value = detail;
await formApi.setValues({
...detail,
menuIds: getTreeCheckedValues(
menuTree.value,
detail.menuIds ?? [],
(menu) => menu.id,
),
});
} finally {
modalApi.unlock();
}
@@ -94,7 +111,7 @@ const [Modal, modalApi] = useVbenModal({
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
const data = await getSimpleMenusList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;

View File

@@ -27,7 +27,9 @@ export function getTenantPackagePage(params: PageParam) {
/** 查询租户套餐详情 */
export function getTenantPackage(id: number) {
return requestClient.get(`/system/tenant-package/get?id=${id}`);
return requestClient.get<SystemTenantPackageApi.TenantPackage>(
`/system/tenant-package/get?id=${id}`,
);
}
/** 新增租户套餐 */

View File

@@ -8,7 +8,11 @@ import { nextTick, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { NCheckbox, NSpin } from 'naive-ui';
@@ -53,7 +57,11 @@ const [Modal, modalApi] = useVbenModal({
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
menuIds: getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
),
});
// 关闭并提示
await modalApi.close();
@@ -77,7 +85,10 @@ const [Modal, modalApi] = useVbenModal({
try {
// 加载角色菜单
const menuIds = await getRoleMenuList(data.id);
await formApi.setFieldValue('menuIds', menuIds);
await formApi.setFieldValue(
'menuIds',
getTreeCheckedValues(menuTree.value, menuIds, (menu) => menu.id),
);
await formApi.setValues(data);
} finally {

View File

@@ -5,13 +5,17 @@ import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { computed, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { NCheckbox, NSpin } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/naive';
import { getMenuList } from '#/api/system/menu';
import { getSimpleMenusList } from '#/api/system/menu';
import {
createTenantPackage,
getTenantPackage,
@@ -57,6 +61,11 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data =
(await formApi.getValues()) as SystemTenantPackageApi.TenantPackage;
data.menuIds = getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
);
try {
await (formData.value
? updateTenantPackage(data)
@@ -83,8 +92,16 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
formData.value = await getTenantPackage(data.id);
await formApi.setValues(data);
const detail = await getTenantPackage(data.id);
formData.value = detail;
await formApi.setValues({
...detail,
menuIds: getTreeCheckedValues(
menuTree.value,
detail.menuIds ?? [],
(menu) => menu.id,
),
});
} finally {
modalApi.unlock();
}
@@ -95,7 +112,7 @@ const [Modal, modalApi] = useVbenModal({
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
const data = await getSimpleMenusList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;

View File

@@ -27,7 +27,9 @@ export function getTenantPackagePage(params: PageParam) {
/** 查询租户套餐详情 */
export function getTenantPackage(id: number) {
return requestClient.get(`/system/tenant-package/get?id=${id}`);
return requestClient.get<SystemTenantPackageApi.TenantPackage>(
`/system/tenant-package/get?id=${id}`,
);
}
/** 新增租户套餐 */

View File

@@ -8,7 +8,11 @@ import { nextTick, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { SystemMenuTypeEnum } from '@vben/constants';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { Checkbox } from 'tdesign-vue-next';
@@ -53,7 +57,11 @@ const [Modal, modalApi] = useVbenModal({
try {
await assignRoleMenu({
roleId: data.id,
menuIds: data.menuIds,
menuIds: getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
),
});
// 关闭并提示
await modalApi.close();
@@ -77,7 +85,10 @@ const [Modal, modalApi] = useVbenModal({
try {
// 加载角色菜单
const menuIds = await getRoleMenuList(data.id);
await formApi.setFieldValue('menuIds', menuIds);
await formApi.setFieldValue(
'menuIds',
getTreeCheckedValues(menuTree.value, menuIds, (menu) => menu.id),
);
await formApi.setValues(data);
} finally {

View File

@@ -5,13 +5,17 @@ import type { SystemTenantPackageApi } from '#/api/system/tenant-package';
import { computed, ref } from 'vue';
import { Tree, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import {
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
} from '@vben/utils';
import { Checkbox } from 'tdesign-vue-next';
import { useVbenForm } from '#/adapter/form';
import { message } from '#/adapter/tdesign';
import { getMenuList } from '#/api/system/menu';
import { getSimpleMenusList } from '#/api/system/menu';
import {
createTenantPackage,
getTenantPackage,
@@ -57,6 +61,11 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data =
(await formApi.getValues()) as SystemTenantPackageApi.TenantPackage;
data.menuIds = getTreeValuesWithAncestors(
menuTree.value,
data.menuIds ?? [],
(menu) => menu.id,
);
try {
await (formData.value
? updateTenantPackage(data)
@@ -83,8 +92,16 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
formData.value = await getTenantPackage(data.id);
await formApi.setValues(data);
const detail = await getTenantPackage(data.id);
formData.value = detail;
await formApi.setValues({
...detail,
menuIds: getTreeCheckedValues(
menuTree.value,
detail.menuIds ?? [],
(menu) => menu.id,
),
});
} finally {
modalApi.unlock();
}
@@ -95,7 +112,7 @@ const [Modal, modalApi] = useVbenModal({
async function loadMenuTree() {
menuLoading.value = true;
try {
const data = await getMenuList();
const data = await getSimpleMenusList();
menuTree.value = handleTree(data) as SystemMenuApi.Menu[];
} finally {
menuLoading.value = false;

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { filterTree, mapTree, traverseTreeValues } from '../tree';
import {
filterTree,
getTreeCheckedValues,
getTreeValuesWithAncestors,
mapTree,
traverseTreeValues,
} from '../tree';
describe('traverseTreeValues', () => {
interface Node {
@@ -68,6 +74,173 @@ describe('traverseTreeValues', () => {
});
});
describe('getTreeValuesWithAncestors', () => {
const tree = [
{
id: 1,
children: [
{ id: 2 },
{
id: 3,
children: [{ id: 4 }, { id: 5 }],
},
],
},
{ id: 6 },
];
it('handles empty trees and selections', () => {
expect(getTreeValuesWithAncestors(tree, [], (node) => node.id)).toEqual([]);
expect(
getTreeValuesWithAncestors<{ id: number }, number>(
[],
[1],
(node) => node.id,
),
).toEqual([1]);
});
it('includes every ancestor of a selected node', () => {
expect(getTreeValuesWithAncestors(tree, [4], (node) => node.id)).toEqual([
4, 1, 3,
]);
});
it('deduplicates selected values and shared ancestors', () => {
expect(
getTreeValuesWithAncestors(tree, [4, 5, 4], (node) => node.id),
).toEqual([4, 5, 1, 3]);
});
it('keeps ancestor-complete selections unchanged', () => {
const values = [1, 2, 3, 4, 5];
expect(getTreeValuesWithAncestors(tree, values, (node) => node.id)).toEqual(
values,
);
});
it('does not add descendants when only a parent is selected', () => {
expect(getTreeValuesWithAncestors(tree, [3], (node) => node.id)).toEqual([
3, 1,
]);
});
it('preserves falsy and unknown selected values', () => {
const treeWithZeroId = [{ id: 0, children: [{ id: 1 }] }];
expect(
getTreeValuesWithAncestors(treeWithZeroId, [1, 999], (node) => node.id),
).toEqual([1, 999, 0]);
});
it('does not mutate the tree or selected values', () => {
const values = [4];
const treeSnapshot = structuredClone(tree);
getTreeValuesWithAncestors(tree, values, (node) => node.id);
expect(values).toEqual([4]);
expect(tree).toEqual(treeSnapshot);
});
it('supports a custom children property', () => {
const customTree = [
{
key: 'root',
items: [{ key: 'leaf' }],
},
];
expect(
getTreeValuesWithAncestors(customTree, ['leaf'], (node) => node.key, {
childProps: 'items',
}),
).toEqual(['leaf', 'root']);
});
});
describe('getTreeCheckedValues', () => {
const tree = [
{
id: 1,
children: [
{ id: 2 },
{
id: 3,
children: [{ id: 4 }, { id: 5 }],
},
],
},
];
it('removes half-selected ancestors', () => {
expect(getTreeCheckedValues(tree, [1, 2], (node) => node.id)).toEqual([2]);
});
it('keeps a fully selected branch under a half-selected ancestor', () => {
expect(getTreeCheckedValues(tree, [1, 3, 4, 5], (node) => node.id)).toEqual(
[3, 4, 5],
);
});
it('keeps an ancestor-complete selection unchanged', () => {
const values = [1, 2, 3, 4, 5];
expect(getTreeCheckedValues(tree, values, (node) => node.id)).toEqual(
values,
);
});
it('keeps an explicitly selected parent without selected descendants', () => {
expect(getTreeCheckedValues(tree, [1], (node) => node.id)).toEqual([1]);
});
it('restores missing parents when all descendants are selected', () => {
expect(getTreeCheckedValues(tree, [2, 4, 5], (node) => node.id)).toEqual([
2, 4, 5, 3, 1,
]);
});
it('round-trips persisted checked and half-selected values', () => {
const persistedValues = [1, 3, 4, 5];
const checkedValues = getTreeCheckedValues(
tree,
persistedValues,
(node) => node.id,
);
const submittedValues = getTreeValuesWithAncestors(
tree,
checkedValues,
(node) => node.id,
);
expect(new Set(submittedValues)).toEqual(new Set(persistedValues));
});
it('drops unknown values and supports falsy node values', () => {
const treeWithZeroId = [{ id: 0, children: [{ id: 1 }, { id: 2 }] }];
expect(
getTreeCheckedValues(treeWithZeroId, [0, 1, 999], (node) => node.id),
).toEqual([1]);
});
it('supports a custom children property', () => {
const customTree = [
{
key: 'root',
items: [{ key: 'leaf' }],
},
];
expect(
getTreeCheckedValues(customTree, ['root', 'leaf'], (node) => node.key, {
childProps: 'items',
}),
).toEqual(['root', 'leaf']);
});
it('does not mutate the tree or selected values', () => {
const values = [1, 2];
const treeSnapshot = structuredClone(tree);
getTreeCheckedValues(tree, values, (node) => node.id);
expect(values).toEqual([1, 2]);
expect(tree).toEqual(treeSnapshot);
});
});
describe('filterTree', () => {
const tree = [
{

View File

@@ -45,6 +45,114 @@ function traverseTreeValues<T, V>(
return result.filter(Boolean);
}
/**
* @zh_CN 从包含半选祖先的值中还原完整选中的树节点值。
* @param tree 树形结构数组
* @param values 已选节点与半选祖先的值
* @param getValue 获取节点值的函数
* @param options 作为子节点数组的可选属性名称
* @returns 完整选中的节点值数组
*/
function getTreeCheckedValues<T, V>(
tree: T[],
values: readonly V[],
getValue: (node: T) => V,
options?: TreeConfigOptions,
): V[] {
const selectedValues = new Set(values);
const checkedValues = new Set<V>();
const { childProps } = options || {
childProps: 'children',
};
const dfs = (
treeNode: T,
): { hasSelectedValue: boolean; isChecked: boolean } => {
const value = getValue(treeNode);
const children = (treeNode as Record<string, any>)?.[childProps];
const childStates = Array.isArray(children)
? children.map((child) => dfs(child))
: [];
const hasSelectedDescendant = childStates.some(
(state) => state.hasSelectedValue,
);
const areChildrenChecked = childStates.every((state) => state.isChecked);
const isSelected = selectedValues.has(value);
const isChecked =
(childStates.length > 0 && areChildrenChecked) ||
(isSelected && !hasSelectedDescendant);
if (isChecked) {
checkedValues.add(value);
}
return {
hasSelectedValue: isSelected || hasSelectedDescendant,
isChecked,
};
};
for (const treeNode of tree) {
dfs(treeNode);
}
// 先保留输入值中已知(在树内)的选中项,再追加由子节点全选推导出的父节点。
const result = [...new Set(values)].filter((value) =>
checkedValues.has(value),
);
const resultSet = new Set(result);
for (const value of checkedValues) {
if (!resultSet.has(value)) {
resultSet.add(value);
result.push(value);
}
}
return result;
}
/**
* @zh_CN 为已选树节点补齐所有祖先节点的值。
* @param tree 树形结构数组
* @param values 已选节点的值
* @param getValue 获取节点值的函数
* @param options 作为子节点数组的可选属性名称
* @returns 已选节点与其祖先节点的去重值数组
*/
function getTreeValuesWithAncestors<T, V>(
tree: T[],
values: readonly V[],
getValue: (node: T) => V,
options?: TreeConfigOptions,
): V[] {
const result = [...new Set(values)];
const resultSet = new Set(result);
const { childProps } = options || {
childProps: 'children',
};
const dfs = (treeNode: T, ancestors: V[]) => {
const value = getValue(treeNode);
if (resultSet.has(value)) {
for (const ancestor of ancestors) {
if (!resultSet.has(ancestor)) {
resultSet.add(ancestor);
result.push(ancestor);
}
}
}
const children = (treeNode as Record<string, any>)?.[childProps];
if (!Array.isArray(children)) {
return;
}
for (const child of children) {
dfs(child, [...ancestors, value]);
}
};
for (const treeNode of tree) {
dfs(treeNode, []);
}
return result;
}
/**
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
* @param tree 要过滤的树结构的根节点数组。
@@ -238,6 +346,8 @@ function sortTree<T extends Record<string, any>>(
export {
filterTree,
getTreeCheckedValues,
getTreeValuesWithAncestors,
handleTree,
mapTree,
sortTree,

View File

@@ -126,7 +126,9 @@ function updateTreeValue() {
function updateModelValue(val: Arrayable<Recordable<any>>) {
if (Array.isArray(val)) {
const filteredVal = val.filter((v) => !get(v, props.disabledField));
modelValue.value = filteredVal.map((v) => get(v, props.valueField));
modelValue.value = [
...new Set(filteredVal.map((v) => get(v, props.valueField))),
];
} else {
if (val && !get(val, props.disabledField)) {
modelValue.value = get(val, props.valueField);

View File

@@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import Tree from '../tree.vue';
describe('tree', () => {
it('deduplicates descendants when selecting a half-selected parent', async () => {
const treeData = [
{
id: 1,
name: 'parent',
children: [
{ id: 2, name: 'child-2' },
{ id: 3, name: 'child-3' },
],
},
];
const selected = ref<number[]>([2]);
const wrapper = mount(
defineComponent({
setup() {
return () =>
h(Tree, {
defaultExpandedKeys: [1],
labelField: 'name',
modelValue: selected.value,
multiple: true,
'onUpdate:modelValue': (value: number[]) => {
selected.value = value;
},
treeData,
valueField: 'id',
});
},
}),
);
await nextTick();
const nodes = wrapper.findAll('.tree-node');
expect(nodes).toHaveLength(3);
await nodes[0]!.find('button').trigger('click');
await nextTick();
expect(new Set(selected.value)).toEqual(new Set([1, 2, 3]));
expect(selected.value).toHaveLength(3);
});
});