基于Element Tree 组件和vue-contextm

Title: 基于Element Tree 组件和vue-contextmenujs 实现文件夹树

基于Element Tree 组件和vue-contextmenujs 实现文件夹树

插件版本号

{
  "vue-contextmenujs": "^1.4.9",
  "element-ui": "^2.4.5",
}

实现效果

Screen-2023-08-18-151841.gif

# 完整代码
<!-- 文件夹Tree -->
<template>
    <div v-loading="loading">
        <div @contextmenu="onTreeWrapperContextmenu">
            <el-card>
                <div class="tree-wrapper">
                    <el-tree
                        ref="treeRef"
                        :data="treeData"
                        :props="treeProps"
                        :highlight-current="true"
                        node-key="id"
                        draggable
                        @node-contextmenu="onContextmenu"
                        @node-drop="onDropSuccess"
                        @node-click="onNodeClick"
                        @node-drag-start="onNodeDragStart"
                        :allow-drop="onAllowDrop"
                    ></el-tree>
                </div>
            </el-card>
        </div>
​
        <!-- 重命名 -->
        <el-dialog
            :visible.sync="renameData.visible"
            title="重命名"
        >
            <el-input v-model="renameData.value"></el-input>
            <div class="mt-5 flex justify-end">
                <el-button @click="onCloseRenameDialog">取消</el-button>
                <el-button
                    type="primary"
                    @click="onConfirmRename"
                    :loading="renameData.confirmLoading"
                >确定</el-button>
            </div>
        </el-dialog>
​
        <!-- 新增菜单 -->
        <el-dialog
            :visible.sync="createData.visible"
            title="新增"
        >
            <el-input v-model="createData.value"></el-input>
            <div class="mt-5 flex justify-end">
                <el-button @click="onCloseCreateDialog">取消</el-button>
                <el-button
                    type="primary"
                    @click="onConfirmCreate"
                    :loading="createData.confirmLoading"
                >确定</el-button>
            </div>
        </el-dialog>
    </div>
