From b5029a0b674026f9b1d121fe590e084a8a18e321 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 11 Jul 2026 19:07:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(system):=20=E4=BF=AE=E5=A4=8D=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=A0=91=E5=8D=8A=E9=80=89=E7=88=B6=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回显时还原完整选中节点,提交时补齐祖先菜单 - 去重 Tree 级联选择产生的重复节点 - 对齐五端租户套餐菜单接口与详情回填 - 修复五端租户套餐及角色菜单授权 - 补全租户套餐详情 API 返回类型 - 增加 Tree 工具和组件回归测试 --- .../src/api/system/tenant-package/index.ts | 4 +- .../system/role/modules/assign-menu-form.vue | 17 +- .../system/tenantPackage/modules/form.vue | 27 ++- .../src/api/system/tenant-package/index.ts | 4 +- .../system/role/modules/assign-menu-form.vue | 17 +- .../system/tenantPackage/modules/form.vue | 27 ++- .../src/api/system/tenant-package/index.ts | 4 +- .../system/role/modules/assign-menu-form.vue | 17 +- .../system/tenantPackage/modules/form.vue | 27 ++- .../src/api/system/tenant-package/index.ts | 4 +- .../system/role/modules/assign-menu-form.vue | 17 +- .../system/tenantPackage/modules/form.vue | 27 ++- .../src/api/system/tenant-package/index.ts | 4 +- .../system/role/modules/assign-menu-form.vue | 17 +- .../system/tenantPackage/modules/form.vue | 27 ++- .../shared/src/utils/__tests__/tree.test.ts | 175 +++++++++++++++++- packages/@core/base/shared/src/utils/tree.ts | 110 +++++++++++ .../ui-kit/shadcn-ui/src/ui/tree/tree.vue | 4 +- .../components/tree/__tests__/tree.test.ts | 50 +++++ 19 files changed, 532 insertions(+), 47 deletions(-) create mode 100644 packages/effects/common-ui/src/components/tree/__tests__/tree.test.ts diff --git a/apps/web-antd/src/api/system/tenant-package/index.ts b/apps/web-antd/src/api/system/tenant-package/index.ts index 53be78f73..e593d4aac 100644 --- a/apps/web-antd/src/api/system/tenant-package/index.ts +++ b/apps/web-antd/src/api/system/tenant-package/index.ts @@ -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( + `/system/tenant-package/get?id=${id}`, + ); } /** 新增租户套餐 */ diff --git a/apps/web-antd/src/views/system/role/modules/assign-menu-form.vue b/apps/web-antd/src/views/system/role/modules/assign-menu-form.vue index de7b51cc7..235862037 100644 --- a/apps/web-antd/src/views/system/role/modules/assign-menu-form.vue +++ b/apps/web-antd/src/views/system/role/modules/assign-menu-form.vue @@ -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 { diff --git a/apps/web-antd/src/views/system/tenantPackage/modules/form.vue b/apps/web-antd/src/views/system/tenantPackage/modules/form.vue index 19702165b..b4284c587 100644 --- a/apps/web-antd/src/views/system/tenantPackage/modules/form.vue +++ b/apps/web-antd/src/views/system/tenantPackage/modules/form.vue @@ -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; diff --git a/apps/web-antdv-next/src/api/system/tenant-package/index.ts b/apps/web-antdv-next/src/api/system/tenant-package/index.ts index 53be78f73..e593d4aac 100644 --- a/apps/web-antdv-next/src/api/system/tenant-package/index.ts +++ b/apps/web-antdv-next/src/api/system/tenant-package/index.ts @@ -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( + `/system/tenant-package/get?id=${id}`, + ); } /** 新增租户套餐 */ diff --git a/apps/web-antdv-next/src/views/system/role/modules/assign-menu-form.vue b/apps/web-antdv-next/src/views/system/role/modules/assign-menu-form.vue index f06d1d59f..7f1c590c7 100644 --- a/apps/web-antdv-next/src/views/system/role/modules/assign-menu-form.vue +++ b/apps/web-antdv-next/src/views/system/role/modules/assign-menu-form.vue @@ -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 { diff --git a/apps/web-antdv-next/src/views/system/tenantPackage/modules/form.vue b/apps/web-antdv-next/src/views/system/tenantPackage/modules/form.vue index 5c8f8442b..10f4c23d3 100644 --- a/apps/web-antdv-next/src/views/system/tenantPackage/modules/form.vue +++ b/apps/web-antdv-next/src/views/system/tenantPackage/modules/form.vue @@ -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; diff --git a/apps/web-ele/src/api/system/tenant-package/index.ts b/apps/web-ele/src/api/system/tenant-package/index.ts index 53be78f73..e593d4aac 100644 --- a/apps/web-ele/src/api/system/tenant-package/index.ts +++ b/apps/web-ele/src/api/system/tenant-package/index.ts @@ -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( + `/system/tenant-package/get?id=${id}`, + ); } /** 新增租户套餐 */ diff --git a/apps/web-ele/src/views/system/role/modules/assign-menu-form.vue b/apps/web-ele/src/views/system/role/modules/assign-menu-form.vue index 463a16a30..1c77e248e 100644 --- a/apps/web-ele/src/views/system/role/modules/assign-menu-form.vue +++ b/apps/web-ele/src/views/system/role/modules/assign-menu-form.vue @@ -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 { diff --git a/apps/web-ele/src/views/system/tenantPackage/modules/form.vue b/apps/web-ele/src/views/system/tenantPackage/modules/form.vue index c789d6417..b5122eaaa 100644 --- a/apps/web-ele/src/views/system/tenantPackage/modules/form.vue +++ b/apps/web-ele/src/views/system/tenantPackage/modules/form.vue @@ -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; diff --git a/apps/web-naive/src/api/system/tenant-package/index.ts b/apps/web-naive/src/api/system/tenant-package/index.ts index 53be78f73..e593d4aac 100644 --- a/apps/web-naive/src/api/system/tenant-package/index.ts +++ b/apps/web-naive/src/api/system/tenant-package/index.ts @@ -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( + `/system/tenant-package/get?id=${id}`, + ); } /** 新增租户套餐 */ diff --git a/apps/web-naive/src/views/system/role/modules/assign-menu-form.vue b/apps/web-naive/src/views/system/role/modules/assign-menu-form.vue index bf29ece72..b5ecf7bd6 100644 --- a/apps/web-naive/src/views/system/role/modules/assign-menu-form.vue +++ b/apps/web-naive/src/views/system/role/modules/assign-menu-form.vue @@ -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 { diff --git a/apps/web-naive/src/views/system/tenantPackage/modules/form.vue b/apps/web-naive/src/views/system/tenantPackage/modules/form.vue index 6daf7b407..a38a3b590 100644 --- a/apps/web-naive/src/views/system/tenantPackage/modules/form.vue +++ b/apps/web-naive/src/views/system/tenantPackage/modules/form.vue @@ -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; diff --git a/apps/web-tdesign/src/api/system/tenant-package/index.ts b/apps/web-tdesign/src/api/system/tenant-package/index.ts index 53be78f73..e593d4aac 100644 --- a/apps/web-tdesign/src/api/system/tenant-package/index.ts +++ b/apps/web-tdesign/src/api/system/tenant-package/index.ts @@ -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( + `/system/tenant-package/get?id=${id}`, + ); } /** 新增租户套餐 */ diff --git a/apps/web-tdesign/src/views/system/role/modules/assign-menu-form.vue b/apps/web-tdesign/src/views/system/role/modules/assign-menu-form.vue index 62944edbd..93f406a5d 100644 --- a/apps/web-tdesign/src/views/system/role/modules/assign-menu-form.vue +++ b/apps/web-tdesign/src/views/system/role/modules/assign-menu-form.vue @@ -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 { diff --git a/apps/web-tdesign/src/views/system/tenantPackage/modules/form.vue b/apps/web-tdesign/src/views/system/tenantPackage/modules/form.vue index f77848362..e35400bc0 100644 --- a/apps/web-tdesign/src/views/system/tenantPackage/modules/form.vue +++ b/apps/web-tdesign/src/views/system/tenantPackage/modules/form.vue @@ -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; diff --git a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts b/packages/@core/base/shared/src/utils/__tests__/tree.test.ts index afe43cc5c..71fcdb9f4 100644 --- a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts +++ b/packages/@core/base/shared/src/utils/__tests__/tree.test.ts @@ -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 = [ { diff --git a/packages/@core/base/shared/src/utils/tree.ts b/packages/@core/base/shared/src/utils/tree.ts index 31026f18a..6a2e98af4 100644 --- a/packages/@core/base/shared/src/utils/tree.ts +++ b/packages/@core/base/shared/src/utils/tree.ts @@ -45,6 +45,114 @@ function traverseTreeValues( return result.filter(Boolean); } +/** + * @zh_CN 从包含半选祖先的值中还原完整选中的树节点值。 + * @param tree 树形结构数组 + * @param values 已选节点与半选祖先的值 + * @param getValue 获取节点值的函数 + * @param options 作为子节点数组的可选属性名称 + * @returns 完整选中的节点值数组 + */ +function getTreeCheckedValues( + tree: T[], + values: readonly V[], + getValue: (node: T) => V, + options?: TreeConfigOptions, +): V[] { + const selectedValues = new Set(values); + const checkedValues = new Set(); + const { childProps } = options || { + childProps: 'children', + }; + + const dfs = ( + treeNode: T, + ): { hasSelectedValue: boolean; isChecked: boolean } => { + const value = getValue(treeNode); + const children = (treeNode as Record)?.[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( + 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)?.[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>( export { filterTree, + getTreeCheckedValues, + getTreeValuesWithAncestors, handleTree, mapTree, sortTree, diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue index 0a6e8806d..4fe58203d 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue @@ -126,7 +126,9 @@ function updateTreeValue() { function updateModelValue(val: Arrayable>) { 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); diff --git a/packages/effects/common-ui/src/components/tree/__tests__/tree.test.ts b/packages/effects/common-ui/src/components/tree/__tests__/tree.test.ts new file mode 100644 index 000000000..d1e983e2e --- /dev/null +++ b/packages/effects/common-ui/src/components/tree/__tests__/tree.test.ts @@ -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([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); + }); +});