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