vue实现一个@他人的功能 可以多选人员 输入文本中间插入@人员

116 阅读4分钟

Snipaste_2024-07-16_11-06-39.png

需求描述:

1.需要单选多选人员
2.@输入弹出人员选择组件
3.点击@弹出人员选择组件
4.@XX 需要组合一起删除
5.可以输入其他文本,文本中间需要可以插入@XX
<template>
  <div class="progress">
    <div class="list">
      <div class="description pointer">
        <el-dropdown trigger="click" @command="onDropClick">
          <span class="el-dropdown-link">
            {{activeLabel}}<i class="el-icon-arrow-down el-icon--right"></i>
          </span>
          <el-dropdown-menu slot="dropdown">
            <el-dropdown-item :command="item" v-for="item in dropOptions" :key="item.label">{{ item.label }}</el-dropdown-item>
          </el-dropdown-menu>
        </el-dropdown>
      </div>
      <div class="msg-content" v-if="logList.length>0">
        <div class="item flex" v-for="item in logList">
          <div class="flex flex1">
            <xr-avatar
            :name="item.createUserIdText"
            :size="45"
            key="userName"/>
            <div class="ml10">
                <div>
                  <span class="color_99">{{item.createUserIdText}}</span>
                  <span class="color_99 ml10">{{ item.typeText }}</span>
                </div>
                <div class="message mt5">
                  <p class="color_txt">{{item.title}}</p>
                  <p class="flex-col bor-top" v-if="item.hasContent">
                    <span class="mt8">状态 
                      <span>{{ item.startStatus == 1 ? '正常' :( item.startStatus == 2 ? '风险' : '逾期') }}</span>
                      <span v-if="item.endStatus && item.endStatus!='null'">- {{ item.endStatus == 1 ? '正常' :( item.endStatus == 2 ? '风险' : '逾期') }}</span>
                    </span>
                    <span class="mt8">进度 {{item.startProgress}}% - {{item.endProgress}}% 
                      <span> <i class="el-icon-caret-top"></i>{{item.differ}}%</span>
                    </span>
                    <span class="mt8">说明 {{ item.remarks!='null' ? item.remarks : '-' }}</span>
                  </p>
                </div>
            </div>
          </div>
          <div class="color_99">{{ filterTime(item.createTime) }}</div>
        </div>
      </div>
      <div v-else class="msg-content">
       <xr-empty :top="250" :width="150"></xr-empty>
      </div>
    </div>
    <div class="send-box">
        <div class="choose-box" v-if="showChoose">
          <div class="user-list">
              <select-employee
              :radio="false"
              v-model="users"
              closeDep
              @select="userChange(arguments[0])"
              >
              </select-employee>
              <div class="list-btn">
                <el-button type="primary" plain size="mini" @click="showChoose = false">取消</el-button>
                <el-button type="primary" size="mini" @click="handleSelectUser">确定</el-button>
              </div>
          </div>
        </div>
        <div
          ref="editor"
          class="send-content"
          spellcheck="false"
          :contenteditable="true"
          @keyup="handkeKeyUp"
          @input="saveCursorPosition"
          @click="saveCursorPosition"
          @keydown="handleKeyDown"
        />
        <div class="send-txt com-flex">
            <span class="icon">
              <i @click="chooseOwner">@</i>
              <i class="el-icon-paperclip ml5"></i>
            </span>
          <el-button type="primary" v-debounce='confirm'>发送</el-button>
        </div>
    </div>
  </div>
</template>

