fix(system): 修复菜单树半选父节点权限丢失
- 回显时还原完整选中节点,提交时补齐祖先菜单 - 去重 Tree 级联选择产生的重复节点 - 对齐五端租户套餐菜单接口与详情回填 - 修复五端租户套餐及角色菜单授权 - 补全租户套餐详情 API 返回类型 - 增加 Tree 工具和组件回归测试
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增租户套餐 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增租户套餐 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增租户套餐 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增租户套餐 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 新增租户套餐 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user