Explorar o código

fix:
1、VbenTree新增是否全选、展开折叠功能;
2、解决当点击子节点label文字区域,而非checkbox时,关联父组件不能选中问题;
3、优化子节点选中时关联父节点选中功能:删除VbenTree中processParentSelection方法,改为在onSelect中实现,原因:processParentSelection在每次模型值更新时都会被调用,且计算复杂度为O(n^2),onSelect只在交互时触发,复杂度为O(n);
4、新增中间层tree组件,处理无数据时显示场景(显示图标Inbox和国际化comom.noData文本);
5、为防止父组件传值子组件boolean类型默认false问题,新增treePropsDefaults方法,为TreeProps赋默认值,Tree组件和VbenTree组件统一使用;
6、优化VbenTree组件整体样式(优化padding、margin、gap值,优化type为button时outline左右空白区域不对称问题),优化内部header、footer插槽样式。

zouawen hai 9 meses
pai
achega
39820c783c

+ 1 - 0
packages/@core/base/icons/src/lucide.ts

@@ -32,6 +32,7 @@ export {
   Grip,
   GripVertical,
   Menu as IconDefault,
+  Inbox,
   Info,
   InspectionPanel,
   Languages,

+ 2 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts

@@ -1,2 +1,4 @@
 export { default as VbenTree } from './tree.vue';
+export type { TreeProps } from './types';
+export { treePropsDefaults } from './types';
 export type { FlattenedItem } from 'radix-vue';

+ 144 - 105
packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue

@@ -14,25 +14,9 @@ import { cn, get } from '@vben-core/shared/utils';
 import { TreeItem, TreeRoot } from 'radix-vue';
 
 import { Checkbox } from '../checkbox';
+import { treePropsDefaults } from './types';
 
-const props = withDefaults(defineProps<TreeProps>(), {
-  allowClear: false,
-  autoCheckParent: true,
-  bordered: false,
-  checkStrictly: false,
-  defaultExpandedKeys: () => [],
-  defaultExpandedLevel: 0,
-  disabled: false,
-  disabledField: 'disabled',
-  expanded: () => [],
-  iconField: 'icon',
-  labelField: 'label',
-  multiple: false,
-  showIcon: true,
-  transition: true,
-  valueField: 'value',
-  childrenField: 'children',
-});
+const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
 
 const emits = defineEmits<{
   expand: [value: FlattenedItem<Recordable<any>>];
@@ -41,7 +25,9 @@ const emits = defineEmits<{
 
 interface InnerFlattenItem<T = Recordable<any>, P = number | string> {
   hasChildren: boolean;
+  id: P;
   level: number;
+  parentId: null | P;
   parents: P[];
   value: T;
 }
@@ -50,24 +36,25 @@ function flatten<T = Recordable<any>, P = number | string>(
   items: T[],
   childrenField: string = 'children',
   level = 0,
+  parentId: null | P = null,
   parents: P[] = [],
 ): InnerFlattenItem<T, P>[] {
   const result: InnerFlattenItem<T, P>[] = [];
   items.forEach((item) => {
     const children = get(item, childrenField) as Array<T>;
-    const val = {
+    const id = get(item, props.valueField) as P;
+    const val: InnerFlattenItem<T, P> = {
       hasChildren: Array.isArray(children) && children.length > 0,
+      id,
       level,
+      parentId,
       parents: [...parents],
       value: item,
     };
     result.push(val);
     if (val.hasChildren)
       result.push(
-        ...flatten(children, childrenField, level + 1, [
-          ...parents,
-          get(item, props.valueField),
-        ]),
+        ...flatten(children, childrenField, level + 1, id, [...parents, id]),
       );
   });
   return result;
@@ -103,15 +90,10 @@ function updateTreeValue() {
     treeValue.value = undefined;
   } else {
     if (Array.isArray(val)) {
-      let filteredValues = val.filter((v) => {
+      const filteredValues = val.filter((v) => {
         const item = getItemByValue(v);
         return item && !get(item, props.disabledField);
       });
-
-      if (!props.checkStrictly && props.autoCheckParent) {
-        filteredValues = processParentSelection(filteredValues);
-      }
-
       treeValue.value = filteredValues.map((v) => getItemByValue(v));
 
       if (filteredValues.length !== val.length) {
@@ -128,35 +110,7 @@ function updateTreeValue() {
     }
   }
 }
-function processParentSelection(
-  selectedValues: Array<number | string>,
-): Array<number | string> {
-  if (props.checkStrictly) return selectedValues;
-
-  const result = [...selectedValues];
-
-  for (let i = result.length - 1; i >= 0; i--) {
-    const currentValue = result[i];
-    if (currentValue === undefined) continue;
-    const currentItem = getItemByValue(currentValue);
-
-    if (!currentItem) continue;
 
-    const children = get(currentItem, props.childrenField);
-    if (Array.isArray(children) && children.length > 0) {
-      const hasSelectedChildren = children.some((child) => {
-        const childValue = get(child, props.valueField);
-        return result.includes(childValue);
-      });
-
-      if (!hasSelectedChildren) {
-        result.splice(i, 1);
-      }
-    }
-  }
-
-  return result;
-}
 function updateModelValue(val: Arrayable<Recordable<any>>) {
   if (Array.isArray(val)) {
     const filteredVal = val.filter((v) => !get(v, props.disabledField));
@@ -204,6 +158,22 @@ function collapseAll() {
   expanded.value = [];
 }
 
+function checkAll() {
+  if (props.multiple) {
+    modelValue.value = flattenData.value.map((item) =>
+      get(item.value, props.valueField),
+    );
+    updateTreeValue();
+  }
+}
+
+function unCheckAll() {
+  if (props.multiple) {
+    modelValue.value = [];
+    updateTreeValue();
+  }
+}
+
 function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
   return props.disabled || get(item.value, props.disabledField);
 }
@@ -229,8 +199,45 @@ function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
         );
       })
       ?.parents?.forEach((p) => {
-        if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) {
-          modelValue.value.push(p);
+      if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) {
+        modelValue.value.push(p);
+      }
+    });
+  }
+  if (
+    !props.checkStrictly &&
+    props.multiple &&
+    props.autoCheckParent &&
+    !isSelected
+  ) {
+    flattenData.value
+      .find((i) => {
+        return (
+          get(i.value, props.valueField) === get(item.value, props.valueField)
+        );
+      })
+      ?.parents?.reverse()
+      .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)) {
+          const hasSelectedChild = children.some((child) =>
+            (modelValue.value as unknown[]).includes(
+              get(child.value, props.valueField),
+            ),
+          );
+          if (!hasSelectedChild) {
+            const index = modelValue.value.indexOf(p);
+            if (index !== -1) {
+              modelValue.value.splice(index, 1);
+            }
+          }
         }
       });
   }
