el-tree-select封装组件 treeSelect
远程下拉分页
组件

我项目中需要的地市区县表数据和插件市场的数据不太一样 我的数据格式是 id pid name 类似于无限级分类一样 。如果直接替换插件市场的地市数据 很多数据表数据都要改 ,直接使用 开发示例 感觉不太客观 所以 参考 remoteSelect 组件 写了一个 兼容buildadmin 官网文档的 treeSelect 组件。
大概使用方式是这样的 。
前端的

vue 复制代码
<template>
    <div class="ba-tree-select">
        <div class="select-trigger" @click.stop="toggleDropdown">
            <el-input
                :model-value="displayValue"
                :placeholder="placeholder"
                :disabled="disabled"
                :clearable="clearable"
                @clear.stop="onClear"
                readonly
            >
                <template #suffix>
                    <el-icon class="el-input__icon">
                        <ArrowDown />
                    </el-icon>
                </template>
            </el-input>
        </div>
        
        <div v-if="state.dropdownVisible" class="tree-dropdown" @click.stop>
            <div v-if="filterable" class="filter-container">
                <el-input
                    v-model="state.filterText"
                    placeholder="请输入关键字过滤"
                    clearable
                    @input="onFilterInput"
                >
                    <template #prefix>
                        <el-icon><Search /></el-icon>
                    </template>
                </el-input>
            </div>
            
            <div class="tree-container">
                <el-tree
                    ref="treeRef"
                    :data="state.data"
                    :props="defaultProps"
                    :default-expand-all="defaultExpandAll"
                    :filter-node-method="filterNode"
                    :check-strictly="checkStrictly"
                    @node-click="handleNodeClick"
                    highlight-current
                    node-key="id"
                />
            </div>
        </div>
    </div>
</template>

cleanXss
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ArrowDown, Search } from '@element-plus/icons-vue'
import { getSelectData } from '/@/api/common'
import type { ElTree } from 'element-plus'

const props = defineProps({
    modelValue: {
        type: [String, Number],
        default: '',
    },
    // 表单字段name
    name: {
        type: String,
        default: '',
    },
    // 是否严格的遵守父子节点不互相关联
    checkStrictly: {
        type: Boolean,
        default: true,
    },
    // 默认是否展开所有节点
    defaultExpandAll: {
        type: Boolean,
        default: false,
    },
    // 是否可以清空选项
    clearable: {
        type: Boolean,
        default: true,
    },
    // 是否可搜索
    filterable: {
        type: Boolean,
        default: true,
    },
    // 远程接口地址
    remoteUrl: {
        type: String,
        default: '',
    },
    // 占位符
    placeholder: {
        type: String,
        default: '',
    },
    // 实际的值字段
    pk: {
        type: String,
        default: 'id',
    },
    // 显示的文字字段
    field: {
        type: String,
        default: 'name',
    },
    // 请求参数
    params: {
        type: Object,
        default: () => {},
    },
    // 空值
    emptyValues: {
        type: Array,
        default: () => [],
    },
    // 清空按钮出现时的值
    valueOnClear: {
        type: [String, Number],
        default: '',
    },
    // 禁用状态
    disabled: {
        type: Boolean,
        default: false,
    },
    // 宽度
    width: {
        type: [String, Number],
        default: 240,
    },
})

const emit = defineEmits(['update:modelValue', 'change'])

const treeRef = ref<InstanceType<typeof ElTree>>()
const defaultProps = {
    children: 'children',
    label: props.field
}

const state = reactive({
    data: [] as any[],
    loading: false,
    filterText: '',
    dropdownVisible: false,
    nodeMap: {} as Record<string | number, any>,
})

// 当前选中值
const currentValue = computed({
    get: () => {
        return props.modelValue
    },
    set: (val) => {
        let newVal = val
        // 处理空值
        if (props.emptyValues && props.emptyValues.includes(val)) {
            newVal = props.valueOnClear
        }
        emit('update:modelValue', newVal)
        emit('change', newVal)
    },
})

