vue 输入框@人功能组件,支持复制粘贴

文章目录

  • 概要
  • 整体架构流程
  • 技术细节
  • 小结
  • 自定义输入框代码
  • 外部调用示例

概要

开发任务系统中,业务需求:需要在任务描述、评论等地方支持@人员功能,可以将任务外部人员添加至当前任务中。

功能:1、支持输入@展开下拉,可通过鼠标点击或键盘上下移动 + 回车选中人员;

           2、支持@后继续输入以搜索人员列表;

           3、输入框@内容支持复制粘贴;

           4、添加输入框最大输入限制;

整体架构流程


因时间紧迫,虽然看到了el-input支持Autocomplete属性,但是没空去研究它了,还是用div + contenteditable="true"开撸吧;

  1. 使用div(ref[customInput]),添加contenteditable="true"可编辑状态,得到一个自定义输入框;
  2. 使用el-popover,用于展示@人员列表;
  3. 监听customInput 的input事件,当输入为@时,保存当前输入框光标位置,打开el-popover
  4. 打开el-popover后,继续监听customInput 的input事件,使用diff.diffChars对比输入差异,拿到差异输入进行@人员列表的搜索;
  5. 选中人时,为了方便,我用的方案是:拿到人员数据,生成一个span标签(给span添加contenteditable="false",不然插入的人员块也能被编辑),将人员的信息绑定在span标签上,我绑定的为data-id(工号)、data-realname(姓名);span标签创建好后,根据步骤3保存的光标位置,将span插入至customInput中,同时后移光标至插入的元素后;
  6. 为了实现复制粘贴功能,需要对customInput的复制粘贴事件(copy、paste)进行自定义;
  7. 组件内写了格式化数据的方法,通过changeChosen将处理好的@人员数组数据传递给父组件;

如业务需求包含重新编辑功能(如表单提交后需要二次编辑或修改),需要告知后端需额外保存一个存html代码的字段,因为当前组件的输入框内容,因为@人员块的缘故,只能通过html保持原本的正确格式,否则就是纯文本,通俗易懂就是两个字段分别存储customInput的textContent和innerHTML字段;编辑时只需用到保存的innerHTML字段,其余的textContent字段以及处理好的@人员数组,对于我们编辑而言,是没有用的。

技术细节

  • 在监听input事件的同时,需要同时监听keydown,用来限制用户的输入;
  • 插入span以及处理自定义粘贴事件时,需要注意光标位置;
  • 还有el-popover的弹出位置,我这里是针对我们的业务需求进行的调整,有具体需要可以自己手动调整

小结

代码看起来很简单,但因为其中一些知识之前没有涉足过,一个小问题就耽误很久,三天的艰苦奋斗,中间每次觉得完美了,就会有莫名其妙的问题跳出来,暂时就这样了,目前用起来还行,就是用户体验方面需要稍微优化一下。(对了,还写了一个小程序的@组件,小程序是uniapp写的,有需要滴滴我),下边贴代码了,懒得整理了,复制粘贴拿走用吧。

如发现什么问题,或者有大哥给优化建议的,欢迎滴滴。

自定义输入框代码

<template>
  <div class="custom-at-box">
    <div class="custom-textarea-box">
      <div
        :class="[
          'custom-textarea custom-scroll',
          { 'show-word-limit': showWordLimit && maxlength },
          { 'custom-textarea-disabled': disabled },
        ]"
        :style="{ height: height }"
        ref="customInput"
        :contenteditable="!disabled"
        :placeholder="placeholder"
        @input="onInput($event)"
        @keydown="onKeyDownInput($event)"
        @paste="onPaste($event)"
        @copy="onCopy($event)"
        @click="showList(false)"
      ></div>
      <div class="custom-at-limit" v-if="showWordLimit && maxlength">
        {{ inputValueLen }}/{{ maxlength }}
      </div>
    </div>
    <div :key="`customInput${taskPanelIsInFullScreen}`">
      <el-popover
        v-model="showPopover"
        trigger="click"
        class="custom-select-box"
        ref="popoverRef"
        :append-to-body="taskPanelPopoverAppendToBody"
        @hide="hidePoppver"
        :style="{ top: popoverOffset + 'px' }"
      >
        <div
          class="custom-select-content custom-scroll"
          ref="customSelectContent"
        >
          <div class="custom-select-empty load" v-if="searchOperatorLoad">
            <i class="el-icon-loading"></i>
            <span>加载中</span>
          </div>
          <div
            class="custom-select-empty"
            v-else-if="searchOperatorList.length === 0"
          >
            没有查询到该用户
          </div>
          <div
            v-else
            :class="[
              'custom-select-item',
              { hoverItem: selectedIndex === index },
            ]"
            v-for="(item, index) in searchOperatorList"
            :key="item.employeeNo"
            @click="handleClickOperatorItem(item)"
          >
            <div class="custom-select-item-content">
              {{ item.realname }}({{ item.employeeNo }})
            </div>
          </div>
        </div>
      </el-popover>
    </div>
  </div>
</template>
<script>
const diff = require("diff");
import {
  queryEmployeeByParam,
  addRemark,
} from "@/api/recruitmentSystem/childUtils.js";
export default {
  props: {
    // 输入框placeholder
    placeholder: {
      type: String,
      default: "请输入...",
    },
    // 是否显示输入字数统计
    showWordLimit: {
      type: Boolean,
      default: true,
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false,
    },
    // 最大输入长度
    maxlength: {
      type: [Number, String],
      default: "300",
    },
    // 输入框高度
    height: {
      type: String,
      default: "100px",
    },
    setRefresh: {
      type: Object,
      default: () => {},
    },
    // 输入框输入的内容
    value: {
      type: String,
      default: "",
    },
  },
  data() {
    return {
      // 已输入内容的长度
      inputValueLen: 0,
      top: "",
      left: "",
      message: "",
      startOffset: 0,
      // @搜索人dom
      searSpan: null,
      // 筛选人数据
      searchOperatorList: [],
      // 筛选人数据加载状态
      searchOperatorLoad: false,
      // @插入位置
      selectionIndex: 0,
      // 当前编辑的dom
      dom: null,
      // 当前编辑dom的index
      domIndex: 0,
      // 当前编辑dom的childNodes的index
      childDomIndex: 0,
      // 编辑前dom内容
      beforeDomVal: "",
      // 筛选人选择框
      showPopover: false,
      // 筛选人选择框偏移量
      popoverOffset: 0,
      listInput: false,
      listInputValue: "",
      // 防抖
      timer: null,
      // 保存弹窗加载状态
      addDataLoad: false,

      // 鼠标选择人的索引
      selectedIndex: 0,
    };
  },
  mounted() {
    this.setNativeInputValue();
  },
  computed: {
    // 计算属性,用于同步父组件的数据
    model: {
      get() {
        return this.value;
      },
      set(newValue) {
        this.$emit("input", newValue);
        if (this.$refs.customInput) {
          this.$emit("inputText", this.$refs.customInput.textContent);
        }
        const nodeList = this.$refs.customInput.childNodes;
        let list = [];
        nodeList.forEach((e) => {
          if(e.childNodes) {
            e.childNodes.forEach(i => {
              if (i.className === "active-text") {
                list.push({
                  jobNumber: i.getAttribute("data-id"),
                  name: i.textContent.replace(/@/g, "").replace(/\s/g, ""),
                });
              }
            })
          }
          if (e.className === "active-text") {
            list.push({
              jobNumber: e.getAttribute("data-id"),
              name: e.textContent.replace(/@/g, "").replace(/\s/g, ""),
            });
          }
        });
        this.$emit("changeChosen", list);
      },
    },
    taskPanelIsInFullScreen() {
      return this.$store.getters.taskPanelIsInFullScreen;
    },
    taskPanelPopoverAppendToBody() {
      return this.$store.getters.taskPanelPopoverAppendToBody;
    },
  },
  methods: {
    // 设置输入框的值
    setNativeInputValue() {
      if (this.$refs.customInput) {
        if (this.value === this.$refs.customInput.innerHTML) return;
        this.$refs.customInput.innerHTML = this.value;
        this.inputValueLen = this.$refs.customInput.innerText.length;
      }
    },
    // 筛选人弹窗数据选择
    handleClickOperatorItem(item) {
      this.addData(JSON.parse(JSON.stringify(item)));
      this.$refs.customSelectContent.scrollTop = 0;
      this.selectedIndex = 0;
      this.showPopover = false;
      this.listInput = false;
      this.listInputValue = "";
    },
    // 艾特人弹窗关闭
    hidePoppver() {
      this.$refs.customSelectContent.scrollTop = 0;
      this.selectedIndex = 0;
      this.showPopover = false;
      this.listInput = false;
      this.listInputValue = "";
    },
    // 创建艾特需要插入的元素
    createAtDom(item) {
      // 先判断剩余输入长度是否能够完整插入元素
      const dom = document.createElement("span");

      dom.classList.add("active-text");
      // 这里的contenteditable属性设置为false,删除时可以整块删除
      dom.setAttribute("contenteditable", "false");
      // 将id存储在dom元素的标签上,便于后续数据处理
      dom.setAttribute("data-id", item.employeeNo);

      dom.innerHTML = `@${item.realname}&nbsp;`;

      return dom;
    },
    // 插入元素
    addData(item) {
      const spanElement = this.createAtDom(item);

      const maxlength = Number(this.maxlength) || 300;
      // 因为插入后需要删除之前输入的@,所以判断长度时需要减去这个1
      if (maxlength - this.inputValueLen < spanElement.innerText.length - 1) {
        this.$message("剩余字数不足");
        return;
      }
      this.$refs.customInput.focus();

      // 获取当前光标位置的范围
      const selection = window.getSelection();
      const range = selection.getRangeAt(0);

      // 找到要插入的节点
      const nodes = Array.from(this.$refs.customInput.childNodes);
      let insertNode = "";
      // 是否是子元素
      let domIsCustomInputChild = true;
      if (nodes[this.domIndex].nodeType === Node.TEXT_NODE) {
        insertNode = nodes[this.domIndex];
      } else {
        const childNodeList = nodes[this.domIndex].childNodes;
        insertNode = childNodeList[this.childDomIndex];
        domIsCustomInputChild = false;
      }

      // 如果前一个节点是空的文本节点,@用户无法删除
      // 添加判断条件:如果前一个节点是空的文本节点,则插入一个空的<span>节点
      const html = insertNode.textContent;
      // 左边的节点
      const textLeft = document.createTextNode(
        html.substring(0, this.selectionIndex - 1) + ""
      );
      const emptySpan = document.createElement("span");

      // 如果找到了要插入的节点,则在其前面插入新节点
      if (insertNode) {
        if (!textLeft.textContent) {
          if (domIsCustomInputChild) {
            this.$refs.customInput.insertBefore(emptySpan, insertNode);
          } else {
            nodes[this.domIndex].insertBefore(emptySpan, insertNode);
          }
        }
        insertNode.parentNode.insertBefore(spanElement, insertNode.nextSibling);
        // 删除多余的@以及搜索条件
        const textContent = insertNode.textContent.slice(
          0,
          -(1 + this.listInputValue.length)
        );
        if (!textContent && insertNode.nodeName === "#text") {
          insertNode.remove();
        } else {
          insertNode.textContent = textContent;
        }
      } else {
        // 如果未找到要插入的节点,则将新节点直接追加到末尾
        this.$refs.customInput.appendChild(spanElement);
      }

      // 将光标移动到 span 元素之后
      const nextNode = spanElement.nextSibling;
      range.setStart(
        nextNode || spanElement.parentNode,
        nextNode ? 0 : spanElement.parentNode.childNodes.length
      );
      range.setEnd(
        nextNode || spanElement.parentNode,
        nextNode ? 0 : spanElement.parentNode.childNodes.length
      );
      selection.removeAllRanges();
      selection.addRange(range);

      this.model = this.$refs.customInput.innerHTML;
      this.inputValueLen = this.$refs.customInput.innerText.length;
      this.showList(false);
    },
    // 检查是否发生了全选操作
    isSelectAll() {
      const selection = window.getSelection();
      return selection.toString() === this.$refs.customInput.innerText;
    },
    // 获取输入框是否选中文字
    isSelect() {
      try {
        const selection = window.getSelection();
        return selection.toString().length;
      } catch (error) {
        return 0;
      }
    },
    // 输入事件
    onKeyDownInput(event) {
      // 获取当前输入框的长度
      let currentLength = this.$refs.customInput.innerText.length;
      // 获取最大输入长度限制
      let maxLength = Number(this.maxlength) || 300;

      // 如果按下的键是非控制键并且当前长度已经达到了最大长度限制
      if (currentLength >= maxLength) {
        // 获取按键的 keyCode
        var keyCode = event.keyCode || event.which;

        // 检查是否按下了 Ctrl 键
        var ctrlKey = event.ctrlKey || event.metaKey; // metaKey 用于 macOS 上的 Command 键

        // 允许的按键:Backspace(8)、Delete(46)、方向键和 
        var allowedKeys = [8, 46, 37, 38, 39, 40];

        // 允许的按键 Ctrl+A、Ctrl+C、Ctrl+V
        let allowedCtrlKey = [65, 67, 86]

        // 检查按键是否在允许列表中并且没有执行选中操作
        if (!allowedKeys.includes(keyCode) && !this.isSelect()) {
          if((allowedCtrlKey.includes(keyCode) && ctrlKey)) {
            return;
          }
          // 阻止默认行为
          event.preventDefault();
          return false;
        }
      }

      if (this.showPopover) {
        let listElement = this.$refs.customSelectContent;
        let itemHeight = listElement.children[0].clientHeight;
        if (event.key === "ArrowDown") {
          // 防止光标移动
          event.preventDefault();
          // 移动选中索引
          if (this.selectedIndex === this.searchOperatorList.length - 1) {
            this.selectedIndex = 0; // 跳转到第一项
            listElement.scrollTop = 0; // 滚动到列表顶部
          } else {
            this.selectedIndex++;
            let itemBottom = (this.selectedIndex + 1) * itemHeight;
            let scrollBottom = listElement.scrollTop + listElement.clientHeight;
            if (itemBottom > scrollBottom) {
              listElement.scrollTop += itemHeight;
            }
          }
        } else if (event.key === "ArrowUp") {
          event.preventDefault();
          if (this.selectedIndex === 0) {
            this.selectedIndex = this.searchOperatorList.length - 1; // 跳转到最后一项
            listElement.scrollTop = listElement.scrollHeight; // 滚动到列表底部
          } else {
            this.selectedIndex--;
            let itemTop = this.selectedIndex * itemHeight;
            if (itemTop < listElement.scrollTop) {
              listElement.scrollTop -= itemHeight;
            }
          }
        } else if (event.key === "Enter") {
          event.preventDefault();
          if (!this.searchOperatorLoad) {
            this.handleClickOperatorItem(
              this.searchOperatorList[this.selectedIndex]
            );
          }
        }
      } else if (event.key === "Backspace" && this.isSelectAll()) {
        // 如果执行了全选操作并删除,清空输入框内容
        this.$refs.customInput.innerText = "";
        this.model = this.$refs.customInput.innerHTML;
        this.inputValueLen = 0;
      }
    },
    // 监听输入事件
    onInput(e) {
      this.inputValueLen = this.$refs.customInput.innerText.length;
      if (
        ["<div><br></div>", "<br>", "<span></span><br>"].includes(
          this.$refs.customInput.innerHTML
        )
      ) {
        this.$refs.customInput.innerHTML = "";
        this.inputValueLen = 0;
      } else if (e.data === "@") {
        // 保存焦点位置
        this.saveIndex();
        this.showList();
        this.listInput = true;
      } else if (this.showPopover) {
        const diffResult = diff.diffChars(
          this.beforeDomVal,
          this.dom.textContent
        );
        let result = "";
        // 遍历差异信息数组
        for (let i = 0; i < diffResult.length; i++) {
          const change = diffResult[i];

          // 如果当前差异是添加或修改类型,则将其添加到结果字符串中
          if (change.added) {
            result += change.value;
          } else if (change.removed && change.value === "@") {
            this.showList(false);
            this.listInputValue = "";
          }
        }
        if (this.timer) {
          clearTimeout(this.timer);
        }
        this.listInputValue = result;
        this.timer = setTimeout(() => {
          this.remoteMethod();
        }, 300);
      }
      this.model = this.$refs.customInput.innerHTML;
    },
    onPaste(event) {
      event.preventDefault();
      // 获取剪贴板中的 HTML 和文本内容
      const html = (event.clipboardData || window.clipboardData).getData(
        "text/html"
      );
      const text = (event.clipboardData || window.clipboardData).getData(
        "text/plain"
      );

      // 设置最大输入限制
      const maxLength = Number(this.maxlength) || 300;

      // 此时加个条件  看鼠标选中的文本长度,剩余可输入长度加上选中文本长度
      const selection1 = window.getSelection();
      const range1 = selection1.getRangeAt(0);
      const clonedSelection = range1.cloneContents();
      let selectTextLen = 0
      if(clonedSelection.textContent && clonedSelection.textContent.length) {
        selectTextLen = clonedSelection.textContent.length;
      }

      // 剩余可输入长度
      const remainingLength = maxLength - this.inputValueLen + selectTextLen;

      // 过滤掉不可见字符
      const cleanText = text.replace(/\s/g, "");

      // 创建一个临时 div 用于处理粘贴的 HTML 内容
      const tempDiv = document.createElement("div");
      tempDiv.innerHTML = html;

      // 过滤掉不需要的内容,例如注释和换行符
      const fragment = document.createDocumentFragment();
      let totalLength = 0;

      
      if (cleanText) {
        if (remainingLength >= cleanText.length) {
          fragment.appendChild(document.createTextNode(cleanText));
        } else {
          const truncatedText = cleanText.substr(0, remainingLength);
          fragment.appendChild(document.createTextNode(truncatedText));
        }
      }else {
        Array.from(tempDiv.childNodes).forEach((node) => {
          const regex = /<span class="active-text" contenteditable="false" data-id="(\d+)">@([^<]+)<\/span>/g;
          // 过滤注释和空白节点
          if (
            node.nodeType !== 8 &&
            !(node.nodeType === 3 && !/\S/.test(node.textContent))
          ) {
            const childText = node.textContent || "";
            const childLength = childText.length;
            const childHtml = node.outerHTML || node.innerHTML;
            // 如果剩余空间足够,插入节点
            if ((regex.exec(childHtml) !== null) && totalLength + childLength <= remainingLength) {
              fragment.appendChild(node.cloneNode(true));
              totalLength += childLength;
            } else if (remainingLength - totalLength > 0) {
              // 如果还有剩余长度,不插入节点,插入文本内容
              const lastNodeLength = remainingLength - totalLength;
              const truncatedText = childText.substr(0, lastNodeLength);
              fragment.appendChild(document.createTextNode(truncatedText));
              totalLength += truncatedText.length;
            } else {
              // 如果添加当前节点的内容会超出剩余可插入长度,则结束循环
              return;
            }
          }
        });
      }

      // 插入处理后的内容到光标位置
      const selection = window.getSelection();
      const range = selection.getRangeAt(0);
      range.deleteContents();
      range.insertNode(fragment);

      // 更新输入框内容和长度
      this.model = this.$refs.customInput.innerHTML;
      this.inputValueLen = this.$refs.customInput.innerText.length;

      // 设置光标位置为插入内容的后面一位
      const newRange = document.createRange();
      newRange.setStart(range.endContainer, range.endOffset);
      newRange.collapse(true);
      selection.removeAllRanges();
      selection.addRange(newRange);
    },
    // 修改默认复制事件
    onCopy(e) {
      e.preventDefault();
      const selection = window.getSelection();
      const range = selection.getRangeAt(0);
      const clonedSelection = range.cloneContents();

      // 检查复制的内容是否包含符合条件的元素
      const hasActiveText =
        clonedSelection.querySelector(
          '.active-text[contenteditable="false"][data-id]'
        ) !== null;

      const clipboardData = e.clipboardData || window.clipboardData;
      if(hasActiveText) {
        const div = document.createElement("div");
        div.appendChild(clonedSelection);
        const selectedHtml = div.innerHTML;
        clipboardData.setData("text/html", selectedHtml);
      }else {
        clipboardData.setData("text/plain", clonedSelection.textContent || "");
      }
    },
    // 保存焦点位置
    async saveIndex() {
      const selection = getSelection();
      this.selectionIndex = selection.anchorOffset;
      const nodeList = this.$refs.customInput.childNodes;
      const range = selection.getRangeAt(0);

      // 保存当前编辑的dom节点
      for (const [index, value] of nodeList.entries()) {
        // 这里第二个参数要配置成true,没配置有其他的一些小bug
        // (range.startContainer.contains(value) && range.endContainer.contains(value))  是为了处理兼容性问题
        if (
          selection.containsNode(value, true) ||
          (range.startContainer.contains(value) &&
            range.endContainer.contains(value))
        ) {
          if (value.nodeType === Node.TEXT_NODE) {
            this.dom = value;
            this.beforeDomVal = value.textContent;
            this.domIndex = index;
            const selection = window.getSelection();
            const range = selection.getRangeAt(0);
            this.startOffset = range.startOffset - 1;
          } else {
            const childNodeList = value.childNodes;
            for (const [childIndex, childValue] of childNodeList.entries()) {
              if (selection.containsNode(childValue, true)) {
                this.dom = value;
                this.beforeDomVal = value.textContent;
                this.domIndex = index;
                this.childDomIndex = childIndex;
                const selection = window.getSelection();
                const range = selection.getRangeAt(0);
                this.startOffset = range.startOffset - 1;
              }
            }
          }
        }
      }
    },
    // 筛选人弹窗
    showList(bool = true) {
      this.showPopover = bool;
      if (bool) {
        const offset =
          this.getCursorDistanceFromDivBottom(this.$refs.customInput) || -1;
        if (offset < 0) {
          this.popoverOffset = 0;
        } else {
          this.popoverOffset = -(offset - 1);
        }
      }
      if (!bool) {
        this.listInputValue = "";
        this.remoteMethod();
      }
    },
    // 获取光标位置
    getCursorDistanceFromDivBottom(editableDiv) {
      // 获取选区
      const selection = window.getSelection();
      // 获取选区的范围
      const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;

      if (range) {
        // 创建一个临时元素来标记范围的结束位置
        const markerElement = document.createElement("span");
        // 插入临时标记元素
        range.insertNode(markerElement);
        markerElement.appendChild(document.createTextNode("\u200B")); // 零宽空格

        // 获取标记元素的位置信息
        const markerOffsetTop = markerElement.offsetTop;
        const markerHeight = markerElement.offsetHeight;

        // 计算光标距离div底部的距离
        const cursorDistanceFromBottom =
          editableDiv.offsetHeight - (markerOffsetTop + markerHeight);

        // 滚动条距顶部的高度
        const scrollTop = editableDiv.scrollTop || 0;
        // 移除临时标记元素
        markerElement.parentNode.removeChild(markerElement);

        // 返回光标距离底部的距离
        return cursorDistanceFromBottom + scrollTop;
      }

      // 如果没有选区,则返回-1或者其他错误值
      return -1;
    },
    // 搜索筛选人
    async remoteMethod() {
      let query = this.listInputValue;
      this.searchOperatorLoad = true;
      let params = {
        keyword: query,
        pageNo: 1,
        pageSize: 500,
      };
      await queryEmployeeByParam(params)
        .then((res) => {
          this.searchOperatorList = res.list
            .filter((i) => i.employeeStatusId === 1)
            .map((e) => {
              e.value = e.employeeNo + "_" + e.realname;
              return e;
            });
        })
        .catch(() => {});

      this.searchOperatorLoad = false;
    },
    handleNameShift(item) {
      const name = item.realname || "";
      if (!name) return "--";
      if (name.length > 1) {
        return name.slice(0, 1);
      } else {
        return name;
      }
    },
    // 按钮div点击 聚焦textarea
    handleBtnBoxClick() {
      this.$refs.customInput.focus();
    },
    // 获取@人的姓名
    getInnerText() {
      const customInput = this.$refs.customInput;
      if (!customInput) return;
      return customInput.innerText;
    },
    // 获取@人的工号
    getJobId() {
      const nodeList = this.$refs.customInput.childNodes;
      let list = [];
      nodeList.forEach((e) => {
        if (e.className === "active-text") {
          list.push(e.getAttribute("data-id"));
        }
      });
      return list;
    },
    clearInput() {
      this.$refs.customInput.innerText = "";
      this.$refs.customInput.innerHTML = "";
      this.inputValueLen = 0;
      this.$emit("input", "");
      this.$emit("inputText", "");
      this.$emit("changeChosen", []);
    },
  },
};
</script>
<style lang="scss" scoped>
.custom-textarea-btn {
  position: absolute;
  bottom: 1px;
  right: 4px;
  left: 4px;
  text-align: right;
  // background: #fff;
  padding-bottom: 3px;
  .el-button {
    font-size: 12px;
    padding: 4px 10px;
  }
}
.custom-textarea-box {
  position: relative;
}
.custom-at-limit {
  position: absolute;
  right: 12px;
  bottom: 4px;
  font-size: 12px;
  color: #999;
  line-height: 12px;
}
::v-deep.custom-textarea {
  width: 100%;
  min-height: 50px;
  max-height: 200px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  background-color: #ffffff;
  padding: 5px 15px;
  color: #606266;
  overflow-y: auto;
  line-height: 20px;
  font-size: 14px;
  transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
  position: relative;
  word-break: break-all;
  &.show-word-limit {
    padding-bottom: 16px;
  }
  &.custom-textarea-disabled {
    cursor: not-allowed;
    background-color: #f5f7fa;
    border-color: #e4e7ed;
    color: #c0c4cc;
  }
  &:focus {
    border-color: #f98600 !important;
  }
  &:empty::before {
    content: attr(placeholder);
    font-size: 14px;
    color: #c0c4cc;
  }
  .active-text {
    color: #909399;
    // padding: 2px 6px;
    // background: #f4f4f5;
    margin-right: 4px;
    // border-radius: 4px;
    // font-size: 12px;
  }
  // &:focus::before {
  //   content: "";
  // }
}

::v-deep.custom-select-box {
  position: relative;

  .el-popover {
    padding: 0;
    top: 0;
    box-shadow: 0 4px 8px 0 rgba(89, 88, 88, 0.8);
  }
  .custom-select-content {
    width: 259px;
    padding: 8px;
    max-height: 260px;
    overflow-y: auto;
  }

  .custom-select-item {
    // font-size: 14px;
    // padding: 0 20px;
    // position: relative;
    // height: 34px;
    // line-height: 34px;
    // box-sizing: border-box;
    display: flex;
    padding: 8px 12px;
    border-bottom: 1px solid #ebebeb;
    align-items: center;
    color: #606266;
    cursor: pointer;
    &:last-child {
      border-bottom: none;
    }
    .avatar-box {
      flex-shrink: 0;
      .custom-select-item-avatar {
        width: 24px;
        height: 24px;
        background-color: #ffb803;
        border-radius: 50%;
        text-align: center;
        line-height: 24px;
        color: #ffffff;
      }
    }
    .custom-select-item-content {
      flex: 1;
      padding-left: 12px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    &:hover {
      background-color: #f5f7fa;
    }
    &.hoverItem {
      background-color: #dbdbdb;
    }
  }
  .custom-select-empty {
    padding: 10px 0;
    text-align: center;
    color: #999;
    font-size: 14px;

    &.load {
      display: flex;
      align-items: center;
      justify-content: center;
    }
  }
  .custom-scroll {
    overflow: auto;
    &::-webkit-scrollbar {
      width: 8px;
      height: 8px;
    }
    &::-webkit-scrollbar-thumb {
      border-radius: 8px;
      background-color: #b4b9bf;
    }
  }
}
</style>

外部调用示例

<template>
  <div>
    <CustomInput
      v-model="customInputHTML"
      placeholder="这是一个支持@的输入框组件"
      height="unset"
      @inputText="handleChangeInputText"
      @changeChosen="handleChangeChosen"
    />
  </div>
</template>
<script>
import CustomInput from "@/components/CustomInput/index.vue";
export default {
  components: { CustomInput },
  data() {
    return {
      // 输入框的html代码
      customInputHTML: "",
      // 输入框的文本,可让后端使用,如不使用,也没啥用
      customInputText: "",
      // 输入框返回的@人员数据
      customInputMentions: []
    }
  },
  methods: {
    // 获取输入框返回的文本
    handleChangeInputText(val) {
      this.customInputText = val;
    },
    // 获取输入框返回的文本
    handleChangeChosen(val) {
      this.customInputMentions = val;
    },
  }
}
</script>

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/601799.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Express初体验

介绍 Express是一个基于Node.js平台的极简、灵活的Web应用开发框架&#xff0c;官方地址&#xff1a;https://www.expressjs.com.cn/&#xff0c;简单来说&#xff0c;Express是一个封装好的工具包&#xff0c;封装了很多功能&#xff0c;便于我们开发Web应用&#xff08;HTTP…

提升Go语言数学运算能力:math包使用指南

提升Go语言数学运算能力&#xff1a;math包使用指南 介绍数学函数的使用基本数学运算幂和根的计算三角函数对数计算 特殊数学常数和函数数学常数超越数学函数错误处理和精度问题 高级应用实例统计数据的标准偏差计算利用三角函数解决实际问题 性能优化技巧避免不必要的函数调用…

机器学习——4.案例: 简单线性回归求解

案例目的 寻找一个良好的函数表达式,该函数表达式能够很好的描述上面数据点的分布&#xff0c;即对上面数据点进行拟合。 求解逻辑步骤 使用Sklearn生成数据集定义线性模型定义损失函数定义优化器定义模型训练方法&#xff08;正向传播、计算损失、反向传播、梯度清空&#…

计算机系列之数据结构

19、数据结构&#xff08;重点、考点&#xff09; 1、线性结构 线性结构&#xff1a;每个元素最多只有一个出度和一个入读&#xff0c;表现为一条线状。线性表按存储方式分为顺序表和链表。 1、顺序存储和链式存储 存储结构&#xff1a; 顺序存储&#xff1a;用一组地址连续…

【功耗仪使用】

一&#xff0c;功耗仪使用 1.1&#xff0c;功耗仪外观及与手机&#xff0c;电脑连接方式 power monitor设备图 同时power monitor设备的后端有一个方形插孔通过数据线与电脑主机USB接口相连接&#xff0c;圆形插孔为电源插孔&#xff0c;用来给power monitor设备通电 pow…

图算法必备指南:《图算法:行业应用与实践》全面解读,解锁主流图算法奥秘!

《图算法&#xff1a;行业应用与实践》于近日正式与读者见面了&#xff01; 该书详解6大类20余种经典的图算法的原理、复杂度、参数及应用&#xff0c;旨在帮助读者在分析和处理各种复杂的数据关系时能更好地得其法、善其事、尽其能。 全书共分为10章&#xff1a; 第1~3章主要…

FFmpeg 音视频处理工具三剑客(ffmpeg、ffprobe、ffplay)

【导读】FFmpeg 是一个完整的跨平台音视频解决方案&#xff0c;它可以用于音频和视频的转码、转封装、转推流、录制、流化处理等应用场景。FFmpeg 在音视频领域享有盛誉&#xff0c;号称音视频界的瑞士军刀。同时&#xff0c;FFmpeg 有三大利器是我们应该清楚的&#xff0c;它们…

idea Maven 插件 项目多环境打包配置

背景 不同环境的配置文件不一样&#xff0c;打包方式也有差异 1. 准备配置文件 这里 local 为本地开发环境 可改为 dev 名称自定义 test 为测试环境 prod 为生产环境 根据项目业务自行定义 application.yml 配置&#xff1a; spring:profiles:#对应pom中的配置active: spring.…

二分图(染色法与匈牙利算法)

二分图当且仅当一个图中不含奇数环 1.染色法 简单来说&#xff0c;将顶点分成两类&#xff0c;边只存在于不同类顶点之间&#xff0c;同类顶点之间没有边。 e.g. 如果判断一个图是不是二分图&#xff1f; 开始对任意一未染色的顶点染色。 判断其相邻的顶点中&#xff0c;若未…

打造文旅客运标杆!吐鲁番国宾旅汽携苏州金龙升级国宾级出行体验

新疆&#xff0c;这片神秘的大地&#xff0c;从无垠沙漠到高耸天山&#xff0c;从古老丝路到繁华都市&#xff0c;处处都散发着独特的魅力&#xff0c;吸引着四面八方的游客。据新疆维吾尔自治区文化和旅游厅数据显示&#xff0c;刚刚过去的“五一”小长假&#xff0c;新疆全区…

5月白银现货最新行情走势

美联储5月的议息会议举行在即&#xff0c;但从联邦公开市场委员会&#xff08;FOMC&#xff09;近期透露的信息来看&#xff0c;降息似乎并没有迫切性。——美联储理事鲍曼认为通胀存在"上行风险"&#xff0c;明尼阿波利斯联邦储备银行行长卡什卡利提出了今年不降息的…

算法学习:数组 vs 链表

&#x1f525; 个人主页&#xff1a;空白诗 文章目录 &#x1f3af; 引言&#x1f6e0;️ 内存基础什么是内存❓内存的工作原理 &#x1f3af; &#x1f4e6; 数组&#xff08;Array&#xff09;&#x1f4d6; 什么是数组&#x1f300; 数组的存储&#x1f4dd; 示例代码&#…

【Spark】 Spark核心概念、名词解释(五)

Spark核心概念 名词解释 1)ClusterManager&#xff1a;在Standalone(上述安装的模式&#xff0c;也就是依托于spark集群本身)模式中即为Master&#xff08;主节点&#xff09;&#xff0c;控制整个集群&#xff0c;监控Worker。在YARN模式中为资源管理器ResourceManager(国内s…

编程入门(六)【Linux系统基础操作三】

读者大大们好呀&#xff01;&#xff01;!☀️☀️☀️ &#x1f525; 欢迎来到我的博客 &#x1f440;期待大大的关注哦❗️❗️❗️ &#x1f680;欢迎收看我的主页文章➡️寻至善的主页 文章目录 &#x1f525;前言&#x1f680;LInux的进程管理和磁盘管理top命令显示查看进…

SpringBoot整合Redis(文末送书)

文章目录 Redis介绍使用IDEA构建项目&#xff0c;同时引入对应依赖配置Redis添加Redis序列化方法心跳检测连接情况存取K-V字符串数据&#xff08;ValueOperations&#xff09;存取K-V对象数据&#xff08;ValueOperations&#xff09;存取hash数据&#xff08;HashOperations&a…

2024年武汉市工业投资和技术改造及工业智能化改造专项资金申报补贴标准、条件程序和时间

一、申报政策类别 (一)投资和技改补贴。对符合申报条件的工业投资和技术改造项目,依据专项审计报告明确的项目建设有效期(最长不超过两年)内实际完成的生产性设备购置与改造投资的8%,给予最高不超过800万元专项资金支持。 (二)智能化改造补贴。对符合申报条件的智能化改造项目…

互联网产品为什么要搭建会员体系?

李诞曾经说过一句话&#xff1a;每个人都可以讲5分钟脱口秀。这句话换到会员体系里面同样适用&#xff0c;每个人都能聊点会员体系相关的东西。 比如会员体系属于用户运营的范畴&#xff0c;比如怎样用户分层&#xff0c;比如用户标签及CDP、会员积分、会员等级、会员权益和付…

鸿蒙通用组件弹窗简介

鸿蒙通用组件弹窗简介 弹窗----Toast引入ohos.promptAction模块通过点击按钮&#xff0c;模拟弹窗 警告对话框----AlertDialog列表弹窗----ActionSheet选择器弹窗自定义弹窗使用CustomDialog声明一个自定义弹窗在需要使用的地方声明自定义弹窗&#xff0c;完整代码 弹窗----Toa…

Seata之TCC 模式的使用

系列文章目录 文章目录 系列文章目录前言前言 前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站,这篇文章男女通用,看懂了就去分享给你的码吧。 Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能…

【python数据分析基础】—pandas透视表和交叉表

目录 前言一、pivot_table 透视表二、crosstab 交叉表三、实际应用 前言 透视表是excel和其他数据分析软件中一种常见的数据汇总工具。它是根据一个或多个键对数据进行聚合&#xff0c;并根据行和列上的分组键将数据分配到各个矩形区域中。 一、pivot_table 透视表 pivot_tabl…
最新文章