<script>
  import { userListAPI } from '@/api/common'
  import { getOkrLog, saveOkrLog } from '@/api/task'
  import SelectEmployee from '@/components/SelectEmployee/main'

  import moment from 'moment'
  
  export default{
    components:{
      SelectEmployee
    },
    data(){
      return {
        dropOptions:[
          { label:'所有记录', num:6, type:'' },
          { label:'目标更新进度', num:6, type:1 },
          { label:'评论', num:6, type:3 },
          { label:'操作日志', num:6, type:4 },
          { label:'附件', num:6, type:5 },
        ],
        activeLabel:'所有记录',
        activeNum:6,
        showChoose:false,
        ownerId:'',
        userList:null,
        content:'',
        logType:'',
        logList:[],
        objectiveItemId:'',
        currentMention: '', // 当前正在输入的提及用户名
        attachId:[],
        users:[],
        userNames:[],
        queryString:'',
        endIndex: '', // 光标最后停留位置
        node: '', // 获取到节点
        savedRange:''
      }
    },
    props:{
      detail:{
        type:Object,
        default:()=>{}
      }
    },
    created(){
      this.getUserList()
      this.getLog()
        // 监听导入
      this.$bus.on('getLog', () => {
        this.getLog()
      })
      this.detail.krList && this.detail.krList.map((item,i)=>{
        let obj = {
          label:`KR${i+1}更新进度`,
          type:2,
          ...item
        }
        this.dropOptions.push(obj)
      })
    },
    computed: {
    },
    methods:{
      async chooseOwner(){
        this.$refs.editor.focus()
        this.restoreCursorPosition()
        // document.execCommand('selectAll', false, null)
        // document.getSelection().collapseToEnd()
        const node = this.getRangeNode()
        console.log(node)
        const endIndex = this.getCursorIndex()
        this.node = node
        this.endIndex = endIndex
        this.queryString = this.getAtUser() || ''
        this.showChoose = true
      },
      saveCursorPosition() {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
          this.savedRange = selection.getRangeAt(0);
          console.log(this.savedRange,'11111111111111')
        }
      },
      restoreCursorPosition() {
        if (this.savedRange) {
          const selection = window.getSelection();
          selection.removeAllRanges();
          console.log(this.savedRange,'55555555555555')
          selection.addRange(this.savedRange);
        }
      },
      // 键盘抬起事件
      handkeKeyUp() {
        if (this.showAt()) {
          const node = this.getRangeNode()
          const endIndex = this.getCursorIndex()
          this.node = node
          this.endIndex = endIndex
          this.queryString = this.getAtUser() || ''
          this.showChoose = true
        } else {
          this.showChoose = false
        }
        if (this.$refs.editor.innerText.length > 100) {
          this.$refs.editor.innerText = this.$refs.editor.innerText.substr(
            0,
            100
          )
          // 光标移动到最后
          document.execCommand('selectAll', false, null)
          document.getSelection().collapseToEnd()
        }
      },
      // 获取光标位置
      getCursorIndex() {
        const selection = window.getSelection()
        return selection.focusOffset // 选择开始处 focusNode 的偏移量
      },
      // 获取节点
      getRangeNode() {
        const selection = window.getSelection()
        return selection.focusNode // 选择的结束节点
      },
      // 是否展示 @
      showAt() {
        const node = this.getRangeNode()
        if (!node || node.nodeType !== Node.TEXT_NODE) return false
        const content = node.textContent || ''
        const regx = /@([^@\s]*)$/
        const match = regx.exec(content.slice(0, this.getCursorIndex()))
        console.log(match,'match')
        return match && match.length === 2
      },
      // 获取 @ 用户
      getAtUser() {
        const content = this.getRangeNode().textContent || ''
        const regx = /@([^@\s]*)$/
        const match = regx.exec(content.slice(0, this.getCursorIndex()))
        if (match && match.length === 2) {
          return match[1]
        }
        return undefined
      },
      // 键盘按下事件
      handleKeyDown(e) {
        if (this.showChoose) {
          if (e.code === 'ArrowUp' ||
            e.code === 'ArrowDown' ||
            e.code === 'Enter') {
            e.preventDefault()
          }
        }
      },
      // 插入标签后隐藏选择框
      handleSelectUser() {
        this.replaceAtUser()
        this.showChoose = false
      },
      replaceAtUser() {
        const node = this.node
        if (node && this.users.length>0) {
          const content = node.textContent || ''
          const endIndex = this.endIndex
          const preSlice = this.replaceString(content.slice(0, endIndex), '')
          const restSlice = content.slice(endIndex)
          const parentNode = node.nodeType == 3 ? node.parentNode : node
          const nextNode = node.nodeType == 3 ? node.nextSibling : ''
          const previousTextNode = new Text(preSlice)
          const nextTextNode = new Text('\u200b' + restSlice) // 添加 0 宽字符
          const atButton = this.createAtButtonUsers()
          // const atButton = this.createAtButton(this.users[0])
          node.nodeType == 3 && parentNode.removeChild(node)
          // 插在文本框中
          if (nextNode) {
            parentNode.insertBefore(previousTextNode, nextNode)
            parentNode.insertBefore(atButton, nextNode)
            parentNode.insertBefore(nextTextNode, nextNode)
          } else {
            parentNode.appendChild(previousTextNode)
            parentNode.appendChild(atButton)
            parentNode.appendChild(nextTextNode)
          }
          // 重置光标的位置
          const range = new Range()
          const selection = window.getSelection()
          range.setStart(nextTextNode, 0)
          range.setEnd(nextTextNode, 0)
          selection.removeAllRanges()
          selection.addRange(range)
        }
      },
      // 插入@标签
      // replaceAtUser(user) {
      //   const node = this.node
      //   if (node && user) {
      //     const content = node.textContent || ''
      //     const endIndex = this.endIndex
      //     const preSlice = this.replaceString(content.slice(0, endIndex), '')
      //     const restSlice = content.slice(endIndex)
      //     const parentNode = node.parentNode
      //     const nextNode = node.nextSibling
      //     const previousTextNode = new Text(preSlice)
      //     const nextTextNode = new Text('\u200b' + restSlice) // 添加 0 宽字符
      //     const atButton = this.createAtButton(user)
      //     parentNode.removeChild(node)
      //     // 插在文本框中
      //     if (nextNode) {
      //       parentNode.insertBefore(previousTextNode, nextNode)
      //       parentNode.insertBefore(atButton, nextNode)
      //       parentNode.insertBefore(nextTextNode, nextNode)
      //     } else {
      //       parentNode.appendChild(previousTextNode)
      //       parentNode.appendChild(atButton)
      //       parentNode.appendChild(nextTextNode)
      //     }
      //     // 重置光标的位置
      //     const range = new Range()
      //     const selection = window.getSelection()
      //     range.setStart(nextTextNode, 0)
      //     range.setEnd(nextTextNode, 0)
      //     selection.removeAllRanges()
      //     selection.addRange(range)
      //   }
      // },
      createAtButtonUsers(){
        const btns = this.users.map(user=>{
          return this.createAtButton(user)
        })
        let fragment = document.createDocumentFragment();
        btns.map(item=>{
          fragment.appendChild(item);
        })
        return fragment
      },
      // 创建标签
      createAtButton(user) {
        const btn = document.createElement('span')
        btn.style.display = 'inline-block'
        btn.dataset.user = JSON.stringify(user)
        btn.className = 'bpm-at-button'
        btn.contentEditable = 'false'
        btn.textContent = `@${user.authName}`
        const wrapper = document.createElement('span')
        wrapper.style.display = 'inline-block'
        wrapper.contentEditable = 'false'
        const spaceElem = document.createElement('span')
        spaceElem.style.whiteSpace = 'pre'
        spaceElem.textContent = '\u200b'
        spaceElem.contentEditable = 'false'
        const clonedSpaceElem = spaceElem.cloneNode(true)
        wrapper.appendChild(spaceElem)
        wrapper.appendChild(btn)
        wrapper.appendChild(clonedSpaceElem)
        return wrapper
      },
      
      replaceString(raw, replacer) {
        console.log(raw,'rrrrrrrrrrrrr')
        return raw.replace(/@([^@\s]*)$/, replacer)
      },
      userChange(value){
        this.users = value
      },
      filterTime(time){
        return moment(time).format('MM/DD hh:mm')
      },
      confirm(){
        const dom = document.querySelectorAll('.bpm-at-button')
        console.log(typeof dom,'ddddd')
        let domUser = []
        dom.forEach(item=>{
          const user = JSON.parse(item.dataset.user)
          domUser.push(user) 
        })
        let params = {
          id: this.detail.id,
          objectiveId: this.detail.id,
          objectiveItemId: this.objectiveItemId,
          type:this.logType,
          title: this.content,
          notifyUserIds:domUser.map(item=>item.userId),
          attachId:this.attachId
        }
        saveOkrLog(params).then(res=>{
          if(res.code !=0 ){
            this.$message.error('请重试')
          }else{
            this.content = ''
            this.getLog()
          }
          
        })
      },
      getLog(){
        let params = {
          id:this.detail.id,
          objectiveId: this.detail.id,
          objectiveItemId: this.objectiveItemId,
          type:this.logType,
        }
        getOkrLog(params).then(res=>{
        
         let data = res.data || []
         this.logList = data.map(item=>{
            let content = (item.type!=4 &&  JSON.parse(item.content)) || false
            return {
              ...item,
              hasContent:item.content&&item.type!=4 ? true:false,
              startStatus: (item.type!=4 && content&& content.status && content.status.toString().includes(',') ? content.status.split(',')[0] : content.status) || '',
              endStatus:(item.type!=4 && content&& content.status &&content.status.toString().includes(',') ? content.status.split(',')[1] : content.status) || '',
              startProgress: item.type!=4 && content && Math.round(content.progress.split(',')[0]*100),
              endProgress: item.type!=4 && content && Math.round(content.progress.split(',')[1]*100),
              differ:item.type!=4 && content && Math.round(content.progress.split(',')[1]*100) - Math.round(content.progress.split(',')[0]*100),
              remarks:content && content.remarks
            }
         })
        })
      },
      getUserList(){
        userListAPI({page:1,limit:500}).then(res=>{
          this.userList  = res.data || []
        })
      },
      /* 下拉按钮点击 */
      onDropClick(command) {
        this.activeLabel = command.label
        this.activeNum = command.num
        this.logType = command.type
        if(command.type == 2){
          this.objectiveItemId = command.id
        }else{
          this.objectiveItemId = ''
        }
        this.getLog()
      },
      
    }
  }
</script>

<style lang="scss" scoped>
.progress{
  .msg-content{
    height:calc(100vh - 210px);
    overflow-y:auto;
  }
  .ml10{
    margin-left:10px;
  }
  .mt5{
    margin-top:5px;
  }
  .ml5{
    margin-left:5px;
  }
  .mr5{
    margin-right:5px;
  }
  .mt10{
    margin-top: 10px;
  }
  .pd20{
    padding-bottom:20px;
  }
  .com-flex{
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  .start-flex{
    display: flex;
    align-items: center;
  }
  
  .flex-end {
    align-items: flex-end;
  }
  .flex{
    display: flex;
  }
  .flex1 {
    flex:1
  }
  .color_99{
    color:#999;
  }
  .flex-col{
    display: flex;
    flex-direction: column;
  }
  .item{
    padding:10px;
  }
  .color_txt{
    color:#94ACC3;
    padding-bottom:10px;
  }
  .bor-top{
    border-top:1px solid #A5CFF3;
  }
  .message {
    padding:10px;
    background: #C9E6FF;
    border:1px solid #A5CFF3;
    border-radius: 0 8px 8px 8px;
    width:285px;
  }
  .description{
    padding:10px;
  }
  .send-box{
    position: absolute;
    bottom: 0;
    width:460px;
    margin:10px;
    padding:2px;
    border-radius: 4px;
    border:1px solid #dddddd;
    :deep(.el-textarea__inner){
      border:none
    }
    .cusor{
      animation: blink 1s linear infinite;
    }
    @keyframes blink {
        0% { opacity: 1; }
        50% { opacity: 0; }
        100% { opacity: 1; }
    }
    .send-content{
      height:50px;
      padding:5px;
    }
    .choose-box{
      padding: 10px 0 0 10px;
      position: relative;
      .user-list{
        width:450px;
        position: absolute;
        box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
        bottom:calc(100% + 10px);
        border-radius: 6px;
        left:5px;
        background:#ffffff;
        .title{
          padding:10px 20px;
          border-bottom: 1px solid #ededed;
          .type{
            color:#48a1f0
          }
        }
        .mr10{
          margin-right:10px;
        }
        .content{
          display: flex;
          flex-direction: column;
          align-items: center;
          width:100%;
          height:300px;
          overflow-y: auto;
        }
        .content-item{
          display: flex;
          align-items: center;
          width:100%;
          padding:3px 20px;
          .label{
            margin-left:5px;
          }
        }

      }
      .list-btn{
        padding:5px 20px;
        text-align: right
      }
    }
    .send-txt{
      text-align: right;
      .icon{
        color:#999;
        cursor: pointer;
      }
    }
  }
  .empty-box.msg-content{
    margin-top:200px;
    display:flex;
    flex-direction:column;
    align-items:center;
    img{
      width:200px;
      height:auto;
    }
  }
}
</style>

参考:vue实现(@、At、艾特)demo - 掘金 (juejin.cn)