// 显示值(下拉框内显示的文本)
const displayValue = computed(() => {
    const value = currentValue.value
    if (!value || props.emptyValues.includes(value)) {
        return ''
    }
    
    // 从节点映射中找到对应的节点
    const node = state.nodeMap[value]
    return node ? node[props.field] : ''
})

// 切换下拉框显示状态
const toggleDropdown = () => {
    if (props.disabled) return
    
    state.dropdownVisible = !state.dropdownVisible
    
    if (state.dropdownVisible) {
        // 下拉框显示时,添加全局点击事件用于关闭下拉框
        nextTick(() => {
            document.addEventListener('click', handleOutsideClick)
        })
    }
}

// 处理外部点击,关闭下拉框
const handleOutsideClick = (e: MouseEvent) => {
    const target = e.target as HTMLElement
    const dropdown = document.querySelector('.ba-tree-select')
    
    if (dropdown && !dropdown.contains(target)) {
        state.dropdownVisible = false
        document.removeEventListener('click', handleOutsideClick)
    }
}

// 清空选项
const onClear = (e?: Event) => {
    if (e) e.stopPropagation()
    currentValue.value = props.valueOnClear
    state.dropdownVisible = false
}

// 过滤输入处理
const onFilterInput = () => {
    treeRef.value?.filter(state.filterText)
}

// 节点点击事件
const handleNodeClick = (node: any) => {
    currentValue.value = node[props.pk]
    state.dropdownVisible = false
    document.removeEventListener('click', handleOutsideClick)
}

// 过滤节点方法
const filterNode = (value: string, data: any) => {
    if (!value) return true
    return data[props.field].toLowerCase().includes(value.toLowerCase())
}

// 获取树形数据
const getTreeData = () => {
    state.loading = true
    getSelectData(props.remoteUrl, '', { ...props.params, isTree: true })
        .then((res: any) => {
            if (res.data && res.data.list) {
                state.data = processTreeData(res.data.list)
            } else {
                state.data = processTreeData(res.data)
            }
            
            // 选中当前值对应的节点
            nextTick(() => {
                if (currentValue.value && state.nodeMap[currentValue.value]) {
                    const currentNode = state.nodeMap[currentValue.value]
                    treeRef.value?.setCurrentKey(currentNode[props.pk])
                }
            })
        })
        .finally(() => {
            state.loading = false
        })
}

// 处理树形数据,构建节点映射
const processTreeData = (data: any[]) => {
    state.nodeMap = {}
    
    const buildNodeMap = (nodes: any[]) => {
        nodes.forEach(node => {
            // 存储节点映射
            state.nodeMap[node[props.pk]] = node
            
            // 处理子节点
            if (node.children && node.children.length > 0) {
                buildNodeMap(node.children)
            }
        })
    }
    
    buildNodeMap(data)
    return data
}

// 监听 remoteUrl 变化
watch(
    () => props.remoteUrl,
    (newVal) => {
        if (newVal) {
            getTreeData()
        }
    }
)

// 监听 params 变化
watch(
    () => props.params,
    (newVal, oldVal) => {
        if (props.remoteUrl && JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
            getTreeData()
        }
    },
    { deep: true }
)

// 监听过滤文本变化
watch(
    () => state.filterText,
    (val) => {
        treeRef.value?.filter(val)
    }
)

// 组件卸载前移除事件监听
onBeforeUnmount(() => {
    document.removeEventListener('click', handleOutsideClick)
})

// 初始化
onMounted(() => {
    if (props.remoteUrl) {
        getTreeData()
    }
})
cleanXss

<style scoped>
.ba-tree-select {
    position: relative;
    width: 100%;
}

.select-trigger {
    width: 100%;
    cursor: pointer;
}

.tree-dropdown {
    position: absolute;
    top: 100%;
    left: 0;
    width: 100%;
    min-width: 210px;
    margin-top: 5px;
    background-color: #fff;
    border: 1px solid #e4e7ed;
    border-radius: 4px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    padding: 10px;
    z-index: 2000;
}

.filter-container {
    margin-bottom: 8px;
}

.tree-container {
    max-height: 300px;
    overflow-y: auto;
}

:deep(.el-tree-node__content) {
    height: auto;
    min-height: 32px;
}

:deep(.el-tree-node__label) {
    word-break: break-all;
    white-space: normal;
}

:deep(.el-input__inner) {
    cursor: pointer;
}

:deep(.el-tree) {
    background-color: transparent;
    color: inherit;
}
</style> 

这个treeSelect.vue 文件放在 web/src/components/baInput/components 目录下
然后 web/src/components/baInput/index.ts 文件加上 treeSelect

然后想用的地方直接 调用

vue 复制代码
<FormItem
                        :label="t('regions.pid')"
                        v-model="baTable.form.items!.pid"
                        type="treeSelect"
                        prop="pid"
                        :input-attr="{
                            field: 'name',
                            remoteUrl: baTable.api.actionUrl.get('index'),
                            placeholder: t('Click select'),
                            emptyValues: ['', null, undefined, 0],
                            valueOnClear: 0,
                            params: { isTree: true, select: true, simpleFields: 'true' }
                        }"
                    />


参数什么的跟 remoteSelect 一样

展示效果

后端的代码

php 复制代码
if ($this->request->param('select') && $this->request->param('isTree')) {
            // 如果指定了simpleFields参数,则只返回必要的字段
            $fields = $this->request->param('simpleFields') ? 'id,pid,name' : $this->indexField;
            
            $res = $this->model
                ->field($fields)
                ->alias($alias)
                ->where($where)
                ->select()
                ->toArray();

            $res = $this->tree->assembleChild($res);

        
            
            $this->success('', [
                'list'   => $res,
                'remark' => get_route_remark(),
            ]);
            return;
        }

完整的后端代码

php 复制代码
<?php

namespace app\admin\controller;

use app\common\controller\Backend;
use ba\Tree;
use think\Paginator;
use Throwable;

/**
 * 地市列管理
 */
class Regions extends Backend
{
    /**
     * Regions模型对象
     * @var object
     * @phpstan-var \app\admin\model\Regions
     */
    protected object $model;

    protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];

    protected string|array $quickSearchField = ['id','name'];
    protected ?Tree $tree;

    public function initialize(): void
    {
        parent::initialize();
        $this->tree  = Tree::instance();
        $this->model = new \app\admin\model\Regions();
    }


    /**
     * 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
     */

    /**
     * 重写查看方法
     * @throws Throwable
     */
    public function index(): void
    {
        $this->request->filter(['strip_tags', 'trim']);

        list($where, $alias, $limit, $order) = $this->queryBuilder();
        
        // 如果是远程选择模式,并且包含isTree参数,需要返回所有数据用于树形展示
        if ($this->request->param('select') && $this->request->param('isTree')) {
            // 如果指定了simpleFields参数,则只返回必要的字段
            $fields = $this->request->param('simpleFields') ? 'id,pid,name' : $this->indexField;
            
            $res = $this->model
                ->field($fields)
                ->alias($alias)
                ->where($where)
                ->select()
                ->toArray();

            /**
             * 树状表格必看注释一
             * 1. 获取表格数据(没有分页,所以简化了以上的数据查询代码)
             * 2. 递归的根据指定字段组装 children 数组,此时直接给前端,表格就可以正常的渲染为树状了,一个方法搞定
             */
            $res = $this->tree->assembleChild($res);

            /**
             * 树状表格必看注释二
             * 1. 在远程 select 中,数据要成树状显示,需要对数据做一些改动
             * 2. 通过已组装好 children 的数据,建立`树枝`结构,并最终合并为一个二维数组方便渲染
             * 3. 简单讲就是把组装好 children 的数据,给以下这两个方法即可
             */
            //$res = $this->tree->assembleTree($this->tree->getTreeArray($res));
            
            $this->success('', [
                'list'   => $res,
                'remark' => get_route_remark(),
            ]);
            return;
        }
        
        // 常规表格模式,默认只加载最顶级数据
        // 添加搜索条件,如果传递了quickSearch参数,则使用精确或模糊搜索
        $quickSearch = $this->request->param('quickSearch', '');
        $pid = $this->request->param('pid', null);
        
        // 如果指定了父ID,优先加载该ID下的子节点
        if ($pid !== null) {
            $where[] = ['pid', '=', $pid];
            // 限制每次最多返回30条记录,避免返回过多数据
            $childLimit = 30;
            
            $res = $this->model
                ->field($this->indexField)
                ->alias($alias)
                ->where($where)
                ->order($order)
                ->limit($childLimit)
                ->select()
                ->toArray();
                
            // 对每个节点标记是否有子节点
            foreach ($res as &$item) {
                $hasChildren = $this->model->where('pid', '=', $item['id'])->count() > 0;
                $item['hasChildren'] = $hasChildren;
            }
            
            $this->success('', [
                'list'   => $res,
                'remark' => get_route_remark(),
            ]);
            return;
        }
        
        // 添加搜索条件,如果传递了quickSearch参数,则使用精确或模糊搜索
        if ($quickSearch) {
            // 在已有查询条件基础上添加快速搜索
            if (is_array($this->quickSearchField)) {
                $searchWhere = [];
                foreach ($this->quickSearchField as $field) {
                    $searchWhere[] = [$field, 'LIKE', "%{$quickSearch}%"];
                }
                if (count($searchWhere) > 0) {
                    $where[] = function ($query) use ($searchWhere) {
                        $query->whereOr($searchWhere);
                    };
                }
            } else {
                $where[] = [$this->quickSearchField, 'LIKE', "%{$quickSearch}%"];
            }
            $page  =  Paginator::getCurrentPage();
            // 搜索模式下查询所有匹配的数据(不限于顶级)
            $res = $this->model
                ->field($this->indexField)
                ->alias($alias)
                ->where($where)
                ->order($order)
                ->page($page,$limit)
                ->select()
                ->toArray();

            $total = $this->model
                ->field($this->indexField)
                ->alias($alias)
                ->where($where)
                ->count();
            
        } else {
            // 非搜索模式下只获取顶级节点,减少初始数据量
            $where[] = ['pid', '=', 0];
            $page  =  Paginator::getCurrentPage();
            $res = $this->model
                ->field($this->indexField)
                ->alias($alias)
                ->where($where)
                ->order($order)
                ->page($page,$limit)
                ->select()
                ->toArray();

            $total = $this->model
                ->field($this->indexField)
                ->alias($alias)
                ->where($where)
                ->order($order)
                ->count();


            // 标记每个节点是否有子节点
            foreach ($res as &$item) {
                $hasChildren = $this->model->where('pid', '=', $item['id'])->count() > 0;
                $item['hasChildren'] = $hasChildren;
            }

            // 如果顶级数量过少,还可以加载第二级
            if (count($res) < 10) {
                $firstLevelIds = array_column($res, 'id');
                $secondLevelData = [];
                if (!empty($firstLevelIds)) {
                    $secondLevelData = $this->model
                        ->field($this->indexField)
                        ->alias($alias)
                        ->where('pid', 'in', $firstLevelIds)
                        ->select()
                        ->toArray();
                    
                    // 标记第二级节点是否有子节点
                    foreach ($secondLevelData as &$item) {
                        $hasChildren = $this->model->where('pid', '=', $item['id'])->count() > 0;
                        $item['hasChildren'] = $hasChildren;
                    }
                }
                
                // 组装第二级数据
                if (!empty($secondLevelData)) {
                    // 修改为手动组装,避免使用tree组件覆盖hasChildren标记
                    $nodeMap = [];
                    foreach ($res as &$node) {
                        $nodeMap[$node['id']] = &$node;
                    }
                    
                    foreach ($secondLevelData as &$child) {
                        if (isset($nodeMap[$child['pid']])) {
                            if (!isset($nodeMap[$child['pid']]['children'])) {
                                $nodeMap[$child['pid']]['children'] = [];
                            }
                            $nodeMap[$child['pid']]['children'][] = $child;
                        }
                    }
                }
            }
        }

        // 递归组装子节点
        $res = $this->tree->assembleChild($res);
        
        // 确保每个节点都有hasChildren标记
        $this->markNodeHasChildren($res);
        
        $this->success('', [
            'list'   => $res,
            'total'  => $total,
            'remark' => get_route_remark(),
        ]);
    }
    
    /**
     * 标记节点是否有子节点
     */
    private function markNodeHasChildren(array &$nodes): void
    {
        foreach ($nodes as &$node) {
            // 如果节点已经有children并且不为空,则一定有子节点
            if (!empty($node['children'])) {
                $node['hasChildren'] = true;
                $this->markNodeHasChildren($node['children']);
            } else {
                // 否则查询数据库确认是否有子节点
                if (!isset($node['hasChildren'])) {
                    $node['hasChildren'] = $this->model->where('pid', '=', $node['id'])->count() > 0;
                }
            }
        }
    }
}

完整的列表代码

vue 复制代码
<template>
    <div class="default-main ba-table-box">
        <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />

        <!-- 表格顶部菜单 -->
        <!-- 自定义按钮请使用插槽,甚至公共搜索也可以使用具名插槽渲染,参见文档 -->
        <TableHeader
            :buttons="['refresh', 'add', 'unfold', 'quickSearch', 'columnDisplay']"
            :quick-search-placeholder="t('Quick search placeholder', { fields: t('regions.quick Search Fields') })"
        ></TableHeader>

        <!-- 表格 -->
        <!-- 表格列有多种自定义渲染方式,比如自定义组件、具名插槽等,参见文档 -->
        <!-- 要使用 el-table 组件原有的属性,直接加在 Table 标签上即可 -->
        <Table
            ref="tableRef"
            :pagination="true"
            :default-expand-all="false"
            row-key="id"
            lazy
            :load="loadChildrenData"
            :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
        ></Table>
        <!-- 表单 -->
        <PopupForm />
    </div>
</template>

cleanXss
import { onMounted, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'

defineOptions({
    name: 'regions',
})

const { t } = useI18n()
const tableRef = ref()
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])

/**
 * baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
 */
const baTable = new baTableClass(
    new baTableApi('/admin/Regions/'),
    {
        pk: 'id',
        column: [
            { label: t('regions.name'), prop: 'name', align: 'left', operator: false, sortable: false },
            { label: t('regions.id'), prop: 'id', align: 'center', width: 120, operator: false, sortable: 'custom' },
            { label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
        ],
        dblClickNotEditColumn: [undefined],
    },
    {
        defaultItems: {},
    }
)

provide('baTable', baTable)

// 懒加载子节点数据
const loadChildrenData = (row: any, treeNode: any, resolve: (data: any[]) => void) => {
    // 使用 row.id 作为父ID查询子节点
    baTable.api
        .postData('index', { pid: row.id })
        .then((res) => {
            if (res.data && res.data.list) {
                // 处理返回的子节点数据
                const children = res.data.list.map((item: any) => {
                    // 检查每个子节点是否有自己的子节点
                    return {
                        ...item,
                        hasChildren: item.hasChildren === undefined ? item.children && item.children.length > 0 : item.hasChildren,
                    }
                })
                resolve(children)
            } else {
                resolve([])
            }
        })
        .catch(() => {
            resolve([])
        })
}

onMounted(() => {
    baTable.table.ref = tableRef.value
    baTable.mount()
    baTable.getIndex()?.then(() => {
        // 初始化表格数据,确保hasChildren标记正确传递
        if (baTable.table.data) {
            const tableData = baTable.table.data as any
            if (tableData.list) {
                tableData.list = tableData.list.map((item: any) => ({
                    ...item,
                    hasChildren: item.hasChildren === undefined ? item.children && item.children.length > 0 : item.hasChildren,
                }))
            }
        }
        baTable.initSort()
        baTable.dragSort()
    })
})
cleanXss

<style scoped lang="scss"></style>


完整的新增,修改页面代码

vue 复制代码
<template>
    <!-- 对话框表单 -->
    <!-- 建议使用 Prettier 格式化代码 -->
    <!-- el-form 内可以混用 el-form-item、FormItem、ba-input 等输入组件 -->
    <el-dialog
        class="ba-operate-dialog"
        :close-on-click-modal="false"
        :model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
        @close="baTable.toggleForm"
        width="50%"
    >
        <template #header>
            <div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
                {{ baTable.form.operate ? t(baTable.form.operate) : '' }}
            </div>
        </template>
        <el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
            <div
                class="ba-operate-form"
                :class="'ba-' + baTable.form.operate + '-form'"
                :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
            >
                <el-form
                    v-if="!baTable.form.loading"
                    ref="formRef"
                    @submit.prevent=""
                    @keyup.enter="baTable.onSubmit(formRef)"
                    :model="baTable.form.items"
                    :label-position="config.layout.shrink ? 'top' : 'right'"
                    :label-width="baTable.form.labelWidth + 'px'"
                    :rules="rules"
                >
                    <FormItem
                        :label="t('regions.name')"
                        type="string"
                        v-model="baTable.form.items!.name"
                        prop="name"
                        :placeholder="t('Please input field', { field: t('regions.name') })"
                    />
                    <FormItem
                        :label="t('regions.pid')"
                        v-model="baTable.form.items!.pid"
                        type="treeSelect"
                        prop="pid"
                        :input-attr="{
                            field: 'name',
                            remoteUrl: baTable.api.actionUrl.get('index'),
                            placeholder: t('Click select'),
                            emptyValues: ['', null, undefined, 0],
                            valueOnClear: 0,
                            params: { isTree: true, select: true, simpleFields: 'true' }
                        }"
                    />
                </el-form>
            </div>
        </el-scrollbar>
        <template #footer>
            <div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
                <el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
                <el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
                    {{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
                </el-button>
            </div>
        </template>
    </el-dialog>
</template>

cleanXss
import type { FormInstance, FormItemRule } from 'element-plus'
import { inject, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'

const config = useConfig()
const formRef = ref<FormInstance>()
const baTable = inject('baTable') as baTableClass

const { t } = useI18n()

const rules: Partial<Record<string, FormItemRule[]>> = reactive({
    name: [buildValidatorData({ name: 'required', title: t('regions.name') })],
    pid: [buildValidatorData({ name: 'number', title: t('regions.pid') })],
    create_time: [buildValidatorData({ name: 'date', title: t('regions.create_time') })],
    update_time: [buildValidatorData({ name: 'date', title: t('regions.update_time') })],
})
cleanXss

<style scoped lang="scss"></style>


2个回答默认排序 投票数排序
arkizat
arkizat
这家伙很懒,什么也没写~
5天前

远程下拉的代码跟系统默认的基本上一样,首页代码变成这么多的原因,展示列表也要分页形式显示并且支持树形展示。组件使用过程有什么问题的话 一起讨论解决
参考网址:https://element-plus.org/zh-CN/component/tree-select.html#attributes

YANG001
YANG001
这家伙很懒,什么也没写~
5天前

感谢分享~

请先登录
0
1
0
2