赞助
在网上找到了一篇文章https://www.jb51.net/javascript/302754q75.htm (可以先看下这个文章,确实给出了很不错的思路)拿到右侧面板id值就能知道对应选项的id值,再触发li标签的孩子标签中的checkbox的点击事件就可以了。但是忽略了一个事情,就是三级的时候面板id都是一样的所以根据索引根本找不到对应的dom。只有二级的时候是有效的看下图对比

image

image

 都是同一个一级下面 面板id虽然不一样,但是之前的9279你通过代码获取到后 这个dom是没有了换成了新的8964所以开头那篇文章不能作为最终的解决方法。
最后我准备获取不到dom的时候就去修改值下面是代码

  <el-cascader
                  ref="prouductSysRef"
                  :key="cascaderKey"
                  v-model="formData.productSysteminfo"
                  :options="productOptions"
                  :props="productSystemProps"
                  @change="handleProductChange"
                >
                  <template slot-scope="{ node, data }">
                    <el-popover
                      placement="top"
                      trigger="hover"
                      :open-delay="1000"
                    >
                      <div class="popover-content">{{ data.atlasName }}</div>
                      <div slot="reference" class="cascader-node-label">
                        {{ data.atlasName }}
                      </div>
                    </el-popover>
                  </template>
                </el-cascader>