</template>
​
<script lang="ts">
import {
    IDataFolderCreate, IDataFolderItem,
    IDataFolderSortRequest, IDataFolderUpdate,
} from '@/models/Data/DataFolder';
import {
    Component, Vue, Ref, Prop, Watch,
} from 'vue-property-decorator';
import { Tree } from 'element-ui';
​
import dataFolderService from '@/services/DataFolderServices';
​
@Component({
    components: {
        [Tree.name]: Tree,
    },
})
export default class FolderTree extends Vue {
    @Ref()
    treeRef!: Tree
​
    @Prop() sourceType!: number
​
    @Watch('sourceType')
    onSourceTypeChange() {
        this.fetchList();
    }
​
    loading = false
​
    renameData = {
        visible: false,
        value: '',
        confirmLoading: false,
        data: null as null | IDataFolderItem,
    }
​
    createData = {
        visible: false,
        value: '',
        confirmLoading: false,
        parentData: null as null | IDataFolderItem,
    }
​
    treeProps = {
        label: 'name',
        children: 'files',
    }
​
    treeData: IDataFolderItem[] = []
​
    backupTreeData: IDataFolderItem[] = [] // 拖拽失败时回退的数据
​
    mounted() {
        this.fetchList();
    }
​
    async fetchList() {
        this.loading = true;
​
        try {
            const res = await dataFolderService.getList({
                type: this.sourceType,
            });
            this.treeData = res;
​
            this.$nextTick(() => {
                this.treeRef.setCurrentKey(this.treeData[0].id);
                this.onNodeClick(this.treeData[0]);
            });
        } finally {
            this.loading = false;
        }
    }
​
    /** 点击右键菜单新增
     */
    onAddMenu(parentItemData: IDataFolderItem) {
        this.createData.value = '';
        this.createData.parentData = parentItemData;
        this.createData.visible = true;
    }
​
    /** 点击右键菜单删除 */
    async onDeleteMenu(data: IDataFolderItem) {
        await this.$confirm(`确定删除 “${data.name}” 文件夹`, {
            type: 'warning',
        });
        this.loading = true;
        try {
            await dataFolderService.delete(data.id);
            this.treeRef.remove(data);
        } finally {
            this.loading = false;
        }
    }
​
    /** 右键处理 */
    onContextmenu(event: Event, dataItem: IDataFolderItem, node: any) {
        console.log(node);
        (this as any).$contextmenu({
            items: [
                {
                    label: '重命名',
                    onClick: () => this.onOpenRenameDialog(dataItem),
                },
                {
                    label: '新增',
                    disabled: node.level >= 5,
                    onClick: () => this.onAddMenu(dataItem),
                },
                {
                    label: '删除',
                    onClick: () => this.onDeleteMenu(dataItem),
                },
            ],
            zIndex: 99,
            event,
        });
        event.stopPropagation();
    }
​
    /** 最外层树新增 */
    onTreeWrapperContextmenu(event: Event) {
        (this as any).$contextmenu({
            items: [
                {
                    label: '新增',
                    onClick: () => this.onAddMenu({
                        id: 0,
                        name: '根目录',
                        files: [],
                        up_id: 0,
                        datas: [],
                    }),
                },
            ],
            zIndex: 99,
            event,
        });
        event.preventDefault();
    }
​
    onOpenRenameDialog(nodeData: IDataFolderItem) {
        this.renameData.value = nodeData.name;
        this.renameData.data = nodeData;
        this.renameData.visible = true;
    }
​
    /** 关闭重命名框 */
    onCloseRenameDialog() {
        this.renameData.visible = false;
    }
​
    /** 确认重命名 */
    async onConfirmRename() {
        this.renameData.confirmLoading = true;
        try {
            const submitData: IDataFolderUpdate = {
                ...this.renameData.data!,
                name: this.renameData.value,
            };
            await dataFolderService.update(submitData);
            this.renameData.visible = false;
            this.renameData.data!.name = this.renameData.value;
        } finally {
            this.renameData.confirmLoading = false;
        }
    }
​
    /** 关闭新增弹窗 */
    onCloseCreateDialog() {
        this.createData.visible = false;
    }
​
    /** 确认新增 */
    async onConfirmCreate() {
        this.createData.confirmLoading = true;
        try {
            const submitData: IDataFolderCreate = {
                name: this.createData.value,
                up_id: this.createData.parentData!.id,
                type: this.sourceType,
            };
            const res = await dataFolderService.create(submitData);
            if (!res.files) {
                res.files = [];
            }
            this.createData.visible = false;
​
            if (!this.createData.parentData!.files) {
                this.createData.parentData!.files = [];
            }
​
            if (this.createData.parentData?.id === 0) { // 根目录新建处理
                this.treeData.push(res);
            } else { // 子目录处理
                this.createData.parentData!.files.push(res);
            }
        } finally {
            this.createData.confirmLoading = false;
        }
    }
​
    /** 拖拽开始 */
    onNodeDragStart() {
        this.backupTreeData = [...this.treeData];
    }
​
    /** 拖拽结束 */
    async onDropSuccess(selfNode: Record<string, any>, afterNode: Record<string, any>) {
        const submitData: IDataFolderSortRequest = {
            from_id: selfNode.data.id,
            to_id: afterNode.data.id,
        };
​
        this.loading = true;
​
        try {
            await dataFolderService.sort(submitData);
        } catch {
            // 失败回退拖拽前的数据
            this.treeData = this.backupTreeData;
        } finally {
            this.loading = false;
        }
    }
​
    /** 拖拽时判定目标节点能否被放置
     *  只能同一级拖拽
     */
    onAllowDrop(draggingNode: any, dropNode: any, type: string) {
        const dragData = draggingNode.data;
        const dropData = dropNode.data;
        const res = dragData.up_id === dropData.up_id && draggingNode.level === dropNode.level && type !== 'inner';
        return res;
    }
​
    /** 节点点击 */
    onNodeClick(data: IDataFolderItem) {
        this.$emit('active-change', data);
    }
}
</script>
​
<style lang="scss" scoped>
.tree-wrapper {
    overflow-x: auto;
    overflow-y: auto;
    height: 500px;
    width: 300px;
​
    ::v-deep {
        .el-tree-node>.el-tree-node__children {
            overflow: visible;
        }
    }
}
</style>

详细步骤

右键菜单实现

新增文件夹分为两部分,空白地方新增文件夹, 在已有的文件夹上面新增文件夹。

先看已有的文件夹上面新增文件夹:

Element Tree 组件自带了 node-contextmenu 方法,可以通过这个emit事件进行处理。

