5 changed files with 353 additions and 3 deletions
@ -0,0 +1,299 @@ |
|||
<script setup lang="ts"> |
|||
import { Wunderbaum } from 'wunderbaum'; |
|||
import 'wunderbaum/dist/wunderbaum.css'; |
|||
import { deptTreeSelect } from '@/api/system/user'; |
|||
import type { DeptTreeVO } from '@/api/system/dept/types'; |
|||
|
|||
// ===== 类型定义 ===== |
|||
interface WbNodeData { |
|||
title: string; |
|||
key: string; |
|||
children?: WbNodeData[]; |
|||
[k: string]: unknown; |
|||
} |
|||
|
|||
// ===== DOM Refs ===== |
|||
const leftTreeEl = ref<HTMLDivElement>(); |
|||
const rightTreeEl = ref<HTMLDivElement>(); |
|||
const leftFilterText = ref(''); |
|||
const rightFilterText = ref(''); |
|||
|
|||
// ===== Wunderbaum 实例 ===== |
|||
let leftTree: Wunderbaum; |
|||
let rightTree: Wunderbaum; |
|||
|
|||
// ===== 数据转换:DeptTreeVO → WbNodeData ===== |
|||
const convertToWbData = (nodes: DeptTreeVO[]): WbNodeData[] => { |
|||
return nodes.map((node) => ({ |
|||
title: node.label, |
|||
key: String(node.id), |
|||
expanded: true, |
|||
children: node.children?.length ? convertToWbData(node.children) : undefined |
|||
})); |
|||
}; |
|||
|
|||
// ===== 加载部门数据 ===== |
|||
const loading = ref(true); |
|||
|
|||
const initTrees = async () => { |
|||
try { |
|||
loading.value = true; |
|||
const { data } = await deptTreeSelect(); |
|||
const wbData = convertToWbData(data); |
|||
|
|||
// 左树 & 右树使用相同数据(深拷贝) |
|||
const leftData = JSON.parse(JSON.stringify(wbData)); |
|||
const rightData = JSON.parse(JSON.stringify(wbData)); |
|||
|
|||
// 共享的 DnD 配置 |
|||
const dndOptions = { |
|||
preventForeignNodes: false, // 允许跨树拖拽 |
|||
preventRecursion: true, // 禁止拖入自身子节点 |
|||
preventVoidMoves: true, // 禁止无意义移动 |
|||
dropEffectDefault: 'move' as const, |
|||
effectAllowed: 'all' as const, |
|||
guessDropEffect: true, |
|||
dragStart: () => true, |
|||
dragEnter: () => { |
|||
// 允许放置在 before / after / over 三个位置 |
|||
return new Set(['before', 'after', 'over'] as const); |
|||
}, |
|||
drop: (e: any) => { |
|||
const sourceNode = e.sourceNode; |
|||
const targetNode = e.node; |
|||
const suggestedDropMode = e.suggestedDropMode; |
|||
|
|||
if (sourceNode && targetNode) { |
|||
sourceNode.moveTo(targetNode, suggestedDropMode); |
|||
// 更新两棵树的渲染 |
|||
leftTree?.update('structure' as any); |
|||
rightTree?.update('structure' as any); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
// 创建左树 |
|||
leftTree = new Wunderbaum({ |
|||
id: 'leftTree', |
|||
element: leftTreeEl.value!, |
|||
source: leftData, |
|||
debugLevel: 0, |
|||
checkbox: false, |
|||
filter: { |
|||
autoExpand: true, |
|||
mode: 'hide' |
|||
}, |
|||
dnd: dndOptions, |
|||
init: () => { |
|||
loading.value = false; |
|||
} |
|||
}); |
|||
|
|||
// 创建右树 |
|||
rightTree = new Wunderbaum({ |
|||
id: 'rightTree', |
|||
element: rightTreeEl.value!, |
|||
source: rightData, |
|||
debugLevel: 0, |
|||
checkbox: false, |
|||
filter: { |
|||
autoExpand: true, |
|||
mode: 'hide' |
|||
}, |
|||
dnd: dndOptions |
|||
}); |
|||
} catch (err) { |
|||
console.error('加载部门树失败:', err); |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
|
|||
// ===== 过滤逻辑 ===== |
|||
const onLeftFilter = () => { |
|||
if (!leftTree) return; |
|||
const keyword = leftFilterText.value.trim(); |
|||
if (keyword) { |
|||
leftTree.filterNodes(keyword, { autoExpand: true, mode: 'hide' }); |
|||
} else { |
|||
leftTree.clearFilter(); |
|||
} |
|||
}; |
|||
|
|||
const onRightFilter = () => { |
|||
if (!rightTree) return; |
|||
const keyword = rightFilterText.value.trim(); |
|||
if (keyword) { |
|||
rightTree.filterNodes(keyword, { autoExpand: true, mode: 'hide' }); |
|||
} else { |
|||
rightTree.clearFilter(); |
|||
} |
|||
}; |
|||
|
|||
// ===== 展开 / 收起全部 ===== |
|||
const expandAll = (tree: Wunderbaum | undefined) => { |
|||
tree?.expandAll(true); |
|||
}; |
|||
|
|||
const collapseAll = (tree: Wunderbaum | undefined) => { |
|||
tree?.expandAll(false); |
|||
}; |
|||
|
|||
// ===== 生命周期 ===== |
|||
onMounted(() => { |
|||
initTrees(); |
|||
}); |
|||
|
|||
onBeforeUnmount(() => { |
|||
leftTree?.destroy(); |
|||
rightTree?.destroy(); |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="wunderbaum-demo-page"> |
|||
<h3 class="page-title">Wunderbaum 双树跨树拖拽 Demo</h3> |
|||
<p class="page-desc">左右两棵部门树支持互相拖拽节点(拖入节点前方、后方、或内部),支持搜索过滤。拖拽结果不保存回服务端。</p> |
|||
|
|||
<div v-loading="loading" class="tree-container"> |
|||
<!-- 左树 --> |
|||
<div class="tree-panel"> |
|||
<div class="tree-toolbar"> |
|||
<span class="tree-label">部门树 A</span> |
|||
<el-input |
|||
v-model="leftFilterText" |
|||
placeholder="搜索..." |
|||
clearable |
|||
size="small" |
|||
style="width: 180px" |
|||
@input="onLeftFilter" |
|||
@clear="onLeftFilter" |
|||
/> |
|||
<el-button size="small" @click="expandAll(leftTree)">展开全部</el-button> |
|||
<el-button size="small" @click="collapseAll(leftTree)">收起全部</el-button> |
|||
</div> |
|||
<div ref="leftTreeEl" class="tree-wrapper"></div> |
|||
</div> |
|||
|
|||
<!-- 中间拖拽提示 --> |
|||
<div class="drag-hint"> |
|||
<el-icon :size="28"><Sort /></el-icon> |
|||
<span>拖拽</span> |
|||
</div> |
|||
|
|||
<!-- 右树 --> |
|||
<div class="tree-panel"> |
|||
<div class="tree-toolbar"> |
|||
<span class="tree-label">部门树 B</span> |
|||
<el-input |
|||
v-model="rightFilterText" |
|||
placeholder="搜索..." |
|||
clearable |
|||
size="small" |
|||
style="width: 180px" |
|||
@input="onRightFilter" |
|||
@clear="onRightFilter" |
|||
/> |
|||
<el-button size="small" @click="expandAll(rightTree)">展开全部</el-button> |
|||
<el-button size="small" @click="collapseAll(rightTree)">收起全部</el-button> |
|||
</div> |
|||
<div ref="rightTreeEl" class="tree-wrapper"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped lang="scss"> |
|||
.wunderbaum-demo-page { |
|||
padding: 20px; |
|||
} |
|||
|
|||
.page-title { |
|||
margin: 0 0 8px 0; |
|||
font-size: 18px; |
|||
} |
|||
|
|||
.page-desc { |
|||
margin: 0 0 16px 0; |
|||
color: #666; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.tree-container { |
|||
display: flex; |
|||
align-items: stretch; |
|||
gap: 12px; |
|||
min-height: 500px; |
|||
} |
|||
|
|||
.tree-panel { |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
border: 1px solid #dcdfe6; |
|||
border-radius: 4px; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.tree-toolbar { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
padding: 8px 12px; |
|||
background: #f5f7fa; |
|||
border-bottom: 1px solid #dcdfe6; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.tree-label { |
|||
font-weight: 600; |
|||
font-size: 14px; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.tree-wrapper { |
|||
flex: 1; |
|||
min-height: 400px; |
|||
overflow: auto; |
|||
} |
|||
|
|||
.drag-hint { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 4px; |
|||
color: #909399; |
|||
font-size: 12px; |
|||
flex-shrink: 0; |
|||
width: 50px; |
|||
} |
|||
|
|||
// Wunderbaum 拖拽样式增强 |
|||
:deep(.wunderbaum) { |
|||
height: 100%; |
|||
} |
|||
|
|||
:deep(.wb-row.wb-drop-over) { |
|||
background-color: #ecf5ff; |
|||
} |
|||
|
|||
:deep(.wb-row.wb-drop-before::before) { |
|||
content: ''; |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
height: 2px; |
|||
background: #409eff; |
|||
} |
|||
|
|||
:deep(.wb-row.wb-drop-after::after) { |
|||
content: ''; |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
right: 0; |
|||
height: 2px; |
|||
background: #409eff; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue