基于Element Tree 组件和vue-contextm
Title: 基于Element Tree 组件和vue-contextmenujs 实现文件夹树
基于Element Tree 组件和vue-contextmenujs 实现文件夹树
插件版本号
{
"vue-contextmenujs": "^1.4.9",
"element-ui": "^2.4.5",
}
实现效果
<!-- 文件夹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实现原理
这里暴露了一个 install 方法, 供Vue.use 使用, 变量last
是保证只有一个实例存在。每次调用$contextmenu
都会销毁上一个实例, 然后重新创建一个实例
根据传入的 options 进行组件挂载, 事件监听等。 组件挂载的时候计算了 left 和 top的值,用来处理边界情况
mouseDown 和 mouseClick 里面的 while 都是为了在鼠标按下事件发生时,从点击的元素开始向上查找其父元素,直到找到具有特定类名的元素(可能是菜单元素)。如果没有找到这样的元素,就会执行一个关闭菜单的操作。
这里为什么不用 clickOutSide 来处理