|
|
@@ -0,0 +1,431 @@
|
|
|
+<template>
|
|
|
+ <div class="tree-select-wrapper">
|
|
|
+ <el-select
|
|
|
+ ref="selectRef"
|
|
|
+ v-model="selectedLabel"
|
|
|
+ :popper-append-to-body="popperAppendToBody"
|
|
|
+ :teleported="teleported"
|
|
|
+ :placeholder="placeholder"
|
|
|
+ :disabled="disabled"
|
|
|
+ :clearable="clearable"
|
|
|
+ :filterable="filterable"
|
|
|
+ :filter-method="filterMethod"
|
|
|
+ popper-class="tree-select-popper"
|
|
|
+ @clear="handleClear"
|
|
|
+ @visible-change="handleVisibleChange"
|
|
|
+ >
|
|
|
+ <!-- 关键修复:el-option 的 value 不能绑定 selectedLabel,需要绑定一个唯一值 -->
|
|
|
+ <el-option
|
|
|
+ :value="selectedValue"
|
|
|
+ :label="selectedLabel"
|
|
|
+ style="height: auto; padding: 0"
|
|
|
+ class="tree-select-option"
|
|
|
+ >
|
|
|
+ <!-- 搜索框 -->
|
|
|
+ <div v-if="filterable" class="tree-search">
|
|
|
+ <el-input
|
|
|
+ v-model="searchText"
|
|
|
+ size="small"
|
|
|
+ placeholder="输入关键词搜索"
|
|
|
+ clearable
|
|
|
+ @input="handleSearch"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 树形结构 -->
|
|
|
+ <el-tree
|
|
|
+ ref="treeRef"
|
|
|
+ :data="treeData"
|
|
|
+ :props="treeProps"
|
|
|
+ :show-checkbox="multiple"
|
|
|
+ :node-key="nodeKey"
|
|
|
+ :default-expand-all="defaultExpandAll"
|
|
|
+ :default-expanded-keys="defaultExpandedKeys"
|
|
|
+ :filter-node-method="filterNode"
|
|
|
+ :check-strictly="checkStrictly"
|
|
|
+ :expand-on-click-node="false"
|
|
|
+ :highlight-current="!multiple"
|
|
|
+ :current-node-key="currentNodeKey"
|
|
|
+ @node-click="handleNodeClick"
|
|
|
+ @check="handleCheck"
|
|
|
+ >
|
|
|
+ <span slot-scope="{ node, data }" class="custom-tree-node">
|
|
|
+ <span>{{ node.label }}</span>
|
|
|
+ <span v-if="data.count" class="node-count">({{ data.count }})</span>
|
|
|
+ </span>
|
|
|
+ </el-tree>
|
|
|
+ </el-option>
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+ import { listOrganizations } from '@/api/system/organization';
|
|
|
+
|
|
|
+ export default {
|
|
|
+ name: 'TreeSelect',
|
|
|
+ props: {
|
|
|
+ value: {
|
|
|
+ type: [String, Number, Array],
|
|
|
+ default: null
|
|
|
+ },
|
|
|
+ data: {
|
|
|
+ type: Array,
|
|
|
+ required: true,
|
|
|
+ default: () => []
|
|
|
+ },
|
|
|
+ multiple: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ placeholder: {
|
|
|
+ type: String,
|
|
|
+ default: '请选择'
|
|
|
+ },
|
|
|
+ disabled: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ clearable: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ filterable: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ nodeKey: {
|
|
|
+ type: String,
|
|
|
+ default: 'id'
|
|
|
+ },
|
|
|
+ treeProps: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({
|
|
|
+ children: 'children',
|
|
|
+ label: 'name'
|
|
|
+ })
|
|
|
+ },
|
|
|
+ defaultExpandAll: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ defaultExpandedKeys: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
+ },
|
|
|
+ checkStrictly: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ popperAppendToBody: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ teleported: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ selectedLabel: '', // 显示的文本
|
|
|
+ selectedValue: null, // 选中的值(用于单选)
|
|
|
+ selectedValues: [], // 多选时选中的值数组
|
|
|
+ currentNodeKey: null, // 当前高亮的节点key
|
|
|
+ searchText: '', // 搜索关键词
|
|
|
+ treeData: [], // 过滤后的树数据
|
|
|
+ dataLoaded: false // 新增:标记数据是否加载完成
|
|
|
+ };
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ value: {
|
|
|
+ handler() {
|
|
|
+ // 等待数据加载完成后再初始化
|
|
|
+ if (this.dataLoaded) {
|
|
|
+ this.initSelectedValue();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ immediate: true
|
|
|
+ },
|
|
|
+ // 新增:监听 treeData 变化,数据加载完成后初始化选中值
|
|
|
+ treeData: {
|
|
|
+ handler(val) {
|
|
|
+ if (val && val.length && !this.dataLoaded) {
|
|
|
+ this.dataLoaded = true;
|
|
|
+ this.initSelectedValue();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ immediate: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async created() {
|
|
|
+ await this.getData(); // 改为 await 等待数据加载完成
|
|
|
+ },
|
|
|
+ mounted() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ this.$refs.treeRef.$on('node-expand', this.updatePopperPosition);
|
|
|
+ this.$refs.treeRef.$on('node-collapse', this.updatePopperPosition);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ this.$refs.treeRef.$off('node-expand', this.updatePopperPosition);
|
|
|
+ this.$refs.treeRef.$off('node-collapse', this.updatePopperPosition);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ async getData(params = {}) {
|
|
|
+ const data = await listOrganizations(params);
|
|
|
+ this.treeData = this.$util.toTreeData({
|
|
|
+ data: data || [],
|
|
|
+ idField: 'id',
|
|
|
+ parentIdField: 'parentId'
|
|
|
+ });
|
|
|
+ // 数据加载完成后标记
|
|
|
+ this.dataLoaded = true;
|
|
|
+ },
|
|
|
+
|
|
|
+ // 初始化选中的值(优化:从 this.treeData 查找,而不是 this.data)
|
|
|
+ initSelectedValue() {
|
|
|
+ // 如果数据还没加载完成,先不处理
|
|
|
+ if (!this.dataLoaded && !this.treeData.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.multiple) {
|
|
|
+ const val = this.value || [];
|
|
|
+ this.selectedValues = [...val];
|
|
|
+ this.selectedLabel = this.getSelectedLabels(val).join('、');
|
|
|
+ this.selectedValue = val.length ? val.join(',') : '';
|
|
|
+
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ this.$refs.treeRef.setCheckedKeys(this.selectedValues);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ this.selectedValue = this.value;
|
|
|
+ if (this.value !== null && this.value !== undefined) {
|
|
|
+ // 关键修复:从 this.treeData 查找,而不是 this.data
|
|
|
+ const node = this.findNodeByValue(this.value, this.treeData);
|
|
|
+ this.selectedLabel = node ? node[this.treeProps.label] : '';
|
|
|
+ this.currentNodeKey = this.value;
|
|
|
+ } else {
|
|
|
+ this.selectedLabel = '';
|
|
|
+ this.currentNodeKey = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.treeRef && this.value) {
|
|
|
+ this.$refs.treeRef.setCurrentKey(this.value);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 根据值查找节点(从指定数据源查找)
|
|
|
+ findNodeByValue(value, dataSource = this.treeData) {
|
|
|
+ if (!dataSource || !dataSource.length) return null;
|
|
|
+
|
|
|
+ for (const item of dataSource) {
|
|
|
+ if (item[this.nodeKey] === value) {
|
|
|
+ return item;
|
|
|
+ }
|
|
|
+ const children = item[this.treeProps.children];
|
|
|
+ if (children && children.length) {
|
|
|
+ const found = this.findNodeByValue(value, children);
|
|
|
+ if (found) return found;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ },
|
|
|
+
|
|
|
+ // 获取节点标签
|
|
|
+ getNodeLabel(value, dataSource = this.treeData) {
|
|
|
+ const node = this.findNodeByValue(value, dataSource);
|
|
|
+ return node ? node[this.treeProps.label] : '';
|
|
|
+ },
|
|
|
+
|
|
|
+ // 批量获取标签文本(多选)
|
|
|
+ getSelectedLabels(values, dataSource = this.treeData, result = []) {
|
|
|
+ if (!dataSource || !dataSource.length) return result;
|
|
|
+
|
|
|
+ for (const item of dataSource) {
|
|
|
+ if (values.includes(item[this.nodeKey])) {
|
|
|
+ result.push(item[this.treeProps.label]);
|
|
|
+ }
|
|
|
+ const children = item[this.treeProps.children];
|
|
|
+ if (children && children.length) {
|
|
|
+ this.getSelectedLabels(values, children, result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ },
|
|
|
+
|
|
|
+ // 树节点点击(单选模式)
|
|
|
+ handleNodeClick(data) {
|
|
|
+ if (!this.multiple) {
|
|
|
+ const value = data[this.nodeKey];
|
|
|
+ const label = data[this.treeProps.label];
|
|
|
+
|
|
|
+ this.selectedValue = value;
|
|
|
+ this.selectedLabel = label;
|
|
|
+ this.currentNodeKey = value;
|
|
|
+
|
|
|
+ this.$emit('input', value);
|
|
|
+ this.$emit('change', value, data);
|
|
|
+
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.selectRef) {
|
|
|
+ this.$refs.selectRef.visible = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 树节点勾选(多选模式)
|
|
|
+ handleCheck(data, checkedState) {
|
|
|
+ const checkedKeys = checkedState.checkedKeys;
|
|
|
+ this.selectedValues = [...checkedKeys];
|
|
|
+ this.selectedLabel = this.getSelectedLabels(checkedKeys).join('、');
|
|
|
+ this.selectedValue = checkedKeys.length ? checkedKeys.join(',') : '';
|
|
|
+
|
|
|
+ this.$emit('input', checkedKeys);
|
|
|
+ this.$emit('change', checkedKeys, data);
|
|
|
+ },
|
|
|
+
|
|
|
+ // 清空
|
|
|
+ handleClear() {
|
|
|
+ if (this.multiple) {
|
|
|
+ this.selectedValues = [];
|
|
|
+ this.selectedLabel = '';
|
|
|
+ this.selectedValue = '';
|
|
|
+ this.$emit('input', []);
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ this.$refs.treeRef.setCheckedKeys([]);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ this.selectedValue = null;
|
|
|
+ this.selectedLabel = '';
|
|
|
+ this.currentNodeKey = null;
|
|
|
+ this.$emit('input', null);
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ this.$refs.treeRef.setCurrentKey(null);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 下拉框显示/隐藏
|
|
|
+ handleVisibleChange(visible) {
|
|
|
+ if (visible) {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ if (this.multiple) {
|
|
|
+ this.$refs.treeRef.setCheckedKeys(this.selectedValues);
|
|
|
+ } else {
|
|
|
+ this.$refs.treeRef.setCurrentKey(this.selectedValue);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.updatePopperPosition();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ // 更新下拉框位置
|
|
|
+ updatePopperPosition() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.selectRef && this.$refs.selectRef.visible) {
|
|
|
+ const select = this.$refs.selectRef;
|
|
|
+ if (select.popper) {
|
|
|
+ select.popper.update();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ // 树节点过滤
|
|
|
+ filterNode(value, data) {
|
|
|
+ if (!value) return true;
|
|
|
+ const label = data[this.treeProps.label];
|
|
|
+ return label && label.toLowerCase().includes(value.toLowerCase());
|
|
|
+ },
|
|
|
+
|
|
|
+ // 搜索方法
|
|
|
+ filterMethod(query) {
|
|
|
+ this.searchText = query;
|
|
|
+ },
|
|
|
+
|
|
|
+ // 处理搜索
|
|
|
+ handleSearch(value) {
|
|
|
+ if (this.$refs.treeRef) {
|
|
|
+ this.$refs.treeRef.filter(value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+ .tree-select-wrapper {
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-search {
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-bottom: 1px solid #e4e7ed;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-search >>> .el-input__inner {
|
|
|
+ height: 28px;
|
|
|
+ line-height: 28px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .custom-tree-node {
|
|
|
+ display: inline-block;
|
|
|
+ width: 100%;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .node-count {
|
|
|
+ margin-left: 8px;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+</style>
|
|
|
+
|
|
|
+<style>
|
|
|
+ /* 下拉框样式 - 全局样式 */
|
|
|
+ .tree-select-popper {
|
|
|
+ max-height: 400px;
|
|
|
+ overflow: auto;
|
|
|
+ padding: 0 !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-select-popper .el-tree {
|
|
|
+ min-width: 200px;
|
|
|
+ max-height: 350px;
|
|
|
+ overflow: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-select-popper .el-tree-node__content {
|
|
|
+ height: 32px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-select-popper .el-checkbox {
|
|
|
+ margin-right: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 关键修复:让 el-option 内容完全展开 */
|
|
|
+ .tree-select-option {
|
|
|
+ height: auto !important;
|
|
|
+ line-height: normal !important;
|
|
|
+ padding: 0 !important;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tree-select-option .el-select-dropdown__item {
|
|
|
+ height: auto !important;
|
|
|
+ padding: 0 !important;
|
|
|
+ }
|
|
|
+</style>
|