data(){
return {
cascaderKey:0,
     productOptions: [],
      productSystemProps: {
        value: 'atlasId',
        label: 'atlasName',
        children: 'children',
        lazy: false,
        multiple: true,
        checkStrictly: true,
        leaf: 'leaf'
      },
}
}

  // 同一父级下只选一个子级
    handleProductChange(value) {
      const selectedPaths = this.formData.productSysteminfo || [];

      // 单班型模式:只能选一个,选中新的取消之前的
      if (this.singleClassTypeInfo) {
        if (value && value.length > 1) {
          const prev = this.prevValue || [];
          const added = value.find(v => !prev.some(p => JSON.stringify(p) === JSON.stringify(v)));
          this.prevValue = value;
          if (added) {
            this.formData.productSysteminfo = [added];
          }
        } else {
          this.prevValue = [selectedPaths[selectedPaths.length - 1]];
        }
        return;
      }

      // 多班型模式:每个一级节点下只能选一个,选中新的通过 JS 点击取消同一一级下之前的
      // 如果是取消勾选触发的二次 change,直接更新 prevValue 并返回
      if (this.isCancellingItem) {
        this.isCancellingItem = false;
        this.prevValue = [...value.filter(p => p.length > 1)];
        return;
      }

      // 过滤掉一级节点的选中项
      if (!value || !value.length) {
        this.prevValue = [];
        return;
      }

      const validValue = value.filter(p => p.length > 1);
      const validPrev = (this.prevValue || []).filter(p => p.length > 1);

      // 按一级分组
      const groupByRoot = arr => {
        const groups = {};
        for (const path of arr) {
          const rootId = path[0];
          if (!groups[rootId]) groups[rootId] = [];
          groups[rootId].push(path);
        }
        return groups;
      };

      const currentGroups = groupByRoot(validValue);
      const prevGroups = groupByRoot(validPrev);
      console.log(currentGroups, 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc');
      console.log(prevGroups, 'pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp');
      // 遍历当前每个一级,找新增项和需要取消的项
      for (const rootId in currentGroups) {
        const currentPaths = currentGroups[rootId];
        const prevPaths = prevGroups[rootId] || [];

        // 找出新增的项
        const added = currentPaths.find(v => !prevPaths.some(p => this.isSamePath(p, v)));
        console.log(added, '111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111', prevPaths);
        if (added && prevPaths.length > 0) {
          // 有新增项,且之前该一级下有选中项,需要取消之前的
          const toCancel = prevPaths[0];
          // 通过 JS 点击取消勾选
          this.cancelCascaderItem(toCancel);
          // 只处理第一个有变化的,因为取消后会再次触发 change
          return;
        }
      }

      // 没有需要取消的,直接更新 prevValue
      this.prevValue = [...validValue];
    },
   // 通过 JS 点击取消级联选择器中的勾选
    cancelCascaderItem(path) {
      this.$nextTick(() => {
        try {
          // 获取要取消的项的 atlasId(路径最后一项)
          const targetId = path[path.length - 1];

          // 直接通过 atlasId 查找对应的 DOM 元素
          const labelElement = document.getElementById(targetId);
          if (labelElement) {
            // 找到了,向上查找最近的 li,然后找 el-checkbox
            const liElement = labelElement.closest('li');
            if (liElement) {
              const checkbox = liElement.querySelector('.el-checkbox');
              if (checkbox) {
                // 设置标志位,跳过取消勾选触发的二次 change 处理
                this.isCancellingItem = true;
                checkbox.click();
                return;
              }
            }
          }

          // 找不到 DOM 元素,直接修改值
          this.cancelByValue(path);
        } catch (e) {
          console.error('取消级联选择器勾选失败:', e);
          this.cancelByValue(path);
        }
      });
    },

    // 通过直接修改值来取消勾选(当 DOM 操作不可用时)
    cancelByValue(path) {
      const currentValue = this.formData.productSysteminfo || [];
      const newValue = currentValue.filter(p => !this.isSamePath(p, path));
      this.prevValue = [...newValue.filter(p => p.length > 1)];
      this.formData.productSysteminfo = newValue;
      // 强制级联选择器重新渲染
      // ++this.cascaderKey;
    },
    isSamePath(a, b) {
      return JSON.stringify(a) === JSON.stringify(b);
    }
代码层面确实可以准确的每级只选一个。但是还是有个致命bug,级联面板中右侧的部分(即子节点部分),会自动刷新跳转到第一个父级下的子级面板,就是赋值完自动刷新到第一个赋值的节点下面。
因为js取消checkbox就能避免这个问题,但是三级js就解决不了了。无解只能自己再手搓代码改变了。所以只用联级面板自己写
el-tag回显内容,可以尝试一下.

 然后我自己写个组件,自己调用联级面板el-cascader-panel不用el-cascader。发现确实通过input  name属性可以完美做到每个一级节点下只能选择一个。

<template>
  <div v-click-outside="closePanel" class="custom-cascader-wrap">
    <!-- 触发器:显示已选标签 -->
    <div class="trigger" :class="{ 'is-focus': panelVisible }" @click="togglePanel">
      <template v-if="selectedItems.length">
        <el-tag
          v-for="item in selectedItems"
          :key="item.key"
          closable
          size="small"
          @close.stop="removeTag(item)"
        >
          {{ item.label }}
        </el-tag>
      </template>
      <span v-else class="placeholder">请选择产品体系</span>
      <i class="el-icon-arrow-down trigger-arrow" :class="{ 'is-reverse': panelVisible }" />
    </div>

    <!-- 级联面板 -->
    <transition name="el-zoom-in-top">
      <el-cascader-panel
        v-show="panelVisible"
        ref="cascaderPanel"
        v-model="innerValue"
        :options="productOptions"
        :props="panelProps"
        class="cascader-panel-dropdown"
      >
        <!-- 自定义节点内容 -->
        <template slot-scope="{ node, data }">
          <!-- 叶子节点:一个 radio-group 里只有一个 radio,但分组由外部 radioSelected 对象控制 -->
          <input
            :id="data.atlasId"
            type="radio"
            :name="getRadioName(node)"
            :value="data.atlasId"
            :checked="isRadioSelected(node, data)"
            @click.stop="radioClick(node, data)"
          >
          {{ data.atlasName }}
        </template>
      </el-cascader-panel>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'cascader-panel',
  directives: {
    clickOutside: {
      bind(el, binding) {
        el._clickOutside = e => {
          if (!el.contains(e.target)) {
            binding.value();
          }
        };
        document.addEventListener('click', el._clickOutside);
      },
      unbind(el) {
        document.removeEventListener('click', el._clickOutside);
      }
    }
  },
  props: {
    value: {
      type: Array,
      default: () => []
    },
    productOptions: {
      type: Array,
      default: () => []
    },
    singleClassTypeInfo: {
      type: Object,
      default: null
    },
    panelProps: {
      type: Object,
      default: ()=>({})
    }
  },
  data() {
    return {
      rootId: 'sing',
      panelVisible: false,
      innerValue: [],
      // 存储每个一级节点下的选中路径 { rootId: pathArray }
      selectionsByRoot: {}
    };
  },
  computed: {
    // 已选中的项目,用于显示标签
    selectedItems() {
      const items = [];
      for (const rootId in this.selectionsByRoot) {
        const path = this.selectionsByRoot[rootId];
        if (path && path.length > 0) {
          const label = this.getPathLabel(path);
          items.push({
            key: JSON.stringify(path),
            label,
            path,
            rootId
          });
        }
      }
      return items;
    }
  },
  watch: {
    value: {
      handler(val) {
        this.initSelections(val);
      },
      immediate: true,
      deep: true
    },
    productOptions: {
      handler() {
        this.initSelections(this.value);
      },
      deep: true
    }
  },
  methods: {
    togglePanel() {
      this.panelVisible = !this.panelVisible;
      if (this.panelVisible) {
        this.initSelections(this.value);
      }
    },
    closePanel() {
      this.panelVisible = false;
    },
    // 判断是否是一级节点
    isFirstLevel(node) {
      return node.level === 1;
    },
    // 判断节点是否被选中
    isSelected(data) {
      const { atlasId } = data;
      for (const rootId in this.selectionsByRoot) {
        const path = this.selectionsByRoot[rootId];
        if (path && path.includes(atlasId)) {
          return true;
        }
      }
      return false;
    },
    // 处理节点点击
    handleNodeClick(node, data) {
      if (data.disabled) return;

      // 一级节点不可选,只能展开
      if (this.isFirstLevel(node)) {
        return;
      }

      // 获取完整路径
      const { path } = node;
      // 获取一级节点 ID
      const rootId = path[0];

      // 单班型模式:只能选一个
      if (this.singleClassTypeInfo) {
        this.selectionsByRoot = {
          single: path
        };
      } else {
        // 多班型模式:每个一级节点下只能选一个
        // 检查是否已经选中了这个路径
        const currentPath = this.selectionsByRoot[rootId];
        if (currentPath && JSON.stringify(currentPath) === JSON.stringify(path)) {
          // 取消选中
          this.$delete(this.selectionsByRoot, rootId);
        } else {
          // 选中新路径
          this.$set(this.selectionsByRoot, rootId, path);
        }
      }

      this.emitChange();
    },
    // 移除标签
    removeTag(item) {
      const { rootId } = item;
      this.$delete(this.selectionsByRoot, rootId);
      this.emitChange();
    },
    // 发送变更事件
    emitChange() {
      const paths = [];
      for (const rootId in this.selectionsByRoot) {
        const path = this.selectionsByRoot[rootId];
        if (path && path.length > 0) {
          paths.push(path);
        }
      }
      this.$emit('input', paths);
      this.$emit('change', paths);
    },
    // 初始化选中状态
    initSelections(val) {
      if (!val || val.length === 0) {
        this.selectionsByRoot = {};
        this.innerValue = [];
        return;
      }

      const selections = {};

      // 单班型模式
      if (this.singleClassTypeInfo) {
        if (val.length > 0) {
          selections.single = val[val.length - 1];
        }
      } else {
        // 多班型模式:按一级节点分组
        val.forEach(path => {
          if (path && path.length > 0) {
            const rootId = path[0];
            selections[rootId] = path;
          }
        });
      }

      this.selectionsByRoot = selections;
      this.innerValue = [...val];
    },
    // 获取路径的完整标签
    getPathLabel(path) {
      if (!path || path.length === 0) return '';

      const labels = [];
      let nodes = this.productOptions;

      // 单班型模式:productOptions 没有 disabled 一级
      if (this.singleClassTypeInfo) {
        for (const atlasId of path) {
          const node = this.findNodeById(nodes, atlasId);
          if (node) {
            labels.push(node.atlasName);
            nodes = node.children || [];
          }
        }
      } else {
        // 多班型模式:path[0] 是一级节点
        for (let i = 0; i < path.length; i++) {
          const atlasId = path[i];
          const node = this.findNodeById(nodes, atlasId);
          if (node) {
            labels.push(node.atlasName);
            nodes = node.children || [];
          }
        }
      }

      return labels.join(' / ');
    },
    // 根据 ID 查找节点
    findNodeById(nodes, id) {
      for (const node of nodes) {
        if (node.atlasId === id) {
          return node;
        }
        if (node.children && node.children.length > 0) {
          const found = this.findNodeById(node.children, id);
          if (found) return found;
        }
      }
      return null;
    },
    // 获取 radio 的 name(按一级节点分组)
    getRadioName(node) {
      // 单班型模式:所有节点共用同一个 name
      if (this.singleClassTypeInfo) {
        return this.rootId;
      }
      // 多班型模式:按一级节点分组
      const {path} = node;
      return path && path.length > 0 ? String(path[0]) : 'default';
    },
    // 判断 radio 是否选中
    isRadioSelected(node, data) {
      const {path} = node;
      if (!path || path.length === 0) return false;

      // 单班型模式
      if (this.singleClassTypeInfo) {
        const selectedPath = this.selectionsByRoot.single;
        if (!selectedPath) return false;
        // 比较最后一级(叶子节点)
        return selectedPath[selectedPath.length - 1] === data.atlasId;
      }

      // 多班型模式:按一级节点分组
      const rootId = path[0];
      const selectedPath = this.selectionsByRoot[rootId];
      if (!selectedPath) return false;
      // 比较最后一级(叶子节点)
      return selectedPath[selectedPath.length - 1] === data.atlasId;
    },
    // 处理 radio 点击
    radioClick(node, data) {
      if (data.disabled) return;

      const {path} = node;
      if (!path || path.length === 0) return;

      // 单班型模式:只能选一个
      if (this.singleClassTypeInfo) {
        // 如果点击的是已选中的,取消选中
        const currentPath = this.selectionsByRoot.single;
        if (currentPath && currentPath[currentPath.length - 1] === data.atlasId) {
          this.$delete(this.selectionsByRoot, 'single');
          this.innerValue = [];
        } else {
          this.$set(this.selectionsByRoot, 'single', [...path]);
          this.innerValue = [[...path]];
        }
      } else {
        // 多班型模式:每个一级节点下只能选一个
        const rootId = path[0];
        const currentPath = this.selectionsByRoot[rootId];

        // 如果点击的是已选中的,取消选中
        if (currentPath && currentPath[currentPath.length - 1] === data.atlasId) {
          this.$delete(this.selectionsByRoot, rootId);
        } else {
          this.$set(this.selectionsByRoot, rootId, [...path]);
        }

        // 更新 innerValue 用于 el-cascader-panel 的选中状态
        this.updateInnerValue();
      }

      this.emitChange();
    },
    // 更新 innerValue
    updateInnerValue() {
      const paths = [];
      for (const rootId in this.selectionsByRoot) {
        const path = this.selectionsByRoot[rootId];
        if (path && path.length > 0) {
          paths.push([...path]);
        }
      }
      this.innerValue = paths;
    }
  }
};
</script>

<style lang="less" scoped>
.custom-cascader-wrap {
  position: relative;
  width: 100%;
  .trigger {
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  padding: 0 30px 0 10px;
  min-height: 36px;
  cursor: pointer;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  background: #fff;
  transition: border-color 0.2s;

  &:hover {
    border-color: #c0c4cc;
  }

  &.is-focus {
    border-color: #409eff;
  }

.trigger-arrow {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 0.3s;
  color: #c0c4cc;

  &.is-reverse {
    transform: translateY(-50%) rotate(180deg);
  }
}
.placeholder {
  color: #c0c4cc;
  font-size: 14px;
}
}

}

.cascader-panel-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 2001;
  margin-top: 4px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  border-radius: 4px;
  background: #fff;
  border: 1px solid #e4e7ed;

  // 隐藏 el-cascader-panel 默认的 radio/checkbox
  // ::v-deep .el-cascader-node__prefix,
  ::v-deep .el-radio{
    margin: 0;
  }
  ::v-deep .el-checkbox {
    display: none;
  }

  // 让自定义节点占满整个行
  ::v-deep .el-cascader-node__label {
    padding: 0;
  }
}

.custom-node {
  // display: inline-flex;
  // align-items: center;
  // padding: 0 10px;
  // height: 34px;
  cursor: pointer;

  // &.is-disabled {
  //   cursor: not-allowed;
  //   opacity: 0.5;
  // }

  // &:hover:not(.is-disabled) {
  //   background: #f5f7fa;
  // }
}

