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