@@ -243,6 +250,8 @@ defineExpose({
   collapseNodes,
   expandAll,
   expandNodes,
+  checkAll,
+  unCheckAll,
   expandToLevel,
   getItemByValue,
 });
@@ -263,15 +272,41 @@ defineExpose({
     v-slot="{ flattenItems }"
     :class="
       cn(
-        'text-blackA11 container select-none list-none rounded-lg p-2 text-sm font-medium',
+        'text-blackA11 container select-none list-none rounded-lg text-sm font-medium',
         $attrs.class as unknown as ClassType,
         bordered ? 'border' : '',
       )
     "
   >
-    <div class="w-full" v-if="$slots.header">
+    <div
+      :class="
+        cn('my-0.5 flex w-full items-center p-1', bordered ? 'border-b' : '')
+      "
+      v-if="$slots.header"
+    >
       <slot name="header"> </slot>
     </div>
+    <div
+      :class="
+        cn('my-0.5 flex w-full items-center p-1', bordered ? 'border-b' : '')
+      "
+      v-if="treeData.length > 0"
+    >
+      <div
+        class="flex size-5 flex-1 cursor-pointer items-center"
+        @click="() => (expanded?.length > 0 ? collapseAll() : expandAll())"
+      >
+        <ChevronRight
+          :class="{ 'rotate-90': expanded?.length > 0 }"
+          class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
+        />
+        <Checkbox
+          v-if="multiple"
+          @click.stop
+          @update:checked="(checked) => (checked ? checkAll() : unCheckAll())"
+        />
+      </div>
+    </div>
     <TransitionGroup :name="transition ? 'fade' : ''">
       <TreeItem
         v-for="item in flattenItems"
@@ -283,7 +318,7 @@ defineExpose({
           handleToggle,
         }"
         :key="item._id"
-        :style="{ 'padding-left': `${item.level - 0.5}rem` }"
+        :style="{ 'margin-left': `${item.level - 1}rem` }"
         :class="
           cn('cursor-pointer', getNodeClass?.(item), {
             'data-[selected]:bg-accent': !multiple,
@@ -317,7 +352,7 @@ defineExpose({
             !isNodeDisabled(item) && onToggle(item);
           }
         "
-        class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
+        class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-none focus:ring-2"
       >
         <ChevronRight
           v-if="
@@ -325,7 +360,7 @@ defineExpose({
             Array.isArray(item.value[childrenField]) &&
             item.value[childrenField].length > 0
           "
-          class="size-4 cursor-pointer transition"
+          class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
           :class="{ 'rotate-90': isExpanded }"
           @click.stop="
             () => {
@@ -334,52 +369,56 @@ defineExpose({
             }
           "
         />
-        <div v-else class="h-4 w-4">
-          <!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> -->
-        </div>
-        <Checkbox
-          v-if="multiple"
-          :checked="isSelected && !isNodeDisabled(item)"
-          :disabled="isNodeDisabled(item)"
-          :indeterminate="isIndeterminate && !isNodeDisabled(item)"
-          @click="
-            (event: MouseEvent) => {
-              if (isNodeDisabled(item)) {
-                event.preventDefault();
-                event.stopPropagation();
-                return;
+        <div v-else class="h-4 w-4"></div>
+        <div class="flex items-center gap-1">
+          <Checkbox
+            v-if="multiple"
+            :checked="isSelected && !isNodeDisabled(item)"
+            :disabled="isNodeDisabled(item)"
+            :indeterminate="isIndeterminate && !isNodeDisabled(item)"
+            @click="
+              (event: MouseEvent) => {
+                if (isNodeDisabled(item)) {
+                  event.preventDefault();
+                  event.stopPropagation();
+                  return;
+                }
+                handleSelect();
               }
-              handleSelect();
-            }
-          "
-        />
-        <div
-          class="flex items-center gap-1 pl-2"
-          @click="
-            (event: MouseEvent) => {
-              if (isNodeDisabled(item)) {
-                event.preventDefault();
-                event.stopPropagation();
-                return;
+            "
+          />
+          <div
+            class="flex items-center gap-1"
+            @click="
+              (event: MouseEvent) => {
+                if (isNodeDisabled(item)) {
+                  event.preventDefault();
+                  event.stopPropagation();
+                  return;
+                }
+                handleSelect();
               }
-              event.stopPropagation();
-              event.preventDefault();
-              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>
+            "
+          >
+            <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"></div>
       </TreeItem>
     </TransitionGroup>
-    <div class="w-full" v-if="$slots.footer">
+    <div
+      :class="
+        cn('my-0.5 flex w-full items-center p-1', bordered ? 'border-t' : '')
+      "
+      v-if="$slots.footer"
+    >
       <slot name="footer"> </slot>
     </div>
   </TreeRoot>

+ 21 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts

@@ -40,3 +40,24 @@ export interface TreeProps {
   /** 值字段 */
   valueField?: string;
 }
+
+export function treePropsDefaults() {
+  return {
+    allowClear: false,
+    autoCheckParent: true,
+    bordered: false,
+    checkStrictly: false,
+    defaultExpandedKeys: () => [],
+    defaultExpandedLevel: 0,
+    disabled: false,
+    disabledField: 'disabled',
+    expanded: () => [],
+    iconField: 'icon',
+    labelField: 'label',
+    multiple: false,
+    showIcon: true,
+    transition: true,
+    valueField: 'value',
+    childrenField: 'children',
+  };
+}

+ 1 - 1
packages/effects/common-ui/src/components/index.ts

@@ -9,6 +9,7 @@ export * from './loading';
 export * from './page';
 export * from './resize';
 export * from './tippy';
+export * from './tree';
 export * from '@vben-core/form-ui';
 export * from '@vben-core/popup-ui';
 
@@ -27,7 +28,6 @@ export {
   VbenPinInput,
   VbenSelect,
   VbenSpinner,
-  VbenTree,
 } from '@vben-core/shadcn-ui';
 
 export type { FlattenedItem } from '@vben-core/shadcn-ui';

+ 1 - 0
packages/effects/common-ui/src/components/tree/index.ts

@@ -0,0 +1 @@
+export { default as Tree } from './tree.vue';

+ 25 - 0
packages/effects/common-ui/src/components/tree/tree.vue

@@ -0,0 +1,25 @@
+<script setup lang="ts">
+import type { TreeProps } from '@vben-core/shadcn-ui';
+
+import { Inbox } from '@vben/icons';
+import { $t } from '@vben/locales';
+
+import { treePropsDefaults, VbenTree } from '@vben-core/shadcn-ui';
+
+const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
+</script>
+
+<template>
+  <VbenTree v-if="props.treeData?.length > 0" v-bind="props">
+    <template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
+      <slot :name="key" v-bind="slotProps"> </slot>
+    </template>
+  </VbenTree>
+  <div
+    v-else
+    class="flex-col-center text-muted-foreground cursor-pointer rounded-lg border p-10 text-sm font-medium"
+  >
+    <Inbox class="size-10" />
+    <div class="mt-1">{{ $t('common.noData') }}</div>
+  </div>
+</template>

+ 3 - 6
playground/src/views/system/role/modules/form.vue

@@ -7,7 +7,7 @@ import type { SystemRoleApi } from '#/api/system/role';
 
 import { computed, nextTick, ref } from 'vue';
 
-import { useVbenDrawer, VbenTree } from '@vben/common-ui';
+import { Tree, useVbenDrawer } from '@vben/common-ui';
 import { IconifyIcon } from '@vben/icons';
 
 import { Spin } from 'ant-design-vue';
@@ -92,9 +92,6 @@ function getNodeClass(node: Recordable<any>) {
   const classes: string[] = [];
   if (node.value?.type === 'button') {
     classes.push('inline-flex');
-    if (node.index % 3 >= 1) {
-      classes.push('!pl-0');
-    }
   }
 
   return classes.join(' ');
@@ -105,7 +102,7 @@ function getNodeClass(node: Recordable<any>) {
     <Form>
       <template #permissions="slotProps">
         <Spin :spinning="loadingPermissions" wrapper-class-name="w-full">
-          <VbenTree
+          <Tree
             :tree-data="permissions"
             multiple
             bordered
@@ -120,7 +117,7 @@ function getNodeClass(node: Recordable<any>) {
               <IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
               {{ $t(value.meta.title) }}
             </template>
-          </VbenTree>
+          </Tree>
         </Spin>
       </template>
     </Form>