// .node-label {
//   margin-left: 8px;
// }

// 自定义 radio 样式
// .custom-radio {
//   position: relative;
//   display: inline-block;
//   width: 14px;
//   height: 14px;
//   cursor: pointer;

//   &.is-disabled {
//     cursor: not-allowed;
//   }

//   .custom-radio__inner {
//     position: absolute;
//     top: 0;
//     left: 0;
//     width: 14px;
//     height: 14px;
//     border: 1px solid #dcdfe6;
//     border-radius: 50%;
//     background: #fff;
//     box-sizing: border-box;

//     &::after {
//       content: '';
//       position: absolute;
//       top: 50%;
//       left: 50%;
//       width: 6px;
//       height: 6px;
//       border-radius: 50%;
//       background: transparent;
//       transform: translate(-50%, -50%);
//       transition: background 0.2s;
//     }
//   }

//   &.is-checked .custom-radio__inner {
//     border-color: #409eff;

//     &::after {
//       background: #409eff;
//     }
//   }

//   &.is-disabled .custom-radio__inner {
//     background: #f5f7fa;
//     border-color: #e4e7ed;
//   }
// }
</style>
父组件调用
<cascaderPanel
                  v-if="formData.productSysteminfo"
                  v-model="formData.productSysteminfo"
                  :productOptions="productOptions"
                  :panelProps="productSystemProps"
                  :singleClassTypeInfo="singleClassTypeInfo"
                  @change="handleProductChange"
                />
      // 单班型模式下的班型信息(用于取值时补充一级节点信息)
      singleClassTypeInfo: null,

