<template>
<div style="margin: 100px;width: 1000px;height: 500px;border:1px solid #e4e7ed;border-radius:8px;box-shadow:0 2px 12px 0 rgba(0,0,0,0.08);">
<el-form :model="form" ref="formRef" label-width="100px">
<div class="tree-transfer">
<!-- 左侧可选资源树 -->
<div class="transfer-panel">
<div class="transfer-header">可选资源</div>
<el-input
v-model="leftFilterText"
placeholder="搜索资源"
class="tree-search"
clearable
/>
<el-tree
ref="leftTree"
node-key="id"
:data="leftTreeData"
:props="treeProps"
:default-expand-all="true"
:show-checkbox="true"
:filter-node-method="filterNode"
:check-strictly="false"
@check-change="handleLeftCheckChange"
/>
</div>
<!-- 中间操作按钮 -->
<div class="transfer-buttons">
<el-button
type="primary"
:icon="ArrowRight"
@click="addToRight"
:disabled="!leftCheckedKeys.length"
/>
<el-button
type="primary"
:icon="ArrowLeft"
@click="removeFromRight"
:disabled="!rightCheckedKeys.length"
/>
<el-button
type="primary"
:icon="DArrowRight"
@click="addAllToRight"
/>
<el-button
type="primary"
:icon="DArrowLeft"
@click="removeAllFromRight"
/>
</div>
<!-- 右侧已选资源树 -->
<div class="transfer-panel">
<div class="transfer-header">已选资源</div>
<el-input
v-model="rightFilterText"
placeholder="搜索资源"
class="tree-search"
clearable
/>
<el-tree
ref="rightTree"
node-key="id"
:data="rightTreeData"
:props="treeProps"
:default-expand-all="true"
:show-checkbox="true"
:filter-node-method="filterNode"
:check-strictly="false"
@check-change="handleRightCheckChange"
/>
</div>
</div>
</el-form>
</div>
</template>
<script setup>
// 1. vue基础API
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
// 2. Element Plus 消息提示
import { ElMessage } from 'element-plus'
// 3. 箭头图标
import { ArrowRight, ArrowLeft, DArrowRight, DArrowLeft } from '@element-plus/icons-vue'
onMounted(() => {
getResourceTree()
})
/** 表单数据 */
const form = reactive({
resourceIds: []
})
/** 表单引用 */
const formRef = ref()
const allResourceTree = ref([])
/** 树形穿梭框状态 */
const leftTree = ref()
const rightTree = ref()
const leftFilterText = ref('')
const rightFilterText = ref('')
const leftCheckedKeys = ref([])
const rightCheckedKeys = ref([])
/** 树配置 */
const treeProps = {
label: 'resourceName',
children: 'children',
disabled: 'disabled'
}
/**
* 获取所有子节点ID
* @param tree 树形数据
* @param nodeId 节点ID
* @returns {Array}
*/
const getAllChildrenIds = (tree, nodeId) => {
const result = []
const findNode = (nodes, targetId) => {
for (const node of nodes) {
if (node.id === targetId) {
if (node.children && node.children.length > 0) {
const collectChildren = (children) => {
for (const child of children) {
result.push(child.id)
if (child.children && child.children.length > 0) {
collectChildren(child.children)
}
}
}
collectChildren(node.children)
}
return
}
if (node.children && node.children.length > 0) {
findNode(node.children, targetId)
}
}
}
findNode(tree, nodeId)
return result
}
/**
* 过滤树节点
* @param value 过滤文本
* @param data 节点数据
* @returns {boolean}
*/
const filterNode = (value, data) => {
if (!value) return true
return data.resourceName.toLowerCase().includes(value.toLowerCase())
}
/**
* 从树中移除指定节点
* @param tree 树形数据
* @param ids 要移除的ID列表
* @returns {Array}
*/
const removeNodesByIds = (tree, ids) => {
const result = []
for (const node of tree) {
if (!ids.includes(node.id)) {
const newNode = { ...node }
if (node.children && node.children.length > 0) {
newNode.children = removeNodesByIds(node.children, ids)
}
result.push(newNode)
} else {
// 如果父节点被选中,但子节点未被选中,保留父节点结构并显示未被选中的子节点
if (node.children && node.children.length > 0) {
const remainingChildren = removeNodesByIds(node.children, ids)
if (remainingChildren.length > 0) {
// 创建一个临时父节点,标记为已选中状态,显示未被选中的子节点
result.push({
...node,
disabled: true, // 已选中父节点无法再选
children: remainingChildren,
isSelectedParent: true // 标记这是一个已选中的父节点
})
}
}
}
}
return result
}
/**
* 获取树中所有叶子节点ID
* @param tree 树形数据
* @returns {Array}
*/
const getAllLeafIds = (tree) => {
const result = []
const collectLeafs = (nodes) => {
for (const node of nodes) {
// 可选1、只收集叶子节点ID
// if (!node.children || node.children.length === 0) {
// result.push(node.id)
// } else {
// collectLeafs(node.children)
// }
// 可选2、收集所有节点ID(包括父节点)
result.push(node.id)
if (node.children && node.children.length > 0) {
collectLeafs(node.children)
}
}
}
collectLeafs(tree)
return result
}
/**
* 根据ID列表构建子树
* @param tree 原始树形数据
* @param ids 选中的ID列表
* @returns {Array}
*/
const buildSubTreeByIds = (tree, ids) => {
const result = []
for (const node of tree) {
if (ids.includes(node.id)) {
const newNode = { ...node }
if (node.children && node.children.length > 0) {
newNode.children = buildSubTreeByIds(node.children, ids)
}
result.push(newNode)
} else if (node.children && node.children.length > 0) {
const childResult = buildSubTreeByIds(node.children, ids)
if (childResult.length > 0) {
result.push({
...node,
children: childResult
})
}
}
}
return result
}
// ===================== API 方法 =====================
/**
* 获取资源树列表
*/
const getResourceTree = () => {
request.get('/tree').then(res => {
if (res.code === '200' || res.code === 200) {
allResourceTree.value = res.data || []
nextTick(() => {
syncTreeData()
})
}
}).catch(() => {
ElMessage.error('获取资源列表失败')
})
}
/**
* 同步左右树数据
*/
const syncTreeData = () => {
if (leftTree.value) {
leftTree.value.setCheckedKeys([])
}
if (rightTree.value) {
rightTree.value.setCheckedKeys([])
}
leftCheckedKeys.value = []
rightCheckedKeys.value = []
}
/**
* 获取左侧树数据(排除已选)
*/
const leftTreeData = computed(() => {
if (!allResourceTree.value.length || !form.resourceIds.length) {
return allResourceTree.value
}
return removeNodesByIds(allResourceTree.value, form.resourceIds)
})
/**
* 获取右侧树数据(已选资源)
*/
const rightTreeData = computed(() => {
if (!allResourceTree.value.length || !form.resourceIds.length) {
return []
}
return buildSubTreeByIds(allResourceTree.value, form.resourceIds)
})
/**
* 左侧树勾选变化处理
*/
const handleLeftCheckChange = (data, checked, indeterminate) => {
const childIds = getAllChildrenIds(allResourceTree.value, data.id)
const allIds = [data.id, ...childIds]
if (checked) {
leftCheckedKeys.value = [...new Set([...leftCheckedKeys.value, ...allIds])]
} else {
leftCheckedKeys.value = leftCheckedKeys.value.filter(id => !allIds.includes(id))
}
}
/**
* 右侧树勾选变化处理
*/
const handleRightCheckChange = (data, checked, indeterminate) => {
const childIds = getAllChildrenIds(allResourceTree.value, data.id)
const allIds = [data.id, ...childIds]
if (checked) {
rightCheckedKeys.value = [...new Set([...rightCheckedKeys.value, ...allIds])]
} else {
rightCheckedKeys.value = rightCheckedKeys.value.filter(id => !allIds.includes(id))
}
}
/**
* 添加选中项到右侧
*/
const addToRight = () => {
if (leftCheckedKeys.value.length === 0) return
form.resourceIds = [...new Set([...form.resourceIds, ...leftCheckedKeys.value])]
if (leftTree.value) {
leftTree.value.setCheckedKeys([])
}
leftCheckedKeys.value = []
}
/**
* 从右侧移除选中项
*/
const removeFromRight = () => {
if (rightCheckedKeys.value.length === 0) return
form.resourceIds = form.resourceIds.filter(id => !rightCheckedKeys.value.includes(id))
if (rightTree.value) {
rightTree.value.setCheckedKeys([])
}
rightCheckedKeys.value = []
}
/**
* 添加全部到右侧
*/
const addAllToRight = () => {
const leafIds = getAllLeafIds(leftTreeData.value)
form.resourceIds = [...new Set([...form.resourceIds, ...leafIds])]
}
/**
* 移除全部
*/
const removeAllFromRight = () => {
form.resourceIds = []
rightCheckedKeys.value = []
if (rightTree.value) {
rightTree.value.setCheckedKeys([])
}
}
/**
* 监听左侧过滤文本变化
*/
watch(leftFilterText, (val) => {
if (leftTree.value) {
leftTree.value.filter(val)
}
})
/**
* 监听右侧过滤文本变化
*/
watch(rightFilterText, (val) => {
if (rightTree.value) {
rightTree.value.filter(val)
}
})
// ============ 模拟request对象(mock专用)============
const request = {
get: (url) => {
// 匹配你请求的 /tree 接口
if (url === '/tree') {
// 返回模拟Promise,结构和后端一致 {code, data, msg}
return new Promise((resolve) => {
// 模拟接口延迟200ms
setTimeout(() => {
const mockTreeData = [
{
id: 1,
resourceName: '系统管理',
children: [
{ id: 11, resourceName: '用户管理', children: [] },
{ id: 12, resourceName: '角色管理', children: [] },
{
id: 13,
resourceName: '菜单权限',
children: [
{ id: 131, resourceName: '新增菜单', children: [] },
{ id: 132, resourceName: '编辑菜单', children: [] }
]
}
]
},
{
id: 2,
resourceName: '订单模块',
children: [
{ id: 21, resourceName: '全部订单', children: [] },
{ id: 22, resourceName: '退款订单', children: [] }
]
},
{ id: 3, resourceName: '财务中心', children: [] }
]
resolve({
code: '200',
data: mockTreeData,
msg: '查询成功'
})
}, 200)
})
}
// 其他接口可继续扩展
return Promise.reject({ msg: '接口不存在' })
}
}
</script>
<style scoped>
/* 树形穿梭框样式 */
.tree-transfer {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.transfer-panel {
flex: 0 0 45%;
/* flex: 1; */
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.transfer-header {
padding: 12px 15px;
background-color: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-weight: 500;
}
.tree-search {
padding: 10px;
border-bottom: 1px solid #e4e7ed;
}
.transfer-panel :deep(.el-tree) {
flex: 1;
max-height: 400px;
overflow-y: auto;
}
.transfer-buttons {
/* 垂直居中核心 */
align-self: center;
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px;
}
.transfer-buttons :deep(.el-button) {
width: 40px;
height: 40px;
padding: 0;
}
.transfer-buttons :deep(.el-button + .el-button) {
margin-left: 0;
}
</style>