通过items 配置相关的处理函数。 使用event.stopPropagation()是为了点击空白新增文件夹。 阻止冒泡。onAddMenu 方法是打开弹窗。输入对应的数据

/** 右键处理 */
    onContextmenu(event: Event, dataItem: IDataFolderItem, node: any) {
        console.log(node);
        (this as any).$contextmenu({
            items: [
                {
                    label: '重命名',
                    onClick: () => this.onOpenRenameDialog(dataItem),
                },
                {
                    label: '新增',
                    disabled: node.level >= 5,
                    onClick: () => this.onAddMenu(dataItem),
                },
                {
                    label: '删除',
                    onClick: () => this.onDeleteMenu(dataItem),
                },
            ],
            zIndex: 99,
            event,
        });
        event.stopPropagation();
    }

那么空白地方新增文件夹就另外处理了。空白地方新增目录只需要一个item就行。

<div @contextmenu="onTreeWrapperContextmenu">
            <el-card>
                <div class="tree-wrapper">
                    <el-tree
                        @node-contextmenu="onContextmenu"
                    ></el-tree>
              </div>
            </el-card>
        </div>
/** 最外层树新增 */
    onTreeWrapperContextmenu(event: Event) {
        (this as any).$contextmenu({
            items: [
                {
                    label: '新增',
                    onClick: () => this.onAddMenu({
                        id: 0,
                        name: '根目录',
                        files: [],
                        up_id: 0,
                        datas: [],
                    }),
                },
            ],
            zIndex: 99,
            event,
        });
        event.preventDefault();
    }

tree组件拖动实现

Tree组件自带了拖动的方法,需求是只能在同级文件夹里面拖动。 可以这样实现

/** 拖拽时判定目标节点能否被放置
     *  只能同一级拖拽
     */
    onAllowDrop(draggingNode: any, dropNode: any, type: string) {
        const dragData = draggingNode.data;
        const dropData = dropNode.data;
        const res = dragData.up_id === dropData.up_id && draggingNode.level === dropNode.level && type !== 'inner';
        return res;
    }

但是拖拽后我们需要同步到服务端。 所有要考虑同步失败的问题, 需要把节点复原。大概逻辑就是先备份现有的数据, 然后如果失败, 将tree组件的数据修改成备份的数据。 利用Vue的diff算法自动更新。

/** 拖拽开始 */
    onNodeDragStart() {
        this.backupTreeData = [...this.treeData];
    }
​
    /** 拖拽结束 */
    async onDropSuccess(selfNode: Record<string, any>, afterNode: Record<string, any>) {
        const submitData: IDataFolderSortRequest = {
            from_id: selfNode.data.id,
            to_id: afterNode.data.id,
        };
​
        this.loading = true;
​
        try {
            await dataFolderService.sort(submitData);
        } catch {
            // 失败回退拖拽前的数据
            this.treeData = this.backupTreeData;
        } finally {
            this.loading = false;
        }

vue-contextmenujs实现原理

github.dev/GitHub-Lazi…

这里暴露了一个 install 方法, 供Vue.use 使用, 变量last 是保证只有一个实例存在。每次调用$contextmenu 都会销毁上一个实例, 然后重新创建一个实例

github.dev/GitHub-Lazi…

根据传入的 options 进行组件挂载, 事件监听等。 组件挂载的时候计算了 left 和 top的值,用来处理边界情况

mouseDown 和 mouseClick 里面的 while 都是为了在鼠标按下事件发生时,从点击的元素开始向上查找其父元素,直到找到具有特定类名的元素(可能是菜单元素)。如果没有找到这样的元素,就会执行一个关闭菜单的操作。

这里为什么不用 clickOutSide 来处理

全部评论

相关推荐

点赞 评论 收藏
分享
05-03 12:45
西南大学 Java
nsnzkv:你这项目写的内容太多了,说实话都是在给自己挖坑,就算简历过了,后面面试也难受
点赞 评论 收藏
分享
05-12 17:00
门头沟学院 Java
king122:你的项目描述至少要分点呀,要实习的话,你的描述可以使用什么技术,实现了什么难点,达成了哪些数字指标,这个数字指标尽量是真实的,这样面试应该会多很多,就这样自己包装一下,包装不好可以找我,我有几个大厂最近做过的实习项目也可以包装一下
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务