|
|
@@ -0,0 +1,293 @@
|
|
|
+<template>
|
|
|
+ <view class="ie-tree-node">
|
|
|
+ <!-- 节点内容 -->
|
|
|
+ <view @click.stop="handleClick">
|
|
|
+ <slot :node="nodeData" :parent="parentData">
|
|
|
+ <!-- 默认节点内容 -->
|
|
|
+ <view class="ie-tree-node__default">
|
|
|
+ <uv-icon
|
|
|
+ v-if="!isLeafNode"
|
|
|
+ name="arrow-right"
|
|
|
+ size="14"
|
|
|
+ color="#888"
|
|
|
+ :custom-class="['ie-tree-node__expand-icon', isExpanded ? 'ie-tree-node__expand-icon--expanded' : '']"
|
|
|
+ />
|
|
|
+ <text class="ie-tree-node__label">{{ nodeLabel }}</text>
|
|
|
+ </view>
|
|
|
+ </slot>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 子节点容器 -->
|
|
|
+ <view
|
|
|
+ v-if="hasChildren"
|
|
|
+ ref="childrenRef"
|
|
|
+ class="ie-tree-node__children"
|
|
|
+ :id="`ie-tree-children-${nodeKey}`"
|
|
|
+ :class="['ie-tree-node__children', { 'ie-tree-node__children--measuring': isMeasuringHeight }]"
|
|
|
+ :style="childrenStyle"
|
|
|
+ >
|
|
|
+ <ie-tree-node
|
|
|
+ v-for="child in childrenData"
|
|
|
+ :key="getNodeKey(child)"
|
|
|
+ :node-data="child"
|
|
|
+ :parent-data="nodeData"
|
|
|
+ :tree-props="treeProps"
|
|
|
+ @node-click="handleChildNodeClick"
|
|
|
+ >
|
|
|
+ <template #default="slotProps: { node: any; parent: any }">
|
|
|
+ <slot :node="slotProps.node" :parent="slotProps.parent"></slot>
|
|
|
+ </template>
|
|
|
+ </ie-tree-node>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup>
|
|
|
+import { getCurrentInstance, computed, ref, nextTick } from 'vue';
|
|
|
+import IeTreeNode from './ie-tree-node.vue';
|
|
|
+import type { TreeProps } from '@/types';
|
|
|
+
|
|
|
+defineOptions({
|
|
|
+ name: 'ie-tree-node',
|
|
|
+ options: {
|
|
|
+ virtualHost: true
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+const instance = getCurrentInstance();
|
|
|
+
|
|
|
+// Props
|
|
|
+const props = defineProps<{
|
|
|
+ nodeData: any; // 节点数据
|
|
|
+ parentData?: any; // 父节点数据
|
|
|
+ treeProps: TreeProps; // 树配置项
|
|
|
+}>();
|
|
|
+
|
|
|
+// Emits
|
|
|
+const emit = defineEmits<{
|
|
|
+ (e: 'node-click', data: { node: any; parent: any | null }): void;
|
|
|
+}>();
|
|
|
+
|
|
|
+// Slots
|
|
|
+defineSlots<{
|
|
|
+ default(props: { node: any; parent: any }): any;
|
|
|
+}>();
|
|
|
+
|
|
|
+// 默认配置
|
|
|
+const defaultTreeProps: Required<TreeProps> = {
|
|
|
+ children: 'children',
|
|
|
+ label: 'name',
|
|
|
+ nodeKey: 'id',
|
|
|
+ isLeaf: 'isLeaf',
|
|
|
+ disabled: 'disabled',
|
|
|
+ isExpanded: 'isExpanded'
|
|
|
+};
|
|
|
+
|
|
|
+// 合并配置
|
|
|
+const mergedProps = computed(() => ({
|
|
|
+ ...defaultTreeProps,
|
|
|
+ ...props.treeProps
|
|
|
+}));
|
|
|
+
|
|
|
+// 获取节点属性值
|
|
|
+const getNodeProp = (node: any, prop: string, defaultValue?: any) => {
|
|
|
+ return node?.[prop] ?? defaultValue;
|
|
|
+};
|
|
|
+
|
|
|
+// 获取节点唯一标识
|
|
|
+const getNodeKey = (node: any): string | number => {
|
|
|
+ const key = mergedProps.value.nodeKey;
|
|
|
+ return getNodeProp(node, key, node?.value ?? node?.id ?? '');
|
|
|
+};
|
|
|
+
|
|
|
+// 获取节点标签
|
|
|
+const nodeLabel = computed(() => {
|
|
|
+ return getNodeProp(props.nodeData, mergedProps.value.label, '');
|
|
|
+});
|
|
|
+
|
|
|
+// 获取子节点数据
|
|
|
+const childrenData = computed(() => {
|
|
|
+ const childrenProp = mergedProps.value.children;
|
|
|
+ return getNodeProp(props.nodeData, childrenProp, []) || [];
|
|
|
+});
|
|
|
+
|
|
|
+// 是否有子节点
|
|
|
+const hasChildren = computed(() => {
|
|
|
+ return childrenData.value && childrenData.value.length > 0;
|
|
|
+});
|
|
|
+
|
|
|
+// 判断是否为叶子节点
|
|
|
+const isLeafNode = computed(() => {
|
|
|
+ const isLeafProp = mergedProps.value.isLeaf;
|
|
|
+ const isLeaf = getNodeProp(props.nodeData, isLeafProp);
|
|
|
+ if (isLeaf !== undefined) {
|
|
|
+ return isLeaf;
|
|
|
+ }
|
|
|
+ // 如果没有 isLeaf 属性,根据是否有子节点判断
|
|
|
+ return !hasChildren.value;
|
|
|
+});
|
|
|
+
|
|
|
+// 是否展开
|
|
|
+const isExpanded = computed(() => {
|
|
|
+ const expandedProp = mergedProps.value.isExpanded;
|
|
|
+ return getNodeProp(props.nodeData, expandedProp, false);
|
|
|
+});
|
|
|
+
|
|
|
+// 节点唯一标识
|
|
|
+const nodeKey = computed(() => getNodeKey(props.nodeData));
|
|
|
+
|
|
|
+// 高度测量相关
|
|
|
+const isMeasuringHeight = ref(false);
|
|
|
+const childrenRef = ref();
|
|
|
+const measuredHeight = ref<number | string>(0);
|
|
|
+const isAnimating = ref(false);
|
|
|
+
|
|
|
+// 子节点容器样式
|
|
|
+const childrenStyle = computed(() => {
|
|
|
+ if (isMeasuringHeight.value) {
|
|
|
+ return { height: 'auto' };
|
|
|
+ }
|
|
|
+ const heightValue = typeof measuredHeight.value === 'number' ? `${measuredHeight.value}px` : measuredHeight.value;
|
|
|
+ return { height: heightValue };
|
|
|
+});
|
|
|
+
|
|
|
+// 获取元素尺寸
|
|
|
+const getRect = (selector: string) => {
|
|
|
+ return new Promise<{ top: number; height: number }>((resolve) => {
|
|
|
+ const query = uni.createSelectorQuery().in(instance?.proxy);
|
|
|
+ query.select(selector).boundingClientRect((rect) => {
|
|
|
+ resolve(rect as { top: number; height: number });
|
|
|
+ }).exec();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 测量子节点高度
|
|
|
+const measureHeight = () => {
|
|
|
+ if (!hasChildren.value) return Promise.resolve(0);
|
|
|
+
|
|
|
+ return new Promise<number>((resolve) => {
|
|
|
+ // 如果节点已经展开,说明子节点已经显示,可以直接测量,不需要隐藏
|
|
|
+ const needHiding = !isExpanded.value;
|
|
|
+
|
|
|
+ if (needHiding) {
|
|
|
+ isMeasuringHeight.value = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ nextTick(() => {
|
|
|
+ getRect(`#ie-tree-children-${nodeKey.value}`).then((res) => {
|
|
|
+ if (needHiding) {
|
|
|
+ isMeasuringHeight.value = false;
|
|
|
+ }
|
|
|
+ const height = res?.height ?? 0;
|
|
|
+ resolve(height);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }, 50);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 处理节点点击
|
|
|
+const handleClick = async () => {
|
|
|
+ // 切换展开状态
|
|
|
+ const expandedProp = mergedProps.value.isExpanded;
|
|
|
+ const newExpandedState = !isExpanded.value;
|
|
|
+
|
|
|
+ if (newExpandedState) {
|
|
|
+ // 展开流程:
|
|
|
+ // 1. 先设置高度为 0(起始状态)
|
|
|
+ measuredHeight.value = 0;
|
|
|
+
|
|
|
+ // 2. 更新展开状态,让子节点容器渲染
|
|
|
+ props.nodeData[expandedProp] = newExpandedState;
|
|
|
+
|
|
|
+ // 3. 等待 DOM 更新,确保子节点容器已经渲染出来
|
|
|
+ await nextTick();
|
|
|
+
|
|
|
+ // 4. 测量目标高度(此时子节点已经渲染,但高度是 0)
|
|
|
+ const height = await measureHeight();
|
|
|
+
|
|
|
+ // 5. 再等一帧,确保浏览器已经渲染了高度为 0 的状态
|
|
|
+ await nextTick();
|
|
|
+
|
|
|
+ // 6. 设置目标高度,触发 CSS 动画(0 → height)
|
|
|
+ setTimeout(() => {
|
|
|
+ measuredHeight.value = height;
|
|
|
+ }, 0)
|
|
|
+
|
|
|
+ // 7. 动画完成后(350ms)设置为 auto
|
|
|
+ setTimeout(() => {
|
|
|
+ if (isExpanded.value && props.nodeData[expandedProp]) {
|
|
|
+ measuredHeight.value = 'auto';
|
|
|
+ }
|
|
|
+ }, 350);
|
|
|
+
|
|
|
+ } else {
|
|
|
+ // 收起流程:
|
|
|
+ // 1. 如果当前是 auto,先测量实际高度并设置为固定值
|
|
|
+ if (measuredHeight.value === 'auto') {
|
|
|
+ const height = await measureHeight();
|
|
|
+ measuredHeight.value = height;
|
|
|
+ await nextTick();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 等待一帧,确保浏览器已经应用了固定高度
|
|
|
+ await nextTick();
|
|
|
+
|
|
|
+ // 3. 设置为 0,触发收起动画
|
|
|
+ measuredHeight.value = 0;
|
|
|
+
|
|
|
+ // 4. 更新展开状态
|
|
|
+ props.nodeData[expandedProp] = newExpandedState;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 触发节点点击事件
|
|
|
+ // emit('node-click', {
|
|
|
+ // node: props.nodeData,
|
|
|
+ // parent: props.parentData || null
|
|
|
+ // });
|
|
|
+};
|
|
|
+
|
|
|
+// 处理子节点点击事件
|
|
|
+const handleChildNodeClick = (data: { node: any; parent: any | null }) => {
|
|
|
+ emit('node-click', data);
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.ie-tree-node {
|
|
|
+ &__default {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 10px 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__expand-icon {
|
|
|
+ margin-right: 8px;
|
|
|
+ transition: transform 0.3s;
|
|
|
+
|
|
|
+ &--expanded {
|
|
|
+ transform: rotate(90deg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__label {
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__children {
|
|
|
+ padding-left: 20px;
|
|
|
+ overflow: hidden;
|
|
|
+ transition: height 0.3s ease-in-out;
|
|
|
+ will-change: height;
|
|
|
+
|
|
|
+ &--measuring {
|
|
|
+ opacity: 0;
|
|
|
+ position: fixed;
|
|
|
+ z-index: -1000;
|
|
|
+ transform: translateX(0);
|
|
|
+ height: auto !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|