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

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

基于Element

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

插件版本号

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

实现效果

Screen-2023-08-18-151841

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<!-- 文件夹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 方法是打开弹窗。输入对应的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/** 右键处理 */
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就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<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组件自带了拖动的方法,需求是只能在同级文件夹里面拖动。
可以这样实现

1
2
3
4
5
6
7
8
9
/** 拖拽时判定目标节点能否被放置
* 只能同一级拖拽
*/
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算法自动更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/** 拖拽开始 */
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实现原理

https://github.dev/GitHub-Laziji/menujs/blob/6ab7c3da334621a42f3cdc495d047390c4b56455/src/index.js#L90-L91

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

https://github.dev/GitHub-Laziji/menujs/blob/6ab7c3da334621a42f3cdc495d047390c4b56455/src/index.js#L11

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

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

这里为什么不用 clickOutSide 来处理