最后发现联级面板el-cascader-panel也是个坑。只要是选择三级节点,数据更新后还是默认回到第一个节点,又会自动刷新跳转到第一个父级下的子级面板。服了 真的要手搓一个联级面板吗
最后网上找了个项目https://github.com/Charming2015/el-cascader-multi# 这个可以copy到项目 按需求自己实现就行了这个很不错

最后还是同事做出来了,比较高级的做法还是同事厉害自己得多学习了

    // 同一父级下只选一个子级
    handleProductChange(values) {
      // 之前选中的路径
      const oldSelectedPaths = this.formData.productSysteminfo || [];
      // 新选中的路径
      const selectedPaths = values || [];
      // 删除
      const oldKeys = new Set(oldSelectedPaths.map(v => JSON.stringify(v)));
      if (oldSelectedPaths.length >= selectedPaths.length) {
        this.formData.productSysteminfo = selectedPaths;
        return;
      }
   // 多模式
      const newValues = selectedPaths.filter(v => !oldKeys.has(JSON.stringify(v)));
      const changeKey = newValues[0][0];
      oldSelectedPaths.forEach(v => {
        if(v[0] !== changeKey) {
          newValues.push(v);
        }
      });
      this.formData.productSysteminfo = newValues;
    },

 <el-cascader
                  ref="prouductSysRef"
                  :key="cascaderKey"
                  popper-class="newTextbookDialog_cascader"
                  :value="formData.productSysteminfo"
                  :options="productOptions"
                  :props="productSystemProps"
                  @change="handleProductChange"
                >
                  <template slot-scope="{ node, data }">
                    <el-popover
                      placement="top"
                      trigger="hover"
                      :open-delay="1000"
                    >
                      <div class="popover-content">{{ data.atlasName }}</div>
                      <div slot="reference" class="cascader-node-label">
                        {{ data.atlasName }}
                      </div>
                    </el-popover>
                  </template>
                </el-cascader>

把v-model改成value就能手动去更新不去触发双向绑定 这个思路是我一直考虑不到的。把之前的其他调用

handleProductChange都不用了。就靠change事件来触发。至此这个需求就完事了。
posted on 2026-05-19 10:03  Tsunami黄嵩粟  阅读(11)  评论(0)    收藏  举报