xw 3 hete
szülő
commit
32c3d06fed
55 módosított fájl, 13950 hozzáadás és 0 törlés
  1. 385 0
      src/components/conversation/conversation-item.vue
  2. 220 0
      src/components/conversation/conversation-list.vue
  3. 45 0
      src/components/conversation/conversation-profile.vue
  4. 179 0
      src/components/conversation/conversation-selected-list.vue
  5. 588 0
      src/components/conversation/conversationProfile/add-friend-profile
  6. 71 0
      src/components/conversation/conversationProfile/add-group-member.vue
  7. 247 0
      src/components/conversation/conversationProfile/group-member-info.vue
  8. 155 0
      src/components/conversation/conversationProfile/group-member-list.vue
  9. 873 0
      src/components/conversation/conversationProfile/group-profile.vue
  10. 251 0
      src/components/conversation/conversationProfile/user-profile.vue
  11. 565 0
      src/components/conversation/current-conversation.vue
  12. 230 0
      src/components/friend/add-friend.vue
  13. 150 0
      src/components/friend/friend-application/application-item.vue
  14. 527 0
      src/components/friend/friend-container.vue
  15. 194 0
      src/components/friend/friend-item.vue
  16. 546 0
      src/components/friend/friend-list.vue
  17. 90 0
      src/components/im/eventListeners.js
  18. 359 0
      src/components/layout/side-bar.vue
  19. 222 0
      src/components/message/image-previewer.vue
  20. 621 0
      src/components/message/merger-message/mergerMessage-item.vue
  21. 187 0
      src/components/message/merger-message/message-merger.vue
  22. 292 0
      src/components/message/merger-message/message-relay.vue
  23. 309 0
      src/components/message/message-bubble.vue
  24. 58 0
      src/components/message/message-elements/at-element.vue
  25. 144 0
      src/components/message/message-elements/custom-element.vue
  26. 47 0
      src/components/message/message-elements/face-element.vue
  27. 121 0
      src/components/message/message-elements/file-element.vue
  28. 73 0
      src/components/message/message-elements/geo-element.vue
  29. 138 0
      src/components/message/message-elements/group-system-notice-element.vue
  30. 89 0
      src/components/message/message-elements/group-tip-element.vue
  31. 73 0
      src/components/message/message-elements/image-element.vue
  32. 98 0
      src/components/message/message-elements/merger-element.vue
  33. 151 0
      src/components/message/message-elements/sound-element.vue
  34. 117 0
      src/components/message/message-elements/text-element.vue
  35. 60 0
      src/components/message/message-elements/video-element.vue
  36. 75 0
      src/components/message/message-footer.vue
  37. 114 0
      src/components/message/message-group-live-status.vue
  38. 77 0
      src/components/message/message-header.vue
  39. 419 0
      src/components/message/message-item.vue
  40. 1450 0
      src/components/message/message-send-box.vue
  41. 55 0
      src/components/message/message-status-icon.vue
  42. 957 0
      src/components/message/trtc-calling/calling-index.vue
  43. 177 0
      src/components/message/trtc-calling/group-member-list.vue
  44. 294 0
      src/components/user/login.vue
  45. 67 0
      src/utils/ImSocket.js
  46. 68 0
      src/utils/date.js
  47. 61 0
      src/utils/decodeText.js
  48. 499 0
      src/utils/emojiMap.js
  49. 24 0
      src/utils/formatDuration.js
  50. 39 0
      src/utils/openIM.js
  51. 265 0
      src/utils/rtc-client.js
  52. 1 0
      src/utils/testConnection.js
  53. 176 0
      src/utils/trtc.js
  54. 18 0
      src/utils/trtcCustomMessageMap.js
  55. 639 0
      src/views/im/index.vue

+ 385 - 0
src/components/conversation/conversation-item.vue

@@ -0,0 +1,385 @@
+<template>
+    <div
+        class="conversation-item-container"
+        :class="{ 'choose': conversation.conversationID === currentConversation.conversationID }"
+        @click="selectConversation"
+    >
+      <div class="close-btn">
+        <span class="tim-icon-close" title="删除会话" @click="deleteConversation"></span>
+      </div>
+      <div class="warp">
+        <div class="avatar-wrapper">
+          <avatar :src="avatar" :type="conversation.faceURL" class="avatar" />
+          <img v-if="avatarBorder" :src="avatarBorder" class="avatar-border" />
+        </div>
+        <div class="c-content">
+          <div class="row-1">
+            <div class="name">
+              <div class="text-ellipsis">
+                <span :title="conversation.showName || conversation.userID"
+                  v-if="conversation.conversationType ===  1"
+                  >{{conversation.remark || conversation.showName || conversation.userID}}
+                </span>
+                <span :title="conversation.showName || conversation.groupID"
+                      v-else-if="conversation.conversationType ===  3"
+                >{{conversation.showName || conversation.groupID}}
+                </span>
+                <span
+                  v-else-if="conversation.conversationType === 4"
+                  >系统通知
+                </span>
+              </div>
+            </div>
+            <div class="unread-count">
+              <span class="badge" v-if="showUnreadCount">
+                {{conversation.unreadCount > 99 ? '99+' : conversation.unreadCount}}
+              </span>
+            </div>
+          </div>
+          <div class="row-2">
+            <div class="summary">
+              <div v-if="conversation.conversationID" class="text-ellipsis">
+                <span class="remind" v-if="hasMessageAtMe">{{ messageAtMeText }}</span>
+                <span class="text" :title="messageForShow">
+                  {{ messageForShow }}
+                </span>
+              </div>
+            </div>
+            <div class="date">
+              {{date}}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+</template>
+
+<script>
+import { mapGetters, mapState } from 'vuex'
+import { isToday, getDate, getTime } from '../../utils/date'
+import { getOpenIM } from '@/utils/openIM';
+import doctorBorder from '@/assets/doctor.svg'
+import guanjiaBorder from '@/assets/guanjia.svg'
+import qunliaoBorder from '@/assets/qunliao.svg'
+export default {
+  name: 'conversation-item',
+  props: ['conversation'],
+  data() {
+    return {
+      popoverVisible: false,
+      showMessageAtMe_text:'',
+      OpenIM: null,
+      lastMsg : {},
+    }
+  },
+  computed: {
+    hasMessageAtMe() {
+      return (
+              this.currentConversation.conversationID !==
+              this.conversation.conversationID && this.conversation.groupAtInfoList && this.conversation.groupAtInfoList.length > 0
+      )
+    },
+    avatarBorder() {
+      if (this.conversation.userID?.startsWith('D')) {
+        return doctorBorder
+      } else if (this.conversation.userID?.startsWith('C')) {
+        return guanjiaBorder
+      }else if (this.conversation.conversationID.startsWith('sg')){
+        return qunliaoBorder
+      }
+      return null
+    },
+    messageAtMeText() {
+      let text = ''
+      if (this.conversation.groupAtInfoList.length > 0) {
+        this.conversation.groupAtInfoList.forEach((item) => {
+          if (item.atTypeArray[0] === 1) {
+            text.indexOf('[@所有人]') !== -1 ? text = '[@所有人][有人@我]' : text = '[有人@我]'
+          }
+          if (item.atTypeArray[0] === 2) {
+            text.indexOf('[有人@我]') !== -1 ? text = '[有人@我][@所有人]' : text = '[@所有人]'
+          }
+          if (item.atTypeArray[0] === 3) {
+            text = '[@所有人][有人@我]'
+          }
+        })
+      }
+      return text
+    },
+    showUnreadCount() {
+      if (this.$store.getters.hidden) {
+        return this.conversation.unreadCount > 0
+      }
+      // 是否显示未读计数。当前会话和未读计数为0的会话,不显示。
+      return (
+        this.currentConversation.conversationID !==
+          this.conversation.conversationID && this.conversation.unreadCount > 0
+      )
+    },
+    date() {
+      if (
+        !this.conversation.latestMsg ||
+        !this.conversation.latestMsgSendTime
+      ) {
+        return ''
+      }
+      const date = new Date(this.conversation.latestMsgSendTime)
+      if (isToday(date)) {
+        return getTime(date)
+      }
+      return getDate(date)
+    },
+    avatar: function() {
+      switch (this.conversation.conversationType) {
+        case 3:
+          return this.conversation.faceURL
+        case 1:
+          return this.conversation.faceURL
+        default:
+          return ''
+      }
+    },
+    conversationName: function() {
+      if (this.conversation.conversationType === 1) {
+        return this.conversation.showName || this.conversation.userID
+      }
+      /*if (this.conversation.conversationType === 3) {
+        return this.conversation.groupProfile.name || this.conversation.groupProfile.groupID
+      }*/
+      if (this.conversation.conversationType === 4) {
+        return '系统通知'
+      }
+      return ''
+    },
+    showGrayBadge() {
+      if (this.conversation.conversationType !== 4) {
+        return false
+      }
+      return (
+        this.conversation.groupProfile.selfInfo.messageRemindType ===
+        'AcceptNotNotify'
+      )
+    },
+    messageForShow() {
+      this.lastMsg = JSON.parse(this.conversation.latestMsg)
+      /*if (this.lastMsg.contentType === 2101) {
+        if (this.lastMsg.sendID === this.$store.getters.userID) {
+          return '你撤回了一条消息'
+        }
+        if (this.lastMsg.sessionType === 1) {
+          return '对方撤回了一条消息'
+        }
+        return `${this.lastMsg.sendID}撤回了一条消息`
+      }*/
+      let text = ''
+      switch (this.lastMsg.contentType) {
+        case 2101:
+          if (this.lastMsg.sendID === this.$store.getters.userID) {
+            return '你撤回了一条消息'
+          }
+          if (this.lastMsg.sessionType === 1) {
+            return '对方撤回了一条消息'
+          }
+          return `${this.lastMsg.sendID}撤回了一条消息`
+        case 101:
+          text = this.lastMsg.textElem.content || ''
+          if (text.length > 20) {
+            text = text.slice(0, 20)
+          }
+          return text
+        case 107:
+          return '[聊天记录]'
+        case 102:
+          return '[图片]'
+        case 103:
+          return '[音频]'
+        case 104:
+          return '[视频]'
+        case 110:
+          return '[自定义消息]'
+        case 105:
+          return '[文件]'
+        case 115:
+          return '[动画表情]'
+        case 1701:
+          return '[阅后即焚]'
+        case 1201:
+          return '[成为好友通知]'
+      }
+      console.log("this.lastMsg.textElem",this.lastMsg)
+      //return  this.lastMsg.textElem.content
+    },
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation,
+      currentUserProfile: state => state.user.currentUserProfile,
+      userID: state => state.imuser.userID,
+    }),
+    ...mapGetters(['toAccount'])
+  },
+  mounted() {
+    this.OpenIM = getOpenIM()
+  },
+  methods: {
+    selectConversation() {
+      //console.log("this.currentConversation.conversationID"+this.currentConversation.conversationID)
+      if (this.conversation.conversationID !== this.currentConversation.conversationID) {
+        console.log("用户id"+this.conversation.userID)
+        console.log("会话类型"+this.conversation.conversationType)
+        this.$store.dispatch(
+          'checkoutConversation',
+          this.conversation,
+        )
+      }
+    },
+    deleteConversation(event) {
+      // 停止冒泡,避免和点击会话的事件冲突
+      event.stopPropagation()
+      this.OpenIM
+        .deleteConversationAndDeleteAllMsg(this.conversation.conversationID)
+        .then(() => {
+          this.$store.commit('showMessage', {
+            message: `会话【${this.conversationName}】删除成功!`,
+            type: 'success'
+          })
+          this.popoverVisible = false
+          this.$store.commit('resetCurrentConversation')
+          this.OpenIM.getConversationListSplit({
+            offset: 0,
+            count: 100,
+          })
+            .then(({ data }) => {
+              // 调用成功
+              console.log("获取到会话列表",data)
+              this.conversationList= data
+              this.$store.commit('updateConversationList', data)
+            })
+            .catch(({ errCode, errMsg }) => {
+              // 调用失败
+            })
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            message: `会话【${this.conversationName}】删除失败!, error=${error.message}`,
+            type: 'error'
+          })
+          this.popoverVisible = false
+        })
+    },
+    showContextMenu() {
+      this.popoverVisible = true
+    },
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+  .avatar-wrapper
+    position relative
+    width 40px
+    height 40px
+    margin-right 10px
+    flex-shrink 0
+
+  .avatar
+    width 100%
+    height 100%
+    border-radius 50%
+    display block
+
+  .avatar-border
+    position absolute
+    top 0
+    left 0
+    width 100%
+    height 100%
+    pointer-events none
+.conversation-item-container
+  padding 15px 20px
+  cursor pointer
+  position relative
+  overflow hidden
+  transition .2s
+  // &:first-child
+  //   padding-top 30px
+  &:hover
+    background-color $background
+    .close-btn
+      right 3px
+  .close-btn
+    position absolute
+    right -20px
+    top 3px
+    color $font-dark
+    transition: all .2s ease;
+    &:hover
+      color $danger
+  .warp
+    display flex
+  .avatar
+    width 40px
+    height 40px
+    margin-right 10px
+    border-radius 50%
+    flex-shrink 0
+  .c-content
+    flex 1
+    height 40px
+    overflow hidden
+    .row-1
+      display flex
+      line-height 21px
+      .name
+        color #f7f7f8
+        flex 1
+        min-width 0px
+
+      .unread-count
+        padding-left 10px
+        flex-shrink 0
+        color #76828c
+        font-size 12px
+
+        .badge
+          vertical-align bottom
+          background-color #f35f5f
+          border-radius 10px
+          color #FFF
+          display inline-block
+          font-size 12px
+          height 18px
+          max-width 40px
+          line-height 18px
+          padding 0 6px
+          text-align center
+          white-space nowrap
+
+    .row-2
+      display flex
+      font-size 12px
+      padding-top 3px
+
+      .summary
+        flex 1
+        overflow hidden
+        min-width 0px
+        color: #a5b5c1
+
+        .remind
+          color #f35f5f
+
+      .date
+        padding-left 10px
+        flex-shrink 0
+        text-align right
+        color #76828c
+
+  .choose {
+    background-color: #404953;
+  }
+  .context-menu-button {
+    padding: 10px
+    border: 2px solid #2d8cf0;
+    border-radius: 8px;
+  }
+</style>

+ 220 - 0
src/components/conversation/conversation-list.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="list-container">
+    <div class="header-bar">
+      <button title="刷新列表" @click="handleRefresh">
+        <i class="tim-icon-refresh"></i>
+      </button>
+      <!-- <button title="创建会话" @click="handleAddButtonClick">
+        <i class="tim-icon-add"></i>
+      </button> -->
+    </div>
+    <div class="scroll-container">
+      <conversation-item
+        :conversation="item"
+        v-for="item in conversationList"
+        :key="item.conversationID"
+      />
+    </div>
+    <el-dialog title="快速发起会话" :visible.sync="showDialog" width="30%">
+      <el-input placeholder="请输入用户ID" v-model="userID" @keydown.enter.native="handleConfirm"/>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="showDialog = false">取 消</el-button>
+        <el-button type="primary" @click="handleConfirm">确 定</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import ConversationItem from './conversation-item'
+import { mapState } from 'vuex'
+import { getOpenIM } from '@/utils/openIM';
+export default {
+  name: 'ConversationList',
+  components: { ConversationItem },
+  data() {
+    return {
+      showDialog: false,
+      userID: '',
+      isCheckouting: false, // 是否正在切换会话
+      timeout: null,
+      OpenIM: null,
+      conversationList1:[
+
+      ]
+    }
+  },
+  computed: {
+    ...mapState({
+      conversationList: state => state.conversation.conversationList,
+      currentConversation: state => state.conversation.currentConversation
+    })
+  },
+  mounted() {
+    window.addEventListener('keydown', this.handleKeydown)
+  },
+  destroyed() {
+    window.removeEventListener('keydown', this.handleKeydown)
+  },
+  created() {
+    this.OpenIM = getOpenIM()
+  },
+  methods: {
+    handleRefresh() {
+
+      //
+      /*this.OpenIM.getAllConversationList()
+        .then(({ data }) => {
+          // 调用成功
+          console.log("=========================================================================================================")
+          this.conversationList1= data
+          console.log(typeof this.conversationList1[0].latestMsg);
+          this.$store.commit('updateConversationList', data)
+        })
+        .catch(({ errCode, errMsg }) => {
+          // 调用失败
+        })
+      console.log(this.conversationList)*/
+      this.refreshConversation()()
+    },
+    refreshConversation() {
+      let that = this
+      return function () {
+        if (!that.timeout) {
+          that.timeout = setTimeout(() =>{
+            that.timeout = null
+            that.OpenIM.getAllConversationList().then(({data}) => {
+              console.log("获取所有会话",data)
+              that.$store.commit('updateConversationList', data)
+              that.$store.commit('showMessage', {
+                message: '刷新成功',
+                type: 'success'
+              })
+            })
+          }, 1000)
+        }
+      }
+    },
+    handleAddButtonClick() {
+      this.showDialog = true
+    },
+    handleConfirm() {
+      if (this.userID !== '@TIM#SYSTEM') {
+        this.$store
+          .dispatch('checkoutConversation', `C2C${this.userID}`)
+          .then(() => {
+            this.showDialog = false
+          }).catch(() => {
+          this.$store.commit('showMessage', {
+            message: '没有找到该用户',
+            type: 'warning'
+          })
+        })
+      } else {
+        this.$store.commit('showMessage', {
+          message: '没有找到该用户',
+          type: 'warning'
+        })
+      }
+      this.userID = ''
+    },
+    handleKeydown(event) {
+      if (event.keyCode !== 38 && event.keyCode !== 40 || this.isCheckouting) {
+        return
+      }
+      const currentIndex = this.conversationList.findIndex(
+        item => item.conversationID === this.currentConversation.conversationID
+      )
+      if (event.keyCode === 38 && currentIndex - 1 >= 0) {
+        this.checkoutPrev(currentIndex)
+      }
+      if (
+        event.keyCode === 40 &&
+        currentIndex + 1 < this.conversationList.length
+      ) {
+        this.checkoutNext(currentIndex)
+      }
+    },
+    checkoutPrev(currentIndex) {
+      this.isCheckouting = true
+      this.$store
+        .dispatch(
+          'checkoutConversation',
+          this.conversationList[currentIndex - 1].conversationID
+        )
+        .then(() => {
+          this.isCheckouting = false
+        })
+        .catch(() => {
+          this.isCheckouting = false
+        })
+    },
+    checkoutNext(currentIndex) {
+      this.isCheckouting = true
+      this.$store
+        .dispatch(
+          'checkoutConversation',
+          this.conversationList[currentIndex + 1].conversationID
+        )
+        .then(() => {
+          this.isCheckouting = false
+        })
+        .catch(() => {
+          this.isCheckouting = false
+        })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.list-container
+  height 100%
+  width 100%
+  display flex
+  flex-direction column // -reverse
+  .header-bar
+    flex-shrink 0
+    height 50px
+    border-bottom 1px solid $background-deep-dark
+    padding 10px 10px 10px 20px
+    button
+      float right
+      display: inline-block;
+      cursor: pointer;
+      background $background-deep-dark
+      border: none
+      color: $font-dark;
+      box-sizing: border-box;
+      transition: .3s;
+      -moz-user-select: none;
+      -webkit-user-select: none;
+      -ms-user-select: none;
+      margin: 0 10px 0 0
+      padding 0
+      width 30px
+      height 30px
+      line-height 34px
+      font-size: 24px;
+      text-align: center;
+      white-space: nowrap;
+      border-radius: 50%
+      outline 0
+      &:hover
+        // background $light-primary
+        // color $white
+        transform: rotate(360deg);
+        color $light-primary
+  .scroll-container
+    overflow-y scroll
+    flex 1
+.bottom-circle-btn {
+  position: absolute;
+  bottom: 20px;
+  right: 20px;
+}
+
+.refresh {
+  bottom: 70px;
+}
+</style>

+ 45 - 0
src/components/conversation/conversation-profile.vue

@@ -0,0 +1,45 @@
+<template>
+  <div class="conversation-profile-wrapper">
+    <user-profile
+      v-if="currentConversation.conversationType === 1"
+      :userProfile="currentConversation.userProfile"
+    />
+    <group-profile
+      v-else-if="currentConversation.conversationType === 3"
+      :groupProfile="currentConversation.groupID"
+    />
+  </div>
+</template>
+<script>
+import { mapState } from 'vuex'
+import GroupProfile from './conversationProfile/group-profile.vue'
+import UserProfile from './conversationProfile/user-profile.vue'
+export default {
+  name: 'ConversationProfile',
+  components: {
+    GroupProfile,
+    UserProfile
+  },
+  data() {
+    return {}
+  },
+  computed: {
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation
+    })
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.conversation-profile-wrapper
+  background-color $white
+  height 100%
+  overflow-y scroll
+
+/* 设置滚动条的样式 */
+::-webkit-scrollbar {
+  width: 0px;
+  height: 0px;
+}
+</style>

+ 179 - 0
src/components/conversation/conversation-selected-list.vue

@@ -0,0 +1,179 @@
+<template>
+  <div class="group-member-list-wrapper">
+    <div class="header">
+      <span class="member-count text-ellipsis">会话列表</span>
+    </div>
+    <div class="scroll-content">
+      <div class="group-member-list">
+        <el-checkbox-group v-model="conversationSelected" @change="handleCheckedConversationChange">
+          <el-checkbox v-for="conversation in conversationList"  :label="conversation.conversationID" :key="conversation.conversationID">
+            <!--                        <conversation-item :conversation="item"/>-->
+            <div class="conversation-item-container">
+              <div class="warp">
+                <avatar :src="avatar(conversation)" :type="conversation.type" />
+                <div class="c-content">
+                  <div class="row-1">
+                    <div class="name">
+                      <div class="text-ellipsis">
+                <span :title="conversation.showName || conversation.userID"
+                      v-if="conversation.conversationType ===  1"
+                >{{conversation.showName || conversation.userID}}
+                </span>
+                        <!--<span :title="conversation.groupProfile.name || conversation.groupProfile.groupID" v-else-if="conversation.conversationType === 3">
+                          {{conversation.groupProfile.name || conversation.groupProfile.groupID}}
+                        </span>
+                        <span v-else-if="conversation.conversationType === 4" >
+                          系统通知
+                        </span>-->
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <!--                        <el-divider></el-divider>-->
+          </el-checkbox>
+        </el-checkbox-group>
+      </div>
+    </div>
+    <div class="more">
+      <!--            <el-button v-if="showLoadMore" type="text" @click="loadMore">查看更多</el-button>-->
+    </div>
+  </div>
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+  export default {
+    props:['type'],
+    data() {
+      return {
+        conversationSelected:[],
+        addGroupMemberVisible: false,
+        currentMemberID: '',
+        count: 30 // 显示的群成员数量
+      }
+    },
+    components: {
+    },
+    computed: {
+      ...mapState({
+        userID: state => state.user.userID,
+        currentConversation: state => state.conversation.currentConversation,
+        conversationList: state => state.conversation.conversationList,
+      }),
+      showLoadMore() {
+        return this.members.length < this.currentConversation.groupProfile.memberCount
+      },
+      avatar() {
+        return function (conversation) {
+          switch (conversation.conversationType) {
+            case 3:
+              return conversation.faceURL
+            case 1:
+              return conversation.faceURL
+            default:
+              return ''
+          }
+        }
+
+      },
+      members() {
+        return this.currentMemberList.slice(0, this.count)
+      }
+    },
+    methods: {
+      handleCheckedConversationChange() {
+        this.$emit('getList',this.conversationSelected)
+      },
+      loadMore() {
+        this.$store
+          .dispatch('getGroupMemberList', this.groupProfile.groupID)
+          .then(() => {
+            this.count += 30
+          })
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+    .group-member-list-wrapper
+        .header
+            height 50px
+            padding 10px 16px 10px 20px
+            border-bottom 1px solid $border-base
+            .member-count
+                display inline-block
+                max-width 130px
+                line-height 30px
+                font-size 14px
+                vertical-align bottom
+            .btn-add-member
+                width 30px
+                height 30px
+                font-size 28px
+                text-align center
+                line-height 32px
+                cursor pointer
+                float right
+                &:hover
+                    color $light-primary
+        .scroll-content
+            max-height: 250px;
+            overflow-y: scroll;
+            padding 10px 15px 10px 15px
+            width 100%
+
+
+
+    .conversation-item-container
+        padding 5px 20px
+        cursor pointer
+        position relative
+        overflow hidden
+        transition .2s
+        .warp
+            display flex
+        .avatar
+            width 40px
+            height 40px
+            margin-right 10px
+            border-radius 50%
+            flex-shrink 0
+        .c-content
+            flex 1
+            height 40px
+            overflow hidden
+            font-size 13px
+            .row-1
+                display flex
+                line-height 21px
+                .name
+                    color #000
+                    flex 1
+                    min-width 0px
+                    line-height 40px
+
+ /deep/ .el-checkbox{
+     display block
+ }
+/deep/ .el-checkbox__label{
+    width 100%
+}
+/deep/ .el-divider--horizontal{
+   margin 0
+}
+.group-member-list /deep/ .el-checkbox__input {
+    position absolute
+    top -12px
+    left 3px
+    bottom 0
+    margin auto
+    cursor pointer
+    line-height 1
+    display flex
+    justify-content center
+    align-items center
+}
+</style>

+ 588 - 0
src/components/conversation/conversationProfile/add-friend-profile

@@ -0,0 +1,588 @@
+<template>
+    <div>
+        <group-member-list :groupProfile="groupProfile" />
+        <div class="group-info-content">
+            <div class="info-item">
+                <div class="label">群ID</div>
+                <div class="content">{{ groupProfile.groupID }}</div>
+            </div>
+            <div class="info-item">
+                <div class="label">
+                    群头像
+                    <i
+                            class="el-icon-edit"
+                            v-if="editable"
+                            @click="
+            showEditFaceUrl = true
+            inputFocus('editFaceUrl')
+          "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+                <div class="content" v-if="!showEditFaceUrl">
+                    <avatar :src="groupProfile.avatar"/>
+
+                </div>
+                <el-input
+                        ref="editFaceUrl"
+                        v-else
+                        autofocus
+                        v-model="avatar"
+                        size="mini"
+                        @blur="showEditFaceUrl = false"
+                        @keydown.enter.native="editFaceUrl"
+                />
+            </div>
+
+            <div class="info-item">
+                <div class="label">群类型</div>
+                <div class="content">{{ groupType}}</div>
+            </div>
+            <div class="info-item">
+                <div class="label">
+                    群名称
+                    <i
+                            class="el-icon-edit"
+                            v-if="editable"
+                            @click="
+            showEditName = true
+            inputFocus('editName')
+          "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+
+                <div class="content text-ellipsis" :title="groupProfile.name" v-if="!showEditName">
+                    {{ groupProfile.name || '暂无' }}
+                </div>
+                <el-input
+                        ref="editName"
+                        v-else
+                        autofocus
+                        v-model="name"
+                        size="mini"
+                        @blur="showEditName = false"
+                        @keydown.enter.native="editName"
+                />
+            </div>
+            <div class="info-item">
+                <div class="label">
+                    群介绍
+                    <i
+                            class="el-icon-edit"
+                            v-if="editable"
+                            @click="
+            showEditIntroduction = true
+            inputFocus('editIntroduction')
+          "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+                <div class="long-content" :title="groupProfile.introduction" v-if="!showEditIntroduction">
+                    {{ groupProfile.introduction || '暂无' }}
+                </div>
+                <el-input
+                        ref="editIntroduction"
+                        v-else
+                        autofocus
+                        v-model="introduction"
+                        size="mini"
+                        @blur="showEditIntroduction = false"
+                        @keydown.enter.native="editIntroduction"
+                />
+            </div>
+            <div class="info-item">
+                <div class="label">
+                    群公告
+                    <i
+                            class="el-icon-edit"
+                            v-if="editable"
+                            @click="
+            showEditNotification = true
+            inputFocus('editNotification')
+          "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+                <div class="long-content" :title="groupProfile.notification" v-if="!showEditNotification">
+                    {{ groupProfile.notification || '暂无' }}
+                </div>
+
+                <el-input
+                        ref="editNotification"
+                        v-else
+                        autofocus
+                        v-model="notification"
+                        size="mini"
+                        @blur="showEditNotification = false"
+                        @keydown.enter.native="editNotification"
+                />
+            </div>
+            <div class="info-item" v-if="groupProfile.type !== 'Private'">
+                <div class="label">
+                    申请加群方式
+                    <i
+                            class="el-icon-edit"
+                            v-if="editable"
+                            @click="
+            showEditJoinOption = true
+            inputFocus('editJoinOption')
+          "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+                <div class="content" v-show="!showEditJoinOption">
+                    {{ joinOptionMap[groupProfile.joinOption] }}
+                </div>
+                <el-select
+                        ref="editJoinOption"
+                        v-model="joinOption"
+                        v-show="showEditJoinOption"
+                        size="mini"
+                        automatic-dropdown
+                        @blur="showEditJoinOption = false"
+                        @change="editJoinOption"
+                >
+                    <el-option label="自由加入" value="FreeAccess"></el-option>
+                    <el-option label="需要验证" value="NeedPermission"></el-option>
+                    <el-option label="禁止加群" value="DisableApply"></el-option>
+                </el-select>
+            </div>
+            <div class="info-item">
+                <div class="label">
+                    群消息提示类型
+                    <i
+                            class="el-icon-edit"
+                            v-if="editable"
+                            @click="
+            showEditMessageRemindType = true
+            inputFocus('editMessageRemindType')
+          "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+                <div class="content" v-show="!showEditMessageRemindType">
+                    {{ messageRemindTypeMap[this.groupProfile.selfInfo.messageRemindType] }}
+                </div>
+                <el-select
+                        ref="editMessageRemindType"
+                        v-show="showEditMessageRemindType"
+                        v-model="messageRemindType"
+                        size="mini"
+                        automatic-dropdown
+                        @change="editMessageRemindType"
+                        @blur="showEditMessageRemindType = false"
+                >
+                    <el-option label="接收消息并提示" value="AcceptAndNotify"></el-option>
+                    <el-option label="接收消息但不提示" value="AcceptNotNotify"></el-option>
+                    <el-option label="屏蔽消息" value="Discard"></el-option>
+                </el-select>
+            </div>
+            <div class="info-item">
+                <div class="label">
+                    我的群名片
+                    <i
+                            class="el-icon-edit"
+                            @click="
+              showEditNameCard = true
+              inputFocus('editNameCard')
+            "
+                            style="cursor:pointer; font-size:16px;"
+                    />
+                </div>
+                <div class="content cursor-pointer" v-if="!showEditNameCard">
+                    {{ groupProfile.selfInfo.nameCard || '暂无' }}
+                </div>
+                <el-input
+                        ref="editNameCard"
+                        v-else
+                        autofocus
+                        v-model="nameCard"
+                        size="mini"
+                        @blur="showEditNameCard = false"
+                        @keydown.enter.native="editNameCard"
+                />
+            </div>
+            <div class="info-item">
+                <div class="label" :class="{'active' : active}">全体禁言</div>
+                <el-switch
+                        v-model="muteAllMembers"
+                        active-color="#409eff"
+                        inactive-color="#dcdfe6"
+                        @change='changeMuteStatus'>
+                </el-switch>
+            </div>
+            <div v-if="isOwner">
+                <el-button type="text" @click="showChangeGroupOwner = true">转让群组</el-button>
+                <el-input
+                        v-if="showChangeGroupOwner"
+                        v-model="newOwnerUserID"
+                        placeholder="新群主的userID"
+                        size="mini"
+                        @blur="showChangeGroupOwner = false"
+                        @keydown.enter.native="changeOwner"
+                />
+            </div>
+            <div>
+                <el-button type="text" style="color:red;" @click="quitGroup">退出群组</el-button>
+            </div>
+            <div v-if="showDissmissGroup">
+                <el-button type="text" style="color:red;" @click="dismissGroup">解散群组</el-button>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+  import GroupMemberList from './group-member-list.vue'
+  import { Select, Option, Switch } from 'element-ui'
+  export default {
+    props: ['groupProfile'],
+    components: {
+      GroupMemberList,
+      ElSelect: Select,
+      ElOption: Option,
+      ElSwitch: Switch,
+    },
+    data() {
+      return {
+        showEditName: false,
+        showEditFaceUrl: false,
+        showEditIntroduction: false,
+        showEditNotification: false,
+        showEditJoinOption: false,
+        showChangeGroupOwner: false,
+        showEditMessageRemindType: false,
+        showEditNameCard: false,
+        name: this.groupProfile.name,
+        avatar: this.groupProfile.avatar,
+        introduction: this.groupProfile.introduction,
+        notification: this.groupProfile.notification,
+        joinOption: this.groupProfile.joinOption,
+        newOwnerUserID: '',
+        messageRemindType: this.groupProfile.selfInfo.messageRemindType,
+        nameCard: this.groupProfile.selfInfo.nameCard || '',
+        muteAllMembers: this.groupProfile.muteAllMembers,
+        messageRemindTypeMap: {
+          AcceptAndNotify: '接收消息并提示',
+          AcceptNotNotify: '接收消息但不提示',
+          Discard: '屏蔽消息'
+        },
+        joinOptionMap: {
+          FreeAccess: '自由加入',
+          NeedPermission: '需要验证',
+          DisableApply: '禁止加群'
+        },
+        active:false
+      }
+    },
+    computed: {
+      editable() {
+        return (
+          this.groupProfile.type === 2 ||
+          [100, 60].includes(this.groupProfile.selfInfo.role)
+        )
+      },
+      isOwner() {
+        return this.groupProfile.selfInfo.role === 100
+      },
+      isAdmin() {
+        return this.groupProfile.selfInfo.role === 60
+      },
+      showDissmissGroup() {
+        // 好友工作群不能解散
+        //return this.isOwner && this.groupProfile.type !== this.OpenIM.TYPES.GRP_WORK
+      },
+      groupType() {
+        //switch (this.groupProfile.type) {
+        //  case this.OpenIM.TYPES.GRP_WORK:
+        //    return '好友工作群(Work)'
+        //  case this.OpenIM.TYPES.GRP_PUBLIC:
+        //    return '陌生人社交群(Public)'
+        //  case this.OpenIM.TYPES.GRP_CHATROOM:
+        //    return '临时会议群(Meeting)'
+        //  case this.OpenIM.TYPES.GRP_AVCHATROOM:
+        //    return '直播群(AVChatRoom)'
+        //  default:
+        //    return ''
+        //}
+      }
+    },
+    watch: {
+      groupProfile(groupProfile) {
+        Object.assign(this, {
+          showEditName: false,
+          showEditFaceUrl: false,
+          showEditIntroduction: false,
+          showEditNotification: false,
+          showEditJoinOption: false,
+          showChangeGroupOwner: false,
+          showEditNameCard: false,
+          name: groupProfile.name,
+          avatar: groupProfile.avatar,
+          introduction: groupProfile.introduction,
+          notification: groupProfile.notification,
+          joinOption: groupProfile.joinOption,
+          messageRemindType: groupProfile.messageRemindType,
+          nameCard: groupProfile.selfInfo.nameCard || '',
+          muteAllMembers: groupProfile.muteAllMembers,
+        })
+      }
+    },
+    methods: {
+      inputFocus(ref) {
+        this.$nextTick(() => {
+          this.$refs[ref].focus()
+        })
+      },
+      editName() {
+        this.tim
+          .updateGroupProfile({
+            groupID: this.groupProfile.groupID,
+            name: this.name.trim()
+          })
+          .then(() => {
+            this.showEditName = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      editFaceUrl() {
+        this.tim
+          .updateGroupProfile({
+            groupID: this.groupProfile.groupID,
+            avatar: this.avatar.trim()
+          })
+          .then(() => {
+            this.showEditFaceUrl = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      changeMuteStatus() {
+        if (this.isOwner || this.isAdmin) {
+          this.tim
+            .updateGroupProfile({
+              muteAllMembers: this.muteAllMembers,
+              groupID: this.groupProfile.groupID
+            })
+            .then(imResponse => {
+              this.muteAllMembers = imResponse.data.group.muteAllMembers
+              if (this.muteAllMembers) {
+                this.active = true
+                this.$store.commit('showMessage', {
+                  message: '全体禁言'
+                })
+              } else {
+                this.active = false
+                this.$store.commit('showMessage', {
+                  message: '取消全体禁言'
+                })
+              }
+            })
+            .catch(error => {
+              this.$store.commit('showMessage', {
+                type: 'error',
+                message: error.message
+              })
+            })
+        } else {
+          this.muteAllMembers = this.groupProfile.muteAllMembers
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: '普通群成员不能对全体禁言'
+          })
+        }
+      },
+      editIntroduction() {
+        this.tim
+          .updateGroupProfile({
+            groupID: this.groupProfile.groupID,
+            introduction: this.introduction.trim()
+          })
+          .then(() => {
+            this.showEditIntroduction = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      editNotification() {
+        this.tim
+          .updateGroupProfile({
+            groupID: this.groupProfile.groupID,
+            notification: this.notification.trim()
+          })
+          .then(() => {
+            this.showEditNotification = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      editJoinOption() {
+        this.tim
+          .updateGroupProfile({
+            groupID: this.groupProfile.groupID,
+            joinOption: this.joinOption
+          })
+          .then(() => {
+            this.showEditJoinOption = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      changeOwner() {
+        this.tim
+          .changeGroupOwner({
+            groupID: this.groupProfile.groupID,
+            newOwnerID: this.newOwnerUserID
+          })
+          .then(() => {
+            this.showChangeGroupOwner = false
+            this.$store.commit('showMessage', {
+              message: `转让群主成功,新群主ID:${this.newOwnerUserID}`
+            })
+            this.newOwnerUserID = ''
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      quitGroup() {
+        this.tim.quitGroup(this.groupProfile.groupID).then(({ data: { groupID } }) => {
+          this.$store.commit('showMessage', {
+            message: '退群成功'
+          })
+          if (groupID === this.groupProfile.groupID) {
+            this.$store.commit('resetCurrentConversation')
+          }
+        })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      dismissGroup() {
+        this.tim.dismissGroup(this.groupProfile.groupID).then(({ data: { groupID } }) => {
+          this.$store.commit('showMessage', {
+            message: `群:${this.groupProfile.name || this.groupProfile.groupID}解散成功!`,
+            type: 'success'
+          })
+          if (groupID === this.groupProfile.groupID) {
+            this.$store.commit('resetCurrentConversation')
+          }
+        })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      editMessageRemindType() {
+        this.tim
+          .setMessageRemindType({
+            groupID: this.groupProfile.groupID,
+            messageRemindType: this.messageRemindType
+          })
+          .then(() => {
+            this.showEditMessageRemindType = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      editNameCard() {
+        if (this.nameCard.trim().length === 0) {
+          this.$store.commit('showMessage', {
+            message: '不能设置空的群名片',
+            type: 'warning'
+          })
+          return
+        }
+        this.tim
+          .setGroupMemberNameCard({
+            groupID: this.groupProfile.groupID,
+            nameCard: this.nameCard.trim()
+          })
+          .then(() => {
+            this.showEditNameCard = false
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      }
+    }
+  }
+</script>
+
+<style lang="stylus">
+    .group-info-content
+        padding 10px 10px
+        .avatar
+            width 40px
+            height 40px
+            border-radius 50%
+    .info-item {
+        margin-bottom: 12px;
+
+        .label {
+            font-size: 14px;
+            color: $secondary;
+        }
+        .active {
+            color: $black
+        }
+        .content {
+            color: $background;
+            word-wrap: break-word;
+            word-break: break-all;
+        }
+        .long-content {
+            word-wrap:break-word;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            display: -webkit-box;
+            -webkit-box-orient: vertical;
+            -webkit-line-clamp: 3;
+        }
+    }
+    .cursor-pointer {
+        cursor: pointer;
+    }
+    /* 设置滚动条的样式 */
+    ::-webkit-scrollbar {
+        width: 0px;
+        height: 0px;
+    }
+</style>

+ 71 - 0
src/components/conversation/conversationProfile/add-group-member.vue

@@ -0,0 +1,71 @@
+<template>
+  <div>
+    <el-input v-model="userID" placeholder="输入userID后 按回车键" @keydown.enter.native="addGroupMember"></el-input>
+  </div>
+</template>
+
+<script>
+import { Input } from 'element-ui'
+import { mapState } from 'vuex'
+export default {
+  components: {
+    ElInput: Input
+  },
+  data() {
+    return {
+      userID: ''
+    }
+  },
+  computed: {
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation
+    })
+  },
+  methods: {
+    addGroupMember() {
+      const groupID = this.currentConversation.conversationID.replace('GROUP', '')
+      this.tim
+        .addGroupMember({
+          groupID,
+          userIDList: [this.userID]
+        })
+        .then((imResponse) => {
+          const {
+            successUserIDList,
+            failureUserIDList,
+            existedUserIDList
+          } = imResponse.data
+          if (successUserIDList.length > 0) {
+            this.$store.commit('showMessage', {
+              message: `群成员:${successUserIDList.join(',')},加群成功`,
+              type: 'success'
+            })
+            this.tim.getGroupMemberProfile({groupID, userIDList: successUserIDList})
+            .then(({ data: { memberList }}) => {
+              this.$store.commit('updateCurrentMemberList', memberList)
+            })
+          }
+          if (failureUserIDList.length > 0) {
+            this.$store.commit('showMessage', {
+              message: `群成员:${failureUserIDList.join(',')},添加失败!`,
+              type: 'error'
+            })
+          }
+          if (existedUserIDList.length > 0) {
+            this.$store.commit('showMessage', {
+              message: `群成员:${existedUserIDList.join(',')},已在群中`
+            })
+          }
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped></style>

+ 247 - 0
src/components/conversation/conversationProfile/group-member-info.vue

@@ -0,0 +1,247 @@
+<template>
+  <div>
+    <div>
+      <span class="label">userID:</span>
+      {{ member.userID }}
+      <el-button v-if="showCancelBan" type="text" @click="cancelMute">取消禁言</el-button>
+      <el-popover title="禁言" v-model="popoverVisible" v-show="showBan">
+        <el-input
+          v-model="muteTime"
+          placeholder="请输入禁言时间"
+          @keydown.enter.native="setGroupMemberMuteTime"
+        />
+        <el-button slot="reference" type="text" style="color:red;">禁言</el-button>
+      </el-popover>
+    </div>
+    <div>
+      <span class="label">nick:</span>
+      {{ member.nick || '暂无' }}
+    </div>
+    <div>
+      <span class="label">nameCard:</span>
+      {{ member.nameCard || '暂无' }}
+      <el-popover title="修改群名片" v-model="nameCardPopoverVisible" v-show="showEditNameCard">
+        <el-input
+          v-model="nameCard"
+          placeholder="请输入群名片"
+          @keydown.enter.native="setGroupMemberNameCard"
+        />
+        <i
+          class="el-icon-edit"
+          title="修改群名片"
+          slot="reference"
+          style="cursor:pointer; font-size:1.6rem;"
+        ></i>
+      </el-popover>
+    </div>
+    <div>
+      <span class="label">role:</span>
+      <span class="content role" :title="changeRoleTitle">{{ member.role }}</span>
+    </div>
+    <div v-if="showMuteUntil">
+      <span class="label">禁言至:</span>
+      <span class="content">{{ muteUntil }}</span>
+    </div>
+    <el-button type="text" v-if="canChangeRole" @click="changeMemberRole">
+      {{
+      member.role === 'Admin' ? '取消管理员' : '设为管理员'
+      }}
+    </el-button>
+    <el-button type="text" v-if="showKickout" style="color:red;" @click="kickoutGroupMember">踢出群组</el-button>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { Popover } from 'element-ui'
+import { getFullDate } from '../../../utils/date'
+export default {
+  components: {
+    ElPopover: Popover
+  },
+  props: ['member'],
+  data() {
+    return {
+      muteTime: '',
+      popoverVisible: false,
+      nameCardPopoverVisible: false,
+      nameCard: this.member.nameCard
+    }
+  },
+  computed: {
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation,
+      currentUserProfile: state => state.user.currentUserProfile,
+      current: state => state.current
+    }),
+    // 是否显示踢出群成员按钮
+    showKickout() {
+      return (this.isOwner || this.isAdmin) && !this.isMine
+    },
+    isOwner() {
+      return this.currentConversation.groupProfile.selfInfo.role === 'Owner'
+    },
+    isAdmin() {
+      return this.currentConversation.groupProfile.selfInfo.role === 'Admin'
+    },
+    isMine() {
+      return this.currentUserProfile.userID === this.member.userID
+    },
+    canChangeRole() {
+      return (
+        this.isOwner &&
+        ['ChatRoom', 'Public'].includes(this.currentConversation.subType)
+      )
+    },
+    changeRoleTitle() {
+      if (!this.canChangeRole) {
+        return ''
+      }
+      return this.isOwner && this.member.role === 'Admin'
+        ? '设为:Member'
+        : '设为:Admin'
+    },
+    // 是否显示禁言时间
+    showMuteUntil() {
+      // 禁言时间小于当前时间
+      return this.member.muteUntil * 1000 > this.current
+    },
+    // 是否显示取消禁言按钮
+    showCancelBan() {
+      if (
+        this.showMuteUntil &&
+        this.currentConversation.type === 3 &&
+        !this.isMine
+      ) {
+        return this.isOwner || this.isAdmin
+      }
+      return false
+    },
+    // 是否显示禁言按钮
+    showBan() {
+      if (this.currentConversation.type === 3) {
+        return this.isOwner || this.isAdmin
+      }
+      return false
+    },
+    // 是否显示编辑群名片按钮
+    showEditNameCard() {
+      return this.isOwner || this.isAdmin
+    },
+    // 日期格式化后的禁言时间
+    muteUntil() {
+      return getFullDate(new Date(this.member.muteUntil * 1000))
+    }
+  },
+  methods: {
+    kickoutGroupMember() {
+      this.tim
+        .deleteGroupMember({
+          groupID: this.currentConversation.groupProfile.groupID,
+          reason: '我要踢你出群',
+          userIDList: [this.member.userID]
+        })
+        .then(() => {
+          this.$store.commit('deleteGroupMemeber', this.member.userID)
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    changeMemberRole() {
+      if (!this.canChangeRole) {
+        return
+      }
+      let currentRole = this.member.role
+      this.tim
+        .setGroupMemberRole({
+          groupID: this.currentConversation.groupProfile.groupID,
+          userID: this.member.userID,
+          role: currentRole === 'Admin' ? 'Member' : 'Admin'
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    setGroupMemberMuteTime() {
+      this.tim
+        .setGroupMemberMuteTime({
+          groupID: this.currentConversation.groupProfile.groupID,
+          userID: this.member.userID,
+          muteTime: Number(this.muteTime)
+        })
+        .then(() => {
+          this.muteTime = ''
+          this.popoverVisible = false
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    // 取消禁言
+    cancelMute() {
+      this.tim
+        .setGroupMemberMuteTime({
+          groupID: this.currentConversation.groupProfile.groupID,
+          userID: this.member.userID,
+          muteTime: 0
+        })
+        .then(() => {
+          this.muteTime = ''
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    setGroupMemberNameCard() {
+      if (this.nameCard.trim().length === 0) {
+        this.$store.commit('showMessage', {
+          message: '不能设置空的群名片',
+          type: 'warning'
+        })
+        return
+      }
+      this.tim
+        .setGroupMemberNameCard({
+          groupID: this.currentConversation.groupProfile.groupID,
+          userID: this.member.userID,
+          nameCard: this.nameCard
+        })
+        .then(() => {
+          this.nameCardPopoverVisible = false
+          this.$store.commit('showMessage', {
+            message: '修改成功'
+          })
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.label {
+  color: rgb(204, 200, 200);
+}
+
+.cursor-pointer {
+  cursor: pointer;
+}
+</style>

+ 155 - 0
src/components/conversation/conversationProfile/group-member-list.vue

@@ -0,0 +1,155 @@
+<template>
+  <div class="group-member-list-wrapper">
+    <div class="header">
+      <span class="member-count text-ellipsis">群成员:{{currentConversation.groupProfile.memberCount}}</span>
+      <popover v-model="addGroupMemberVisible">
+        <add-group-member></add-group-member>
+        <div slot="reference" class="btn-add-member" title="添加群成员">
+          <span class="tim-icon-friend-add"></span>
+        </div>
+      </popover>
+    </div>
+    <div class="scroll-content">
+      <div class="group-member-list">
+        <div v-for="member in members" :key="member.userID">
+          <popover placement="right" :key="member.userID">
+            <group-member-info :member="member" />
+            <div slot="reference" class="group-member" @click="currentMemberID = member.userID">
+              <avatar :title=getGroupMemberAvatarText(member.role) :src="member.avatar" />
+              <div class="member-name text-ellipsis">
+                <span v-if="member.nameCard" :title=member.nameCard>{{ member.nameCard }}</span>
+                <span v-else-if="member.nick" :title=member.nick>{{ member.nick }}</span>
+                <span v-else :title=member.userID>{{ member.userID }}</span>
+              </div>
+            </div>
+          </popover>
+        </div>
+      </div>
+    </div>
+    <div class="more">
+      <el-button v-if="showLoadMore" type="text" @click="loadMore">查看更多</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import { Popover } from 'element-ui'
+import { mapState } from 'vuex'
+import AddGroupMember from './add-group-member.vue'
+import GroupMemberInfo from './group-member-info.vue'
+export default {
+  data() {
+    return {
+      addGroupMemberVisible: false,
+      currentMemberID: '',
+      count: 30 // 显示的群成员数量
+    }
+  },
+  props: ['groupProfile'],
+  components: {
+    Popover,
+    AddGroupMember,
+    GroupMemberInfo
+  },
+  computed: {
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation,
+      currentMemberList: state => state.group.currentMemberList
+    }),
+    showLoadMore() {
+      return this.members.length < this.groupProfile.memberCount
+    },
+    members() {
+      return this.currentMemberList.slice(0, this.count)
+    }
+  },
+  methods: {
+    getGroupMemberAvatarText(role) {
+      switch (role) {
+        case 'Owner':
+          return '群主'
+        case 'Admin':
+          return '管理员'
+        default:
+          return '群成员'
+      }
+    },
+    loadMore() {
+      this.$store
+        .dispatch('getGroupMemberList', this.groupProfile.groupID)
+        .then(() => {
+          this.count += 30
+        })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.group-member-list-wrapper
+  .header
+    height 50px
+    padding 10px 16px 10px 20px
+    border-bottom 1px solid $border-base
+    .member-count
+      display inline-block
+      max-width 130px
+      line-height 30px
+      font-size 14px
+      vertical-align bottom
+    .btn-add-member
+      width 30px
+      height 30px
+      font-size 28px
+      text-align center
+      line-height 32px
+      cursor pointer
+      float right
+      &:hover
+        color $light-primary
+  .scroll-content
+    max-height: 250px;
+    overflow-y: scroll;
+    padding 10px 15px 10px 15px
+    width 100%
+    .group-member-list
+      display flex
+      justify-content flex-start
+      flex-wrap wrap
+      width 100%
+    .group-member
+      width 40px
+      height 70px
+      display: flex;
+      justify-content center
+      align-content center
+      flex-direction: column;
+      text-align: center;
+      color: $black;
+      cursor: pointer;
+      margin: 0 20px 10px 0;
+      padding: 10px 0 0 0;
+      .avatar
+        width 40px
+        height 40px
+        border-radius 50%
+      .member-name
+        font-size 12px
+        width: 50px;
+        text-align center
+  .more
+    padding 0 20px
+    border-bottom 1px solid $border-base
+
+// .add-group-member {
+//   cursor: pointer;
+// }
+// .add-button {
+//   border: 1px solid gray;
+//   text-align: center;
+//   line-height: 30px;
+// }
+
+
+
+</style>

+ 873 - 0
src/components/conversation/conversationProfile/group-profile.vue

@@ -0,0 +1,873 @@
+<template>
+  <div>
+    <div v-if="loading" class="loading">加载中...</div>
+    <div v-else-if="error" class="error">{{ error }}</div>
+    <div v-else-if="groupData">
+      <!--      <group-member-list :groupProfile="groupData" />-->
+      <div class="group-info-content">
+        <div class="info-item">
+          <div class="label">群ID</div>
+          <div class="content">{{ groupData.groupID }}</div>
+        </div>
+
+        <!-- 群头像 -->
+        <div class="info-item">
+          <div class="label">
+            群头像
+            <!--            <i-->
+            <!--              class="el-icon-edit"-->
+            <!--              v-if="isOwner"-->
+            <!--              @click="showEditFaceUrl = true; inputFocus('editFaceUrl')"-->
+            <!--              style="cursor:pointer; font-size:16px;"-->
+            <!--            />-->
+          </div>
+          <div class="content" v-if="!showEditFaceUrl">
+            <avatar :src="groupData.faceURL" />
+          </div>
+          <el-input
+            ref="editFaceUrl"
+            v-else
+            autofocus
+            v-model="avatar"
+            size="mini"
+            @blur="showEditFaceUrl = false"
+            @keydown.enter.native="editFaceUrl"
+          />
+        </div>
+
+        <!-- 群名称 -->
+        <!-- 群名称 -->
+        <div class="info-item">
+          <div class="label">群名称</div>
+          <div v-if="!showEditName" class="text-box">
+            <span class="content-text text-ellipsis">{{ groupData.groupName || '暂无' }}</span>
+            <i
+              class="el-icon-edit"
+              v-if="isOwner"
+              @click.stop="showEditName = true; inputFocus('editName')"
+              style="cursor:pointer; font-size:16px; margin-left:6px"
+            />
+          </div>
+          <div v-else>
+            <el-input
+              ref="editName"
+              v-model="name"
+              size="mini"
+              style="width: 300px;"
+            />
+            <div style="margin-top: 5px;">
+              <el-button type="primary" size="mini" @click="editName">保存</el-button>
+              <el-button size="mini" @click="showEditName = false" style="margin-left: 5px;">取消</el-button>
+            </div>
+          </div>
+        </div>
+
+        <!-- 群介绍 -->
+        <div class="info-item">
+          <div class="label">群介绍</div>
+          <div v-if="!showEditIntroduction" class="text-box">
+            <span class="content-text text-ellipsis">{{ groupData.introduction || '暂无' }}</span>
+            <i
+              class="el-icon-edit"
+              v-if="isOwner"
+              @click.stop="showEditIntroduction = true; inputFocus('editIntroduction')"
+              style="cursor:pointer; font-size:16px; margin-left:6px"
+            />
+          </div>
+          <div v-else>
+            <el-input
+              ref="editIntroduction"
+              v-model="introduction"
+              size="mini"
+              type="textarea"
+              style="width: 400px;"
+            />
+            <div style="margin-top: 5px;">
+              <el-button type="primary" size="mini" @click="editIntroduction">保存</el-button>
+              <el-button size="mini" @click="showEditIntroduction = false" style="margin-left: 5px;">取消</el-button>
+            </div>
+          </div>
+        </div>
+
+        <!-- 群公告 -->
+        <div class="info-item">
+          <div class="label">群公告</div>
+          <div v-if="!showEditNotification" class="text-box">
+            <span class="content-text text-ellipsis">{{ groupData.notification || '暂无' }}</span>
+            <i
+              class="el-icon-edit"
+              v-if="isOwner"
+              @click.stop="showEditNotification = true; inputFocus('editNotification')"
+              style="cursor:pointer; font-size:16px; margin-left:6px"
+            />
+          </div>
+          <div v-else>
+            <el-input
+              ref="editNotification"
+              v-model="notification"
+              size="mini"
+              type="textarea"
+              style="width: 400px;"
+            />
+            <div style="margin-top: 5px;">
+              <el-button type="primary" size="mini" @click="editNotification">保存</el-button>
+              <el-button size="mini" @click="showEditNotification = false" style="margin-left: 5px;">取消</el-button>
+            </div>
+          </div>
+        </div>
+
+
+
+
+        <!--        <div class="info-item" v-if="groupData.type !== 'Private'">-->
+        <!--          <div class="label">-->
+        <!--            申请加群方式-->
+        <!--            <i-->
+        <!--              class="el-icon-edit"-->
+        <!--              v-if="editable"-->
+        <!--              @click="showEditJoinOption = true; inputFocus('editJoinOption')"-->
+        <!--              style="cursor:pointer; font-size:16px;"-->
+        <!--            />-->
+        <!--          </div>-->
+        <!--          <div class="content" v-show="!showEditJoinOption">-->
+        <!--            {{ joinOptionMap[groupData.joinOption] }}-->
+        <!--          </div>-->
+        <!--          <el-select-->
+        <!--            ref="editJoinOption"-->
+        <!--            v-model="joinOption"-->
+        <!--            v-show="showEditJoinOption"-->
+        <!--            size="mini"-->
+        <!--            automatic-dropdown-->
+        <!--            @blur="showEditJoinOption = false"-->
+        <!--            @change="editJoinOption"-->
+        <!--          >-->
+        <!--            <el-option label="自由加入" value="FreeAccess" />-->
+        <!--            <el-option label="需要验证" value="NeedPermission" />-->
+        <!--            <el-option label="禁止加群" value="DisableApply" />-->
+        <!--          </el-select>-->
+        <!--        </div>-->
+
+        <!--        <div class="info-item">-->
+        <!--          <div class="label">-->
+        <!--            群消息提示类型-->
+        <!--            <i-->
+        <!--              class="el-icon-edit"-->
+        <!--              v-if="editable"-->
+        <!--              @click="showEditMessageRemindType = true; inputFocus('editMessageRemindType')"-->
+        <!--              style="cursor:pointer; font-size:16px;"-->
+        <!--          />-->
+        <!--        </div>-->
+        <!--        <div class="content" v-show="!showEditMessageRemindType">-->
+        <!--          {{ messageRemindTypeMap[this.groupProfile.selfInfo.messageRemindType] }}-->
+        <!--        </div>-->
+        <!--        <el-select-->
+        <!--            ref="editMessageRemindType"-->
+        <!--            v-show="showEditMessageRemindType"-->
+        <!--            v-model="messageRemindType"-->
+        <!--            size="mini"-->
+        <!--            automatic-dropdown-->
+        <!--            @change="editMessageRemindType"-->
+        <!--            @blur="showEditMessageRemindType = false"-->
+        <!--          >-->
+        <!--            <el-option label="接收消息并提示" value="AcceptAndNotify" />-->
+        <!--            <el-option label="接收消息但不提示" value="AcceptNotNotify" />-->
+        <!--            <el-option label="屏蔽消息" value="Discard" />-->
+        <!--          </el-select>-->
+        <!--        </div>-->
+
+        <!--        <div class="info-item">-->
+        <!--          <div class="label">我的群名片</div>-->
+        <!--          <div class="content cursor-pointer" v-if="!showEditNameCard">-->
+        <!--            {{ groupProfile.selfInfo.nameCard || '暂无' }}-->
+        <!--          </div>-->
+        <!--          <el-input-->
+        <!--            ref="editNameCard"-->
+        <!--            v-else-->
+        <!--            autofocus-->
+        <!--            v-model="nameCard"-->
+        <!--            size="mini"-->
+        <!--            @blur="showEditNameCard = false"-->
+        <!--            @keydown.enter.native="editNameCard"-->
+        <!--          />-->
+        <!--        </div>-->
+
+        <div class="info-item">
+          <div class="label">全体禁言</div>
+          <el-switch
+            :value="groupData.status === 3"
+            active-color="#409eff"
+            inactive-color="#dcdfe6"
+            @change="changeMuteStatus"
+          />
+        </div>
+
+
+        <div v-if="isOwner" class="change-owner-box">
+          <el-button type="text" @click="showChangeGroupOwner = !showChangeGroupOwner">
+            转让群组
+          </el-button>
+
+          <div v-if="showChangeGroupOwner" class="change-owner-select">
+            <el-select
+              v-model="newOwnerUserID"
+              placeholder="选择新的群主"
+              filterable
+              clearable
+              size="mini"
+              style="width: 200px; margin-top: 8px;"
+              @change="changeOwner"
+            >
+              <el-option
+                v-for="member in memberList"
+                :key="member.userID"
+                :label="`${member.nickname || member.userID}`"
+                :value="member.userID"
+              >
+                <div style="display:flex;align-items:center;">
+                  <img
+                    :src="member.faceURL || defaultAvatar"
+                    style="width:24px;height:24px;border-radius:50%;margin-right:8px;"
+                  />
+                  <span>{{ member.nickname || member.userID }}</span>
+                </div>
+              </el-option>
+            </el-select>
+          </div>
+        </div>
+
+
+        <div>
+          <el-button type="text" style="color:red;" @click="quitGroup">退出群组</el-button>
+        </div>
+        <div v-if="showDissmissGroup&& isOwner">
+          <el-button type="text" style="color:red;" @click="dismissGroup">解散群组</el-button>
+        </div>
+        <div class="group-member-list-box">
+          <h4>群成员列表</h4>
+          <el-table
+            :data="memberList"
+            style="width: 100%"
+            :row-key="row => row.userID"
+          >
+            <el-table-column prop="nickname" label="昵称" width="180">
+              <template slot-scope="scope">
+                <div style="display:flex; align-items:center;">
+                  <img :src="scope.row.faceURL || defaultAvatar" style="width:24px;height:24px;border-radius:50%;margin-right:8px;" />
+                  <span>{{ scope.row.nickname || scope.row.userID }}</span>
+                </div>
+              </template>
+            </el-table-column>
+
+            <el-table-column label="操作" width="100">
+              <template slot-scope="scope">
+                <el-button
+                  type="text"
+                  size="mini"
+                  style="color:red;"
+                  v-if="isOwner"
+                  @click="kickMember(scope.row.userID)"
+                >踢出</el-button>
+                <el-button
+                  type="text"
+                  size="mini"
+                  style="color:blue;"
+                  @click="addFriend(scope.row.userID)"
+                >加好友</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+        <div v-if="isOwner" class="invite-box">
+          <el-button type="text" @click="toggleInviteFriends">
+            邀请好友入群
+          </el-button>
+
+          <div v-if="showInviteFriends" style="margin-top: 8px;">
+            <el-select
+              v-model="inviteUserIDs"
+              placeholder="选择好友"
+              multiple
+              filterable
+              clearable
+              size="mini"
+              style="width: 250px;"
+            >
+              <!-- 已在群聊的好友禁用选择 -->
+              <el-option
+                v-for="friend in friendList"
+                :key="friend.userID"
+                :label="friend.nickname || friend.userID"
+                :value="friend.userID"
+                :disabled="friend.isInGroup"
+              >
+                <span>{{ friend.nickname || friend.userID }}</span>
+                <span
+                  v-if="friend.isInGroup"
+                  style="color: #aaa; margin-left: 4px; font-size: 12px;"
+                >
+          (已在群聊)
+        </span>
+              </el-option>
+
+              <!-- 分页加载按钮 -->
+              <div
+                class="load-more"
+                @click.stop="loadMoreOptions"
+                style="padding: 5px; text-align: center; color: #409EFF; cursor: pointer;"
+              >
+                加载更多...
+              </div>
+            </el-select>
+
+            <div style="margin-top: 5px;">
+              <el-button
+                type="primary"
+                size="mini"
+                @click="inviteFriendsToGroup"
+                :disabled="!inviteUserIDs.length"
+              >
+                邀请
+              </el-button>
+              <el-button
+                size="mini"
+                @click="showInviteFriends = false"
+                style="margin-left: 5px;"
+              >
+                取消
+              </el-button>
+            </div>
+          </div>
+        </div>
+
+
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import GroupMemberList from './group-member-list.vue'
+import { Select, Option, Switch } from 'element-ui'
+import { getOpenIM } from '@/utils/openIM';
+import {mapGetters, mapState} from "vuex";
+import {addFriend, createGroup} from '@/api/group';
+export default {
+  props: ['groupProfile'], // 这里其实是 groupID
+  components: {
+    GroupMemberList,
+    ElSelect: Select,
+    ElOption: Option,
+    ElSwitch: Switch,
+  },
+  data() {
+    return {
+      groupData: null,
+      loading: true,
+      error: null,
+      friendList: [],
+      offset:0,
+      count:20,
+
+      // 编辑状态
+      showEditName: false,
+      showEditFaceUrl: false,
+      showEditIntroduction: false,
+      showEditNotification: false,
+      showEditJoinOption: false,
+      showChangeGroupOwner: false,
+      showEditMessageRemindType: false,
+      showEditNameCard: false,
+      showInviteFriends:false,
+
+      // 临时变量
+      name: '',
+      avatar: '',
+      introduction: '',
+      notification: '',
+      joinOption: '',
+      newOwnerUserID: '',
+      messageRemindType: '',
+      nameCard: '',
+      muteAllMembers: false,
+      memberList: [],
+      inviteUserIDs:[],
+      defaultAvatar: 'https://cdn.his.cdwjyyh.com/fs/default-avatar.png',
+
+      active: false,
+      messageRemindTypeMap: {
+        AcceptAndNotify: '接收消息并提示',
+        AcceptNotNotify: '接收消息但不提示',
+        Discard: '屏蔽消息'
+      },
+      joinOptionMap: {
+        FreeAccess: '自由加入',
+        NeedPermission: '需要验证',
+        DisableApply: '禁止加群'
+      },
+    }
+  },
+  created() {
+    this.OpenIM = getOpenIM()
+    this.friendList = []
+    this.offset = 0
+    this.fetchGroupProfile()
+    this.fetchMemberList()
+    this.fetchFriendList()
+  },
+  mounted() {
+    // 如果当前用户是群主才加载成员列表
+    console.log("this.isOwner",this.isOwner)
+    if (this.isOwner) {
+      this.fetchMemberList()
+    }
+  },
+  watch: {
+    groupProfile(newVal) {
+      if (newVal) {
+        this.fetchGroupProfile()
+        if (this.isOwner) {
+          this.fetchMemberList()
+        }
+      }
+    }
+  },
+  computed: {
+    ...mapGetters(['toAccount', 'currentConversationType']),
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation,
+      userID: state => state.imuser.userID,
+    }),
+    editable() {
+      if (!this.groupData) return false
+      return true
+    },
+    isOwner() {
+      return this.groupData.ownerUserID === this.userID
+    },
+    isAdmin() {
+      //return this.groupProfile.selfInfo.role === this.OpenIM.TYPES.GRP_MBR_ROLE_ADMIN
+      return true
+    },
+    showDissmissGroup() {
+      // 好友工作群不能解散
+      //return this.isOwner && this.groupProfile.type !== this.OpenIM.TYPES.GRP_WORK
+      return true
+    },
+    groupType() {
+      /*switch (this.groupProfile.type) {
+        case this.OpenIM.TYPES.GRP_WORK:
+          return '好友工作群'
+        case this.OpenIM.TYPES.GRP_PUBLIC:
+          return '陌生人社交群'
+        case this.OpenIM.TYPES.GRP_CHATROOM:
+          return '会议群'
+        case this.OpenIM.TYPES.GRP_AVCHATROOM:
+          return '直播群'
+        default:
+          return ''
+      }*/
+    }
+  },
+  methods: {
+    async toggleInviteFriends() {
+      this.showInviteFriends = !this.showInviteFriends
+      if (this.showInviteFriends) {
+        this.offset = 0
+        this.friendList = []
+        await this.fetchFriendList()
+      }
+    },
+    deleteConversation() {
+      console.log(this.currentConversation)
+      this.OpenIM.deleteConversationAndDeleteAllMsg(this.currentConversation.conversationID).then(() => {
+        this.$store.commit('showMessage', {
+          message: `会话【${this.conversationName}】删除成功!`,
+          type: 'success'
+        })
+        this.popoverVisible = false
+        this.$store.commit('resetCurrentConversation')
+        this.OpenIM.getConversationListSplit({
+          offset: 0,
+          count: 100,
+        })
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到会话列表",data)
+            this.conversationList= data
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+      })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            message: `会话【${this.conversationName}】删除失败!, error=${error.message}`,
+            type: 'error'
+          })
+          this.popoverVisible = false
+        })
+    },
+    async fetchFriendList() {
+      console.log("获取到好友列表", this.offset, this.count);
+      try {
+        // 获取好友分页
+        const { data: friendData } = await this.OpenIM.getFriendListPage({
+          offset: this.offset,
+          count: this.count
+        });
+
+        if (friendData && friendData.length) {
+          // 获取群成员列表
+          const { data: memberData } = await this.OpenIM.getGroupMemberList({
+            groupID: this.groupProfile,
+            filter: 0,
+            offset: 0,
+            count: 1000,
+          });
+
+          const memberIDs = memberData.map(m => m.userID);
+          console.log("群成员列表", memberIDs);
+
+          // 给好友打上 isInGroup 标记
+          const friendListWithFlag = friendData.map(f => ({
+            ...f,
+            isInGroup: memberIDs.includes(f.userID)
+          }));
+
+          // 拼接或覆盖
+          if (this.offset > 0) {
+            this.friendList = [...this.friendList, ...friendListWithFlag];
+          } else {
+            this.friendList = friendListWithFlag;
+          }
+
+          console.log("拼接后的好友列表(含群内标记)", this.friendList);
+        }
+      } catch (e) {
+        console.error("获取好友列表失败", e);
+      }
+    },
+
+    async loadMoreOptions() {
+      console.log("加载更多好友列表");
+      this.offset += this.count; // 偏移量增加
+      await this.fetchFriendList();
+    },
+
+    async inviteFriendsToGroup() {
+      if (!this.inviteUserIDs.length) {
+        this.$store.commit('showMessage', { type: 'warning', message: '请选择好友' })
+        return
+      }
+
+      try {
+        await this.OpenIM.inviteUserToGroup({
+          groupID: this.groupData.groupID,
+          reason:"邀请进群",
+          userIDList: this.inviteUserIDs
+        })
+        this.$store.commit('showMessage', {
+          message: `已成功邀请 ${this.inviteUserIDs.length} 位好友入群`
+        })
+        this.showInviteFriends = false
+        this.inviteUserIDs = []
+        this.fetchMemberList() // 更新群成员列表
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async kickMember(userID) {
+      console.log(userID)
+      console.log(this.groupData.groupID)
+      if (!this.isOwner) {
+        this.$store.commit('showMessage', { type: 'error', message: '普通成员无法操作' })
+        return
+      }
+
+      try {
+        await this.OpenIM.kickGroupMember({
+          groupID: this.groupData.groupID,
+          userIDList: [userID],
+          reason: '被群主踢出'
+        })
+        this.deleteConversation()
+        this.$store.commit('showMessage', { message: `已踢出成员:${userID}` })
+        // 更新成员列表
+        this.memberList = this.memberList.filter(m => m.userID !== userID)
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async addFriend(userID) {
+      console.log(userID)
+      console.log(this.groupData.groupID)
+      addFriend(userID).then(res => {
+        this.$store.commit('showMessage', {
+          type: 'success',
+          message: '添加好友成功'
+        })
+      })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    async fetchMemberList() {
+      try {
+        const { data } = await this.OpenIM.getGroupMemberList({
+          groupID: this.groupProfile,
+          filter: 0,
+          offset: 0,
+          count: 1000,
+        })
+        this.memberList = data || []
+      } catch (e) {
+        console.error('获取群成员失败', e)
+      }
+    },
+    fetchGroupProfile() {
+      console.log('groupProfile', this.groupProfile)
+      if (!this.groupProfile) return
+      this.loading = true
+      this.error = null
+      this.OpenIM.getSpecifiedGroupsInfo([this.groupProfile])
+        .then(({ data }) => {
+          if (!data || !data.length) {
+            throw new Error('群资料为空')
+          }
+          this.groupData = data[0]
+          console.log('群资料', this.groupData)
+          this.name = this.groupData.groupName
+          this.avatar = this.groupData.faceURL
+          this.introduction = this.groupData.introduction
+          this.notification = this.groupData.notification
+          // this.nameCard = this.groupData.selfInfo?.nameCard || ''
+        })
+        .catch((e) => {
+          console.log('获取群资料失败', e)
+          if (this.groupData && this.groupData.groupID) {
+            // 已经成功拿到数据了,说明是 SDK 的重复 reject,可忽略
+            return
+          }
+          this.error = e?.errMsg || e?.message || '获取群资料失败'
+        })
+        .finally(() => {
+          this.loading = false
+        })
+
+    },
+    inputFocus(ref) {
+      this.$nextTick(() => {
+        this.$refs[ref].focus()
+      })
+    },
+    editName() {
+      this.updateGroupProfile({ groupName: this.name.trim() }, '修改群名称成功')
+      this.showEditName = false
+    },
+    editFaceUrl() {
+      this.updateGroupProfile({ faceURL: this.avatar.trim() }, '修改群头像成功')
+      this.showEditFaceUrl = false
+    },
+    editIntroduction() {
+      this.updateGroupProfile({ introduction: this.introduction.trim() }, '修改群介绍成功')
+      this.showEditIntroduction = false
+    },
+    editNotification() {
+      this.updateGroupProfile({ notification: this.notification.trim() }, '修改群公告成功')
+      this.showEditNotification = false
+    },
+
+    editJoinOption() {
+      this.updateGroupProfile({ joinOption: this.joinOption }, '修改加群方式成功')
+      this.showEditJoinOption = false
+    },
+    async updateGroupProfile(payload, successMsg) {
+      console.log(payload)
+      let load = {
+        groupID: this.groupData.groupID,
+        ...payload
+      }
+      console.log(load)
+      try {
+        await this.OpenIM.setGroupInfo(load)
+        this.$store.commit('showMessage', { message: successMsg })
+        this.fetchGroupProfile()
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async changeMuteStatus(val) {
+      if (!this.isOwner) {
+        this.$store.commit('showMessage', { type: 'error', message: '普通成员无法操作' })
+        return
+      }
+
+      try {
+        await this.OpenIM.changeGroupMute({
+          groupID: this.groupData.groupID,
+          isMute: val  // 开关传过来的布尔值
+        })
+        // 成功后更新 groupData.status
+        this.groupData.status = val ? 3 : 0
+        this.$store.commit('showMessage', {
+          message: val ? '已开启全体禁言' : '已取消全体禁言'
+        })
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async changeOwner() {
+      console.log(this.newOwnerUserID)
+      console.log(this.groupData.ownerUserID)
+      if(this.newOwnerUserID === this.groupData.ownerUserID){
+        this.$store.commit('showMessage', { type: 'error', message: '您已经是群主了' })
+        return
+      }
+      try {
+        await this.OpenIM.transferGroupOwner({
+          groupID: this.groupData.groupID,
+          newOwnerUserID: this.newOwnerUserID
+        })
+        this.$store.commit('showMessage', {
+          message: `转让群主成功,新群主ID:${this.newOwnerUserID}`
+        })
+        this.showChangeGroupOwner = false
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async quitGroup() {
+      this.deleteConversation()
+      this.OpenIM.quitGroup(this.groupData.groupID )
+        .then(() => {
+          this.$store.commit('showMessage', {
+            message: '已退出该群聊',
+            type: 'success'
+          })
+          this.$store.commit('resetGroupContent')
+        })
+        .catch(err => {
+          this.$store.commit('showMessage', {
+            message: err.message || '退出群聊失败',
+            type: 'error'
+          })
+        })
+    },
+    async dismissGroup() {
+      try {
+        console.log(this.groupData.groupID)
+        await this.OpenIM.dismissGroup(this.groupData.groupID)
+        this.$store.commit('showMessage', {
+          message: `群 ${this.groupData.name || this.groupData.groupID} 已解散`,
+          type: 'success'
+        })
+        this.$store.commit('resetCurrentConversation')
+        this.OpenIM.getConversationListSplit({
+          offset: 0,
+          count: 100,
+        })
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到会话列表",data)
+            this.conversationList= data
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              message: `会话【${this.conversationName}】删除失败!, error=${error.message}`,
+              type: 'error'
+            })
+            this.popoverVisible = false
+          })
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async editMessageRemindType() {
+      try {
+        await this.tim.setMessageRemindType({
+          groupID: this.groupData.groupID,
+          messageRemindType: this.messageRemindType
+        })
+        this.showEditMessageRemindType = false
+        this.$store.commit('showMessage', { message: '修改成功' })
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    },
+    async editNameCard() {
+      if (!this.nameCard.trim()) {
+        this.$store.commit('showMessage', { type: 'warning', message: '不能设置空名片' })
+        return
+      }
+      try {
+        await this.tim.setGroupMemberNameCard({
+          groupID: this.groupData.groupID,
+          nameCard: this.nameCard.trim()
+        })
+        this.$store.commit('showMessage', { message: '修改名片成功' })
+        this.showEditNameCard = false
+      } catch (error) {
+        this.$store.commit('showMessage', { type: 'error', message: error.message })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="stylus">
+.group-info-content
+  padding 10px 10px
+  .avatar
+    width 40px
+    height 40px
+    border-radius 50%
+.info-item {
+  margin-bottom: 12px;
+
+  .label {
+    font-size: 14px;
+    color: $secondary;
+  }
+  .active {
+    color: $black
+  }
+  .content {
+    color: $background;
+    word-wrap: break-word;
+    word-break: break-all;
+  }
+  .long-content {
+    word-wrap:break-word;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 3;
+  }
+}
+.cursor-pointer {
+  cursor: pointer;
+}
+/* 设置滚动条的样式 */
+::-webkit-scrollbar {
+  width: 0px;
+  height: 0px;
+}
+/* 加载更多占位符样式 */
+.load-more {
+  padding: 8px;
+  text-align: center;
+  color: #909399;
+  font-size: 12px;
+  cursor: pointer;
+  background-color: #f5f7fa;
+}
+</style>

+ 251 - 0
src/components/conversation/conversationProfile/user-profile.vue

@@ -0,0 +1,251 @@
+<template>
+  <div class="profile-user">
+    <avatar :title=userProfile.userID :src="userProfile.avatar" />
+    <div class="nick-name text-ellipsis">
+      <span v-if="userProfile.nick" :title=userProfile.nick>
+        {{ userProfile.nick }}
+      </span>
+      <span v-else class="anonymous" title="该用户未设置昵称">
+        [Anonymous]
+      </span>
+    </div>
+    <div class="gender" v-if="genderClass">
+      <span :title="gender" class="iconfont" :class="genderClass"></span>
+    </div>
+    <!-- <el-button
+      title="将该用户加入黑名单"
+      type="text"
+      @click="addToBlackList"
+      v-if="!isInBlacklist && userProfile.userID !== myUserID"
+      class="btn-add-blacklist"
+      >加入黑名单</el-button
+    >
+    <el-button title="将该用户移出黑名单" type="text" @click="removeFromBlacklist" v-else-if="isInBlacklist">移出黑名单</el-button>
+    <el-button title="删除好友" type="text" @click="removeFromFriendList" v-if="isFriend">删除好友</el-button>
+    <el-button
+          title="加好友"
+          type="text"
+          @click="dialogAddFriendVisible = true"
+          v-if="!isFriend"
+          class="btn-add-friend"
+    >添加好友</el-button>
+    <el-dialog title="添加好友" :visible.sync="dialogAddFriendVisible" width="600px">
+      <el-form :model="addInfo">
+        <el-form-item label="" :label-width="formLabelWidth">
+          <div class="add-item">
+            <img
+                    class="avatar"
+                    :src="userProfile.avatar ? userProfile.avatar : 'http://imgcache.qq.com/open/qcloud/video/act/webim-avatar/avatar-2.png'"
+            />
+            <div  class="item-nick">{{userProfile.nick||userProfile.userID}}</div>
+          </div>
+        </el-form-item>
+        <el-form-item label="请输入验证信息" :label-width="formLabelWidth">
+          <el-input
+                  type="textarea"
+                  :rows="2"
+                  placeholder="请输入内容"
+                  v-model="addInfo.wording">
+          </el-input>
+        </el-form-item>
+        <el-form-item label="分组" :label-width="formLabelWidth">
+          <el-select v-model="addInfo.groupName" placeholder="">
+            <el-option label="选择分组" value=""></el-option>
+            <el-option  v-for="name in friendGroupNameList" :key="name" :label="name" :value="name"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注" :label-width="formLabelWidth">
+          <el-input v-model="addInfo.remark" autocomplete="off"></el-input>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialogAddFriendVisible = false">取 消</el-button>
+        <el-button type="primary" @click="addFriend">确 定</el-button>
+      </div>
+    </el-dialog> -->
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { Select, Option, Form ,FormItem, Input} from 'element-ui'
+
+// import FriendItem from './friend-item.vue'
+export default {
+  props: {
+    userProfile: {
+      type: Object,
+      required: true
+    }
+  },
+  components: {
+    ElSelect: Select,
+    ElOption: Option,
+    ElForm: Form,
+    ElFormItem: FormItem,
+    ElInput: Input,
+  },
+  data() {
+    return {
+      addInfo: {
+        remark: '',
+        groupName: '',
+        wording: '',
+        type: this.OpenIM.TYPES.SNS_ADD_TYPE_BOTH,
+      },
+      formLabelWidth: '120px',
+      dialogAddFriendVisible: false,
+    }
+  },
+  computed: {
+    ...mapState({
+      blacklist: state => state.blacklist.blacklist,
+      friendList: state => state.friend.friendList,
+      myUserID: state => state.imuser.currentUserProfile.userID,
+      friendGroupList: state => state.friend.friendGroupList
+    }),
+    isInBlacklist() {
+      return this.blacklist.findIndex(item => item.userID === this.userProfile.userID) >= 0
+    },
+    isFriend() {
+      return this.friendList.findIndex(item => item.userID === this.userProfile.userID) >= 0
+    },
+    friendGroupNameList() {
+      return this.friendGroupList.map((item) => {return item.name})
+    },
+    gender() {
+      switch (this.userProfile.gender) {
+        case this.OpenIM.TYPES.GENDER_MALE:
+          return '男'
+        case this.OpenIM.TYPES.GENDER_FEMALE:
+          return '女'
+        default:
+          return '未设置'
+      }
+    },
+    genderClass() {
+      switch (this.userProfile.gender) {
+        case this.OpenIM.TYPES.GENDER_MALE:
+          return 'icon-male'
+        case this.OpenIM.TYPES.GENDER_FEMALE:
+          return 'icon-female'
+        default:
+          return ''
+      }
+    }
+  },
+  created() {
+  },
+  methods: {
+    addToBlackList() {
+      this.tim
+        .addToBlacklist({ userIDList: [this.userProfile.userID] })
+        .then(() => {
+          this.$store.dispatch('getBlacklist')
+        })
+        .catch(imError => {
+          this.$store.commit('showMessage', {
+            message: imError.message,
+            type: 'error'
+          })
+        })
+    },
+    addFriend() {
+      this.tim.addFriend({
+        to: this.userProfile.userID,
+        remark: this.addInfo.remark,
+        groupName: this.addInfo.groupName,
+        wording: this.addInfo.wording,
+        source: 'AddSource_Type_Web',
+        type: this.addInfo.type
+      }).then((res) => {
+          // console.log(res)
+        if (res.data.code === 30539) {
+          this.$store.commit('showMessage', {
+            message: res.data.message,
+            type: 'warning'
+          })
+        }
+      })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            message: error.message,
+            type: 'warning'
+          })
+        })
+      this.dialogAddFriendVisible = false
+      this.addInfo = {
+        remark: '',
+        groupName: '',
+        wording: '',
+      }
+    },
+    removeFromFriendList() {
+      let options = {
+        userIDList: [this.userProfile.userID],
+        type: this.OpenIM.TYPES.SNS_DELETE_TYPE_BOTH
+      }
+      this.tim.deleteFriend(options).then(() => {
+      }).catch(error => {
+        this.$store.commit('showMessage', {
+          type: 'error',
+          message: error.message
+        })
+      })
+    },
+    removeFromBlacklist() {
+      this.tim.removeFromBlacklist({ userIDList: [this.userProfile.userID] }).then(() => {
+        this.$store.commit('removeFromBlacklist', this.userProfile.userID)
+      })
+      .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.profile-user
+  width 100%
+  text-align center
+  padding 0 20px
+  .avatar
+    width 100px
+    height 100px
+    border-radius 50%
+    margin 30px auto
+  .nick-name
+    width 100%
+    color $base
+    font-size 20px
+    font-weight bold
+    text-shadow $font-dark 0 0 0.1em
+    .anonymous
+      color $first
+      text-shadow none
+  .gender
+    padding 5px 0 10px 0
+    border-bottom 1px solid $border-base
+  .btn-add-blacklist
+    color $danger
+  .el-select
+    margin-left -248px
+  .add-item
+    display flex
+    .avatar
+      display block
+      width 48px
+      height 48px
+      border-radius 50%
+      margin 0
+    .item-nick
+      line-height 48px
+      margin-left  20px
+
+
+
+</style>

+ 565 - 0
src/components/conversation/current-conversation.vue

@@ -0,0 +1,565 @@
+<template>
+  <div class="current-conversation-wrapper">
+    <FriendProfile style="position: relative"/>
+    <div class="current-conversation" @scroll="onScroll" v-if="showCurrentConversation">
+      <div class="header">
+        <div class="name">{{ name }}</div>
+        <div class="btn-more-info"
+             :class="showConversationProfile ? '' : 'left-arrow'"
+             @click="showMore"
+             v-show="!currentConversation.conversationID.includes('SYSTEM')"
+             title="查看详细信息">
+        </div>
+      </div>
+      <div class="cc-content">
+        <div class="message-list" ref="message-list" @scroll="this.onScroll">
+          <div class="more" v-if="!isCompleted">
+            <el-button
+              type="text"
+              @click="$store.dispatch('getMessageList', currentConversation.conversationID)"
+            >查看更多</el-button>
+          </div>
+          <div class="no-more" v-else>没有更多了</div>
+          <el-checkbox-group v-model="checkList" v-if="selectMessage">
+            <el-checkbox :label="message.clientMsgID" v-for="message in currentMessageList" :key="message.clientMsgID" :disabled="message.status==='fail'">
+              <message-item   :message="message"/>
+            </el-checkbox>
+          </el-checkbox-group>
+          <message-item v-else v-for="message in currentMessageList" :key="message.clientMsgID" :message="message">
+          </message-item>
+        </div>
+        <div v-show="isShowScrollButtomTips" class="newMessageTips" @click="scrollMessageListToButtom">回到最新位置</div>
+      </div>
+      <div class="footer" v-if="showMessageSendBox" >
+        <div class="merger-btn"  v-if="selectMessage">
+          <div  class="relay-btn" @click="singleRelay">
+            <img class="relay-icon" src="../../assets/image/sig-relay.png">
+            <span class="relay-title">逐条转发</span>
+          </div>
+          <div  class="relay-btn" @click="mergerRelay">
+            <img class="relay-icon" src="../../assets/image/merger-relay.png">
+            <span class="relay-title">合并转发</span>
+          </div>
+          <div  class="relay-btn" @click="closeSelectMessage">
+            <img class="relay-icon" src="../../assets/image/close-relay.png">
+            <span class="relay-title">取消</span>
+          </div>
+        </div>
+        <message-send-box v-else/>
+      </div>
+    </div>
+    <div>
+      <message-relay v-if="isShowConversationList "></message-relay>
+    </div>
+    <div class="profile" v-if="showConversationProfile" >
+      <conversation-profile/>
+    </div>
+    <!-- 群成员资料组件 -->
+    <member-profile-card />
+    <el-popover
+      ref="dropdown"
+      placement="left-start"
+      width="700"
+      v-model="mergerMessagePop">
+      <div class="pop-header">
+        <img src="../../assets/image/back.png" v-if="mergerMessageList.length >1" class="pop-back" @click="mergerMessageBack"/>
+        <span  class="title">{{mergerTitle}}</span>
+        <img src="../../assets/image/close.png" class="pop-close" @click="closeMessagePop"/>
+      </div>
+      <transition
+        name="custom-classes-transition"
+        enter-active-class="animated fadeIn"
+        leave-active-class="animated fadeOut"
+      >
+        <message-merger v-if="mergerMessagePop"></message-merger>
+      </transition>
+    </el-popover>
+  </div>
+</template>
+
+<script>
+  import { mapGetters, mapState } from 'vuex'
+  import MessageSendBox from '../message/message-send-box'
+  import MessageItem from '../message/message-item'
+  import ConversationProfile from './conversation-profile.vue'
+  import MemberProfileCard from '../group/member-profile-card'
+  import MessageMerger from '../message/merger-message/message-merger'
+  import MessageRelay from '../message/merger-message/message-relay'
+  import FriendProfile from '../friend/friend-container'
+  import close from '../../assets/image/close.png'
+  import { getOpenIM } from '@/utils/openIM';
+  export default {
+    name: 'CurrentConversation',
+    components: {
+      MessageSendBox,
+      MessageItem,
+      ConversationProfile,
+      MemberProfileCard,
+      MessageMerger,
+      MessageRelay,
+      FriendProfile
+    },
+    data() {
+      return {
+        close: close,
+        isShowScrollButtomTips: false,
+        preScrollHeight: 0,
+        showConversationProfile: false,
+        timeout: '',
+        checkList: [],
+        // selectMessage: false,
+        selectedMessageList: [],
+        mergerMessagePop: false,
+        mergerMessage: null,
+        positionX: 0,
+        positionY: 0,
+        OpenIM: null,
+      }
+    },
+    computed: {
+      ...mapState({
+        currentConversation: state => state.conversation.currentConversation,
+        currentUnreadCount: state => state.conversation.currentConversation.unreadCount,
+        currentMessageList: state => state.conversation.currentMessageList,
+        isCompleted: state => state.conversation.isCompleted,
+        mergerMessageList: state => state.conversation.mergerMessageList,
+        isShowConversationList: state => state.conversation.isShowConversationList,
+        selectMessage: state => state.conversation.selectMessage,
+        friendContent: state => state.friend.friendContent,
+      }),
+      ...mapGetters(['toAccount', 'hidden']),
+      // 是否显示当前会话组件
+      showCurrentConversation() {
+        return !!this.currentConversation.conversationID
+      },
+      showFriendContent() {
+        return this.friendContent
+      },
+      name() {
+        if (this.currentConversation.conversationType === 1) {
+          // let name = this.currentConversation.userProfile.nick || this.toAccount
+          // let list = this.currentMessageList
+          // let len = list.length
+          // for (let i = len - 1; i >= 0; i--) {
+          //   // C2C 会话对端更新nick时需要更新会话title
+          //   if (list[i].flow === 'in' && list[i].nick && name !== list[i].nick) {
+          //     name = list[i].nick
+          //     break
+          //   }
+          // }
+          return this.currentConversation.remark || this.currentConversation.showName || this.currentConversation.userID
+
+        } else if (this.currentConversation.conversationType === 3) {
+          this.OpenIM.getSpecifiedGroupsInfo([this.currentConversation.groupID])
+            .then(({ data }) => {
+              if (!data || !data.length) {
+                throw new Error('群资料为空')
+              }
+              this.groupData = data[0]
+              console.log('群资料', this.groupData)
+              this.name = this.groupData.groupName
+              this.avatar = this.groupData.faceURL
+              this.introduction = this.groupData.introduction
+              this.notification = this.groupData.notification
+
+              // this.nameCard = this.groupData.selfInfo?.nameCard || ''
+            })
+            .catch((e) => {
+              console.log('获取群资料失败', e)
+              if (this.groupData && this.groupData.groupID) {
+                return
+              }
+              this.error = e?.errMsg || e?.message || '获取群资料失败'
+            })
+            .finally(() => {
+              this.loading = false
+            })
+          return this.currentConversation.showName+'('+this.groupData.memberCount +')'|| this.toAccount
+        } else if (this.currentConversation.conversationType === 4) {
+          return '系统通知'
+        }
+        return this.toAccount
+      },
+      showMessageSendBox() {
+        return this.currentConversation.type !== 4
+      },
+      mergerTitle() {
+        console.log("this.mergerMessage",this.mergerMessage)
+        if (this.mergerMessage) {
+          return this.mergerMessage.mergeElem.title || '聊天记录'
+        } else {
+          return  '聊天记录'
+        }
+      }
+    },
+
+    mounted() {
+      if (this.$refs.dropdown && this.$refs.dropdown.$el) {
+        this.$refs.dropdown.$el.addEventListener('mousedown', this.move)
+      }
+      this.$bus.$on('image-loaded', this.onImageLoaded)
+      this.$bus.$on('scroll-bottom', this.scrollMessageListToButtom)
+      this.$bus.$on('mergerSelected', this.mergerSelectedHandler)
+      this.$bus.$on('mergerMessageShow', this.mergerShow)
+
+
+      if (this.currentConversation.conversationType === 4) {
+        return false
+      }
+    },
+    beforeDestroy() {
+      if (this.$refs.dropdown && this.$refs.dropdown.$el) {
+        this.$refs.dropdown.$el.removeEventListener('mousedown', this.move)
+      }
+    },
+    updated() {
+      this.keepMessageListOnButtom()
+      // 1. 系统会话隐藏右侧资料组件
+      // 2. 没有当前会话时,隐藏右侧资料组件。
+      //    背景:退出群组/删除会话时,会出现一处空白区域
+      if (this.currentConversation.conversationType === 4 ||
+        typeof this.currentConversation.conversationType === 'undefined') {
+        this.showConversationProfile = false
+      }
+    },
+    watch: {
+      currentUnreadCount(next) {
+        if (!this.hidden && next > 0) {
+          this.OpenIM.markConversationMessageAsRead(this.currentConversation.conversationID)
+        }
+      },
+      hidden(next) {
+        if (!next && this.currentUnreadCount > 0) {
+          this.OpenIM.markConversationMessageAsRead(this.currentConversation.conversationID )
+        }
+      }
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+    },
+    methods: {
+      move(e) {
+        let odiv = this.$refs.dropdown.$el.children[0]//e.target        //获取目标元素
+        //算出鼠标相对元素的位置
+        let disX = e.clientX - odiv.offsetLeft
+        let disY = e.clientY - odiv.offsetTop
+        document.onmousemove = (e)=>{       //鼠标按下并移动的事件
+                                            //用鼠标的位置减去鼠标相对元素的位置,得到元素的位置
+          let left = e.clientX - disX
+          let top = e.clientY - disY
+
+          //绑定元素位置到positionX和positionY上面
+          this.positionX = top
+          this.positionY = left
+
+          //移动当前元素
+          odiv.style.left = left + 'px'
+          odiv.style.top = top + 'px'
+        }
+        document.onmouseup = () => {
+          document.onmousemove = null
+          document.onmouseup = null
+        }
+      },
+      mergerMessageBack() {
+        let index = this.mergerMessageList.length - 2
+        this.$store.commit('updateMergerMessage', this.mergerMessageList[index])
+        // this.mergerMessageList.pop()
+      },
+      closeMessagePop() {
+        this.mergerMessagePop = false
+        this.$store.commit('resetMergerMessage')
+      },
+      closeSelectMessage() {
+        this.$store.commit('resetSelectedMessage', false)
+      },
+      mergerSelectedHandler() {
+        this.selectedMessageList = []
+        this.checkList = []
+        this.$store.commit('setSelectedMessage', true)
+      },
+      mergerShow(value) {
+        this.mergerMessagePop = true
+        this.mergerMessage = value
+        this.$store.commit('setMergerMessage', value)
+      },
+      mergerRelay() {
+        this.selectedMessage()
+        this.$store.commit('setRelayType', 3)
+      },
+      singleRelay() {
+        this.selectedMessage()
+        this.$store.commit('setRelayType', 2)
+      },
+      selectedMessage() {
+        this.selectedMessageList = this.checkList.map((id) => {
+          return this.currentMessageList.find((item) => item.clientMsgID === id)
+        }).filter(Boolean)
+
+        console.log("checkList:", this.checkList)
+        console.log("currentMessageList:", this.currentMessageList)
+        console.log("selectedMessageList:", this.selectedMessageList)
+
+        this.$store.commit('showConversationList', true)
+        this.$store.commit('setSelectedMessageList', this.selectedMessageList)
+      },
+      onScroll({ target: { scrollTop } }) {
+        let messageListNode = this.$refs['message-list']
+        if (!messageListNode) {
+          return
+        }
+        if (this.preScrollHeight - messageListNode.clientHeight - scrollTop < 20) {
+          this.isShowScrollButtomTips = false
+        }
+      },
+      // 如果滚到底部就保持在底部,否则提示是否要滚到底部
+      keepMessageListOnButtom() {
+        let messageListNode = this.$refs['message-list']
+        if (!messageListNode) {
+          return
+        }
+        // 距离底部20px内强制滚到底部,否则提示有新消息
+        if (this.preScrollHeight - messageListNode.clientHeight - messageListNode.scrollTop < 20) {
+          this.$nextTick(() => {
+            messageListNode.scrollTop = messageListNode.scrollHeight
+          })
+          this.isShowScrollButtomTips = false
+        } else {
+          this.isShowScrollButtomTips = true
+        }
+        this.preScrollHeight = messageListNode.scrollHeight
+      },
+      // 直接滚到底部
+      scrollMessageListToButtom() {
+        this.$nextTick(() => {
+          let messageListNode = this.$refs['message-list']
+          if (!messageListNode) {
+            return
+          }
+          messageListNode.scrollTop = messageListNode.scrollHeight
+          this.preScrollHeight = messageListNode.scrollHeight
+          this.isShowScrollButtomTips = false
+        })
+      },
+      showMore() {
+        this.showConversationProfile = !this.showConversationProfile
+      },
+      onImageLoaded() {
+        this.keepMessageListOnButtom()
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+/* 当前会话的骨架屏 */
+.current-conversation-wrapper
+  height 80vh
+  background-color #f5f5f5
+  color #1c2438
+  display flex
+  .current-conversation
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    height: 80vh;
+  .profile
+    height: $height;
+    overflow-y: scroll;
+    width 310px
+    border-left 1px solid $border-base
+    flex-shrink 0
+  .more
+    display: flex;
+    justify-content: center;
+    font-size: 12px;
+  .no-more
+    display: flex;
+    justify-content: center;
+    color: $secondary;
+    font-size: 12px;
+    padding: 10px 10px;
+
+.header
+  border-bottom 1px solid $border-base
+  height 50px
+  position relative
+  .name
+    padding 0 20px
+    color $base
+    font-size 18px
+    font-weight bold
+    line-height 50px
+    text-shadow $font-dark 0 0 0.1em
+  .btn-more-info
+    position absolute
+    top 10px
+    right -15px
+    border-radius 50%
+    width 30px
+    height 30px
+    cursor pointer
+    &::before
+      position absolute
+      right 0
+      z-index 0
+      content ""
+      width: 15px
+      height: 30px
+      border: 1px solid #e7e7e7;
+      border-radius: 0 100% 100% 0/50%
+      border-left: none
+      background-color #f5f5f5;
+    &::after
+      content ""
+      width: 8px;
+      height: 8px;
+      transition: transform 0.8s;
+      border-top: 2px solid #a5b5c1;
+      border-right: 2px solid #a5b5c1;
+      float:right;
+      position:relative;
+      top: 11px;
+      right: 8px;
+      transform:rotate(45deg)
+    &.left-arrow
+      transform rotate(180deg)
+      &::before
+        background-color #ffffff;
+    &:hover
+      &::after
+        border-color #5cadff;
+.cc-content
+  display: flex;
+  flex 1
+  flex-direction: column;
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+  .message-list
+    width: 100%;
+    box-sizing: border-box;
+    overflow-y: scroll;
+    padding: 0 20px;
+  .newMessageTips
+    position: absolute
+    cursor: pointer;
+    padding: 5px;
+    width: 120px;
+    margin: auto;
+    left: 0;
+    right: 0;
+    bottom: 5px;
+    font-size: 12px;
+    text-align: center;
+    border-radius: 10px;
+    border: $border-light 1px solid;
+    background-color: $white;
+    color: $primary;
+.footer
+  border-top: 1px solid $border-base;
+  .merger-btn {
+    height 150px
+    padding 3px 20px 20px 20px
+    box-sizing border-box
+    display flex
+    justify-content space-between
+    padding-top 50px
+    .relay-btn {
+      display flex
+      flex-direction column
+      justify-content center
+      align-items center
+      width 60px
+      height 50px
+      .relay-icon {
+        display block
+        border-radius 50%
+        width 30px
+        height 30px
+        background-color #ffffff
+        margin-bottom 5px
+      }
+      .relay-title {
+        display block
+        font-size 13px
+      }
+    }
+  }
+
+.show-more {
+  text-align: right;
+  padding: 10px 20px 0 0;
+}
+/deep/ .el-checkbox {
+  width 100%
+  font-weight 300
+  margin-right 0
+  white-space normal
+}
+/deep/ .el-checkbox__label {
+  width 100%
+  padding-right 20px
+  box-sizing border-box
+}
+
+/deep/ .el-popover {
+  cursor pointer
+  width 700px
+  position fixed
+  left 30vw
+  /*right 0*/
+  margin 0
+  background-color #F7F7F7
+  padding 0
+  top 15vh
+}
+/deep/ .el-checkbox__inner {
+  width 18px
+  height 18px
+  border-radius 50%
+}
+/deep/ .el-checkbox__inner::after {
+  height 8px
+  left 6px
+  top 2px
+}
+/deep/ .el-checkbox__label {
+  line-height normal
+  margin -10px 0
+  margin-left 10px
+}
+ /deep/ .el-checkbox__input {
+   position absolute
+   top 30px
+   left -10px
+ }
+  .pop-header {
+    /*display flex*/
+    position relative
+    /*justify-content space-between*/
+    margin-bottom 10px
+    border-bottom 1px solid #DEDEDE
+    background-color #F3F3F3
+    padding 8px 8px
+    & img {
+      display block
+      width 22px
+      height 22px
+      cursor pointer
+      position absolute
+      top 8px
+    }
+    .title {
+      display block
+      text-align center
+    }
+    .pop-close {
+      right 5px
+    }
+    .pop-back {
+      left  5px
+    }
+  }
+
+</style>

+ 230 - 0
src/components/friend/add-friend.vue

@@ -0,0 +1,230 @@
+<template>
+  <div class="add-friend-wrapper">
+    <div class="search-section">
+      <input
+        v-model.trim="searchKey"
+        class="search-input"
+        placeholder="请输入用户ID或手机号"
+        @keyup.enter="handleSearch"
+      />
+      <button class="search-btn" @click="handleSearch">搜索</button>
+    </div>
+
+    <div class="result-section" v-if="searchResults && searchResults.length">
+      <div
+        v-for="user in searchResults"
+        :key="user.userID"
+        class="user-card"
+      >
+        <img :src="user.faceURL || defaultAvatar" class="avatar" />
+        <div class="info">
+          <div class="nick">{{ user.nickname || '未设置昵称' }}</div>
+          <div class="user-id">ID:{{ user.userID }}</div>
+        </div>
+        <div class="action">
+          <button
+            v-if="!user.isFriend"
+            class="add-btn"
+            @click="handleAddFriend(user)"
+          >
+            添加好友
+          </button>
+          <button
+            v-else
+            class="msg-btn"
+            @click="checkoutConversation(user)"
+          >
+            发送消息
+          </button>
+        </div>
+      </div>
+    </div>
+
+    <div class="empty" v-else-if="searched">
+      未找到用户
+    </div>
+
+    <div class="tip" v-if="message">
+      {{ message }}
+    </div>
+  </div>
+</template>
+
+<script>
+import {addFriend, getUserList} from "@/api/company/friend";
+
+import { getOpenIM } from '@/utils/openIM'
+
+export default {
+  name: 'AddFriend',
+  data() {
+    return {
+      searchKey: '',
+      searchResults: [],
+      searched: false,
+      sending: false,
+      message: '',
+      defaultAvatar: 'https://cdn.jsdelivr.net/gh/edent/SuperTinyIcons/images/svg/user.svg',
+      OpenIM: null,
+      isFriend: false
+    }
+  },
+  created() {
+    console.log("添加好友")
+    this.OpenIM = getOpenIM()
+  },
+  methods: {
+    checkoutConversation(user) {
+      console.log(user)
+
+      //查询会话
+      this.OpenIM.getOneConversation({
+        sourceID: user.userID.toString(),
+        sessionType: 1,
+      }).then(({ data }) => {
+        console.log("查询单条回话",data)
+        this.$store
+          .dispatch('checkoutConversation', data)
+          .then(() => {
+            this.showDialog = false
+            this.$bus.$emit('checkoutConversation')
+          }).catch(() => {
+          this.$store.commit('showMessage', {
+            message: '没有找到该用户',
+            type: 'warning'
+          })
+        })
+      })
+    },
+    async handleSearch() {
+      if (!this.searchKey) {
+        this.message = '请输入用户ID或手机号'
+        return
+      }
+      this.message = ''
+      this.searched = true
+      this.searchResult = null
+      try {
+        const response = await getUserList(this.searchKey)
+        console.log('搜索结果:', response)
+        const users = response.users || []
+
+        if (users.length > 0) {
+          this.searchResults = users
+          //this.checkFriendStatus(this.searchResult.userID)
+        } else {
+          this.message = '未找到用户'
+        }
+
+      } catch (error) {
+        console.error(error)
+        this.message = error.message || '搜索失败'
+      }
+    },
+
+
+    async handleAddFriend(user) {
+      console.log(user)
+      try {
+        await addFriend(user.userID)
+        this.$message.success("添加成功");
+      } catch (error) {
+        this.$message.error("添加失败");
+      } finally {
+        this.sending = false
+      }
+    }
+
+  }
+}
+</script>
+
+<style scoped>
+.add-friend-wrapper {
+  padding: 20px;
+  color: #333;
+}
+
+.search-section {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 16px;
+}
+
+.search-input {
+  flex: 1;
+  padding: 8px 12px;
+  border-radius: 8px;
+  border: 1px solid #ddd;
+  outline: none;
+}
+
+.search-btn {
+  padding: 8px 16px;
+  border: none;
+  border-radius: 8px;
+  background: #409eff;
+  color: #fff;
+  cursor: pointer;
+}
+
+.result-section {
+  margin-top: 12px;
+}
+
+.user-card {
+  display: flex;
+  align-items: center;
+  background: #f8f8f8;
+  border-radius: 10px;
+  padding: 12px;
+  gap: 12px;
+}
+
+.avatar {
+  width: 48px;
+  height: 48px;
+  border-radius: 50%;
+}
+
+.info {
+  flex: 1;
+}
+
+.nick {
+  font-weight: bold;
+  margin-bottom: 4px;
+}
+
+.add-btn {
+  background: #67c23a;
+  color: #fff;
+  border: none;
+  border-radius: 8px;
+  padding: 6px 12px;
+  cursor: pointer;
+}
+.msg-btn {
+  background: #409eff;
+  color: #fff;
+  border: none;
+  border-radius: 8px;
+  padding: 6px 12px;
+  cursor: pointer;
+}
+
+
+.added-text {
+  color: #aaa;
+}
+
+.tip {
+  margin-top: 10px;
+  color: #666;
+}
+
+.empty {
+  margin-top: 10px;
+  color: #999;
+}
+</style>

+ 150 - 0
src/components/friend/friend-application/application-item.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="application-item-container" :id="'application-'+ index" @click="selectedItem">
+    <div  class="application-box">
+      <avatar :src="application.avatar" />
+      <div class="application-content">
+        <span class="application-name text-ellipsis">{{application.nick || application.userID}}</span>
+        <span class="application-wording text-ellipsis" v-if="application.wording">{{application.wording}} </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { getOpenIM} from '@/utils/openIM';
+export default {
+  components: {
+  },
+  props: ['application','index'],
+  data() {
+    return {
+      applicationType: 'comeIn',
+      showApplicationList: [],
+      acceptApplication: {
+        remark: '',
+        tag: '',
+        type: 'Response_Action_AgreeAndAdd',
+      },
+      OpenIm:null,
+    }
+  },
+  computed: {
+    ...mapState({
+      applicationList: state => state.friend.applicationList
+    }),
+    comeInApplicationList() {
+      return this.applicationList.filter((item) => {return item.type === this.OpenIM.TYPES.SNS_APPLICATION_SENT_TO_ME})
+    },
+    sendOutApplicationList() {
+      return this.applicationList.filter((item) => {return item.type === this.OpenIM.TYPES.SNS_APPLICATION_SENT_BY_ME})
+    }
+  },
+  created() {
+    this.OpenIm =getOpenIM()
+    this.showApplicationList = this.comeInApplicationList
+  },
+  mounted() {
+  },
+  methods: {
+    selectedItem() {
+      this.$store.commit('resetCurrentConversation')
+      this.$store.dispatch('setApplicationContent', this.application)
+    },
+    // 应答
+    acceptFriendApplication(userID) {
+      this.OpenIm.acceptFriendApplication({
+        toUserID: userID,
+        handleMsg:"",
+        remark: this.acceptApplication.remark,
+        tag: this.acceptApplication.tag,
+        type: this.acceptApplication.type
+      }).then(() => {
+        this.resetContent()
+        this.$store.commit('showMessage', {
+          message: '已同意加好友',
+          type: 'success'
+        })
+
+      }).catch((error) => {
+        this.$store.commit('showMessage', {
+          type: 'error',
+          message: error.message
+        })
+      })
+    },
+    resetContent() {
+      this.$store.commit('resetCurrentConversation')
+      this.$store.commit('resetFriendContent')
+      this.$store.commit('resetApplicationContent')
+    }
+  }
+}
+</script>
+<style lang="stylus" scpoed>
+  .application-item-container {
+    padding-left 40px
+    .application-box {
+      position relative
+      display flex
+      .avatar {
+        width 40px
+        height 40px
+        border-radius 50%
+        flex-shrink 0
+        box-shadow 0 5px 10px 0 rgba(0, 0, 0, 0.1)
+        margin-top 9px
+      }
+      .application-content {
+        padding 5px 10px
+        display flex
+        flex-direction column
+        justify-content center
+        margin-top 5px
+        .application-name {
+          display block
+          font-size 16px
+          color #ffffff
+          height 20px
+          margin-bottom 3px
+          line-height 20px
+          width 80px
+        }
+        .application-wording {
+          display block
+          font-size 12px
+          color #c0c4cc
+          width 80px
+          height 20px
+          line-height 20px
+        }
+      }
+      .accept-btn {
+        position absolute
+        top 16px
+        right 5px
+        width 40px
+        height 24px
+        font-size 13px
+        color #ffffff
+        border-radius 12px
+        line-height 24px
+        text-align center
+        background-color #00a4ff
+      }
+    }
+
+  }
+  .default {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    overflow-y: scroll;
+  }
+  .text-ellipsis {
+    /*overflow hidden*/
+    /*text-overflow ellipsis*/
+    /*white-space nowrap*/
+  }
+</style>

+ 527 - 0
src/components/friend/friend-container.vue

@@ -0,0 +1,527 @@
+<template>
+  <div class="friend-content" v-if="showFriendContent || showApplicationContent" @click="closeHandler">
+    <div class="friend-box" v-if="showFriendContent">
+      <div class="profile-container" >
+        <div class="item-nick text-ellipsis">{{friendProfile.nickname||friendProfile.userID}}</div>
+        <img
+          class="avatar"
+          :src="friendProfile.faceURL ? friendProfile.faceURL : 'https://imgcache.qq.com/open/qcloud/video/act/webim-avatar/avatar-2.png'"
+        />
+      </div>
+      <el-divider></el-divider>
+      <p class="content-box"><span class="content-title">userID</span><span class="content-text">{{friendProfile.userID}}</span></p>
+      <div class="content-box">
+        <span class="content-title">备注名</span>
+
+        <!-- 显示模式 -->
+        <div class="text-box" v-if="!showEditRemark">
+              <span class="content-text content-width text-ellipsis">
+                {{ friendProfile.remark || '暂无' }}
+              </span>
+          <i
+            class="el-icon-edit"
+            @click.stop="
+                showEditRemark = true,
+                inputFocus('remarkInput')"
+            style="cursor: pointer; font-size: 16px; margin-left: 6px"
+          />
+        </div>
+
+        <!-- 编辑模式 -->
+        <div v-else style="display: inline-flex; align-items: center;">
+          <el-input
+            ref="remarkInput"
+            v-model="profileRemark"
+            size="mini"
+            style="width: 200px; margin-right: 8px;"
+          />
+          <el-button type="primary" size="mini" @click="editRemarkHandler">
+            保存
+          </el-button>
+          <el-button size="mini" @click="showEditRemark = false" style="margin-left: 5px;">
+            取消
+          </el-button>
+        </div>
+      </div>
+
+      <p class="content-box"><span class="content-title">来源</span><span class="content-text">{{getSource(friendProfile.addSource)}}</span></p>
+      <el-divider></el-divider>
+      <div class="content-box" v-show="friendType==='friendList'">
+        <span class="content-title" v-show="!showEdit" style="line-height: 35px">所在分组</span>
+        <span class="content-title" v-show="showEdit"  style="line-height: 35px">添加到分组</span>
+        <div class="text-content">
+          <span class="content-text" v-show="!showEdit">{{groupName}}</span>
+          <i class="el-icon-edit edit-icon" v-show="!showEdit"   @click.stop="showEditHandler"></i>
+        </div>
+        <el-select v-if="showEdit" v-model="addGroupName"  placeholder="选择分组" @change="addToFriendGroup">
+          <el-option
+            v-for="item in friendGroupList"
+            @blur="showEdit = false"
+            :key="item.name"
+            :label="item.name"
+            :value="item.name">
+          </el-option>
+        </el-select>
+      </div>
+      <p class="content-box" v-show="friendType==='groupFriend'" style="height: 40px">
+        <span class="content-title" style="line-height: 35px">所在分组</span>
+        <span class="content-text" v-show="!showEdit" style="line-height: 35px">{{groupName}}</span>
+      </p>
+      <div class="sendBtn" @click.stop="checkoutConversation(friendProfile.userID)">发送消息</div>
+      <div class="delete-text" v-show="friendType==='groupFriend'" @click.stop="removeFromFriendGroup(friendProfile.userID)">从该群组中移除</div>
+      <div class="delete-text" v-show="friendType==='friendList'" @click.stop="removeFromFriendList(friendProfile.userID)">删除该好友</div>
+    </div>
+    <div class="friend-box" v-if="showApplicationContent">
+      <div class="profile-container" >
+        <div class="item-nick text-ellipsis">{{applicationContent.nick||applicationContent.userID}}</div>
+        <img
+          class="avatar"
+          :src="applicationContent.avatar ? applicationContent.avatar : 'http://imgcache.qq.com/open/qcloud/video/act/webim-avatar/avatar-2.png'"
+        />
+      </div>
+      <el-divider></el-divider>
+      <p class="content-box"><span class="content-title">userID</span><span class="content-text">{{applicationContent.userID}}</span></p>
+      <p class="content-box"><span class="content-title">来源</span><span class="content-text content-width text-ellipsis">{{getSource(applicationContent.addSource)}}</span></p>
+      <div class="content-box" v-if="showRemark"><span class="content-title">备注名</span>
+        <el-input
+          style="display: inline-block"
+          ref="showRemark"
+          autofocus
+          v-model="acceptApplication.remark"
+          size="mini"
+          @blur="remarkBlurHandler"
+          @keydown.enter.native="editRemarkHandler"
+        />
+      </div>
+      <el-divider></el-divider>
+      <p class="content-box">
+        <span class="content-title">打招呼</span>
+        <el-tooltip class="item" effect="dark" :content="applicationContent.wording" placement="bottom-end">
+          <span class="content-text content-width text-ellipsis">{{applicationContent.wording}}</span>
+        </el-tooltip>
+      </p>
+      <div class="application-box" v-if="!showRemark">
+        <p class="application-refuse" @click="acceptHandler">同意</p>
+        <p class="application-delete"  @click="refuseFriendApplication(applicationContent.userID)">拒绝</p>
+      </div>
+      <div class="application-box" v-else>
+        <p class="application-refuse" @click="acceptFriendApplication(applicationContent.userID)">确认</p>
+        <p class="application-delete"  @click="cancelHandler()">取消</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+  import ScrollPane from "../../layout/components/TagsView/ScrollPane";
+  import { getOpenIM,getCbEvents } from '@/utils/openIM';
+  export default {
+    name: 'index',
+    components: {ScrollPane},
+    data() {
+      return {
+        OpenIM:null,
+        showEdit: false,
+        showEditRemark: false,
+        showRemark: false,
+        addGroupName: '',
+        profileRemark: '',
+        editGroupList: [],
+        isUpdate: false,
+        acceptApplication: {
+          remark: '',
+          type: 'Response_Action_AgreeAndAdd',
+        },
+      }
+    },
+    computed: {
+      ...mapState({
+        friendContent: state => state.friend.friendContent,
+        applicationContent: state => state.friend.applicationContent,
+        friendGroupList: state => state.friend.friendGroupList,
+      }),
+      showFriendContent() {
+        if (JSON.stringify(this.friendContent) === '{}') {
+          return false
+        }
+        return true
+      },
+      showApplicationContent() {
+        if (JSON.stringify(this.applicationContent) === '{}') {
+          return false
+        }
+        return true
+      },
+      friendProfile() {
+        console.log("this.friendContent",this.friendContent)
+        return this.friendContent.friend
+      },
+      groupName() {
+        /*console.log("this.friendProfile",this.friendProfile)
+        if(this.friendProfile.groupList.length>0) {
+          return this.friendProfile.groupList.join(',')
+        }else {
+          return '暂无分组'
+        }*/
+        return '暂无分组'
+      },
+      groupList:{
+        get() {
+          return this.friendProfile.groupList
+        },
+        set(value) {
+          this.editGroupList = value
+        }
+      },
+      friendType() {
+        return this.friendContent.type
+      },
+      getSource() {
+        return function (source) {
+          switch (source) {
+            case 1:
+              return '管理员导入添加'
+            case 2:
+              return '申请添加'
+          }
+        }
+      },
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+    },
+    methods: {
+      showEditHandler() {
+        this.showEdit = true
+        this.addGroupName = ''
+      },
+      inputFocus(ref) {
+        this.profileRemark = this.friendProfile.remark
+        this.$nextTick(() => {
+          this.$refs[ref].focus()
+        })
+      },
+      blurHandler() {
+        this.showEditRemark = false
+        this.isUpdate = true
+      },
+      remarkBlurHandler() {
+        // this.showEditRemark = false
+      },
+      acceptHandler() {
+        this.showRemark = true
+        this.acceptApplication.remark = ''
+        this.$nextTick(() => {
+          this.$refs.showRemark.focus()
+        })
+      },
+      cancelHandler() {
+        this.showRemark = false
+      },
+      closeHandler() {
+        this.showEdit = false
+        if (this.isUpdate) {
+          this.editRemarkHandler()
+        }
+      },
+      editRemarkHandler() {
+        this.showEditRemark = false
+        console.log("this.OpenIM000",this.OpenIM)
+        // 更新好友备注
+
+        this.OpenIM.updateFriends({
+          friendUserIDs: [this.friendProfile.userID],
+          remark: this.profileRemark
+        })
+          .then(({ data }) => {
+            this.$store.commit('showMessage', {
+              message: '备注修改成功',
+              type: 'success',
+            });
+            // 更新 friendProfile.remark 本地显示
+            this.friendProfile.remark = this.profileRemark;
+            // 调用成功,冲新加载好友列表和会话列表
+            //查询会话列表
+            this.OpenIM.getAllConversationList()
+              .then(({ data }) => {
+                // 调用成功
+                console.log("获取到会话列表",data)
+                this.conversationList= data
+                this.$store.commit('updateConversationList', data)
+              })
+            //查询好友列表
+            this.OpenIM.getFriendListPage({ offset:0, count:100 })
+              .then(({ data }) => {
+                // 调用成功
+                console.log("获取到好友列表",data)
+                //this.conversationList= data
+                this.$store.commit('updateFriendList', data)
+              })
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          });
+        this.isUpdate = false
+      },
+      updateFriendGroup() {
+        this.profileRemark = this.friendProfile.remark
+        // 更新好友分组
+        this.tim.updateFriend({
+          userID: this.friendProfile.userID,
+          groupList: this.editGroupList
+        }).then(()=>{
+        }).catch((imError)=> {
+          console.warn('getFriendProfile error:', imError) // 更新失败
+        })
+      },
+      checkoutConversation(userID) {
+        console.log(userID)
+
+        //查询会话
+        this.OpenIM.getOneConversation({
+          sourceID: userID.toString(),
+          sessionType: 1,
+        }).then(({ data }) => {
+          console.log("查询单挑回话",data)
+          this.$store
+            .dispatch('checkoutConversation', data)
+            .then(() => {
+              this.showDialog = false
+              this.$bus.$emit('checkoutConversation')
+            }).catch(() => {
+            this.$store.commit('showMessage', {
+              message: '没有找到该用户',
+              type: 'warning'
+            })
+          })
+        })
+      },
+      addToFriendGroup() {
+        this.tim.addToFriendGroup({name: this.addGroupName, userIDList: [this.friendProfile.userID]}).then(() => {
+          this.$store.commit('showMessage', {
+            message: '添加成功',
+            type: 'success'
+          })
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+        this.showEdit = false
+      },
+      removeFromFriendGroup(userID) {
+        this.tim.removeFromFriendGroup({
+          name: this.friendContent.groupName,
+          userIDList: [userID],
+        }).then(() => {
+          this.resetContent() // 清空页面
+        }).catch(() => {
+        })
+      },
+      removeFromFriendList(userID) {
+        this.tim.deleteFriend({
+          userIDList: [userID],
+          type: 'Delete_Type_Both'
+        }).then(() => {
+          this.resetContent() // 清空页面
+        }).catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+      },
+      // 同意
+      acceptFriendApplication(userID) {
+        this.tim.acceptFriendApplication({
+          userID: userID,
+          remark: this.acceptApplication.remark,
+          tag: this.acceptApplication.tag,
+          type: this.acceptApplication.type
+        }).then(() => {
+          this.resetContent()
+          this.$store.commit('showMessage', {
+            message: '已同意加好友',
+            type: 'success'
+          })
+
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+        this.acceptApplication.remark = ''
+        this.showRemark = false
+      },
+      // application
+      deleteFriendApplication(application) {
+        this.$store.commit('updateUnreadCount', 0)
+        this.tim.deleteFriendApplication({userID: application.userID ,type: application.type}).then(() => {
+          this.resetContent() // 清空页面
+          this.$store.commit('showMessage', {
+            message: '已删除',
+            type: 'success'
+          })
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+      },
+      // 拒绝
+      refuseFriendApplication(userID) {
+        this.$store.commit('updateUnreadCount', 0)
+        this.tim.refuseFriendApplication({
+          userID: userID
+        }).then(() => {
+          this.resetContent() // 清空页面
+          this.$store.commit('showMessage', {
+            message: '已拒绝',
+            type: 'success'
+          }).catch((error) => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+        })
+      },
+      resetContent() {
+        this.$store.commit('resetCurrentConversation')
+        this.$store.commit('resetFriendContent')
+        this.$store.commit('resetApplicationContent')
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scpoed>
+  .friend-content {
+    /*width 60%*/
+    width 100%
+    .friend-box {
+      margin 100px auto 0
+      width 360px
+      .profile-container {
+        display flex
+        justify-content space-between
+        .item-nick {
+          width 200px
+          line-height 48px
+        }
+        .avatar {
+          display block
+          width 48px
+          height 48px
+          border-radius 50%
+          margin-right 25px
+        }
+      }
+      .el-input {
+        width 50%
+      }
+      .el-select .el-input {
+        width 100%
+      }
+      .content-box {
+        display flex
+        /*justify-content space-between*/
+        margin 18px 0
+        .text-content {
+          height 40px
+        }
+        .text-box {
+          display flex
+          justify-content center
+          align-items center
+        }
+        .content-title {
+          display inline-block
+          min-width 80px
+          font-size 16px
+          color #717175
+          margin-right 140px
+        }
+        .content-text {
+          font-size 18px
+          color #000000
+          word-break break-word
+        }
+        .content-width {
+          display inline-block
+          max-width 115px
+          height 28px
+        }
+        .edit-icon {
+          cursor: pointer
+          font-size 16px
+          margin-left 6px
+          margin-top 8px
+        }
+
+      }
+
+      .sendBtn {
+        cursor pointer
+        width 120px
+        height 40px
+        border-radius 5px
+        background-color #00A4FF
+        color #ffffff
+        font-size 16px
+        margin 80px auto 0
+        text-align center
+        line-height 40px
+      }
+
+      .delete-text {
+        position absolute
+        cursor pointer
+        left 0
+        right 0
+        margin auto
+        bottom 25px
+        width 120px
+        height 40px
+        font-size 17px
+        color #586B95
+        text-align center
+      }
+      .application-box {
+        display flex
+        justify-content space-between
+      }
+      .application-delete {
+        cursor pointer
+        width 120px
+        height 40px
+        border-radius 5px
+        border 1px solid #00A4FF
+        color #1c2438
+        font-size 16px
+        margin-top 80px
+        text-align center
+        line-height 40px
+      }
+      .application-refuse {
+        cursor pointer
+        width 120px
+        height 40px
+        border-radius 5px
+        background-color #00A4FF
+        border 1px solid #00A4FF
+        color #ffffff
+        font-size 16px
+        margin-top 80px
+        text-align center
+        line-height 40px
+      }
+    }
+  }
+
+
+</style>

+ 194 - 0
src/components/friend/friend-item.vue

@@ -0,0 +1,194 @@
+<template>
+  <div
+    class="friendList-item-wrapper"
+    :id="'friend-'+name+'-'+index"
+    @click="selectedItem"
+  >
+    <div class="avatar-wrapper">
+      <avatar :src="friend.faceURL" class="avatar" />
+      <img v-if="avatarBorder" :src="avatarBorder" class="avatar-border" />
+    </div>
+
+    <div class="item-nick text-ellipsis">
+      {{ friend.remark || friend.nickname || friend.userID }}
+    </div>
+  </div>
+</template>
+
+
+<script>
+  import {mapState} from "vuex";
+  import doctorBorder from '@/assets/doctor.svg'
+  import guanjiaBorder from '@/assets/guanjia.svg'
+  export default {
+    props: ['friend','friendGroupNameList','type','groupName','index'],
+    data() {
+      return {
+        addInfo: {
+          name: '',
+          userList: ''
+        },
+      }
+    },
+    computed: {
+      avatarBorder() {
+        if (this.friend.userID?.startsWith('D')) {
+          return doctorBorder
+        } else if (this.friend.userID?.startsWith('C')) {
+          return guanjiaBorder
+        }
+        return null
+      },
+      name() {
+        if(this.groupName) {
+          return this.groupName
+        }else {
+          return ''
+        }
+      },
+      borderClass() {
+        // 判断 userID 第一个字母
+        const userID = this.friend?.friendInfo?.userID || '';
+        if (userID.startsWith('D')) {
+          return 'doctor-border';
+        } else if (userID.startsWith('C')) {
+          return 'guanjia-border';
+        } else {
+          return ''; // 默认不加边框
+        }
+      },
+      ...mapState({
+        friendList: state => state.friend.friendList,
+      }),
+    },
+
+    methods:{
+      selectedItem() {
+        console.log("this.friendList",this.friendList)
+        this.$store.commit('resetCurrentConversation')
+        this.$store.dispatch('setFriendContent', {
+          friend: this.friend,
+          groupName: this.groupName,
+          type: this.type
+        })
+      },
+      handleFriendClick() {
+        this.tim.getConversationProfile(`C2C${this.friend.userID}`).then(({data})=>{
+          this.$store.commit('updateCurrentConversation', data)
+        })
+          .catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+      },
+      handleCommand(value) {
+        this.tim.addToFriendGroup({name: value, userIDList: [this.friend.profile.userID]}).then(() => {
+          this.$store.commit('showMessage', {
+            message: '添加成功',
+            type: 'success'
+          })
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+      },
+      removeFromFriendList(data) {
+        this.tim.deleteFriend({
+          userIDList: [data.userID],
+          type: 'Delete_Type_Both'
+        }).then(() => {
+        }).catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+      },
+      removeFromFriendGroup(userID,name) {
+        this.tim.removeFromFriendGroup({
+          name: name,
+          userIDList: [userID],
+        }).then(() => {
+        }).catch(() => {
+        })
+      },
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+  .avatar-wrapper
+    position relative
+    width 40px
+    height 40px
+    margin-right 10px
+    flex-shrink 0
+
+  .avatar
+    width 100%
+    height 100%
+    border-radius 50%
+    display block
+
+  .avatar-border
+    position absolute
+    top 0
+    left 0
+    width 100%
+    height 100%
+    pointer-events none
+  .avatar
+    width 40px
+    height 40px
+    margin-right 10px
+    border-radius 50%
+    flex-shrink 0
+  .item-nick
+    padding-left 20px
+    width 100%
+    color #fff
+    box-sizing border-box
+    word-wrap break-word
+    overflow hidden
+    text-overflow ellipsis
+    font-size 16px
+
+  .friendList-item-wrapper
+    cursor pointer
+    padding 0 40px
+    padding-bottom 15px
+    display flex
+    align-items center
+    justify-content flex-start
+
+  .group-friend-btn
+    position absolute
+    right 13px
+
+  .unread-count
+    padding-left 10px
+    flex-shrink 0
+    color #666
+    font-size 12px
+
+  .badge
+    vertical-align bottom
+    background-color #f56c6c
+    border-radius 10px
+    color #FFF
+    display inline-block
+    font-size 12px
+    height 18px
+    max-width 40px
+    line-height 18px
+    padding 0 6px
+    text-align center
+    white-space nowrap
+
+  .el-icon-chat-dot-round:before
+    color #dddddd
+</style>

+ 546 - 0
src/components/friend/friend-list.vue

@@ -0,0 +1,546 @@
+<template>
+  <div class="friend-container">
+    <!--<div class="add-friend" @click="handleAddButtonClick">
+      <i class="tim-icon-friend-add" style="font-size: 28px"></i>
+      <span style="margin-left: 6px">加好友</span>
+    </div>-->
+    <el-dialog title="快速搜索好友" :visible.sync="showDialog" width="400px">
+      <el-input placeholder="请输入用户ID" v-model="userID" @keydown.enter.native="addFriendConfirm">
+        <el-button slot="append" icon="el-icon-search" @click="addFriendConfirm"></el-button>
+      </el-input>
+      <el-divider></el-divider>
+      <div class="search-item" v-if="searchShow">
+        <avatar :src="profile.avatar" />
+        <div   class="item-nick">{{profile.nick||profile.userID}}</div>
+        <img  class="add-friend-icon" src="../../assets/image/add-friend.png" @click="addFriendPopClick"/>
+      </div>
+    </el-dialog>
+    <!--    添加好友-->
+    <el-dialog title="添加好友" :visible.sync="dialogAddFriendVisible" width="600px">
+      <el-form :model="addForm">
+        <el-form-item label="" :label-width="formLabelWidth">
+          <div class="search-item">
+            <avatar :src="profile.avatar" />
+            <div  class="item-nick">{{profile.nick||profile.userID}}</div>
+          </div>
+        </el-form-item>
+        <el-form-item label="请输入验证信息" :label-width="formLabelWidth">
+          <el-input
+            type="textarea"
+            :rows="2"
+            placeholder="请输入验证信息"
+            v-model="addForm.wording">
+          </el-input>
+        </el-form-item>
+        <el-form-item label="分组" :label-width="formLabelWidth">
+          <el-select v-model="addForm.groupName" placeholder="">
+            <el-option label="选择分组" value=""></el-option>
+            <el-option  v-for="name in friendGroupNameList" :key="name" :label="name" :value="name"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="备注" :label-width="formLabelWidth">
+          <el-input v-model="addForm.remark" autocomplete="off"></el-input>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialogAddFriendVisible = false">取 消</el-button>
+        <el-button type="primary" @click="addFriend">确 定</el-button>
+      </div>
+    </el-dialog>
+    <el-dialog title="新增好友分组" :visible.sync="dialogAddGroup" width="400px">
+      <el-input placeholder="请输入分组名称" v-model="groupName" @keydown.enter.native="addGroupConfirm"/>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="dialogAddGroup = false">取 消</el-button>
+        <el-button type="primary" @click="addGroupConfirm">确 定</el-button>
+      </span>
+    </el-dialog>
+    <el-dialog title="请输入分组名称" :visible.sync="dialogRenameGroup" width="400px">
+      <el-input  v-model="newGroupName" @keydown.enter.native="renameGroupConfirm"/>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="dialogRenameGroup = false">取 消</el-button>
+        <el-button type="primary" @click="renameGroupConfirm">确 定</el-button>
+      </span>
+    </el-dialog>
+    <div class="scroll-container">
+      <div class="menu-container">
+        <div style="padding: 10px; display: flex; gap: 6px;">
+          <el-input
+            v-model="searchKeyword"
+            placeholder="输入昵称/备注"
+            clearable
+            size="small"
+            style="flex: 1;"
+          />
+          <el-button type="primary" size="small" @click="searchFriendList">搜索</el-button>
+          <el-button size="small" @click="resetSearch">清空</el-button>
+        </div>
+        <el-menu
+          default-active="application"
+          class="el-menu-vertical-demo"
+          :default-openeds="openeds"
+          @open="handleOpen"
+          @close="handleClose">
+          <!--<el-submenu index="application" style="border-buttom:1px solid #1c2438">
+            <template slot="title">
+              <i :class="{'el-icon-arrow-right': !active['application'], 'el-icon-arrow-down': active['application']}"></i>
+              <span>新的好友
+                <sup class="unread" v-if="applicationUnreadCount !== 0">
+                  <template v-if="applicationUnreadCount > 99">99+</template>
+                  <template v-else>{{applicationUnreadCount}}</template>
+                </sup>
+            </span>
+            </template>
+            <el-menu-item-group>
+              <el-menu-item :index="application.userID" v-for="(application, index) in comeInApplicationList" :key="application.userID">
+                <friend-application  :application="application" :index="index"></friend-application>
+              </el-menu-item>
+            </el-menu-item-group>
+          </el-submenu>-->
+          <!--<el-submenu index="friendGroup">
+            <template slot="title">
+              <i :class="{'el-icon-arrow-right': !active['friendGroup'], 'el-icon-arrow-down': active['friendGroup']}"></i>
+              <span>好友分组</span>
+              <i class="el-icon-circle-plus-outline friend-group-btn" style="right: 20px;font-size: 20px" @click.stop="dialogAddGroup=true"></i>
+            </template>
+            <el-menu-item-group>
+              <el-submenu :index="friendGroup.name" v-for="friendGroup in friendGroupList" :key="friendGroup.name">
+                <template slot="title">
+                  <i :class="{'el-icon-arrow-right': !active[friendGroup.name], 'el-icon-arrow-down': active[friendGroup.name]}" v-show="friendGroup.userIDList.length>0" class="animated"></i>
+                  <i style="padding-left: 15px" v-show="friendGroup.userIDList.length===0"></i>
+                  <span>{{friendGroup.name}}</span>
+                  <span @click.stop>
+                  <el-popover
+                          placement="bottom-start"
+                          width="200"
+                          trigger="click"
+                  >
+
+                    <p @click="renameFriendGroupHandler(friendGroup.name)">重命名该组</p>
+                    <p @click="deleteFriendGroup(friendGroup.name)">删除该组</p>
+                     <img src="../../assets/image/more-icon.png" class="friend-group-btn more-icon" slot="reference">
+                  </el-popover>
+                </span>
+                </template>
+                <el-menu-item index="1-4-1" v-for="(friend,index) in groupFriend(friendGroup.userIDList)" :key="friend.userID" >
+                  <friend-item  :friend="friend" :friendGroupNameList="friendGroupNameList"  :index="index" :type="'groupFriend'" :groupName="friendGroup.name">
+                  </friend-item>
+                </el-menu-item>
+              </el-submenu>
+            </el-menu-item-group>
+          </el-submenu>-->
+          <el-submenu :hide-timeout="hideTimeOut"  index="friendList" style="border-buttom:1px solid #1c2438">
+            <template slot="title">
+              <i :class="{'el-icon-arrow-right': !active['friendList'], 'el-icon-arrow-down': active['friendList']}"></i>
+              <span>联系人</span>
+            </template>
+
+            <el-menu-item-group>
+              <el-menu-item   :index="friend.userID" v-for="(friend,index) in filteredList"  :key="friend.userID">
+                <friend-item  :index="index" :friend="friend"  :friendGroupNameList="friendGroupNameList" :type="'friendList'"/>
+              </el-menu-item>
+            </el-menu-item-group>
+          </el-submenu>
+        </el-menu>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+  import FriendItem from './friend-item.vue'
+  import FriendApplication from './friend-application/application-item.vue'
+  import { getOpenIM,getCbEvents } from '@/utils/openIM';
+  export default {
+    components: {
+      FriendItem,
+      FriendApplication
+    },
+    data() {
+      return {
+        searchKeyword: '',
+        filteredList: [],
+        OpenIM:null,
+        active: {},
+        hideTimeOut: 1000,
+        openeds: ['friendList'],
+        showDialog: false,
+        searchShow: false,
+        dialogAddFriendVisible: false,
+        dialogRenameGroup: false,
+        dialogAddGroup: false,
+        userID: '',
+        groupName: '',
+        newGroupName: '',
+        oldGroupName: '',
+        addForm: {
+          userID: '',
+          remark: '',
+          groupName: '',
+          wording: '',
+          //type: this.OpenIM.TYPES.SNS_ADD_TYPE_BOTH
+          type: 1
+        },
+        addInfo: {
+          groupName: '',
+          userList: ''
+        },
+        profile: {
+          avatar: '',
+          nick: '',
+          userID: '',
+        },
+        formLabelWidth: '120px',
+        OpenIM:null
+      }
+    },
+    computed: {
+      ...mapState({
+        friendList: state => state.friend.friendList,
+        applicationList: state => state.friend.applicationList,
+        applicationUnreadCount: state => state.friend.unreadCount,
+        myProfile: state => state.user.currentUserProfile,
+        friendGroupList: state => state.friend.friendGroupList,
+      }),
+      hasFriend() {
+        return this.friendList.length > 0
+      },
+      friendGroupNameList() {
+        return this.friendGroupList.map((item) => {return item.name})
+      },
+      comeInApplicationList() {
+        //return this.applicationList.filter((item) => {return item.type === this.OpenIM.TYPES.SNS_APPLICATION_SENT_TO_ME})
+      },
+      groupFriend() {
+        return function (userIDList) {
+          if (userIDList.length === 0) {
+            return
+          }
+          return this.friendList.filter((item) => userIDList.indexOf(item.userID) > -1)
+        }
+      },
+    },
+    mounted() {
+      this.$set(this.active, 'friendList',  true)
+      this.filteredList = this.friendList;
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+      this.searchFriendList();
+    },
+    methods: {
+      searchFriendList() {
+        const keyword = this.searchKeyword.trim().toLowerCase();
+        if (!keyword) {
+          this.OpenIM.getFriendListPage({ offset:0, count:100 })
+            .then(({ data }) => {
+              // 调用成功
+              //this.conversationList= data
+              this.$store.commit('updateFriendList', data)
+              this.filteredList = this.friendList;
+              return;
+            })
+            .catch(({ errCode, errMsg }) => {
+              // 调用失败
+            })
+
+        }
+        this.OpenIM.searchFriends({
+          keywordList: [keyword],
+          isSearchUserID: false,
+          isSearchNickname: true,
+          isSearchRemark: true,
+        })
+          .then(({data}) => {
+            // 调用成功
+            this.$store.commit('updateFriendList', data)
+            this.filteredList = this.friendList;
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          });
+      },
+      resetSearch() {
+        this.searchKeyword = '';
+        this.searchFriendList();
+      },
+      handleOpen(key, keyPath) {
+        if(keyPath.length ===1) {
+          this.$set(this.active, keyPath[0], true)
+        }else {
+          this.$set(this.active, keyPath[1], true)
+        }
+        if(key === 'application' && this.applicationUnreadCount > 0) {
+          this.tim.setFriendApplicationRead().then(()=> {
+            this.$store.commit('updateUnreadCount', 0)
+            // 已读上报成功
+          }).catch((imError)=> {
+            console.warn('setFriendApplicationRead error:', imError)
+          })
+        }
+      },
+      handleClose(key, keyPath) {
+        if(keyPath.length ===1) {
+          this.$set(this.active, keyPath[0], false)
+        }else {
+          this.$set(this.active, keyPath[1], false)
+        }
+      },
+      handleAddButtonClick() {
+        this.showDialog = true
+      },
+      addFriendPopClick() {
+        this.showDialog = false
+        this.dialogAddFriendVisible = true
+        // this.addForm.wording = `我是${this.myProfile.nick || this.myProfile.userID} ...`
+      },
+      addFriendConfirm() {
+        this.tim.getUserProfile({
+          userIDList: [this.userID]
+        }).then((imResponse) => {
+          this.searchShow = true
+          const profile = imResponse.data[0]
+          this.addForm.userID  = profile.userID
+          this.profile.avatar = profile.avatar
+          this.profile.userID = profile.userID
+          this.profile.nick = profile.nick
+        }).catch((imError)=> {
+          console.warn('getUserProfile error:', imError) // 获取其他用户资料失败的相关信息
+          this.searchShow = false
+          this.$store.commit('showMessage', {
+            message: '没有找到该用户',
+            type: 'warning'
+          })
+        })
+        this.userID = ''
+      },
+      addFriend() {
+        this.dialogAddFriendVisible = false
+        this.tim.addFriend({
+          to: this.addForm.userID,
+          remark: this.addForm.remark,
+          groupName: this.addForm.groupName,
+          wording: this.addForm.wording,
+          source: 'AddSource_Type_Web',
+          type: this.addForm.type
+        }).then((res) => {
+          if (res.data.code === 0) {
+            this.$store.commit('showMessage', {
+              message: '添加成功',
+              type: 'success'
+            })
+          }
+          if (res.data.code === 30539) {
+            this.$store.commit('showMessage', {
+              message: res.data.message,
+              type: 'warning'
+            })
+          }
+        }).catch(error => {
+          this.$store.commit('showMessage', {
+            message: error.message,
+            type: 'warning'
+          })
+        })
+        this.searchShow = false
+        this.profile =  {
+          avatar: '',
+          nick: '',
+          userID: '',
+        }
+        this.addForm = {
+          userID: '',
+          remark: '',
+          groupName: '',
+          wording: '',
+        }
+      },
+      addGroupConfirm() {
+        this.tim.createFriendGroup({
+          name: this.groupName
+        }).then(() => {
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+        this.groupName = ''
+        this.dialogAddGroup = false
+      },
+      deleteFriendGroup(name) {
+        this.tim.deleteFriendGroup({
+          name: name
+        }).then(() => {
+          this.$store.commit('showMessage', {
+            message: '删除成功',
+            type: 'success'
+          })
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            message: error.message,
+            type: 'warning'
+          })
+        })
+      },
+      renameFriendGroupHandler(name) {
+        this.dialogRenameGroup = true
+        this.oldGroupName = name
+        this.newGroupName = name
+      },
+      renameGroupConfirm() {
+        this.tim.renameFriendGroup({
+          oldName: this.oldGroupName,
+          newName: this.newGroupName
+        }).then(() => {
+          this.$store.commit('showMessage', {
+            message: '修改成功',
+            type: 'success'
+          })
+        }).catch((error) => {
+          this.$store.commit('showMessage', {
+            message: error.message,
+            type: 'warning'
+          })
+        })
+        this.dialogRenameGroup = false
+      }
+    }
+  }
+</script>
+<style lang="stylus" scpoed>
+.friend-container {
+    height 100%
+    width 100%
+    display flex
+    flex-direction column // -reverse
+  .add-friend {
+    width 100%
+    cursor pointer
+    color #dddddd
+    font-size 18px
+    margin 20px auto 0px
+    text-align center
+    height 40px
+    border-bottom 1px solid #1c2438
+  }
+  .scroll-container  {
+    overflow-y scroll
+    flex 1
+    .menu-container {
+      .unread {
+        position: absolute;
+        top: 10px;
+        margin-left 2px
+        z-index: 999;
+        display: inline-block;
+        height: 18px;
+        padding: 0 6px;
+        font-size: 12px;
+        color: #FFF;
+        line-height: 18px;
+        text-align: center;
+        white-space: nowrap;
+        border-radius: 10px;
+        background-color: #f35f5f;
+      }
+      .friend-group-btn {
+        position absolute
+        top 20px
+        right 15px
+        bottom 0
+        margin auto
+      }
+      .more-icon {
+        display block
+        width 36px
+        top: -3px
+      }
+      .el-submenu__title, .el-menu-item {
+        font-size 16px
+        color #ffffff
+      }
+      .el-menu-item {
+        padding-left 20px !important
+        padding-right 10px
+        height 58px
+        line-height 58px
+      }
+      .el-menu-item * {
+        vertical-align: baseline
+        }
+      .el-submenu {
+        font-size 20px
+      }
+      .el-submenu .el-submenu__title {
+        /*padding-left 0 !important*/
+      }
+      .el-menu-item:focus, .el-menu-item:hover {
+        background-color #404953
+      }
+      .el-submenu__title:focus, .el-submenu__title:hover {
+        background-color #404953
+      }
+      .el-menu-item {
+        padding-left 10px !important
+      }
+      .el-menu {
+        background-color transparent
+        border-right none
+      }
+      .el-menu-vertical-demo {
+        background-color transparent
+      }
+      .el-submenu__icon-arrow {
+        display none
+      }
+    }
+    .friend-list-container {
+      border-top 1px solid #1c2438
+      padding-top 10px
+      padding-left -15px
+    }
+  }
+  .default {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    overflow-y: scroll;
+  }
+  .search-item {
+    display flex
+    .avatar {
+      width: 40px;
+      height: 40px;
+      border-radius: 50%;
+      flex-shrink: 0
+      box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
+    }
+    .item-nick {
+      margin-left 20px
+      line-height 48px
+    }
+
+  }
+
+  .el-icon-circle-plus-outline:before {
+    color #ffffff
+  }
+  .add-friend-icon {
+    cursor pointer
+    width 30px
+    height 30px
+    display block
+    position absolute
+    right 40px
+    bottom 40px
+  }
+  .el-icon-arrow-right {
+    transition: all .2s ease;
+  }
+}
+
+
+</style>

+ 90 - 0
src/components/im/eventListeners.js

@@ -0,0 +1,90 @@
+import { ElMessage } from 'element-plus'
+import SDK from './index'
+import emitter from '@/utils/mitt'
+import store from '@/store'
+
+export const registerIMEvents = () => {
+  SDK.on('onConnectSuccess', () => {
+    console.log('[IM] 连接成功')
+  })
+
+  SDK.on('onConnectFailed', (err) => {
+    console.error('[IM] 连接失败:', err)
+  })
+
+  SDK.on('onKickedOffline', () => {
+    console.warn('[IM] 被踢下线')
+    ElMessage.warning('您已在其他设备登录,当前设备被踢下线')
+    store.commit('app/setLoginVisible', true) // 用于触发登录弹窗
+  })
+
+  SDK.on('onUserSigExpired', () => {
+    console.warn('[IM] Token 过期')
+    ElMessage.warning('登录已过期,请重新登录')
+    store.commit('app/setLoginVisible', true)
+  })
+
+  SDK.on('onSelfInfoUpdated', async () => {
+    console.log('[IM] 用户信息更新')
+    await store.dispatch('user/getSelfUserInfo')
+  })
+
+  SDK.on('onMsgReceived', (data) => {
+    console.log('[IM] 接收到消息:', data)
+    store.dispatch('message/handleNewMessages', data)
+  })
+
+  SDK.on('onConversationChanged', (data) => {
+    console.log('[IM] 会话变更:', data)
+    store.dispatch('conversation/updateConversations', data)
+  })
+
+  SDK.on('onNewConversation', (data) => {
+    console.log('[IM] 新会话:', data)
+    store.dispatch('conversation/addConversations', data)
+  })
+
+  SDK.on('onTotalUnreadMessageCountChanged', (unreadCount) => {
+    console.log('[IM] 未读总数变化:', unreadCount)
+    store.commit('conversation/setTotalUnreadCount', unreadCount)
+  })
+
+  SDK.on('onGroupInfoChanged', (groupList) => {
+    console.log('[IM] 群信息变更:', groupList)
+    store.dispatch('group/updateGroupInfo', groupList)
+  })
+
+  SDK.on('onGroupMemberAdded', (data) => {
+    store.dispatch('group/handleMemberAdded', data)
+  })
+
+  SDK.on('onGroupMemberDeleted', (data) => {
+    store.dispatch('group/handleMemberDeleted', data)
+  })
+
+  SDK.on('onGroupDismissed', (data) => {
+    store.dispatch('group/handleGroupDismissed', data)
+  })
+
+  SDK.on('onGroupRecycled', (data) => {
+    store.dispatch('group/handleGroupRecycled', data)
+  })
+
+  SDK.on('onGroupMemberInfoChanged', (data) => {
+    store.dispatch('group/handleMemberInfoChanged', data)
+  })
+
+  SDK.on('onRecvCustomMessage', (data) => {
+    console.log('[IM] 收到自定义消息:', data)
+    emitter.emit('custom-message', data)
+  })
+
+  SDK.on('onRecvCustomOnlineMessage', (data) => {
+    console.log('[IM] 收到在线自定义消息:', data)
+    emitter.emit('custom-online-message', data)
+  })
+}
+
+export const removeIMEvents = () => {
+  SDK.offAll()
+}

+ 359 - 0
src/components/layout/side-bar.vue

@@ -0,0 +1,359 @@
+<template>
+  <div class="side-bar-wrapper">
+    <div class="bar-left">
+      <my-profile />
+      <div class="tab-items" @click="handleClick">
+        <div
+          id="conversation-list"
+          class="iconfont icon-conversation"
+          :class="{ active: showConversationList }"
+          title="会话列表"
+        >
+          <sup class="unread" v-if="totalUnreadCount !== 0">
+            <template v-if="totalUnreadCount > 99">99+</template>
+            <template v-else>{{totalUnreadCount}}</template>
+          </sup>
+        </div>
+        <div
+          id="friend-list"
+          class="iconfont icon-contact"
+          :class="{ active: showFriendList }"
+          title="好友列表"
+        >
+          <sup class="unread" v-if="applicationUnreadCount !== 0">
+            <template v-if="applicationUnreadCount > 99">99+</template>
+            <template v-else>{{applicationUnreadCount}}</template>
+          </sup>
+        </div>
+        <div
+          id="add-friend"
+           class="iconfont icon-smile"
+          :class="{ active: showAddFriend }"
+          title="添加好友"
+        >
+          <sup class="unread" v-if="applicationUnreadCount !== 0">
+            <template v-if="applicationUnreadCount > 99">99+</template>
+            <template v-else>{{applicationUnreadCount}}</template>
+          </sup>
+        </div>
+        <div
+          id="group-list"
+          class="iconfont icon-group"
+          :class="{ active: showGroupList }"
+          title="群组列表"
+        ></div>
+
+        <!--  <div
+           id="black-list"
+           class="iconfont icon-blacklist"
+           :class="{ active: showBlackList }"
+           title="黑名单列表"
+         ></div>
+         <div
+           id="group-live"
+           class="group-live"
+           title="群直播"
+         ></div> -->
+      </div>
+      <div class="bottom">
+        <div class="iconfont icon-tuichu" @click="$store.dispatch('imlogout')" title="退出"></div>
+      </div>
+    </div>
+    <div class="bar-right">
+      <conversation-list v-show="showConversationList" />
+      <group-list v-show="showGroupList" />
+      <friend-list v-show="showFriendList" />
+      <add-friend v-show="showAddFriend" />
+      <black-list v-show="showBlackList" />
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters, mapState } from 'vuex'
+import MyProfile from '../my-profile'
+import ConversationList from '../conversation/conversation-list'
+import GroupList from '../group/group-list'
+import FriendList from '../friend/friend-list'
+import AddFriend from '../friend/add-friend'
+import BlackList from '../blacklist/blacklist'
+import { getOpenIM,getCbEvents } from '@/utils/openIM';
+const activeName = {
+  CONVERSATION_LIST: 'conversation-list',
+  GROUP_LIST: 'group-list',
+  FRIEND_LIST: 'friend-list',
+  ADD_FRIEND:'add-friend',
+  BLACK_LIST: 'black-list',
+  GROUP_LIVE: 'group-live',
+}
+export default {
+  name: 'SideBar',
+  components: {
+    MyProfile,
+    ConversationList,
+    GroupList,
+    FriendList,
+    BlackList,
+    AddFriend
+  },
+  data() {
+    return {
+      active: activeName.CONVERSATION_LIST,
+      activeName: activeName,
+      OpenIM:null
+    }
+  },
+  computed: {
+    ...mapGetters(['totalUnreadCount']),
+    ...mapState({
+      userID: state => state.imuser.userID,
+      applicationUnreadCount: state => state.friend.unreadCount,
+    }),
+    showConversationList() {
+      return this.active === activeName.CONVERSATION_LIST
+    },
+    showGroupList() {
+      return this.active === activeName.GROUP_LIST
+    },
+    showFriendList() {
+      return this.active === activeName.FRIEND_LIST
+    },
+    showAddFriend(){
+      return this.active === activeName.ADD_FRIEND
+    },
+    showBlackList() {
+      return this.active === activeName.BLACK_LIST
+    },
+    showAddButton() {
+      return [activeName.CONVERSATION_LIST, activeName.GROUP_LIST].includes(
+        this.active
+      )
+    }
+  },
+  mounted() {
+    this.$bus.$on('checkoutConversation',()=>{
+      this.checkoutActive(activeName.CONVERSATION_LIST)
+    })
+
+  },
+  created() {
+    this.OpenIM = getOpenIM();
+  },
+  methods: {
+    checkoutActive(name) {
+      this.active = name
+    },
+
+    handleClick(event) {
+      switch (event.target.id) {
+        case activeName.CONVERSATION_LIST:
+          this.checkoutActive(activeName.CONVERSATION_LIST)
+          break
+        case activeName.GROUP_LIST:
+          this.checkoutActive(activeName.GROUP_LIST)
+          break
+        case activeName.FRIEND_LIST:
+          this.checkoutActive(activeName.FRIEND_LIST)
+          break
+        case activeName.BLACK_LIST:
+          this.checkoutActive(activeName.BLACK_LIST)
+          break
+        case activeName.GROUP_LIVE:
+          this.groupLive()
+          break
+        case activeName.ADD_FRIEND:
+          this.checkoutActive(activeName.ADD_FRIEND)
+          break
+      }
+    },
+    handleRefresh() {
+      switch (this.active) {
+        case activeName.CONVERSATION_LIST:
+          this.OpenIM.getAllConversationList().catch(error => {
+            this.$store.commit('showMessage', {
+              type: 'error',
+              message: error.message
+            })
+          })
+          break
+        case activeName.GROUP_LIST:
+          this.getGroupList()
+          break
+        case activeName.FRIEND_LIST:
+          this.getFriendList()
+          break
+        case activeName.BLACK_LIST:
+          this.$store.dispatch('getBlacklist')
+          break
+      }
+    },
+    getGroupList() {
+      this.tim
+        .getGroupList()
+        .then(({ data: groupList }) => {
+          this.$store.dispatch('updateGroupList', groupList)
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    getFriendList() {
+      this.tim
+        .getFriendList()
+        .then(({ data: friendList }) => {
+          this.$store.commit('upadteFriendList', friendList)
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+    },
+    groupLive() {
+      this.$store.commit('updateGroupLiveInfo', {
+        groupID: 0,
+        anchorID: this.userID,
+      })
+      this.$bus.$emit('open-group-live', { channel: 2 })
+    },
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.side-bar-wrapper {
+  height: 100%;
+  color: #000;
+  display: flex;
+  width: 100%;
+  overflow: hidden;
+
+  .bar-left {
+    display: flex;
+    flex-shrink: 0;
+    flex-direction: column;
+    width: 80px;
+    height: 80vh;
+    background-color: #303841;
+
+    .tab-items {
+      display: flex;
+      flex-direction: column;
+      flex-grow: 1;
+
+      .iconfont {
+        position: relative;
+        margin: 0;
+        height: 70px;
+        line-height: 70px;
+        text-align: center;
+        font-size: 30px;
+        cursor: pointer;
+        color: #a5b5c1;
+        user-select: none;
+        -moz-user-select: none;
+      }
+
+      .active {
+        color: #fff;
+        background-color: #363e47;
+
+        &::after {
+          content: ' ';
+          display: block;
+          position: absolute;
+          top: 0;
+          z-index: 0;
+          height: 70px;
+          // border-left 4px solid $border-highlight
+          border-left: 4px solid #5cadff;
+        }
+      }
+
+      .unread {
+        position: absolute;
+        top: 10px;
+        right: 10px;
+        z-index: 999;
+        display: inline-block;
+        height: 18px;
+        padding: 0 6px;
+        font-size: 12px;
+        color: #FFF;
+        line-height: 18px;
+        text-align: center;
+        white-space: nowrap;
+        border-radius: 10px;
+        background-color: #f35f5f;
+      }
+    }
+
+    .bottom {
+      height: 70px;
+
+      &>span {
+        display: block;
+      }
+
+      .btn-more {
+        width: 100%;
+        height: 70px;
+        line-height: 70px;
+        font-size: 30px;
+        color: #a5b5c1;
+        text-align: center;
+        cursor: pointer;
+      }
+
+      .iconfont {
+        height: 70px;
+        line-height: 70px;
+        text-align: center;
+        font-size: 30px;
+        cursor: pointer;
+        color: #a5b5c1;
+        user-select: none;
+        -moz-user-select: none;
+      }
+
+      .iconfont:hover {
+        color: white;
+      }
+    }
+
+    .btn-more:hover {
+      color: #ffffff;
+    }
+  }
+
+  .bar-right {
+    // flex 1
+    flex: 1 1 auto;
+    width: 100%;
+    min-width: 0;
+    height: 80vh;
+    position: relative;
+    background-color: #363e47;
+  }
+  .group-live {
+    position relative
+    top 10px
+    left 25px
+    width 30px
+    height 30px
+    background url('../../assets/image/live-icon-gray.png') center no-repeat
+    background-size cover
+    cursor pointer
+  }
+}
+</style>

+ 222 - 0
src/components/message/image-previewer.vue

@@ -0,0 +1,222 @@
+<template>
+  <div class="image-previewer-wrapper" 
+    v-show="showPreviewer"    
+    
+   >
+    <div class="image-wrapper">
+      <img
+        @mousewheel="handleMouseWheel"
+        id="pic"
+        :style="{'zoom':zoom,'transform':'translate('+x+'px,'+y+'px) rotate(' + rotate + 'deg)'}" draggable="false"   @mousedown="mousedown($event)" 
+        class="image-preview"
+        :src="previewUrl"
+        
+      />
+    </div>
+    <i class="el-icon-close close-button" @click="close" />
+    <i class="el-icon-back prev-button" @click="goPrev"></i>
+    <i class="el-icon-right next-button" @click="goNext"></i>
+    <div class="actions-bar">
+      <i class="el-icon-zoom-out" @click="zoomOut"></i>
+      <i class="el-icon-zoom-in" @click="zoomIn"></i>
+      <i class="el-icon-refresh-left" @click="rotateLeft"></i>
+      <i class="el-icon-refresh-right" @click="rotateRight"></i>
+      <span class="image-counter">{{index+1}} / {{imgUrlList.length}}</span>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+export default {
+  name: 'ImagePreviewer',
+  data() {
+    return {
+      url: '',
+      index: 0,
+      visible: false,
+      zoom: 1,
+      rotate: 0,
+      minZoom: 0.1,
+      urlFlag: false,
+      x:0,
+      y:0,
+      startx:'',
+      starty:'',
+      endx:0,
+      endy:0,
+    }
+  },
+  computed: {
+    ...mapGetters(['imgUrlList']),
+    showPreviewer() {
+      return this.url.length > 0 && this.visible
+    },
+    imageStyle() {
+      return {
+        transform: `scale(${this.zoom});`
+      }
+    },
+    previewUrl() {
+      if(this.urlFlag) {
+        return this.url
+      } else {
+        return this.formatUrl(this.imgUrlList[this.index])
+      }
+      // this.urlFlag ? return this.url :
+    }
+  },
+  mounted() {
+    this.$bus.$on('image-preview', this.handlePreview)
+  },
+  methods: {
+    mousedown(e){
+      // 绑定mousemove
+      this.startx=e.pageX; this.starty=e.pageY
+      document.addEventListener('mousemove',this.mousemove)
+      document.getElementById('pic').addEventListener('mouseup',this.mouseup)
+    },
+    mousemove(e){
+      this.x=e.pageX-this.startx+this.endx
+      this.y=e.pageY-this.starty+this.endy
+    },
+    mouseup(){
+      // 解除绑定mousemove
+      document.removeEventListener('mousemove',this.mousemove,false)
+      this.endx=this.x
+      this.endy=this.y
+
+    },
+    handlePreview({ url, flag = undefined}) {
+      this.url = url
+      this.urlFlag = flag ? 'merger' : false
+      this.index = this.imgUrlList.findIndex(item => item === url)
+      this.visible = true
+    },
+    handleMouseWheel(event) {
+      if (event.wheelDelta > 0) {
+        this.zoomIn()
+      } else {
+        this.zoomOut()
+      }
+    },
+    zoomIn() {
+      this.zoom += 0.1
+    },
+    zoomOut() {
+      this.zoom =
+        this.zoom - 0.1 > this.minZoom ? this.zoom - 0.1 : this.minZoom
+    },
+    close() {
+      this.x=0
+      this.y=0
+      this.zoom=1
+      this.endx=0
+      this.endy=0
+      Object.assign(this, { zoom: 1 })
+      this.visible = false
+    },
+    rotateLeft() {
+      this.rotate -= 90
+      console.log(this.rotate)
+    },
+    rotateRight() {
+      this.rotate += 90
+      console.log(this.rotate)
+    },
+    goNext() {
+      this.index = (this.index + 1) % this.imgUrlList.length
+    },
+    goPrev() {
+      this.index =
+        this.index - 1 >= 0 ? this.index - 1 : this.imgUrlList.length - 1
+    },
+    formatUrl(url) {
+      if (!url) {
+        return ''
+      }
+      return url.slice(0, 2) === '//' ? `https:${url}` : url
+    }
+  }
+}
+</script>
+
+<style scoped>
+.image-previewer-wrapper {
+  position: fixed;
+  width: 100%;
+  left: 0;
+  top: 0;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
+  background: rgba(14, 12, 12, 0.7);
+  z-index: 99999;
+   
+}
+
+.close-button {
+  cursor: pointer;
+  font-size: 28px;
+  color: #000;
+  position: fixed;
+  top: 50px;
+  right: 50px;
+  background: rgba(255, 255, 255, 0.8);
+  border-radius: 50%;
+  padding: 6px;
+}
+.image-wrapper {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.image-preview {
+  transition: transform 0.1s ease 0s;
+}
+.actions-bar {
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  position: fixed;
+  bottom: 50px;
+  left: 50%;
+  margin-left: -100px;
+  padding: 12px;
+  border-radius: 6px;
+  background: rgba(255, 255, 255, 0.8);
+}
+.actions-bar i {
+  font-size: 24px;
+  cursor: pointer;
+  margin: 0 6px;
+}
+
+.prev-button,
+.next-button {
+  position: fixed;
+  cursor: pointer;
+  background: rgba(255, 255, 255, 0.8);
+  border-radius: 50%;
+  font-size: 24px;
+  padding: 12px;
+}
+.prev-button {
+  left: 0;
+  top: 50%;
+}
+.next-button {
+  right: 0;
+  top: 50%;
+}
+.image-counter {
+  background: rgba(20, 18, 20, 0.53);
+  padding: 3px;
+  border-radius: 3px;
+  color: #fff;
+}
+</style>

+ 621 - 0
src/components/message/merger-message/mergerMessage-item.vue

@@ -0,0 +1,621 @@
+<template>
+  <div class="message-wrapper col-2">
+    <div class="content-wrapper">
+      <!--文本消息-->
+      <div class="message-container" v-if="message.contentType === 101">
+        <div class="text-message" v-for="(item, index) in contentList" :key="index">
+          <span  :key="index" v-if="item.name === 'text'">{{ item.text }}</span>
+          <img v-else-if="item.name === 'img'" :src="item.src" width="20px" height="20px" :key="index"/>
+        </div>
+      </div>
+      <!--图片消息-->
+      <div class="message-container" v-else-if="message.contentType === 102">
+        <img class="image-element" :src="message.pictureElem.sourcePicture.url" @load="onImageLoaded" @click="handlePreview()" />
+      </div>
+      <!--文件消息-->
+      <div class="message-container" v-else-if="message.contentType === 105">
+        <div class="file-element-wrapper" title="单击下载" @click="downloadFile">
+          <div class="file-box">
+            <i class="el-icon-document file-icon"></i>
+            <div class="file-element">
+              <span class="file-name">{{ message.fileElem.fileName }}</span>
+              <span class="file-size">{{ message.fileElem.fileSize }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+      <!--表情消息-->
+
+      <div class="message-container" v-else-if="message.contentType === 115">
+        <img :src="faceUrl"/>
+      </div>
+      <!--视频消息-->
+      <div class="message-container" v-else-if="message.contentType === 104">
+        <video
+          :src="message.videoElem.videoUrl"
+          controls
+          class="merger-video"
+          @error="videoError"
+        ></video>
+      </div>
+      <!--音频消息-->
+      <div class="sound-element-wrapper" v-else-if="message.contentType === 103" :title="playStatus === 'playing' ? '单击暂停' : '单击播放'" @click="handleClick">
+        <i class="iconfont icon-voice"></i>
+        {{ message.soundElem.duration}}
+      </div>
+      <!--自定义消息-->
+      <div class="message-container" v-else-if="message.contentType === 110">
+        <div class="custom-element-wrapper">
+          <div class="survey"  v-if="this.payload.data === 'survey'">
+            <div class="title">对IM DEMO的评分和建议</div>
+            <el-rate
+              v-model="rate"
+              disabled
+              show-score
+              text-color="#ff9900"
+              score-template="{value}">
+            </el-rate>
+            <div class="suggestion">{{this.payload.extension}}</div>
+          </div>
+          <span class="text" title="您可以自行解析自定义消息" v-else>
+                      <template >{{translateCustomMessage(this.payload)}}</template>
+                    </span>
+        </div>
+      </div>
+      <!--合并的消息-->
+      <div class="message-container"  @click="mergerHandler(message)" v-else-if="message.contentType === 107">
+        <div class="merger-item">
+          <p class="merger-title">{{message.mergeElem.title}}</p>
+          <p class="merger-text" v-for="(item, index) in message.mergeElem.abstractList" :key="index">
+            {{item}}
+          </p>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+  import { decodeText } from '../../../utils/decodeText'
+  import { getFullDate } from '../../../utils/date'
+  import { Rate } from 'element-ui'
+
+  export default {
+    name: 'MessageItem',
+    props: {
+      message: {
+        type: Object,
+        required: true
+      },
+      payload: {
+        type: Object,
+        default: () => ({})
+      }
+
+    },
+    components: {
+      ElRate: Rate,
+    },
+    data() {
+      return {
+        renderDom: [],
+        showConversationList: false,
+        relayMessage: {},
+        selectedConversation: [],
+        messageSelected:[],
+        amr: null,
+        audio: null,
+        isAMR: false,
+        playStatus: 'stopped' // 'playing' | 'paused' | 'stopped'
+      }
+    },
+    computed: {
+      url() {
+        return this.message.soundElem.sourceUrl
+      },
+      second() {
+        return this.message.soundElem.duration
+      },
+      ...mapState({
+        currentConversation: state => state.conversation.currentConversation,
+        currentUserProfile: state => state.imuser.currentUserProfile,
+        isShowConversationList: state => state.conversation.isShowConversationList,
+
+      }),
+      // 自定义消息
+      rate() {
+        return parseInt(this.payload.description)
+      },
+      // 图片消息
+      imageUrl() {
+        const url = this.message.pictureElem.sourcePicture.url
+        if (typeof url !== 'string') {
+          return ''
+        }
+        return url.slice(0, 2) === '//' ? `https:${url}` : url
+      },
+      // showProgressBar() {
+      //   return this.$parent.message.status === 'unSend'
+      // },
+      percentage() {
+        return Math.floor((this.$parent.message.progress || 0) * 100)
+      },
+      // 表情消息
+      faceUrl() {
+        let name = ''
+        if (this.payload.data.indexOf('@2x') > 0) {
+          name = this.payload.data
+        } else {
+          name = this.payload.data + '@2x'
+        }
+        return `https://web.sdk.qcloud.com/im/assets/face-elem/${name}.png`
+      },
+      // 时间换算
+      date() {
+        return getFullDate(new Date(this.message.time * 1000))
+      },
+
+      // 文件消息大小
+      fileSize() {
+        const size = this.message.fileElem.fileSize
+        if (size > 1024) {
+          if (size / 1024 > 1024) {
+            return `${this.toFixed(size / 1024 / 1024)} Mb`
+          }
+          return `${this.toFixed(size / 1024)} Kb`
+        }
+        return `${this.toFixed(size)}B`
+      },
+      // 消息昵称
+      from() {
+        const isC2C = this.currentConversation.type === 1
+        // 自己发送的用昵称渲染
+        if (this.isMine) {
+          return  this.currentUserProfile.nick || this.currentUserProfile.userID
+        }
+        // 1. C2C 的消息体中还无 nick / avatar 字段,需从 conversation.userProfile 中获取
+        if (isC2C) {
+          return (
+            this.currentConversation.userProfile.nick ||
+            this.currentConversation.userProfile.userID
+          )
+        }
+        // 2. 群组消息,用消息体中的 nick 渲染。nameCard暂时支持不完善
+        return this.message.nameCard ||  this.message.nick || this.message.from
+      },
+      avatar() {
+        if (this.currentConversation.type === 'C2C') {
+          return this.isMine
+            ? this.currentUserProfile.avatar
+            : this.currentConversation.userProfile.avatar
+        } else if (this.currentConversation.type === 'GROUP') {
+          return this.isMine
+            ? this.currentUserProfile.avatar
+            : this.message.avatar
+        } else {
+          return ''
+        }
+      },
+      currentConversationType() {
+        return this.currentConversation.type
+      },
+      isMine() {
+        return this.message.flow === 'out'
+      },
+      contentList() {
+        console.log("this.payload",this.payload)
+        return decodeText(this.payload)
+      },
+    },
+    methods: {
+      // 自定义消息解析
+      translateCustomMessage(payload) {
+        let videoPayload = {}
+        try{
+          videoPayload = JSON.parse(payload.data)
+        } catch(e) {
+          videoPayload = {}
+        }
+        if (payload.data === 'group_create') {
+          return `${payload.extension}`
+        }
+        if (videoPayload.roomId) {
+          videoPayload.roomId = videoPayload.roomId.toString()
+          videoPayload.isFromGroupLive = 1
+          return videoPayload
+        }
+        if(payload.text) {
+          return payload.text
+        }else{
+          return '[自定义消息]'
+        }
+      },
+      // 图片消息
+      onImageLoaded(event) {
+        this.$bus.$emit('image-loaded', event)
+      },
+      handlePreview() {
+        this.$bus.$emit('image-preview', {
+          url: this.message.pictureElem.sourcePicture.url,
+          flag: true
+        })
+      },
+      toFixed(number, precision = 2) {
+        return number.toFixed(precision)
+      },
+      showGroupMemberProfile(event) {
+        this.tim
+          .getGroupMemberProfile({
+            groupID: this.message.to,
+            userIDList: [this.message.from]
+          })
+          .then(({ data: { memberList } }) => {
+            if (memberList[0]) {
+              this.$bus.$emit('showMemberProfile', { event, member: memberList[0] })
+            }
+          })
+      },
+      messageClick(message) {
+        this.$store.commit('showConversationList', false)
+        this.showConversationList = true
+        this.relayMessage = message   // 需要深拷贝吗?
+      },
+      showMergerMessage() {
+        this.$bus.$emit('mergerMessage', true)
+      },
+      cancel() {
+        this.showConversationList = false
+      },
+      getList(value) {
+        this.selectedConversation = value
+      },
+      messageRelay() {
+        let type = ''
+        let toUserId = ''
+        this.selectedConversation.forEach((item) => {
+          if(item.indexOf(this.OpenIM.TYPES.CONV_C2C) !== -1) {
+            type = 1
+            toUserId = item.substring(3,item.length)
+          }
+          if(item.indexOf(3) !== -1) {
+            type = 3
+            toUserId = item.substring(5,item.length)
+          }
+          const message = this.tim.createForwardMessage({
+            to: toUserId,
+            conversationType: type,
+            payload: this.relayMessage,
+            priority: this.OpenIM.TYPES.MSG_PRIORITY_NORMAL
+          })
+          this.tim.sendMessage(message).catch(imError => {
+            this.$store.commit('showMessage', {
+              message: imError.message,
+              type: 'error'
+            })
+          })
+          this.showConversationList = false
+        })
+      },
+      // 合并的消息
+      mergerHandler(message) {
+        console.log("setMergerMessage",message)
+        this.$store.commit('setMergerMessage', message)
+        // this.$bus.$emit('mergerMessage', message)
+      },
+      // 视频消息
+      videoError(e) {
+        this.$store.commit('showMessage', { type: 'error', message: '视频出错,错误原因:' + e.target.error.message })
+      },
+      // 音频消息
+      play() {
+        this.cleanup()
+
+        // 默认使用 HTML5 audio 播放
+        this.audio = new Audio(this.url)
+        console.log(this.audio)
+        this.audio.addEventListener('error', this.tryPlayAMR)
+        this.audio.addEventListener('ended', this.cleanup)
+        this.audio.play()
+          .then(() => {
+            this.playStatus = 'playing'
+          })
+          .catch(() => {
+            // 播放失败 fallback 到 tryPlayAMR
+          })
+      },
+      handleClick() {
+        console.log("this.message.soundElem.sourceUrl",this.message.soundElem.sourceUrl)
+        if (this.playStatus === 'playing') {
+          this.pause()
+        } else if (this.playStatus === 'paused') {
+          this.resume()
+        } else {
+          this.play()
+        }
+      },
+      pause() {
+        if (this.isAMR && this.amr) {
+          this.amr.pause()
+        } else if (this.audio) {
+          this.audio.pause()
+        }
+        this.playStatus = 'paused'
+      },
+      resume() {
+        if (this.isAMR && this.amr) {
+          this.amr.play()
+        } else if (this.audio) {
+          this.audio.play()
+        }
+        this.playStatus = 'playing'
+      },
+      tryPlayAMR() {
+        this.isAMR = true
+        const isIE = /MSIE|Trident|Edge/.test(window.navigator.userAgent)
+        if (isIE) {
+          this.$store.commit('showMessage', {
+            message: '您的浏览器不支持该格式的语音消息播放,请尝试更换浏览器,建议使用:谷歌浏览器',
+            type: 'warning'
+          })
+          return
+        }
+
+        if (!window.BenzAMRRecorder) {
+          const script = document.createElement('script')
+          script.addEventListener('load', this.playAMR)
+          script.src = '/BenzAMRRecorder.js'
+          document.head.appendChild(script)
+          return
+        }
+
+        this.playAMR()
+      },
+      playAMR() {
+        if (!this.amr && window.BenzAMRRecorder) {
+          this.amr = new window.BenzAMRRecorder()
+          this.amr.onEnded(() => {
+            this.cleanup()
+          })
+        }
+
+        if (this.amr.isInit()) {
+          this.amr.play()
+          this.playStatus = 'playing'
+        } else {
+          this.amr.initWithUrl(this.url).then(() => {
+            this.amr.play()
+            this.playStatus = 'playing'
+          })
+        }
+      },
+      cleanup() {
+        if (this.audio) {
+          this.audio.pause()
+          this.audio = null
+        }
+        if (this.amr) {
+          this.amr.stop()
+        }
+        this.playStatus = 'stopped'
+      },
+      // 文件消息
+      downloadFile() {
+        const fileUrl = this.message.fileElem.sourceUrl;
+        const fileName = this.message.fileElem.fileName;
+        console.log(fileUrl)
+        fetch(fileUrl)
+          .then(response => {
+            if (!response.ok) throw new Error(`下载失败,状态码:${response.status}`);
+            return response.blob();
+          })
+          .then(blob => {
+            /*const mime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+            const docBlob = new Blob([blob], { type: mime });*/ // 💡 明确指定 MIME 类型
+            const blobUrl = window.URL.createObjectURL(blob);
+
+            const a = document.createElement('a');
+            a.href = blobUrl;
+            a.download = fileName;
+            document.body.appendChild(a);
+            a.click();
+            document.body.removeChild(a);
+            window.URL.revokeObjectURL(blobUrl); // 释放资源
+          })
+          .catch(err => {
+            console.error('下载失败:', err);
+          });
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+    .conversation-container {
+        position absolute
+        top 0
+        left 0px
+        width 100%
+        background-color #fff
+        z-index 999
+    }
+    .conversation-list-btn {
+        width 140px
+        display flex
+        float right
+        margin 10px 0
+        .conversation-btn {
+            cursor pointer
+            padding 6px 12px
+            background #00A4FF
+            color #ffffff
+            font-size 14px
+            border-radius 20px
+            margin-left 13px
+        }
+    }
+    .message-wrapper {
+        margin: 5px 5px 10px 5px;
+        .content-wrapper {
+            display: flex
+            align-items: center
+            .message-container {
+                width 100%
+                .text-message {
+                    padding 3px 10px
+                }
+                .image-element {
+                    max-height 300px
+                }
+                .merger-item {
+                    border 1px solid #DEDEDE
+                    background-color #ffffff
+                    padding 0 10px
+                    border-radius 6px
+                    .merger-title {
+                        font-size 15px
+                        max-width 180px
+                        overflow hidden;
+                        text-overflow ellipsis;
+                        white-space nowrap;
+                    }
+                    .merger-text {
+                        color #B3B3B3
+                        margin 10px 0
+                        font-size 13px
+                        max-width 280px
+                        overflow hidden;
+                        text-overflow ellipsis;
+                        white-space nowrap;
+                    }
+                }
+            }
+        }
+    }
+
+    .group-layout, .c2c-layout, .system-layout {
+        display: flex;
+
+        .col-1 {
+            .avatar {
+                width: 56px;
+                height: 56px;
+                border-radius: 50%;
+                box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
+            }
+        }
+
+        .group-member-avatar {
+            cursor: pointer;
+        }
+
+        .col-2 {
+            display: flex;
+            flex-direction: column;
+            // max-width 50% // 此设置可以自适应宽度,目前由bubble限制
+        }
+
+        .col-3 {
+            width: 30px;
+        }
+
+        &.position-left {
+            .col-2 {
+                align-items: flex-start;
+            }
+        }
+
+        &.position-right {
+            flex-direction: row-reverse;
+
+            .col-2 {
+                align-items: flex-end;
+            }
+        }
+
+        &.position-center {
+            justify-content: center;
+        }
+    }
+
+    .c2c-layout {
+        .col-2 {
+            .base {
+                margin-top: 3px;
+            }
+        }
+    }
+
+    .group-layout {
+        .col-2 {
+            .chat-bubble {
+                margin-top: 5px;
+                outline none
+            }
+        }
+    }
+    .right {
+        display: flex;
+        flex-direction: row-reverse;
+    }
+
+    .left {
+        display: flex;
+        flex-direction: row;
+    }
+
+    .base {
+        color: $secondary;
+        font-size: 12px;
+    }
+
+    .name {
+        padding: 0 4px;
+        max-width: 100px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+    .merger-video {
+        width 100%
+        max-height 300px
+    }
+    .file-box {
+        display: flex;
+    }
+    .file-icon {
+        font-size: 40px !important;
+    }
+    .file-element {
+        display: flex;
+        flex-direction: column;
+        margin-left: 12px;
+    }
+    .file-size {
+        font-size: 12px;
+        padding-top 5px
+    }
+
+    .text
+    font-weight bold
+    .title
+        font-size 16px
+        font-weight 600
+        padding-bottom 10px
+    .survey
+        background-color white
+        color black
+        padding 20px
+        display flex
+        flex-direction column
+    .suggestion
+        padding-top 10px
+        font-size 14px
+    .sound-element-wrapper {
+        background-color #fff
+        padding 2px 13px
+        cursor pointer
+        border-radius 3px
+    }
+</style>

+ 187 - 0
src/components/message/merger-message/message-merger.vue

@@ -0,0 +1,187 @@
+<template>
+  <div class="merger-conversation-wrapper">
+    <div class="current-conversation" @scroll="onScroll">
+      <div class="content">
+        <div class="message-list" ref="message-list" @scroll="this.onScroll">
+          <div   v-for="(messageItem, index) in mergerList(mergerMessage)" :key="index">
+            <div class="message-item">
+              <div class="avatar-box">
+                <avatar class="group-member-avatar" :src="messageItem.senderFaceUrl"/>
+              </div>
+              <div class="container-box">
+                <div class="nick-date">
+                  <div class="name text-ellipsis">{{messageItem.senderNickname || messageItem.from || '小晨曦'}}</div>
+                  <div class="date">{{ getDate(messageItem.sendTime) }}</div>
+                </div>
+                <merger-message-item  :message="messageItem" :payload="messageItem.textElem"/>
+              </div>
+            </div>
+            <el-divider></el-divider>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import {  mapState } from 'vuex'
+  import MergerMessageItem from './mergerMessage-item'
+  import { getFullDate } from '../../../utils/date'
+  export default {
+    name: 'CurrentConversation',
+    components: {
+      MergerMessageItem,
+    },
+    data() {
+      return {
+        preScrollHeight: 0,
+        mergerMessageList: [],
+        showMessage: null,
+
+      }
+    },
+    computed: {
+      ...mapState({
+        currentConversation: state => state.conversation.currentConversation,
+        mergerMessage: state => state.conversation.mergerMessage
+      }),
+      mergerList() {
+        return function(message) {
+          console.log(message)
+          return message.mergeElem.multiMessage
+        }
+      }
+    },
+    created () {
+    },
+    mounted() {
+
+    },
+    updated() {
+
+    },
+    watch: {
+
+    },
+    methods: {
+      getDate(time) {
+        return getFullDate(new Date(time))
+      },
+      onScroll({ target: { scrollTop } }) {
+        let messageListNode = this.$refs['message-list']
+        if (!messageListNode) {
+          return
+        }
+        if (this.preScrollHeight - messageListNode.clientHeight - scrollTop < 20) {
+          this.isShowScrollButtomTips = false
+        }
+      },
+      // 如果滚到底部就保持在底部,否则提示是否要滚到底部
+      keepMessageListOnButtom() {
+        let messageListNode = this.$refs['message-list']
+        if (!messageListNode) {
+          return
+        }
+        // 距离底部20px内强制滚到底部,否则提示有新消息
+        if (this.preScrollHeight - messageListNode.clientHeight - messageListNode.scrollTop < 20) {
+          this.$nextTick(() => {
+            messageListNode.scrollTop = messageListNode.scrollHeight
+          })
+          this.isShowScrollButtomTips = false
+        } else {
+          this.isShowScrollButtomTips = true
+        }
+        this.preScrollHeight = messageListNode.scrollHeight
+      },
+      // 直接滚到底部
+      scrollMessageListToButtom() {
+        this.$nextTick(() => {
+          let messageListNode = this.$refs['message-list']
+          if (!messageListNode) {
+            return
+          }
+          messageListNode.scrollTop = messageListNode.scrollHeight
+          this.preScrollHeight = messageListNode.scrollHeight
+          this.isShowScrollButtomTips = false
+        })
+      },
+      onImageLoaded() {
+        this.keepMessageListOnButtom()
+      }
+    }
+  }
+</script>
+<style lang="stylus" scoped>
+    /* 当前会话的骨架屏 */
+    .merger-conversation-wrapper
+        height 54vh
+        /*background-color #ffffff*/
+        color $base
+        display flex
+        .current-conversation
+            display: flex;
+            flex-direction: column;
+            width: 100%;
+            height: 100%;
+    .content
+        display: flex;
+        flex 1
+        flex-direction: column;
+        height: 100%;
+        overflow: hidden;
+        position: relative;
+        .message-list
+            width: 100%;
+            box-sizing: border-box;
+            overflow-y: scroll;
+            padding: 0 20px;
+
+    .footer
+        border-top: 1px solid $border-base;
+    .show-more {
+        text-align: right;
+        padding: 10px 20px 0 0;
+    }
+    /deep/ .el-checkbox {
+        width 100%
+    }
+    /deep/ .el-checkbox__label {
+        width 100%
+        padding-right 20px
+        box-sizing border-box
+    }
+    /deep/ .el-divider--horizontal {
+        display: block;
+        height: 1px;
+        width: 90%;
+        margin: 0 auto 8px;
+    }
+    .message-item {
+        display flex
+        /*border-bottom 1px solid #DEDEDE*/
+        /*padding-bottom  10px*/
+    }
+    .avatar-box {
+        .avatar {
+            width: 36px;
+            height: 36px;
+            border-radius: 50%;
+            box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
+        }
+    }
+    .container-box {
+        display flex
+        flex-direction column
+        .nick-date {
+            font-size 12px
+            color #B3B3B3
+            display flex
+            flex-direction row
+            .name {
+                margin 0 5px
+            }
+        }
+
+    }
+</style>

+ 292 - 0
src/components/message/merger-message/message-relay.vue

@@ -0,0 +1,292 @@
+<template>
+  <div class="chat-bubble" @mousedown.stop @contextmenu.prevent>
+    <div class="conversation-container">
+      <ConversationSelectedList   @getList="getList"></ConversationSelectedList>
+      <div class="conversation-list-btn">
+        <span class="conversation-btn" @click="cancel">取消</span>
+        <span class="conversation-btn" @click="messageRelay">发送</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+  import ConversationSelectedList from '../../conversation/conversation-selected-list'
+  import { getOpenIM } from '@/utils/openIM';
+  export default {
+    name: 'MessageBubble',
+    components: {
+      ConversationSelectedList
+    },
+    data() {
+      return {
+        OpenIM:null,
+        isTimeout: false,
+        showConversationList: false,
+        selectedConversation: [],
+        testMergerMessage: {}
+      }
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+    },
+    mounted() {
+      if (this.$refs.dropdown && this.$refs.dropdown.$el) {
+        this.$refs.dropdown.$el.addEventListener('mousedown', this.handleDropDownMousedown)
+      }
+    },
+    beforeDestroy() {
+      if (this.$refs.dropdown && this.$refs.dropdown.$el) {
+        this.$refs.dropdown.$el.removeEventListener('mousedown', this.handleDropDownMousedown)
+      }
+    },
+    updated() {
+    },
+    computed: {
+      ...mapState({
+        isShowConversationList: state => state.conversation.isShowConversationList,
+        selectedMessageList: state => state.conversation.selectedMessageList,
+        relayType: state => state.conversation.relayType,
+        relayMessage: state => state.conversation.relayMessage
+      }),
+    },
+    methods: {
+      cancel() {
+        this.$store.commit('showConversationList', false)
+      },
+      getList(value) {
+        this.selectedConversation = value
+
+      },
+      sendSingleMessage(to, type, message) {
+        return this.OpenIM.createForwardMessage(message)
+          .then(({ data }) => {
+            console.log("创建转发消息成功111", data);
+            this.$store.commit('pushCurrentMessageList', data);
+            return data
+          })
+          .catch(({ errCode, errMsg }) => {
+            console.error("创建转发消息失败", errCode, errMsg);
+          });
+      },
+      mergerSort() {
+        this.selectedMessageList.sort((a, b) =>  {
+          if(a.time !== b.time) {
+            return a.time - b.time
+          }else {
+            return a.sequence - b.sequence
+          }
+        })
+      },
+      updateConversationList(){
+        this.OpenIM.getAllConversationList()
+          .then(({ data }) => {
+            // 调用成功
+            console.log(data,"147852")
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+      },
+      mergerMessage(to, type, messageList) {
+        let _summaryList = [];
+        const _count = this.selectedMessageList.length < 3 ? this.selectedMessageList.length : 3;
+        for (let i = 0; i < _count; i++) {
+          _summaryList.push(this.setAbstractList(this.selectedMessageList[i]));
+        }
+
+        /*const _title = this.selectedMessageList[0].conversationType === 'GROUP'
+          ? '群聊的聊天记录'
+          : `${this.selectedMessageList[0].senderNickname || this.selectedMessageList[0].from} 和 ${this.selectedMessageList[0].to} 的聊天记录`;*/
+        const _title = "聊天记录"
+        console.log("创建合并转发消息参数",this.selectedMessageList)
+        return this.OpenIM.createMergerMessage({
+          messageList: this.selectedMessageList,
+          title: _title,
+          summaryList: _summaryList
+        })
+          .then(({ data }) => {
+            // 将返回的合并消息对象存入 Vuex 或其他状态管理
+            this.$store.commit('pushCurrentMessageList', data);
+            console.log("wwwwwwwwwww",data)
+            return data;  // 返回创建的合并消息对象
+          })
+          .catch(({ errCode, errMsg }) => {
+            console.error('合并消息创建失败:', errCode, errMsg);
+            throw new Error(errMsg || '合并消息创建失败');
+          });
+      },
+
+      async messageRelay() {
+
+        let _type = '', _to = ''
+        const myId = this.$store.getters.userID
+        for (let item of this.selectedConversation) {
+          if(item.startsWith("si")) {
+            _type = 1
+            _to = item.replace("si","").replace(myId,"").replaceAll("_","");
+          }
+          if(item.startsWith("sg")) {
+            _type = 3
+            _to = item.substring(5, item.length)
+          }
+          // 排序
+          console.log(this.$store.state.conversation,"||||")
+          this.mergerSort()
+          if (this.relayType === 1) {
+            this.relayMessage.recvID = _to
+            let message =await this.sendSingleMessage(_to, _type, this.relayMessage)
+            console.log(message,"message")
+            this.sendMessageHandler(message)
+          }
+
+          if (this.relayType === 2) {
+            if(this.selectedMessageList.length > 30) {
+              this.$store.commit('showMessage', {
+                message: '转发消息仅支持30条以内',
+                type: 'error'
+              })
+              return
+            }
+
+            for (let selectedMessage of this.selectedMessageList) {
+              console.log("selectedMessage",selectedMessage)
+              selectedMessage.recvID = _to
+              let message = await this.sendSingleMessage(_to, _type, selectedMessage)
+              console.log("转发类型",message)
+              const sendText={
+                message:message,
+                recvID:message.recvID,
+                groupID:"",
+                offlinePushInfo : {
+                  title:message.senderNickname,
+                  desc:this.setAbstractList(message),
+                  iOSPushSound:"",
+                  iOSBadgeCount:true,
+                  operatorUserID:"",
+                  ex:""
+                }
+              }
+              console.log("this.OpenIM",this.OpenIM)
+              await this.OpenIM.sendMessage(sendText)
+                .then((res) => {
+                  return res
+                })
+                .catch(error => {
+                  this.$store.commit('showMessage', {
+                    type: 'error',
+                    message: error.message
+                  })
+                })
+
+            }
+          }
+          if (this.relayType === 3) {
+            let message =await this.mergerMessage(_to, _type,this.selectedMessageList)
+
+            message.recvID = _to
+            console.log("wwwwwwwwwwwwwwwwww",message)
+            this.sendMessageHandler(message)
+          }
+        }
+        this.$store.commit('showConversationList', false)
+        this.$store.commit('resetSelectedMessage', false)
+      },
+
+      sendMessageHandler(message) {
+        console.log("this.OpenIM111",message)
+        const sendText={
+          message:message,
+          recvID:message.recvID,
+          groupID:"",
+          offlinePushInfo : {
+            title:message.senderNickname,
+            desc:this.setAbstractList(message),
+            iOSPushSound:"",
+            iOSBadgeCount:true,
+            operatorUserID:"",
+            ex:""
+          }
+        }
+        this.OpenIM.sendMessage(sendText).then(({ data }) => {
+          const msgList = Array.isArray(data) ? data : [data];
+          this.$store.commit('pushCurrentMessageList', msgList)
+          this.updateConversationList()
+          this.$bus.$emit('scroll-bottom')
+        }).catch(({ errCode, errMsg }) => {
+          // 调用失败
+          this.$store.commit('showMessage', {
+            message: errMsg.message,
+            type: 'error'
+          })
+        });
+      },
+      setAbstractList(message) {
+        console.log(message,"message")
+        let nickname = message.senderNickname || message.from
+        let text = ''
+        switch (message.contentType) {
+          case 101:
+            text = message.textElem.content || ''
+            if (text.length > 20) {
+              text = text.slice(0, 20)
+            }
+            return `${nickname}: ${text}`
+          case 107:
+            return `${nickname}: [聊天记录]`
+          case 102:
+            return `${nickname}: [图片]`
+          case 103:
+            return `${nickname}: [音频]`
+          case 104:
+            return `${nickname}: [视频]`
+          case 110:
+            return `${nickname}: [自定义消息]`
+          case 105:
+            return `${nickname}: [文件]`
+          case 115:
+            return `${nickname}: [动画表情]`
+        }
+      },
+      handleDropDownMousedown(e) {
+        if (e.buttons === 2) {
+          if (this.$refs.dropdown.visible) {
+            this.$refs.dropdown.hide()
+          } else {
+            this.$refs.dropdown.show()
+          }
+        }
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+    .conversation-container {
+        position absolute
+        top 0
+        left 0
+        right 0
+        width 80%
+        margin auto
+        background-color #fff
+        z-index 999
+    }
+    .conversation-list-btn {
+        width 140px
+        display flex
+        float right
+        margin 10px 0
+        .conversation-btn {
+            cursor pointer
+            padding 6px 12px
+            background #00A4FF
+            color #ffffff
+            font-size 14px
+            border-radius 20px
+            margin-left 13px
+        }
+    }
+</style>

+ 309 - 0
src/components/message/message-bubble.vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="chat-bubble" @mousedown.stop @contextmenu.prevent>
+    <el-dropdown trigger="" ref="dropdown" placement="bottom-start" v-if="!message.isRevoked" @command="handleCommand">
+      <div style="display: flex">
+        <div v-if="isMine && messageReadByPeer" class="message-status">
+          <span>{{messageReadByPeer}}</span>
+        </div>
+        <div v-if="message.contentType!==2101" class="message-content" :class="bubbleStyle">
+          <slot></slot>
+        </div>
+      </div>
+      <el-dropdown-menu slot="dropdown">
+        <el-dropdown-item command="revoke" v-if="isMine&&!isTimeout">撤回</el-dropdown-item>
+        <el-dropdown-item command="relay" v-show="message.status !=='fail'">转发</el-dropdown-item>
+        <el-dropdown-item command="merger" v-show="message.status !=='fail'">多选</el-dropdown-item>
+      </el-dropdown-menu>
+    </el-dropdown>
+    <div class="group-tip-element-wrapper" v-if="isRevoked">
+      {{text}}
+      <el-button type="text" size="mini" class="edit-button" v-show="isEdit" @click="reEdit">&nbsp;重新编辑</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+  import { getOpenIM } from '@/utils/openIM';
+  import {mapGetters, mapState} from "vuex";
+  export default {
+    name: 'MessageBubble',
+    components: {
+    },
+    data() {
+      return {
+        OpenIM:null,
+        isTimeout: false,
+        showConversationList: false,
+        relayMessage: {},
+        selectedConversation: [],
+        testMergerMessage: {}
+
+      }
+    },
+    props: {
+      isMine: {
+        type: Boolean
+      },
+      isNew: {
+        type: Boolean
+      },
+      message: {
+        type: Object,
+        required: true
+      }
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+      this.isTimeoutHandler()
+    },
+    mounted() {
+      if (this.$refs.dropdown && this.$refs.dropdown.$el) {
+        this.$refs.dropdown.$el.addEventListener('mousedown', this.handleDropDownMousedown)
+      }
+    },
+    beforeDestroy() {
+      if (this.$refs.dropdown && this.$refs.dropdown.$el) {
+        this.$refs.dropdown.$el.removeEventListener('mousedown', this.handleDropDownMousedown)
+      }
+    },
+    updated() {
+    },
+    computed: {
+      bubbleStyle() {
+        let classString = ''
+        if (this.isMine) {
+          classString += 'message-send'
+        } else {
+          classString += 'message-received'
+        }
+        if (this.isNew) {
+          classString += 'new'
+        }
+        return classString
+      },
+      text() {
+        if (this.message.sessionType === 1 && !this.isMine) {
+          return '对方撤回了一条消息'
+        }
+        if (this.message.sessionType === 3 && !this.isMine) {
+          return `${this.message.from}撤回了一条消息`
+        }
+        return '你撤回了一条消息'
+      },
+      isRevoked() {
+        return this.message.contentType === 2101
+      },
+      messageReadByPeer() {
+        if (this.message.status !== 2) {
+          return false
+        }
+        if (this.message.contentType!== 2101){
+          if (this.message.sessionType === 1 && this.message.isRead) {
+            return '已读'
+          }
+          if (this.message.sessionType === 1 && !this.message.isRead) {
+            return '未读'
+          }
+        }
+        return ''
+      },
+      isEdit() {
+        if (!this.isMine) {
+          return false
+        }
+        if (this.message.contentType !== 101) {
+          return false
+        }
+        if (this.isTimeout) {
+          return false
+        }
+        return true
+      },
+    },
+    methods: {
+      handleDropDownMousedown(e) {
+        if (e.buttons === 2) {
+          if (this.$refs.dropdown.visible) {
+            this.$refs.dropdown.hide()
+          } else {
+            this.$refs.dropdown.show()
+          }
+        }
+      },
+      handleCommand(command) {
+        switch (command) {
+          case 'revoke':
+            this.OpenIM.revokeMessage({
+              conversationID:this.$store.state.conversation.currentConversation.conversationID,
+              clientMsgID:this.message.clientMsgID
+            }).then(() => {
+              this.$store.dispatch(
+                'checkoutConversation',
+                this.$store.state.conversation.currentConversation,
+              )
+              this.isTimeoutHandler()
+            }).catch((err) => {
+              this.$store.commit('showMessage', {
+                message: err,
+                type: 'warning'
+              })
+            })
+            break
+          case 'relay':
+            this.showConversationList = true
+            this.$store.commit('setRelayType', 1)
+            this.$store.commit('showConversationList', true)
+            this.$store.commit('setRelayMessage', this.message)
+            break
+          case 'merger':
+            this.$bus.$emit('mergerSelected',true)
+            break
+          default:
+            break
+        }
+      },
+      isTimeoutHandler() { // 从发送消息时间开始算起,两分钟内可以编辑
+        let now = new Date()
+        if (parseInt(now.getTime()) - this.message.sendTime > 2 * 60*1000) {
+          this.isTimeout = true
+          return
+        }
+        setTimeout(this.isTimeoutHandler, 1000)
+      },
+      reEdit() {
+        this.$bus.$emit('reEditMessage', this.message.payload.text)
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+  .conversation-container {
+    position absolute
+    top 0
+    left 0px
+    width 100%
+    background-color #fff
+    z-index 999
+  }
+  .conversation-list-btn {
+    width 140px
+    display flex
+    float right
+    margin 10px 0
+    .conversation-btn {
+      cursor pointer
+      padding 6px 12px
+      background #00A4FF
+      color #ffffff
+      font-size 14px
+      border-radius 20px
+      margin-left 13px
+    }
+  }
+.chat-bubble
+  /*position relative*/
+  .message-status
+    display: flex;
+    min-width: 25px;
+    margin-right: 10px;
+    justify-content: center;
+    align-items: center;
+    font-size: 12px;
+    color: #6e7981;
+  .message-content
+    outline: none
+    font-size 14px
+    position relative
+    max-width 350px
+    word-wrap break-word
+    word-break break-all
+    padding 10px
+    box-shadow: 0 5px 10px 0 rgba(0,0,0,.1);
+    /*overflow hidden*/
+    span
+      white-space pre-wrap
+      margin 0
+      text-shadow #495060 0 0 0.05em
+    img
+      vertical-align bottom
+    &::before
+      position: absolute
+      top: 0
+      width: 12px
+      height: 40px
+      content "\e900"
+      // content "\e906"
+      font-family 'tim' !important
+      font-size 25px // 32px 在mac上会模糊 24px正常 , window 24px模糊 28px 32px正常  36px windows mac 基本一致,但是太大
+  .message-received
+    background-color #ffffff
+    margin-left 15px
+    border-radius 0 4px 4px 4px
+    &::before
+      left -10px
+      transform scaleX(-1)
+      color #ffffff
+    &.new
+      transform: scale(0);
+      transform-origin: top left;
+      animation: bounce 500ms linear both;
+  .message-send
+    background-color #5cadff
+    margin-right 15px
+    border-radius 4px 0 4px 4px
+    color #fff
+    &::before
+      right: -10px
+      color #5cadff
+    &.new
+      transform: scale(0);
+      transform-origin: top right;
+      animation: bounce 500ms linear both;
+  .el-dropdown {
+    vertical-align: top;
+    display flex
+    outline none
+    border none
+    /deep/ .focusing {
+      outline none
+      border none
+    }
+  }
+  .el-dropdown + .el-dropdown {
+    margin-left: 15px;
+  }
+  .el-icon-arrow-down {
+    font-size: 12px;
+  }
+
+  /deep/ .el-dropdown .el-dropdown-selfdefine:focus:active, .el-dropdown .el-dropdown-selfdefine:focus:not(.focusing) {
+    outline-width: 0;
+  }
+.group-tip-element-wrapper
+  background #ffffff
+  padding 4px 15px
+  border-radius 3px
+  color #a5b5c1
+  font-size 12px
+  // text-shadow $secondary 0 0 0.05em
+.edit-button
+  padding-top 4px
+  height 20px
+  font-size 10px
+@keyframes bounce {
+  0% { transform: matrix3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  4.7% { transform: matrix3d(0.45, 0, 0, 0, 0, 0.45, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  9.41% { transform: matrix3d(0.883, 0, 0, 0, 0, 0.883, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  14.11% { transform: matrix3d(1.141, 0, 0, 0, 0, 1.141, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  18.72% { transform: matrix3d(1.212, 0, 0, 0, 0, 1.212, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  24.32% { transform: matrix3d(1.151, 0, 0, 0, 0, 1.151, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  29.93% { transform: matrix3d(1.048, 0, 0, 0, 0, 1.048, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  35.54% { transform: matrix3d(0.979, 0, 0, 0, 0, 0.979, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  41.04% { transform: matrix3d(0.961, 0, 0, 0, 0, 0.961, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  52.15% { transform: matrix3d(0.991, 0, 0, 0, 0, 0.991, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  63.26% { transform: matrix3d(1.007, 0, 0, 0, 0, 1.007, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  85.49% { transform: matrix3d(0.999, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+  100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+}
+</style>

+ 58 - 0
src/components/message/message-elements/at-element.vue

@@ -0,0 +1,58 @@
+<template>
+  <message-bubble :isMine=isMine :message=message>
+    <template v-for="(item, index) in contentList">
+      <span class="text-box" :key="index" v-if="item.name === 'text'">{{ item.text }}</span>
+      <img v-else-if="item.name === 'img'" :src="item.src" width="20px" height="20px" :key="index"/>
+    </template>
+  </message-bubble>
+  <!-- <div class="chat-bubble">
+    <div class="message-content" :class="isMine ? 'message-send' : 'message-received'">
+      <template v-for="(item, index) in contentList">
+        <span :key="index" v-if="item.name === 'text'">{{ item.text }}</span>
+        <img v-else-if="item.name === 'img'" :src="item.src" width="20px" height="20px" :key="index"/>
+      </template>
+    </div>
+  </div> -->
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+import { decodeText } from '../../../utils/decodeText'
+
+export default {
+  name: 'AtElement',
+  components: {
+    MessageBubble
+  },
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  computed: {
+    contentList() {
+      if (this.message.contentType===106){
+        console.log("this.payload",this.payload)
+        this.payload.content = this.payload.text
+        return decodeText(this.payload)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.text-box{
+  display: inline-block;
+  width: 100%;
+  overflow: hidden;
+}
+</style>

+ 144 - 0
src/components/message/message-elements/custom-element.vue

@@ -0,0 +1,144 @@
+<template>
+<message-bubble :isMine=isMine :message=message>
+  <div class="custom-element-wrapper">
+    <span v-if="payload.payload.data === 'startInquiry'" class="text"   >
+      {{text}}
+    </span>
+    <span v-else-if="payload.payload.data === 'finishInquiry'" class="text"   >
+      {{text}}
+    </span>
+    <span v-else-if="payload.payload.data === 'course'" class="text"   >
+      {{text}}
+    </span>
+    <span v-else-if="payload.payload.data === 'package'" class="text"   >
+      {{text}}
+    </span>
+    <span v-else-if="payload.payload.data === 'live'" class="text"   >
+      {{text}}
+    </span>
+    <span v-else  >
+      {{text}}
+    </span>
+    <el-drawer  :append-to-body="true" :with-header="false" size="75%" :title="show.title" :visible.sync="show.open">
+      <followDetails  v-if="show.type==1"    ref="followDetails" />
+      <drugReportDetails    v-if="show.type==2"  ref="drugReportDetails" />
+    </el-drawer>
+  </div>
+</message-bubble>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import MessageBubble from '@/components/message/message-bubble.vue'
+//import followDetails from '@/views/components/follow/followDetails.vue';
+//import drugReportDetails from '@/views/components/drugReport/drugReportDetails.vue';
+export default {
+  name: 'CustomElement',
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  data() {
+    return {
+      show:{
+        open:false,
+        type:0,
+        title:""
+      },
+    }
+  },
+  components: {
+    MessageBubble,
+    //followDetails,
+    //drugReportDetails
+  },
+  computed: {
+    ...mapState({
+      currentUserProfile: state => state.imuser.currentUserProfile
+    }),
+    text() {
+      return this.translateCustomMessage(this.payload)
+    },
+    courseText(){
+      return this.courseCustomMessage(this.payload)
+    }
+  },
+  methods: {
+    /*openFollow(data){
+      var that=this;
+      var followId=data.extension.followId;
+      this.show.open=true;
+      this.show.type=1;
+      this.show.title="随访单"
+      setTimeout(() => {
+        that.$refs.followDetails.getDetails(followId,"随访单");
+      }, 500);
+    },
+    openDrugReport(data){
+      var that=this;
+      var reportId=data.description;
+      this.show.open=true;
+      this.show.type=2;
+      this.show.title="用药报告单"
+      setTimeout(() => {
+        that.$refs.drugReportDetails.getDetails(reportId);
+      }, 500);
+    },*/
+    courseCustomMessage(payload){
+
+    },
+    translateCustomMessage(payload) {
+      let videoPayload = {}
+      /*try{
+        videoPayload = JSON.parse(payload.data)
+      } catch(e) {
+        videoPayload = {}
+      }
+      if (payload.data === 'group_create') {
+        return `${payload.extension}`
+      }
+      if (videoPayload.roomId) {
+        videoPayload.roomId = videoPayload.roomId.toString()
+        videoPayload.isFromGroupLive = 1
+        return videoPayload
+      }*/
+      if(payload.payload.text) {
+        return payload.payload.text
+      }else if(payload.payload.data.live){
+        return payload.payload.data.liveName
+      } else{
+        return payload.payload.extension.title
+        /*var obj=JSON.parse(payload.extension);
+        return obj.title*/
+      }
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.text
+  font-weight bold
+.title
+  font-size 16px
+  font-weight 600
+  padding-bottom 10px
+.survey
+  background-color white
+  color black
+  padding 20px
+  display flex
+  flex-direction column
+.suggestion
+  padding-top 10px
+  font-size 14px
+</style>

+ 47 - 0
src/components/message/message-elements/face-element.vue

@@ -0,0 +1,47 @@
+<template>
+<message-bubble :isMine=isMine :message=message>
+  <div class="face-element-wrapper">
+    <img :src="url"/>
+  </div>
+</message-bubble>
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+export default {
+  name: 'FaceElement',
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  components: {
+    MessageBubble,
+  },
+  computed:{
+    url() {
+      let name = ''
+      if (this.payload.data.indexOf('@2x') > 0) {
+        name = this.payload.data
+      } else {
+        name = this.payload.data + '@2x'
+      }
+      return `https://web.sdk.qcloud.com/im/assets/face-elem/${name}.png`
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.face-element-wrapper
+  img
+    max-width 90px
+</style>

+ 121 - 0
src/components/message/message-elements/file-element.vue

@@ -0,0 +1,121 @@
+<template>
+  <message-bubble :isMine=isMine :message=message>
+  <div class="file-element-wrapper" title="单击下载" @click="downloadFile">
+    <div class="header">
+      <i class="el-icon-document file-icon"></i>
+      <div class="file-element">
+        <span class="file-name">{{ fileName }}</span>
+        <span class="file-size">{{ size }}</span>
+      </div>
+    </div>
+    <el-progress
+      v-if="showProgressBar"
+      :percentage="percentage"
+      :color="percentage => (percentage === 100 ? '#67c23a' : '#409eff')"
+    />
+  </div>
+</message-bubble>
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+import { Progress } from 'element-ui'
+export default {
+  name: 'FileElement',
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  components: {
+    MessageBubble,
+    ElProgress: Progress
+  },
+  computed: {
+    fileName() {
+      return this.payload.fileName
+    },
+    fileUrl() {
+      return this.payload.sourceUrl
+    },
+    size() {
+      const size = this.payload.fileSize
+      if (size > 1024) {
+        if (size / 1024 > 1024) {
+          return `${this.toFixed(size / 1024 / 1024)} Mb`
+        }
+        return `${this.toFixed(size / 1024)} Kb`
+      }
+      return `${this.toFixed(size)}B`
+    },
+    showProgressBar() {
+      return this.$parent.message.status === 'unSend'
+    },
+    percentage() {
+      return Math.floor((this.$parent.message.progress || 0) * 100)
+    }
+  },
+  methods: {
+    toFixed(number, precision = 2) {
+      return number.toFixed(precision)
+    },
+    downloadFile() {
+      console.log(this.payload)
+      console.log(this.message)
+      const fileUrl = this.fileUrl;
+      const fileName = this.fileName;
+      console.log(fileUrl)
+      fetch(fileUrl)
+        .then(response => {
+          if (!response.ok) throw new Error(`下载失败,状态码:${response.status}`);
+          return response.blob();
+        })
+        .then(blob => {
+          const mime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+          const docBlob = new Blob([blob], { type: mime }); // 💡 明确指定 MIME 类型
+          const blobUrl = window.URL.createObjectURL(docBlob);
+
+          const a = document.createElement('a');
+          a.href = blobUrl;
+          a.download = fileName;
+          document.body.appendChild(a);
+          a.click();
+          document.body.removeChild(a);
+          window.URL.revokeObjectURL(blobUrl); // 释放资源
+        })
+        .catch(err => {
+          console.error('下载失败:', err);
+        });
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.file-element-wrapper {
+  cursor pointer
+}
+.header {
+  display: flex;
+}
+.file-icon {
+  font-size: 40px !important;
+}
+.file-element {
+  display: flex;
+  flex-direction: column;
+  margin-left: 12px;
+}
+.file-size {
+  font-size: 12px;
+  padding-top 5px
+}
+</style>

+ 73 - 0
src/components/message/message-elements/geo-element.vue

@@ -0,0 +1,73 @@
+<template>
+<!--  位置消息-->
+  <message-bubble :isMine="isMine" :message=message>
+    <a class="geo-element" :href="href" target="_blank" title="点击查看详情">
+      <span class="el-icon-location-outline">{{payload.description}}</span>
+      <img :src="url" />
+    </a>
+  </message-bubble>
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+
+export default {
+  name: 'GeoElement',
+  components: {
+    MessageBubble
+  },
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  data() {
+    return {
+      url: ''
+    }
+  },
+  computed: {
+    lon() {
+      return this.payload.longitude.toFixed(6)
+    },
+    lat() {
+      return this.payload.latitude.toFixed(6)
+    },
+    href() {
+      return `https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=${
+        this.lon
+      }&pointy=${this.lat}&name=${this.payload.description}`
+    }
+  },
+  mounted() {
+    this.url = `https://apis.map.qq.com/ws/staticmap/v2/?center=${this.lat},${
+      this.lon
+    }&zoom=10&size=300*150&maptype=roadmap&markers=size:large|color:0xFFCCFF|label:k|${
+      this.lat
+    },${this.lon}&key=UBNBZ-PTP3P-TE7DB-LHRTI-Y4YLE-VWBBD`
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.geo-element {
+  text-decoration: none;
+  color: #000;
+  display: flex;
+  flex-direction: column;
+  padding: 6px;
+  font-size: 18px;
+
+  img {
+    margin-top: 12px;
+  }
+}
+</style>

+ 138 - 0
src/components/message/message-elements/group-system-notice-element.vue

@@ -0,0 +1,138 @@
+<template>
+  <message-bubble :isMine="false" :message=message>
+    <div class="group-system-element-wrapper">
+      {{ text }}
+      <el-button v-if="isJoinGroupRequest" type="text" @click="showDialog = true">处理</el-button>
+      <el-dialog title="处理加群申请" :visible.sync="showDialog" width="30%">
+        <el-form ref="form" v-model="form" label-width="100px">
+          <el-form-item label="处理结果:">
+            <el-radio-group v-model="form.handleAction">
+              <el-radio label="Agree">同意</el-radio>
+              <el-radio label="Reject">拒绝</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="附言:" v-show="this.payload.operationType === 1">
+            <el-input
+              type="textarea"
+              resize="none"
+              :rows="3"
+              placeholder="请输入附言"
+              v-model="form.handleMessage"
+            />
+          </el-form-item>
+        </el-form>
+        <span slot="footer" class="dialog-footer">
+          <el-button @click="showDialog = false">取 消</el-button>
+          <el-button type="primary" @click="handleGroupApplication">确 定</el-button>
+        </span>
+      </el-dialog>
+    </div>
+  </message-bubble>
+</template>
+
+<script>
+import { Dialog, Form, FormItem, RadioGroup, Radio } from 'element-ui'
+import MessageBubble from '../message-bubble'
+import { translateGroupSystemNotice } from '../../../utils/common'
+
+export default {
+  name: 'GroupSystemNoticeElement',
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: false
+    }
+  },
+  components: {
+    ElDialog: Dialog,
+    ElForm: Form,
+    ElFormItem: FormItem,
+    ElRadioGroup: RadioGroup,
+    ElRadio: Radio,
+    MessageBubble
+  },
+  data() {
+    return {
+      showDialog: false,
+      form: {
+        handleAction: 'Agree',
+        handleMessage: ''
+      }
+    }
+  },
+  computed: {
+    text() {
+      return translateGroupSystemNotice(this.message)
+    },
+    title() {
+      if (this.message.type === this.OpenIM.TYPES.MSG_GRP_SYS_NOTICE) {
+        return '群系统通知'
+      }
+      return '系统通知'
+    },
+    isJoinGroupRequest() {
+      return this.payload.operationType === 1 || this.payload.operationType === 12
+    }
+  },
+  methods: {
+    handleGroupApplication() {
+      if(this.payload.operationType === 12) {
+        this.tim.callExperimentalAPI('handleGroupInvitation', {
+          handleAction: this.form.handleAction,
+          message: this.message
+        }).then(() => {
+          this.showDialog = false
+          this.$store.commit('removeMessage', this.message)
+        }).catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+          this.showDialog = false
+        })
+      }
+      if(this.payload.operationType === 1) {
+        this.tim
+                .handleGroupApplication({
+                  handleAction: this.form.handleAction,
+                  handleMessage: this.form.handleMessage,
+                  message: this.message
+                })
+                .then(() => {
+                  this.showDialog = false
+                  this.$store.commit('removeMessage', this.message)
+                })
+                .catch(error => {
+                  this.$store.commit('showMessage', {
+                    type: 'error',
+                    message: error.message
+                  })
+                  this.showDialog = false
+                })
+      }
+
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.card {
+  background: #fff;
+  padding: 12px;
+  border-radius: 5px;
+  width: 300px;
+
+  .card-header {
+    font-size: 18px;
+  }
+
+  .card-content {
+    font-size: 14px;
+  }
+}
+</style>

+ 89 - 0
src/components/message/message-elements/group-tip-element.vue

@@ -0,0 +1,89 @@
+<template>
+  <div class="group-tip-element-wrapper">{{text}}</div>
+</template>
+
+<script>
+  import {mapState} from 'vuex'
+  export default {
+  name: 'GroupTipElement',
+  data() {
+    return {
+      callTips:256,
+      userNames:[]
+    }
+  },
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: false
+    }
+  },
+
+  computed: {
+    ...mapState({
+      currentUserProfile: state => state.imuser.currentUserProfile,
+      userID: state => state.user.userID
+    }),
+    text() {
+      return this.getGroupTipContent(this.message)
+    }
+  },
+  methods: {
+    getGroupTipContent(message) {
+      console.log("群聊消息",message)
+      // 群通话tips
+      // let nick = message.nick || ((message.from === this.userID) && this.currentUserProfile.nick) ||  message.from
+      // const userName = message.nick || message.payload.userIDList.join(',')
+      switch (message.contentType) {
+        case 1501:
+          return `群聊创建成功`
+        case 1509:
+          var json = JSON.parse(message.notificationElem.detail)
+          console.log("JSON.parse(message.notificationElem.detail)",json)
+          json.invitedUserList.forEach(member => {
+            this.userNames.push(member.nickname)
+          })
+          return this.userNames+`加入群组`
+        case 1504:
+          return `群成员:${userName}退出群组`
+        case 1508:
+          return `群成员:${userName} 被${message.payload.operatorID}踢出群组`
+        // case this.OpenIM.TYPES.GRP_TIP_MBR_SET_ADMIN:
+        //   return `群成员:${userName} 成为管理员`
+        // case this.OpenIM.TYPES.GRP_TIP_MBR_CANCELED_ADMIN:
+        //   return `群成员:${userName} 被撤销管理员`
+        case 1502:
+          return '群资料修改'
+        // case this.callTips:
+        //   if(message.payload.text.indexOf('结束群聊')> -1) {
+        //     return  `"${message.payload.text}"`
+        //   }else {
+        //     return  `"${nick}" ${message.payload.text}`
+        //   }
+        case 1514:
+          return `群主开启了全体禁言`
+        case 1515:
+          return `群主关闭了全体禁言`
+        case 1519:
+          return `群公告改变通知`
+        default:
+          return '[群提示消息]'
+      }
+    },
+}
+}
+</script>
+
+<style lang="stylus" scoped>
+.group-tip-element-wrapper
+  background $white
+  padding 4px 15px
+  border-radius 3px
+  color #151616
+  font-size 15px
+  // text-shadow $secondary 0 0 0.05em
+</style>

+ 73 - 0
src/components/message/message-elements/image-element.vue

@@ -0,0 +1,73 @@
+<template>
+  <message-bubble :isMine=isMine :message=message>
+     <!-- el-image在IE下会自动加上用于兼容object-fit的类,该类的样式在没设置图片宽高是会 GG -->
+    <img class="image-element" :src="imageUrl" @load="onImageLoaded" @click="handlePreview" />
+    <el-progress
+      v-if="showProgressBar"
+      :percentage="percentage"
+      :color="percentage => (percentage === 100 ? '#67c23a' : '#409eff')"
+    />
+
+
+  </message-bubble>
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+import { Progress } from 'element-ui'
+import { mapGetters } from 'vuex'
+export default {
+  name: 'ImageElemnt',
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  components: {
+    MessageBubble,
+    ElProgress: Progress
+  },
+  computed: {
+    ...mapGetters(['imgUrlList']),
+    imageUrl() {
+      console.log("this.payload",this.payload)
+      const url = this.payload.sourcePicture.url
+      if (typeof url !== 'string') {
+        return ''
+      }
+      return url.slice(0, 2) === '//' ? `https:${url}` : url
+    },
+    showProgressBar() {
+      return this.$parent.message.status === 'unSend'
+    },
+    percentage() {
+      return Math.floor((this.$parent.message.progress || 0) * 100)
+    }
+  },
+  methods: {
+    onImageLoaded(event) {
+      this.$bus.$emit('image-loaded', event)
+    },
+    handlePreview() {
+      this.$bus.$emit('image-preview', {
+        url: this.payload.sourcePicture.url
+      })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.image-element
+  max-width 250px
+  cursor zoom-in
+
+</style>

+ 98 - 0
src/components/message/message-elements/merger-element.vue

@@ -0,0 +1,98 @@
+<template>
+  <div>
+    <message-bubble :isMine=isMine  :message=message>
+      <div class="merger-box" @click="mergerHandler(message)">
+        <p class="merger-title">{{payload.title}}</p>
+        <p class="merger-text" v-for="(item, index) in payload.abstractList" :key="index">
+          {{item}}
+        </p>
+      </div>
+      <span class="merger-text"> 聊天记录</span>
+    </message-bubble>
+  </div>
+</template>
+
+<script>
+  import MessageBubble from '../message-bubble'
+  export default {
+    name: 'MergerElemnt',
+    props: {
+      payload: {
+        type: Object,
+        default: () => ({}),
+      },
+      message: {
+        type: Object,
+        required: true
+      },
+      isMine: {
+        type: Boolean
+      }
+    },
+    components: {
+      MessageBubble
+    },
+    data() {
+      return {
+        mergerContment:{
+          title:'',
+
+        }
+
+      }
+    },
+    computed: {
+    },
+    methods: {
+      mergerHandler(message) {
+        console.log("xsghsdza ",message)
+        this.$bus.$emit('mergerMessageShow', message)
+      },
+      onImageLoaded(event) {
+        this.$bus.$emit('image-loaded', event)
+      },
+      handlePreview() {
+        this.$bus.$emit('image-preview', {
+          url: this.payload.imageInfoArray[0].url
+        })
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+    .image-element
+        max-width 250px
+        cursor zoom-in
+
+    .merger-box {
+        border-bottom: 1px #b3b3b3 solid;
+        padding: 0 5px 5px -5px;
+        margin-bottom 5px
+        .merger-title {
+            max-width 220px
+            min-width 180px
+            overflow: hidden;
+            font-size 15px
+            text-overflow:ellipsis;
+            white-space: nowrap;
+        }
+    }
+    .merger-text {
+        color #b3b3b3
+        margin 10px 0
+        font-size 13px
+        max-width 280px
+        overflow hidden;
+        text-overflow ellipsis;
+        white-space nowrap;
+    }
+    .message-send  .merger-text {
+        color rgba(255,255,255,0.8)
+        margin 10px 0
+        font-size 13px
+    }
+    .message-send .merger-box {
+        border-bottom: 1px rgba(255, 255, 255, 0.6) solid
+    }
+</style>

+ 151 - 0
src/components/message/message-elements/sound-element.vue

@@ -0,0 +1,151 @@
+<template>
+  <message-bubble :isMine="isMine" :message="message">
+    <div class="sound-element-wrapper" :title="playStatus === 'playing' ? '单击暂停' : '单击播放'" @click="handleClick">
+      <i class="iconfont icon-voice"></i>
+      {{ second + '"' }}
+    </div>
+  </message-bubble>
+</template>
+
+<script>
+  import MessageBubble from '@/components/message/message-bubble.vue'
+
+  export default {
+    name: 'SoundElement',
+    props: {
+      payload: {
+        type: Object,
+        required: true
+      },
+      message: {
+        type: Object,
+        required: true
+      },
+      isMine: {
+        type: Boolean
+      }
+    },
+    components: {
+      MessageBubble
+    },
+    data() {
+      return {
+        amr: null,
+        audio: null,
+        isAMR: false,
+        playStatus: 'stopped' // 'playing' | 'paused' | 'stopped'
+      }
+    },
+    computed: {
+      url() {
+        return this.payload.sourceUrl
+      },
+      second() {
+        return this.payload.duration
+      }
+    },
+    methods: {
+      handleClick() {
+        console.log(this.payload.sourceUrl)
+        if (this.playStatus === 'playing') {
+          this.pause()
+        } else if (this.playStatus === 'paused') {
+          this.resume()
+        } else {
+          this.play()
+        }
+      },
+      play() {
+        this.cleanup()
+
+        // 默认使用 HTML5 audio 播放
+        this.audio = new Audio(this.url)
+        console.log(this.audio)
+        this.audio.addEventListener('error', this.tryPlayAMR)
+        this.audio.addEventListener('ended', this.cleanup)
+        this.audio.play()
+          .then(() => {
+            this.playStatus = 'playing'
+          })
+          .catch(() => {
+            // 播放失败 fallback 到 tryPlayAMR
+          })
+      },
+      pause() {
+        if (this.isAMR && this.amr) {
+          this.amr.pause()
+        } else if (this.audio) {
+          this.audio.pause()
+        }
+        this.playStatus = 'paused'
+      },
+      resume() {
+        if (this.isAMR && this.amr) {
+          this.amr.play()
+        } else if (this.audio) {
+          this.audio.play()
+        }
+        this.playStatus = 'playing'
+      },
+      tryPlayAMR() {
+        this.isAMR = true
+        const isIE = /MSIE|Trident|Edge/.test(window.navigator.userAgent)
+        if (isIE) {
+          this.$store.commit('showMessage', {
+            message: '您的浏览器不支持该格式的语音消息播放,请尝试更换浏览器,建议使用:谷歌浏览器',
+            type: 'warning'
+          })
+          return
+        }
+
+        if (!window.BenzAMRRecorder) {
+          const script = document.createElement('script')
+          script.addEventListener('load', this.playAMR)
+          script.src = '/BenzAMRRecorder.js'
+          document.head.appendChild(script)
+          return
+        }
+
+        this.playAMR()
+      },
+      playAMR() {
+        if (!this.amr && window.BenzAMRRecorder) {
+          this.amr = new window.BenzAMRRecorder()
+          this.amr.onEnded(() => {
+            this.cleanup()
+          })
+        }
+
+        if (this.amr.isInit()) {
+          this.amr.play()
+          this.playStatus = 'playing'
+        } else {
+          this.amr.initWithUrl(this.url).then(() => {
+            this.amr.play()
+            this.playStatus = 'playing'
+          })
+        }
+      },
+      cleanup() {
+        if (this.audio) {
+          this.audio.pause()
+          this.audio = null
+        }
+        if (this.amr) {
+          this.amr.stop()
+        }
+        this.playStatus = 'stopped'
+      }
+    },
+    beforeDestroy() {
+      this.cleanup()
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+.sound-element-wrapper {
+  padding: 0px 10px;
+  cursor: pointer;
+}
+</style>

+ 117 - 0
src/components/message/message-elements/text-element.vue

@@ -0,0 +1,117 @@
+<template>
+  <message-bubble :isMine=isMine :message=message>
+    <template v-for="(item, index) in contentList">
+      <span class="text-box" :key="index" v-if="item.name === 'text'">{{ item.text }}</span>
+      <img v-else-if="item.name === 'img'" :src="item.src" width="20px" height="20px" :key="index"/>
+    </template>
+  </message-bubble>
+  <!-- <div class="chat-bubble">
+    <div class="message-content" :class="isMine ? 'message-send' : 'message-received'">
+      <template v-for="(item, index) in contentList">
+        <span :key="index" v-if="item.name === 'text'">{{ item.text }}</span>
+        <img v-else-if="item.name === 'img'" :src="item.src" width="20px" height="20px" :key="index"/>
+      </template>
+    </div>
+  </div> -->
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+import { decodeText } from '../../../utils/decodeText'
+
+export default {
+  name: 'TextElement',
+  components: {
+    MessageBubble
+  },
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+  computed: {
+    contentList() {
+      if (this.message.contentType===101){
+        return decodeText(this.payload)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+  .text-box{
+    display: inline-block;
+    width: 100%;
+    overflow: hidden;
+  }
+// .chat-bubble
+//   position relative
+//   .message-content
+//     font-size 14px
+//     position relative
+//     max-width 350px
+//     word-wrap break-word
+//     word-break break-all
+//     padding 10px
+//     span
+//       white-space pre-wrap
+//       margin 0
+//       text-shadow $regular 0 0 0.05em
+//     &::before
+//       position: absolute
+//       top: 0
+//       width: 12px
+//       height: 40px
+//       content "\e900"
+//       font-family 'tim' !important
+//       font-size 24px // 32px 在mac上会模糊 24px正常
+//   .message-received
+//     background-color $white
+//     margin-left 15px
+//     border-radius 0 4px 4px 4px
+//     &::before
+//       left -10px
+//       transform scaleX(-1)
+//       color $white
+//     &.new
+//       transform: scale(0);
+//       transform-origin: top left;
+//       animation: bounce 500ms linear both;
+//   .message-send
+//     background-color $light-primary
+//     margin-right 15px
+//     border-radius 4px 0 4px 4px
+//     color $white
+//     &::before
+//       right: -10px
+//       color $light-primary
+//     &.new
+//       transform: scale(0);
+//       transform-origin: top right;
+//       animation: bounce 500ms linear both;
+
+// @keyframes bounce {
+//   0% { transform: matrix3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   4.7% { transform: matrix3d(0.45, 0, 0, 0, 0, 0.45, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   9.41% { transform: matrix3d(0.883, 0, 0, 0, 0, 0.883, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   14.11% { transform: matrix3d(1.141, 0, 0, 0, 0, 1.141, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   18.72% { transform: matrix3d(1.212, 0, 0, 0, 0, 1.212, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   24.32% { transform: matrix3d(1.151, 0, 0, 0, 0, 1.151, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   29.93% { transform: matrix3d(1.048, 0, 0, 0, 0, 1.048, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   35.54% { transform: matrix3d(0.979, 0, 0, 0, 0, 0.979, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   41.04% { transform: matrix3d(0.961, 0, 0, 0, 0, 0.961, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   52.15% { transform: matrix3d(0.991, 0, 0, 0, 0, 0.991, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   63.26% { transform: matrix3d(1.007, 0, 0, 0, 0, 1.007, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   85.49% { transform: matrix3d(0.999, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+//   100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); }
+// }
+</style>

+ 60 - 0
src/components/message/message-elements/video-element.vue

@@ -0,0 +1,60 @@
+<template>
+  <message-bubble :isMine=isMine :message=message>
+    <video
+      :src="payload.videoUrl"
+      controls
+      class="video"
+      @error="videoError"
+    ></video>
+    <el-progress
+      v-if="showProgressBar"
+      :percentage="percentage"
+      :color="percentage => (percentage === 100 ? '#67c23a' : '#409eff')"
+    />
+  </message-bubble>
+</template>
+
+<script>
+import MessageBubble from '../message-bubble'
+import { Progress } from 'element-ui'
+export default {
+  name: 'VideoElement',
+  components: {
+    MessageBubble,
+    ElProgress: Progress
+  },
+  props: {
+    payload: {
+      type: Object,
+      required: true
+    },
+    message: {
+      type: Object,
+      required: true
+    },
+    isMine: {
+      type: Boolean
+    }
+  },
+
+  computed: {
+    showProgressBar() {
+      return this.message.status === 'unSend'
+    },
+    percentage() {
+      return Math.floor((this.$parent.message.progress || 0) * 100)
+    }
+  },
+  methods: {
+    videoError(e) {
+      this.$store.commit('showMessage', { type: 'error', message: '视频出错,错误原因:' + e.target.error.message })
+    },
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.video
+  width 100%
+  max-height 300px
+</style>

+ 75 - 0
src/components/message/message-footer.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="base" :class="[ isMine ? 'right' : 'left']">
+    <!-- <div class="name text-ellipsis">{{ from }}</div> -->
+    <div class="date">{{ date }}</div>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { getFullDate } from '../../utils/date'
+export default {
+  name: 'MessageFooter',
+  props: {
+    message: {
+      type: Object,
+      required: true
+    }
+  },
+  computed: {
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation,
+      currentUserProfile: state => state.imuser.currentUserProfile,
+      currentMemberList: state => state.group.currentMemberList,
+      userID: state => state.imuser.userID,
+    }),
+    date() {
+      return getFullDate(new Date(this.message.sendTime))
+    },
+    from() {
+      const isC2C = this.currentConversation.conversationType === 1
+      // 自己发送的用昵称渲染
+      if (this.isMine) {
+        return this.currentUserProfile.nick || this.currentUserProfile.userID
+      }
+      // 1. C2C 的消息体中还无 nick / avatar 字段,需从 conversation.userProfile 中获取
+      if (isC2C) {
+        return (
+          this.currentConversation.showName ||
+          this.currentConversation.groupID
+        )
+      }
+      // 2. 群组消息,用消息体中的 nick 渲染。nameCard暂时支持不完善
+      return this.message.nick || this.message.from
+    },
+    isMine() {
+
+      return this.message.sendID ===  this.$store.getters.userID
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.right {
+  display: flex;
+  flex-direction: row-reverse;
+  margin-right: 15px;
+}
+.left {
+  display: flex;
+  flex-direction: row;
+  margin-left: 15px;
+}
+.base {
+  color: $secondary;
+  font-size: 12px;
+}
+.name {
+  padding: 0 4px;
+  max-width: 100px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 114 - 0
src/components/message/message-group-live-status.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="group-live-custom-message-card" @click="handleClick">
+    <p class="card-title">{{cardTitle}}</p>
+    <p class="card-content">{{cardContent}}</p>
+    <div class="card-footer">
+      <img class="avatar" :src="roomCover" alt="">
+      <span>群直播</span>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import axios from 'axios'
+export default {
+  name: 'MessageGroupLiveStatus',
+  props: {
+    liveInfo: {
+      type: Object,
+      required: true
+    }
+  },
+  computed: {
+    ...mapState({
+      userID: state => state.imuser.userID
+    }),
+    cardTitle() {
+      return `${this.liveInfo.anchorName || this.liveInfo.anchorId}的直播`
+    },
+    cardContent() {
+      return Number(this.liveInfo.roomStatus) === 1 ? '正在直播' : '结束直播'
+    },
+    roomCover() {
+      return this.liveInfo.roomCover || 'https://imgcache.qq.com/open/qcloud/video/act/webim-avatar/avatar-2.png'
+    }
+  },
+  methods: {
+    async handleClick() {
+      const isExisting = await this.checkRoomExist()
+      const { roomId: roomID, anchorId: anchorID, roomName } = this.liveInfo
+      if (!isExisting) {
+        this.$store.commit('showMessage', {
+          message: '直播已结束',
+          type: 'info'
+        })
+        return
+      }
+      // 主播多实例登录时,点击卡片直接返回
+      if (anchorID === this.userID) {
+        this.$store.commit('showMessage', {
+          message: '您正在其它终端或者Web实例上开播,请勿重复开播!',
+          type: 'info'
+        })
+        return
+      }
+      this.$store.commit('updateGroupLiveInfo', {
+        groupID: this.toAccount,
+        roomID: roomID,
+        anchorID: anchorID,
+        roomName: roomName,
+      })
+      this.$bus.$emit('open-group-live',  { channel: 3 })
+    },
+    // 检查房间是否存在
+    async checkRoomExist() {
+      const checkRes = await axios ('https://service-c2zjvuxa-1252463788.gz.apigw.tencentcs.com/release/forTest?method=getRoomList&appId=1400187352&type=groupLive')
+      const list = (checkRes.data && checkRes.data.data) || []
+      const roomIDList = []
+      list.forEach(item => {
+        roomIDList.push(item.roomId)
+      })
+      return roomIDList.includes(this.liveInfo.roomId) 
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.group-live-custom-message-card {
+  min-width: 160px;
+  max-width: 220px;
+  height 100px;
+  padding: 10px;
+  background-color: #fff;
+  color: #000;
+  cursor: pointer;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  // white-space: nowrap;
+
+  .card-title {
+    font-weight: 500;
+    margin: 0;
+  }
+  .card-content {
+    margin-bottom: 5px;
+    font-weight: 400;
+    border-bottom: 1px solid #e6e6e6;
+  }
+  .card-footer {
+    display: flex;
+    align-items: center;
+    color: #8e8b8b;
+    font-weight: 400;
+    font-size: 13px;
+    .avatar {
+      width: 28px;
+      height: 28px;
+      border-radius: 50%;
+      margin-right: 5px;
+    }
+  }
+}
+</style>

+ 77 - 0
src/components/message/message-header.vue

@@ -0,0 +1,77 @@
+<template>
+  <div class="base" :class="[ isMine ? 'right' : 'left']">
+    <div class="name text-ellipsis">{{ from }}</div>
+    <div class="date">{{ date }}</div>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { getFullDate } from '../../utils/date'
+export default {
+  name: 'MessageHeader',
+  props: {
+    message: {
+      type: Object,
+      required: true
+    }
+  },
+  computed: {
+    ...mapState({
+      currentConversation: state => state.conversation.currentConversation,
+      currentUserProfile: state => state.imuser.currentUserProfile,
+      currentMemberList: state => state.group.currentMemberList
+    }),
+    date() {
+      return getFullDate(new Date(this.message.sendTime))
+    },
+    from() {
+      const isC2C = this.currentConversation.conversationType === 3
+      // 自己发送的用昵称渲染
+      if (this.isMine) {
+        return this.message.senderNickname || this.currentUserProfile.userID
+      }
+      if (isC2C&&this.message.sendID !==  this.$store.getters.userID&&this.message.sendID !== "imAdmin") {
+        return (
+          this.message.senderNickname ||
+          this.message.sendID
+        )
+      } else {
+        return this.message.sessionType === 3
+          ? '群系统通知'
+          : '系统通知'
+      }
+      // 2. 群组消息,用消息体中的 nick 渲染。nameCard暂时支持不完善
+      return this.message.nameCard ||  this.message.nick || this.message.from
+    },
+    isMine() {
+      return this.message.sendID ===  this.$store.getters.userID
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.right {
+  display: flex;
+  flex-direction: row-reverse;
+}
+
+.left {
+  display: flex;
+  flex-direction: row;
+}
+
+.base {
+  color: $secondary;
+  font-size: 12px;
+}
+
+.name {
+  padding: 0 4px;
+  max-width: 100px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 419 - 0
src/components/message/message-item.vue

@@ -0,0 +1,419 @@
+<template>
+  <div class="message-wrapper" :class="messagePosition">
+    <div
+      v-if="currentConversationType === 1"
+      class="c2c-layout"
+      :class="messagePosition"
+    >
+      <div class="col-1" v-if="showAvatar">
+        <!-- 头像 -->
+        <avatar :src="avatar" />
+      </div>
+      <div class="col-2">
+        <!-- 消息主体 -->
+        <div class="content-wrapper">
+          <message-status-icon v-if="isMine" :message="message" />
+          <text-element
+            v-if="message.contentType === 101||message.contentType===2101"
+            :isMine="isMine"
+            :payload="message.contentType === 101 ? message.textElem : {}"
+            :message="message"
+          />
+          <image-element
+            v-else-if="message.contentType === 102"
+            :isMine="isMine"
+            :payload="message.pictureElem"
+            :message="message"
+          />
+          <file-element
+            v-else-if="message.contentType === 105"
+            :isMine="isMine"
+            :payload="message.fileElem"
+            :message="message"
+          />
+          <sound-element
+            v-else-if="message.contentType === 103"
+            :isMine="isMine"
+            :payload="message.soundElem"
+            :message="message"
+          />
+          <group-tip-element
+            v-else-if="message.contentType.toString().startsWith('15')"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <group-system-notice-element
+            v-else-if="message.contentType === 1400"
+            :payload="message.notificationElem"
+            :message="message"
+          />
+          <custom-element
+            v-else-if="message.contentType === 110"
+            :isMine="isMine"
+            :payload="parsedCustomPayload"
+            :message="message"
+          />
+          <face-element
+            v-else-if="message.contentType === 115"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <video-element
+            v-else-if="message.contentType === 104"
+            :isMine="isMine"
+            :payload="message.videoElem"
+            :message="message"
+          />
+          <geo-element
+            v-else-if="message.contentType === 109"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <merger-element
+            v-else-if="message.contentType === 107"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <!--艾特消息-->
+          <at-text-element
+            v-else-if="message.contentType === 106"
+            :isMine="isMine"
+            :payload="message.atTextElem"
+            :message="message"
+          />
+          <span v-else>暂未支持的消息类型:{{message.contentType}}</span>
+        </div>
+        <message-footer v-if="showMessageHeader" :message="message" />
+      </div>
+      <div class="col-3">
+        <!-- 消息状态 -->
+      </div>
+    </div>
+
+    <div
+      v-if="currentConversationType === 3"
+      class="group-layout"
+      :class="messagePosition"
+    >
+      <!-- 头像 群组没有获取单个头像的接口,暂时无法显示头像-->
+      <div class="col-1" v-if="showAvatar" >
+        <avatar class="group-member-avatar" :src="avatar" @click.native="showGroupMemberProfile"/>
+      </div>
+      <div class="col-2">
+        <!-- 消息主体 -->
+        <message-header v-if="showMessageHeader" :message="message" />
+        <div class="content-wrapper">
+          <message-status-icon v-if="isMine" :message="message" />
+          <!--文本-->
+          <text-element
+            v-if="message.contentType ===101"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <!--图片-->
+          <image-element
+            v-else-if="message.contentType === 102"
+            :isMine="isMine"
+            :payload="message.pictureElem"
+            :message="message"
+          />
+          <!--文件-->
+          <file-element
+            v-else-if="message.contentType === 105"
+            :isMine="isMine"
+            :payload="message.fileElem"
+            :message="message"
+          />
+          <!--语音-->
+          <sound-element
+            v-else-if="message.contentType === 103"
+            :isMine="isMine"
+            :payload="message.soundElem"
+            :message="message"
+          />
+          <!--群提示-->
+          <group-tip-element
+            v-else-if="message.contentType.toString().startsWith('15')"
+            :isMine="isMine"
+            :payload="message.notificationElem"
+            :message="message"
+          />
+          <!--自定义-->
+          <custom-element
+            v-else-if="message.contentType === 110"
+            :isMine="isMine"
+            :payload="parsedCustomPayload"
+            :message="message"
+          />
+          <!--表情-->
+          <face-element
+            v-else-if="message.contentType === 115"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <!--视频-->
+          <video-element
+            v-else-if="message.contentType === 104"
+            :isMine="isMine"
+            :payload="message.videoElem"
+            :message="message"
+          />
+          <!--位置-->
+          <geo-element
+            v-else-if="message.contentType === 109"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <!--合并消息-->
+          <merger-element
+            v-else-if="message.contentType === 107"
+            :isMine="isMine"
+            :payload="message.textElem"
+            :message="message"
+          />
+          <!--艾特消息-->
+          <at-text-element
+            v-else-if="message.contentType === 106"
+            :isMine="isMine"
+            :payload="message.atTextElem"
+            :message="message"
+          />
+          <span v-else>暂未支持的消息类型:{{message.contentType}}</span>
+        </div>
+      </div>
+      <div class="col-3">
+        <!-- 消息状态 -->
+      </div>
+    </div>
+
+    <div class="system-layout" v-if="currentConversationType === 4 ">
+      <div class="col-1">
+        <avatar :src="avatar" :type="currentConversationType" />
+      </div>
+      <div class="col-2">
+        <message-header :message="message" />
+        <group-system-notice-element :payload="message.textElem" :message="message" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+  import MessageStatusIcon from './message-status-icon.vue'
+  import MessageHeader from './message-header'
+  import MessageFooter from './message-footer'
+  import FileElement from './message-elements/file-element.vue'
+  import FaceElement from './message-elements/face-element.vue'
+  import ImageElement from './message-elements/image-element.vue'
+  import TextElement from './message-elements/text-element.vue'
+  import SoundElement from './message-elements/sound-element.vue'
+  import VideoElement from './message-elements/video-element.vue'
+  import GroupTipElement from './message-elements/group-tip-element.vue'
+  import GroupSystemNoticeElement from './message-elements/group-system-notice-element.vue'
+  import CustomElement from './message-elements/custom-element.vue'
+  import GeoElement from './message-elements/geo-element.vue'
+  import MergerElement from './message-elements/merger-element.vue'
+  import AtTextElement from './message-elements/at-element.vue'
+  export default {
+    name: 'MessageItem',
+    props: {
+      message: {
+        type: Object,
+        required: true
+      }
+    },
+    components: {
+      MessageHeader,
+      MessageFooter,
+      MessageStatusIcon,
+      FileElement,
+      FaceElement,
+      ImageElement,
+      TextElement,
+      SoundElement,
+      GroupTipElement,
+      GroupSystemNoticeElement,
+      CustomElement,
+      VideoElement,
+      GeoElement,
+      MergerElement,
+      AtTextElement,
+
+    },
+    data() {
+      return {
+        renderDom: []
+      }
+    },
+    computed: {
+      parsedCustomPayload() {
+        try {
+          console.log("原始数据",this.message.customElem?.data)
+          if (this.message.customElem?.data) {
+            const rawData = JSON.parse(this.message.customElem.data);
+            return rawData;
+          }
+        } catch (e) {
+          console.error('解析消息数据失败:', e);
+        }
+        return null;
+      },
+      ...mapState({
+        currentConversation: state => state.conversation.currentConversation,
+        currentUserProfile: state => state.imuser.currentUserProfile,
+        userID: state => state.imuser.userID,
+
+      }),
+      // 是否显示头像,群提示消息不显示头像
+      showAvatar() {
+        if (this.currentConversation.conversationType === 1 && this.message.contentType !== 2101) { // C2C且没有撤回的消息
+          return true
+        } else if (this.currentConversation.sessionType === 3 && !this.message.contentType !== 2101) { // group且没有撤回的消息
+          return !this.message.type.toString().startsWith('15')
+        }
+        return false
+      },
+      avatar() {
+        if (this.currentConversation.conversationType === 1) {
+          return this.message.senderFaceUrl
+        } else if (this.currentConversation.conversationType === 3) {
+          return this.isMine
+            ? this.currentUserProfile.faceUrl
+            : this.message.faceUrl
+        } else {
+          return ''
+        }
+      },
+      currentConversationType() {
+        return this.currentConversation.conversationType
+      },
+      isMine() {
+        // console.log(this.currentUserProfile, this.currentConversation);
+        return this.message.sendID ===  this.$store.getters.userID
+      },
+      messagePosition() {
+        if (
+          ['TIMGroupTipElem', 'TIMGroupSystemNoticeElem'].includes(
+            this.message.contentType
+          )
+        ) {
+          return 'position-center'
+        }
+        if (this.message.contentType===2101) { // 撤回消息
+          return 'position-center'
+        }
+        if (this.isMine) {
+          return 'position-right'
+        } else {
+          return 'position-left'
+        }
+      },
+      showMessageHeader() {
+        /*if (
+                ['TIMGroupTipElem', 'TIMGroupSystemNoticeElem'].includes(
+                        this.message.contentType
+                )
+        ) {
+          return false
+        }*/
+        if (this.message.contentType===2101) { // 撤回消息
+          return false
+        }
+        return true
+      },
+    },
+    methods: {
+      showGroupMemberProfile(event) {
+        this.tim.getGroupMemberProfile({
+          groupID: this.message.to,
+          userIDList: [this.message.from]
+        })
+          .then(({data: {memberList}}) => {
+            if (memberList[0]) {
+              this.$bus.$emit('showMemberProfile', {event, member: memberList[0]})
+            }
+          })
+      }
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+.message-wrapper {
+  margin: 20px 0;
+
+  .content-wrapper {
+    display: flex;
+    align-items: center;
+  }
+}
+
+.group-layout, .c2c-layout, .system-layout {
+  display: flex;
+
+  .col-1 {
+    .avatar {
+      width: 56px;
+      height: 56px;
+      border-radius: 50%;
+      box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.1);
+    }
+  }
+
+  .group-member-avatar {
+    cursor: pointer;
+  }
+
+  .col-2 {
+    display: flex;
+    flex-direction: column;
+    // max-width 50% // 此设置可以自适应宽度,目前由bubble限制
+  }
+
+  .col-3 {
+    width: 30px;
+  }
+
+  &.position-left {
+    .col-2 {
+      align-items: flex-start;
+    }
+  }
+
+  &.position-right {
+    flex-direction: row-reverse;
+
+    .col-2 {
+      align-items: flex-end;
+    }
+  }
+
+  &.position-center {
+    justify-content: center;
+  }
+}
+
+.c2c-layout {
+  .col-2 {
+    .base {
+      margin-top: 3px;
+    }
+  }
+}
+
+.group-layout {
+  .col-2 {
+    .chat-bubble {
+      margin-top: 5px;
+      outline none
+    }
+  }
+}
+</style>

+ 1450 - 0
src/components/message/message-send-box.vue

@@ -0,0 +1,1450 @@
+<template>
+  <div id="message-send-box-wrapper" :style="focus ? {'backgroundColor': 'white'} : {}" @drop="dropHandler">
+    <div class="send-header-bar">
+      <el-popover placement="top" width="400" trigger="click">
+        <div class="emojis">
+          <div v-for="item in emojiName" class="emoji" :key="item" @click="chooseEmoji(item)">
+            <!--<img :src="emojiUrl + emojiMap[item]" style="width:30px;height:30px" />-->
+            <span style="width:30px;height:30px">
+              {{emojiCharMap[item]}}
+            </span>
+          </div>
+        </div>
+        <i class="iconfont icon-smile" slot="reference" title="发表情"></i>
+      </el-popover>
+      <i class="iconfont icon-tupian" title="发图片" @click="handleSendImageClick"></i>
+      <i class="el-icon-camera" title="发视频" @click="handleSendVideoClick"></i>
+      <i class="iconfont icon-wenjian" title="发文件" @click="handleSendFileClick"></i>
+      <!-- <i class="iconfont icon-zidingyi" title="发自定义消息" @click="sendCustomDialogVisible = true"></i> -->
+
+      <!-- <i class="iconfont icon-diaocha" title="小调查" @click="surveyDialogVisible = true"></i> -->
+      <el-dropdown>
+      <span class="el-dropdown-link" v-if="currentConversationType !== 3">
+      <i class="el-icon-phone-outline" v-if="toAccount !== userID&&((imType==1&&orderType==2)||imType==2)" title="语音通话"></i>
+      <i class="el-icon-phone-outline" title="语音通话"></i>
+      </span>
+        <el-dropdown-menu slot="dropdown">
+          <el-dropdown-item  @click.native="trtcCalling('video')">视频通话</el-dropdown-item>
+          <el-dropdown-item  @click.native="trtcCalling('audio')">语音通话</el-dropdown-item>
+        </el-dropdown-menu>
+      </el-dropdown>
+      <div class="group-live-icon-box" v-if="currentConversationType === 4&& groupProfile.type !== 'AVChatRoom'" title="群直播" @click="groupLive">
+        <i class="group-live-icon"></i>
+        <i class="group-live-icon-hover"></i>
+      </div>
+      <i class="el-icon-s-order"  title="疗法" @click="handlePackageList()"></i>
+      <i class="el-icon-tickets"  title="药品订单" @click="handleStoreOrder()"></i>
+      <i class="el-icon-edit-outline"  title="会诊" @click="handlePrescribe()"></i>
+      <i class="el-icon-edit-outline"  title="私域疗法券" @click="handleCoupon()"></i>
+      <!--<i class="el-icon-document" v-if="imType==1" title="诊断报告" @click="handleInquiryReport()"></i>
+      <i class="el-icon-finished" v-if="imType==2" title="随访单" @click="handleFollow()"></i>
+      <i class="el-icon-edit-outline" v-if="imType==2" title="开报告" @click="handleDrugReport()"></i>
+      <i class="el-icon-chat-dot-square" title="常用语" @click="handleDoctorWords()"></i>-->
+    </div>
+    <el-dialog title="发自定义消息" :append-to-body="true" :visible.sync="sendCustomDialogVisible" width="30%">
+      <el-form label-width="100px">
+        <el-form-item label="data">
+          <el-input v-model="form.data"></el-input>
+        </el-form-item>
+        <el-form-item label="description">
+          <el-input v-model="form.description"></el-input>
+        </el-form-item>
+        <el-form-item label="extension">
+          <el-input v-model="form.extension"></el-input>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="sendCustomDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="sendCustomMessage">确 定</el-button>
+      </span>
+    </el-dialog>
+    <el-dialog title="对IM Web demo的建议和使用感受" :visible.sync="surveyDialogVisible" width="30%">
+      <el-form label-width="100px">
+        <el-form-item label="评分">
+          <div class="block">
+            <el-rate v-model="rate" :colors="colors" show-text></el-rate>
+          </div>
+        </el-form-item>
+        <el-form-item label="建议">
+          <el-input
+            type="textarea"
+            :rows="2"
+            placeholder="请输入内容"
+            resize="none"
+            v-model="suggestion"
+          ></el-input>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="surveyDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="sendSurvey">确 定</el-button>
+      </span>
+    </el-dialog>
+    <div class="bottom">
+      <textarea
+        ref="text-input"
+        rows="4"
+        resize="false"
+        v-model="messageContent"
+        class="text-input"
+        @focus="focus = true"
+        @blur="focus = false"
+        @input="inputChange"
+        @keydown.enter.exact.prevent="handleEnter"
+        @keyup.ctrl.enter.prevent.exact="handleLine"
+        @keydown.up.stop="handleUp"
+        @keydown.down.stop="handleDown"
+      >
+      </textarea>
+      <el-tooltip
+        class="item"
+        effect="dark"
+        content="按Enter发送消息,Ctrl+Enter换行"
+        placement="left-start"
+      >
+        <div class="btn-send" @click="sendTextMessage">
+          <div class="tim-icon-send"></div>
+        </div>
+      </el-tooltip>
+    </div>
+    <input
+      type="file"
+      id="imagePicker"
+      ref="imagePicker"
+      accept=".jpg, .jpeg, .png, .gif, .bmp"
+      @change="sendImage"
+      style="display:none"
+    />
+    <input type="file" id="filePicker" ref="filePicker" @change="sendFile" style="display:none" />
+    <input type="file" id="videoPicker" ref="videoPicker" @change="sendVideo" style="display:none" accept=".mp4"/>
+    <div class="calling-member-list" v-if="currentConversationType === 3 && showCallingMember">
+      <calling-member-list @getList="getList" :type="listTpye"></calling-member-list>
+      <div class="calling-list-btn">
+        <span class="calling-btn" @click="cancelCalling">取消</span>
+        <span class="calling-btn" @click="callingHandler">确定</span>
+      </div>
+    </div>
+    <el-drawer  :append-to-body="true" :with-header="false" size="75%" :title="show.title" :visible.sync="show.open">
+      <packageList v-if="show.type==1"   ref="packageList" />
+      <storeOrderList v-if="show.type==2"   ref="storeOrderList" />
+      <couponList v-if="show.type==3"   ref="couponList" />
+<!--      <drugStore v-if="show.type==4"   ref="drugStore" />-->
+<!--      <addInquiryOrderReport v-if="show.type==5"   ref="addInquiryOrderReport" />-->
+<!--      <doctorWords v-if="show.type==6"   @sendWords="sendWords"  ref="doctorWords" />-->
+<!--      <addDrugReport v-if="show.type==7"     ref="addDrugReport" />-->
+<!--      <addDrugReport v-if="show.type==8"     ref="addDrugReport" />-->
+    </el-drawer>
+    <el-drawer
+      :append-to-body="true"
+      :with-header="false"
+      size="45%"
+      :title="show.title"
+      :visible.sync="inquiryConfigOpen"
+    >
+      <div style="padding: 20px;">
+        <el-table :data="inquiryConfig" border style="width: 100%">
+          <!-- 名称列 -->
+          <el-table-column prop="lable" label="名称" />
+
+          <!-- 操作列 -->
+          <el-table-column label="操作" align="center" width="120">
+            <template #default="{ row }">
+              <el-button
+                type="primary"
+                size="mini"
+                @click="sendInquiry(row.lable, row.value)"
+              >
+                发送
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-drawer>
+
+  </div>
+</template>
+
+<script>
+//import { finishOrder  } from "@/api/inquiryOrder";
+//import { finishDrugReport  } from "@/api/drugReport";
+import storeOrderList from '@/views/components/order/storeOrderList.vue';
+import couponList from '@/views/components/coupon/index.vue';
+import { mapGetters, mapState } from 'vuex'
+import callingMemberList from './trtc-calling/group-member-list'
+import packageList from '@/views/store/package/index.vue';
+import {getInquiryConfig,sendInquiry} from "@/api/common";
+import {listCoupon} from "@/api/coupon/coupon";
+import {
+  Form,
+  FormItem,
+  Input,
+  Dialog,
+  Popover,
+  Tooltip,
+  Rate
+} from 'element-ui'
+import { getOpenIM } from '@/utils/openIM';
+import { emojiMap, emojiName, emojiUrl,emojiCharMap } from '../../utils/emojiMap'
+
+export default {
+  name: 'message-send-box',
+  props: ['scrollMessageListToButtom'],
+  components: {
+    storeOrderList,
+    packageList,
+    couponList,
+    callingMemberList: callingMemberList,
+    ElInput: Input,
+    ElForm: Form,
+    ElFormItem: FormItem,
+    ElDialog: Dialog,
+    ElPopover: Popover,
+    ElTooltip: Tooltip,
+    ElRate: Rate
+  },
+  data() {
+    return {
+      showAtList: false,
+      atMembers: [],
+      searchMember: '',
+      cursorIndex: 0,
+      aite:"",
+      show:{
+        open:false,
+        title:""
+      },
+      inquiryConfigOpen:false,
+      inquiryConfig:{},
+      callingList: [],
+      groupAtList: [],
+      listTpye:'',
+      callingType: '',
+      groupAt:false,
+      showCallingMember: false,
+      colors: ['#99A9BF', '#F7BA2A', '#FF9900'],
+      messageContent: '',
+      isSendCustomMessage: false,
+      sendCustomDialogVisible: false,
+      surveyDialogVisible: false,
+      form: {
+        data: '',
+        description: '',
+        extension: ''
+      },
+      rate: 5, // 评分
+      suggestion: '', // 建议
+      file: '',
+      emojiMap: emojiMap,
+      emojiName: emojiName,
+      emojiUrl: emojiUrl,
+      emojiCharMap:emojiCharMap,
+      showAtGroupMember: false,
+      atUserID: '',
+      focus: false,
+      popoverVisible: false,
+      faceUrl: 'https://web.sdk.qcloud.com/im/assets/face-elem/',
+      emojiShow: true,
+      bigEmojiShow: false,
+      bigEmojiList: [
+        {
+          icon: 'yz00',
+          list: ['yz00', 'yz01', 'yz02', 'yz03', 'yz04', 'yz05', 'yz06', 'yz07', 'yz08', 'yz09', 'yz10', 'yz11', 'yz12', 'yz13', 'yz14', 'yz15', 'yz16', 'yz17']
+        },
+        {
+          icon: 'ys00',
+          list: ['ys00', 'ys01', 'ys02', 'ys03', 'ys04', 'ys05', 'ys06', 'ys07', 'ys08', 'ys09', 'ys10', 'ys11', 'ys12', 'ys13', 'ys14', 'ys15']
+        },
+        {
+          icon: 'gcs00',
+          list: ['gcs00', 'gcs01', 'gcs02', 'gcs03', 'gcs04', 'gcs05', 'gcs06', 'gcs07', 'gcs08', 'gcs09', 'gcs10', 'gcs11', 'gcs12', 'gcs13', 'gcs14', 'gcs15', 'gcs16']
+        }
+      ],
+      curItemIndex: 0,
+      curBigEmojiItemList: []
+    }
+  },
+  computed: {
+    ...mapGetters(['toAccount', 'currentConversationType']),
+    ...mapState({
+      orderId:state => state.conversation.orderId,
+      followId:state => state.conversation.followId,
+      orderType:state => state.conversation.orderType,
+      imType: state => state.conversation.imType,
+      memberList: state => state.group.currentMemberList,
+      userID: state => state.imuser.userID,
+      currentConversation :state=>state.conversation.currentConversation,
+      groupProfile: state => state.conversation.currentConversation.groupProfile
+    }),
+    icon() {
+      return aite
+    },
+    filteredAtMembers() {
+      if (!this.searchMember) return this.atMembers
+      return this.atMembers.filter(m =>
+        m.nickname?.includes(this.searchMember)
+      )
+    }
+  },
+  mounted() {
+    this.$refs['text-input'].addEventListener('paste', this.handlePaste)
+    this.$bus.$on('reEditMessage', this.reEditMessage)
+  },
+  beforeDestroy() {
+    this.$refs['text-input'].removeEventListener('paste', this.handlePaste)
+  },
+  created() {
+    if (!this.OpenIM) {
+      this.OpenIM = getOpenIM()
+      console.log("OpenIM SDK 初始化完成");
+    }
+  },
+  methods: {
+    handleSelectAtMember(member) {
+      this.showAtList = false
+      const text = member.userID === 'all' ? '@所有人 ' : `@${member.nickname} `
+      this.insertTextAtCursor(text)
+    },
+
+    insertTextAtCursor(text) {
+      const selection = window.getSelection()
+      if (!selection.rangeCount) return
+      const range = selection.getRangeAt(0)
+      range.deleteContents()
+      const textNode = document.createTextNode(text)
+      range.insertNode(textNode)
+      range.setStartAfter(textNode)
+      range.collapse(true)
+      selection.removeAllRanges()
+      selection.addRange(range)
+    },
+
+    getCursorPosition() {
+      const selection = window.getSelection()
+      if (!selection.rangeCount) return 0
+      const range = selection.getRangeAt(0)
+      return range.startOffset
+    },
+    openAtSelector() {
+      if (!this.currentConversation.groupID) return
+
+      this.getGroupMembers()
+      this.showAtList = true
+    },
+
+    getGroupMembers() {
+      // 从 store 或 OpenIM 获取群成员列表
+      this.OpenIM.getGroupMemberList({
+        groupID: this.currentConversation.groupID,
+        filter: 0,
+        offset: 0,
+        count: 1000
+      }).then(({ data }) => {
+        this.atMembers = data
+
+        // 如果当前用户是群主,加入 @所有人 选项
+        if (this.currentConversation.ownerUserID === this.$store.state.userInfo.userID) {
+          this.atMembers.unshift({
+            userID: 'all',
+            nickname: '所有人'
+          })
+        }
+      })
+    },
+    handleInput(e) {
+      const value = e.target.innerText
+      this.cursorIndex = this.getCursorPosition()
+
+      // 检测最后一个字符是否是 @
+      if (value[this.cursorIndex - 1] === '@') {
+        this.openAtSelector()
+      }
+    },
+    sendInquiry(lable, value) {
+      const massage={
+        sendID:this.userID,
+        recvID:this.toAccount,
+        inquiryName:lable,
+        type:value
+      }
+      sendInquiry(massage).then(res => {
+        // 根据接口返回判断
+        if (res.data && res.data.errCode === 0) {
+          this.$message.success("发送成功");
+        } else {
+          this.$message.error("发送失败:" + (res.data?.errMsg || "未知错误"));
+        }
+      }).catch(err => {
+        console.error("发送失败:", err);
+        this.$message.error("发送异常,请稍后重试");
+      });
+      console.log("发送的团队名称:",massage);
+    },
+    sendWords(msg){
+      console.log(msg)
+      this.show.open=false;
+      this.messageContent=msg;
+      this.sendTextMessage();
+
+    },
+    handlePackageList(){
+      console.log("this.$store.state.conversation11111",this.$store.state.conversation.currentConversation)
+      this.show.type = 1;
+      this.show.open = true;
+      setTimeout(() => {
+        this.$refs.packageList.updateOpenFrom(this.$store.state.conversation.currentConversation.userID);
+      }, 500);
+    },
+    handleCoupon() {
+      console.log("this.$store.state.conversation11111",this.$store.state.conversation.currentConversation)
+      this.show.type = 3;
+      this.show.open = true;
+      setTimeout(() => {
+        this.$refs.couponList.updateOpenFrom(this.$store.state.conversation.currentConversation.userID);
+      }, 500);
+    },
+    handleStoreOrder(){
+      var that=this;
+      this.show.type=2;
+      this.show.open=true;
+      this.show.title="药品订单"
+      const conversationID = this.$store.state.conversation.currentConversation.conversationID;
+      const match = conversationID.match(/U(\d+)/);
+      const userId = match ? Number(match[1]) : null;
+      setTimeout(() => {
+        that.$refs.storeOrderList.getData(userId);
+      }, 500);
+
+    },
+    handlePrescribe() {
+      this.inquiryConfigOpen = true;
+      this.show.title = "会诊";
+
+      getInquiryConfig().then(response => {
+        let data = response.msg;
+        try {
+          // 如果是字符串,先尝试 JSON.parse
+          if (typeof data === "string") {
+            data = JSON.parse(data);
+          }
+          // 如果是对象里有 list,就取 list
+          if (data && data.list) {
+            data = data.list;
+          }
+          // 确保最终一定是数组
+          this.inquiryConfig = Array.isArray(data) ? data : [];
+        } catch (e) {
+          this.inquiryConfig = [];
+          console.error("解析 inquiryConfig 失败", e);
+        }
+
+        console.log("最终 inquiryConfig:", this.inquiryConfig);
+      });
+    },
+
+
+    /*handleDoctorWords(){
+      var that=this;
+      this.show.type=6;
+      this.show.open=true;
+      this.show.title="常用语"
+      setTimeout(() => {
+        that.$refs.doctorWords.getData();
+      }, 500);
+    },
+    handleInquiryReport(){
+      var that=this;
+      this.show.type=5;
+      this.show.open=true;
+      this.show.title="问诊报告"
+      setTimeout(() => {
+        that.$refs.addInquiryOrderReport.getDetails(this.$store.state.conversation.orderId);
+      }, 500);
+    },
+
+    handleDrugReport(){
+      var that=this;
+      this.show.type=7;
+      this.show.open=true;
+      this.show.title="用药报告"
+      setTimeout(() => {
+        that.$refs.addDrugReport.openDrugReport(this.$store.state.conversation.followId);
+      }, 500);
+    },
+    handleFollow(){
+      var that=this;
+      this.show.type=3;
+      this.show.open=true;
+      this.show.title="随访单"
+      setTimeout(() => {
+        that.$refs.followDetails.getDetails(this.$store.state.conversation.followId,"随访单");
+      }, 500);
+
+    },
+
+    handleInquiryOrder(){
+      console.log(this.$store.state.conversation.orderId)
+      var that=this;
+      this.show.type=1;
+      this.show.open=true;
+      var userId=this.$store.state.conversation.currentConversation.conversationID.split("-")[1];
+        console.log(userId)
+      this.show.title="问诊订单"
+      setTimeout(() => {
+
+        that.$refs.inquiryOrderDetails.getDetails(userId);
+      }, 500);
+
+    },*/
+   /* handleFinishDrugReport(){
+      var that=this;
+      this.$confirm('确定结束咨询吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        var data={followId:that.$store.state.conversation.followId}
+        console.log(data);
+        finishDrugReport(data).then(res => {
+
+            if(res.code==200){
+              that.$store.commit('setImType',0 )
+            }else{
+              this.msgError(res.msg);
+            }
+        });
+      }).catch(() => {
+
+      });
+    },*/
+    /*handleFinishInquiry(){
+      var that=this;
+      this.$confirm('确定结束问诊吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        var data={orderId:that.$store.state.conversation.orderId}
+        finishOrder(data).then(res => {
+            if(res.code==200){
+              that.$store.commit('setImType',0 )
+            }else{
+              that.msgError(res.msg);
+            }
+        });
+      }).catch(() => {
+
+      });
+    },*/
+    getList(value) {
+      this.callingList = value.map((item) => {
+        let obj = JSON.parse(item)
+        return obj.userID
+      })
+      this.groupAtList = value.map((item) => {
+        let data = JSON.parse(item)
+        return data.nick
+      })
+    },
+    cancelCalling() {
+      this.showCallingMember = false
+    },
+    callingHandler() {
+      if (this.callingList.length < 1) {
+        this.$store.commit('showMessage', {
+          type: 'warning',
+          message: '请选择成员'
+        })
+        return
+      }
+      if (this.listTpye === 'groupAt') {
+        this.groupAtList.forEach((item, index) => {
+          if(index===0) {
+            this.messageContent += `${item} `
+          }else{
+            this.messageContent += `@${item} `
+          }
+        })
+        this.showCallingMember = false
+        this.$refs['text-input'].focus()
+        return
+      }
+      if (this.listTpye === 'calling') {
+        let callingData = {
+          memberList:this.callingList,
+          type:3
+        }
+        this.$store.commit('setCallingList',callingData)
+        if (this.callingType === 'video') {
+          this.$bus.$emit('video-call')
+        }
+        if (this.callingType === 'audio') {
+          this.$bus.$emit('audio-call')
+        }
+        this.showCallingMember = false
+      }
+
+    },
+    trtcCalling(type) {
+      console.log(`尝试发起${type === 'video' ? '视频' : '语音'}通话`)
+
+      // 1. 检查OpenIM是否初始化
+      /*if (!this.OpenIM) {
+        console.error('OpenIM未初始化')
+        this.$message.error('IM服务未就绪')
+        return
+      }*/
+
+      // 2. 设置通话类型
+      this.listTpye = 'calling'
+      this.callingType = type
+
+      // 3. 处理不同类型的会话
+      if (this.currentConversationType === 1) {
+        // 单聊
+        this.startSingleCall(type)
+      } else if (this.currentConversationType === 3) {
+        // 群聊 - 显示成员选择
+        this.showCallingMember = true
+      } else {
+        console.error('不支持的会话类型')
+        this.$message.error('当前会话不支持通话')
+      }
+    },
+// 发起单聊通话
+    async startSingleCall(type) {
+      try {
+        const member = [this.toAccount]
+        const callingData = {
+          memberList: member,
+          type: 1 // 单聊
+        }
+
+        // 1. 存储通话信息
+        this.$store.commit('setCallingList', callingData)
+        console.log("通话信息",callingData)
+        // 2. 触发通话事件
+        this.$bus.$emit(`${type}-call`)
+
+        // 3. 日志记录
+        console.log(`已触发${type}-call事件`, {
+          targetUser: this.toAccount,
+          conversationType: this.currentConversationType
+        })
+      } catch (error) {
+        console.error('发起通话失败:', error)
+        this.$message.error('发起通话失败')
+      }
+    },
+    handleEmojiShow () {
+      this.emojiShow = true
+      this.bigEmojiShow = false
+    },
+    handleBigEmojiShow(index) {
+      let elm = document.getElementById('bigEmojiBox')
+      elm && (elm.scrollTop = 0)
+      this.curItemIndex = index
+      this.curBigEmojiItemList = this.bigEmojiList[index].list
+      this.emojiShow = false
+      this.bigEmojiShow = true
+    },
+    chooseBigEmoji(item) {
+      this.popoverVisible = false
+      let message = this.OpenIM.createFaceMessage({
+        to: this.toAccount,
+        conversationType: this.currentConversationType,
+        payload: {
+          index: this.curItemIndex + 1,
+          data: `${item}@2x`
+        }
+      })
+      let offlinePushInfo = {
+        title:"芳华未来",
+        desc:"表情消息",
+        iOSPushSound:"",
+        iOSBadgeCount:true,
+        operatorUserID:"",
+        ex:""
+      }
+      this.$store.commit('pushCurrentMessageList', message)
+      this.updateConversationList()
+      this.$bus.$emit('scroll-bottom')
+      this.OpenIM.sendMessage(message,offlinePushInfo).catch(error => {
+        this.$store.commit('showMessage', {
+          type: 'error',
+          message: error.message
+        })
+      })
+    },
+    reEditMessage(payload) {
+      this.messageContent = payload
+    },
+    handleUp() {
+      const index = this.memberList.findIndex(
+        member => member.userID === this.atUserID
+      )
+      if (index - 1 < 0) {
+        return
+      }
+      this.atUserID = this.memberList[index - 1].userID
+    },
+    handleDown() {
+      const index = this.memberList.findIndex(
+        member => member.userID === this.atUserID
+      )
+      if (index + 1 >= this.memberList.length) {
+        return
+      }
+      this.atUserID = this.memberList[index + 1].userID
+    },
+    handleEnter() {
+      this.sendTextMessage()
+    },
+    inputChange(value) {
+      if (this.currentConversationType === 3 && value.data === '@') {
+        this.groupAt = true
+        this.listTpye = 'groupAt'
+        this.showCallingMember = true
+      }
+      if (value.data === ' ' && this.messageContent.indexOf('@ ') !== -1) {
+        this.groupAt = false
+        this.listTpye = ''
+        this.showCallingMember = false
+      }
+    },
+    handleLine() {
+      this.messageContent += '\n'
+    },
+    handlePaste(e) {
+      let clipboardData = e.clipboardData
+      let file
+      if (
+        clipboardData &&
+        clipboardData.files &&
+        clipboardData.files.length > 0
+      ) {
+        file = clipboardData.files[0]
+      }
+
+      if (typeof file === 'undefined') {
+        return
+      }
+      // 1. 创建消息实例,接口返回的实例可以上屏
+      let message = this.OpenIM.createImageMessageFromFullPath({
+        to: this.toAccount,
+        conversationType: this.currentConversationType,
+        payload: {
+          file: file
+        },
+        onProgress: percent => {
+          this.$set(message, 'progress', percent) // 手动给message 实例加个响应式属性: progress
+        }
+      })
+      this.$store.commit('pushCurrentMessageList', message)
+      this.updateConversationList()
+      // 2. 发送消息
+      let promise = this.OpenIM.sendMessage(message)
+      promise.catch(error => {
+        this.$store.commit('showMessage', {
+          type: 'error',
+          message: error.message
+        })
+      })
+    },
+    dropHandler(e) {
+      e.preventDefault()
+      let file = e.dataTransfer.files[0]
+      let message = {}
+      if (file.type === 'video/mp4') {
+        message = this.OpenIM.createVideoMessage({
+          to: this.toAccount,
+          conversationType: this.currentConversationType,
+          payload: {
+            file: file
+          },
+          onProgress: percent => {
+            this.$set(message, 'progress', percent) // 手动给message 实例加个响应式属性: progress
+          }
+        })
+      } else {
+        message = this.OpenIM.createFileMessage({
+          to: this.toAccount,
+          conversationType: this.currentConversationType,
+          payload: {
+            file: file
+          },
+          onProgress: percent => {
+            this.$set(message, 'progress', percent) // 手动给message 实例加个响应式属性: progress
+          }
+        })
+      }
+      this.$store.commit('pushCurrentMessageList', message)
+      this.updateConversationList()
+      this.OpenIM
+        .sendMessage(message)
+        .then(() => {
+          this.$refs.videoPicker.value = null
+        })
+        .catch(imError => {
+          this.$store.commit('showMessage', {
+            message: imError.message,
+            type: 'error'
+          })
+        })
+    },
+    sendTextMessage() {
+      if (
+        this.messageContent === '' ||
+        this.messageContent.trim().length === 0
+      ) {
+        this.messageContent = ''
+        this.$store.commit('showMessage', {
+          message: '不能发送空消息哦!',
+          type: 'info'
+        })
+        return
+      }
+      var customData={
+        type:this.$store.state.conversation.type,
+        imType:this.$store.state.conversation.imType,
+        orderId:this.$store.state.conversation.orderId,
+        followId:this.$store.state.conversation.followId,
+        orderType:this.$store.state.conversation.orderType
+      }
+      console.log("创建艾特消息",this.groupAt)
+      if (this.currentConversationType === 3 && this.groupAt) {
+        console.log("创建艾特消息",this.callingList)
+        console.log("创建艾特消息",this.messageContent)
+        console.log("创建艾特消息",customData)
+        const creageAtMessage = {
+          text: this.messageContent.trim(),
+          atUserIDList: this.callingList,
+          atUsersInfo: this.callingList.map((userID, index) => ({
+            atUserID: userID,
+            groupNickname: this.groupAtList[index] || "",
+          })),
+          message: {},
+        }
+        this.OpenIM.createTextAtMessage(creageAtMessage).then(({ data }) => {
+          //创建成功发送消息
+          const sendText={
+            message:data,
+            recvID:"",
+            groupID:this.currentConversation.groupID,
+            offlinePushInfo : {
+              title:data.senderNickname,
+              desc:this.messageContent,
+              iOSPushSound:"",
+              iOSBadgeCount:true,
+              operatorUserID:"",
+              ex:""
+            }
+          }
+          this.OpenIM.sendMessage(sendText).then(({ data }) => {
+            console.log("发送消息返回参数",data)
+            const msgList = Array.isArray(data) ? data : [data];
+            this.$store.commit('pushCurrentMessageList', msgList)
+            this.updateConversationList()
+            this.$bus.$emit('scroll-bottom')
+          }).catch(({ errCode, errMsg }) => {
+            // 调用失败
+          });
+          this.messageContent = ''
+        }).catch(({ errCode, errMsg }) => {
+          // 调用失败
+          console.log("创建@消息失败",errMsg)
+          //this.messageContent = ''
+        });
+        return
+      }
+      console.log("this.messageContent",this.messageContent)
+      console.log("this.currentConversation",this.currentConversation)
+      this.OpenIM.createTextMessage(this.messageContent).then(({ data }) => {
+        console.log("创建文本消息返回参数")
+        console.log(data)
+        console.log("接受哦人id"+ this.$store.getters.toAccount)
+        // 调用成功
+        //console.log("customData",customData)
+        data.ex = JSON.stringify(customData)
+        const sendText={
+          message:data,
+          recvID:this.$store.getters.toAccount||this.currentConversation.userID,
+          groupID:this.currentConversation.groupID,
+          offlinePushInfo : {
+            title:data.senderNickname,
+            desc:this.messageContent,
+            iOSPushSound:"",
+            iOSBadgeCount:true,
+            operatorUserID:"",
+            ex:""
+          }
+        }
+        console.log("发送消息参数",sendText)
+        console.log("发送消息参数",this.OpenIM)
+        console.log("发送消息参数",this.OpenIM.isLogin)
+        /*if (!this.OpenIM || !this.OpenIM.isLogin) {
+          console.warn('⚠️ OpenIM SDK 尚未登录完成,无法发起邀请');
+          return;
+        }*/
+        this.OpenIM.sendMessage(sendText).then(({ data }) => {
+          console.log("发送消息返回参数",data)
+          // 调用成功
+          console.log("customData",customData)
+          const msgList = Array.isArray(data) ? data : [data];
+          this.$store.commit('pushCurrentMessageList', msgList)
+          this.updateConversationList()
+          this.$bus.$emit('scroll-bottom')
+        }).catch(({ errCode, errMsg }) => {
+          // 调用失败
+        });
+        this.messageContent = ''
+      }).catch(({ errCode, errMsg }) => {
+        // 调用失败
+      });
+      /*{
+      cloudCustomData:JSON.stringify(customData),
+      to: this.toAccount,
+      conversationType: this.currentConversationType,
+      payload: { text: this.messageContent }
+    }*/
+
+
+    },
+    updateConversationList(){
+      this.OpenIM.getAllConversationList()
+        .then(({ data }) => {
+          // 调用成功
+          console.log(data,"147852")
+          this.$store.commit('updateConversationList', data)
+        })
+        .catch(({ errCode, errMsg }) => {
+          // 调用失败
+        })
+    },
+    sendCustomMessage() {
+      if (
+        this.form.data.length === 0 &&
+        this.form.description.length === 0 &&
+        this.form.extension.length === 0
+      ) {
+        this.$store.commit('showMessage', {
+          message: '不能发送空消息',
+          type: 'info'
+        })
+        return
+      }
+      var customData={
+          type:this.$store.state.conversation.type,
+          imType:this.$store.state.conversation.imType,
+          orderId:this.$store.state.conversation.orderId,
+          followId:this.$store.state.conversation.followId,
+          orderType:this.$store.state.conversation.orderType
+      }
+      const customMessageData = {
+        data:this.form.data,
+        description: this.form.description,
+        extension: this.form.extension
+      }
+      console.log("customMessageData",customMessageData)
+      this.OpenIM.createCustomMessage(customMessageData)
+        .then(({ data }) => {
+          // 调用成功
+        })
+        .catch(({ errCode, errMsg }) => {
+          // 调用失败
+        });
+      /*const message = this.OpenIM.createCustomMessage({
+        cloudCustomData:JSON.stringify(customData),
+        to: this.toAccount,
+        conversationType: this.currentConversationType,
+        payload: {
+          data: this.form.data,
+          description: this.form.description,
+          extension: this.form.extension
+        }
+      })*/
+      this.$store.commit('pushCurrentMessageList', message)
+      this.updateConversationList()
+      this.OpenIM.sendMessage(message).catch(error => {
+        this.$store.commit('showMessage', {
+          type: 'error',
+          message: error.message
+        })
+      })
+      Object.assign(this.form, {
+        data: '',
+        description: '',
+        extension: ''
+      })
+      this.sendCustomDialogVisible = false
+    },
+    random(min, max) {
+      return Math.floor(Math.random() * (max - min + 1) + min)
+    },
+    sendSurvey() {
+      var customData={
+          type:this.$store.state.conversation.type,
+          imType:this.$store.state.conversation.imType,
+          orderId:this.$store.state.conversation.orderId,
+          followId:this.$store.state.conversation.followId,
+          orderType:this.$store.state.conversation.orderType
+      }
+      console.log("创建自定义消息")
+      const message = this.OpenIM.createCustomMessage({
+        cloudCustomData:JSON.stringify(customData),
+        to: this.toAccount,
+        conversationType: this.currentConversationType,
+        payload: {
+          data: 'survey',
+          description: String(this.rate),
+          extension: this.suggestion
+        }
+      })
+      this.$store.commit('pushCurrentMessageList', message)
+      Object.assign(this.form, {
+        data: '',
+        description: '',
+        extension: ''
+      })
+      this.OpenIM
+        .sendMessage(message)
+        .then(() => {
+          console.log("发送自定义消息")
+          Object.assign(this, {
+            rate: 5,
+            suggestion: ''
+          })
+        })
+        .catch(error => {
+          this.$store.commit('showMessage', {
+            type: 'error',
+            message: error.message
+          })
+        })
+      this.surveyDialogVisible = false
+    },
+    chooseEmoji(item) {
+      const emojiChar = this.emojiCharMap[item] || item;
+      console.log("emojiChar",emojiChar)
+      this.messageContent += emojiChar;
+    },
+    handleSendImageClick() {
+      this.$refs.imagePicker.click()
+    },
+    handleSendFileClick() {
+      this.$refs.filePicker.click()
+    },
+    handleSendVideoClick() {
+      this.$refs.videoPicker.click()
+    },
+    groupLive() {
+      this.$store.commit('updateGroupLiveInfo', {
+        groupID: this.toAccount,
+        anchorID: this.userID,
+      })
+      this.$bus.$emit('open-group-live', { channel: 1 })
+    },
+    generateUUID() {
+      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+        var r = (Math.random() * 16) | 0,
+          v = c == 'x' ? r : (r & 0x3) | 0x8
+        return v.toString(16)
+      })
+    },
+    sendImage() {
+      const input = document.getElementById('imagePicker');
+      const imageFile = input?.files?.[0];
+      if (!imageFile) return;
+
+      const picBaseInfo = {
+        uuid: this.generateUUID(),
+        type: imageFile.type,
+        size: imageFile.size,
+        width: 0,
+        height: 0,
+        url: '',
+      };
+
+      const setImageDimensions = (file, callback) => {
+        const reader = new FileReader();
+        reader.onload = (e) => {
+          const img = new Image();
+          img.onload = () => {
+            callback({ width: img.width, height: img.height });
+          };
+          img.src = e.target.result;
+        };
+        reader.readAsDataURL(file);
+      };
+
+      setImageDimensions(imageFile, ({ width, height }) => {
+        // 创建图片对象
+        const updatedPicInfo = {
+          ...picBaseInfo,
+          width,
+          height,
+        };
+
+        const messageData = {
+          sourcePicture: { ...updatedPicInfo },
+          bigPicture: { ...updatedPicInfo },
+          snapshotPicture: { ...updatedPicInfo },
+          file: imageFile,
+          sourcePath: imageFile.name,
+          onProgress: percent => {
+            this.$set(updatedPicInfo, 'progress', percent);
+          },
+        };
+
+        this.OpenIM.createImageMessageByFile(messageData)
+          .then(({ data: message }) => {
+            const isGroup = this.currentConversationType === 3;
+            return this.OpenIM.sendMessage({
+              message,
+              recvID: isGroup ? '' : this.$store.getters.toAccount||this.currentConversation.userID,
+              groupID: isGroup ? this.currentConversation.groupID : '',
+              offlinePushInfo : {
+                title:message.senderNickname,
+                desc:"[图片]",
+                iOSPushSound:"",
+                iOSBadgeCount:true,
+                operatorUserID:"",
+                ex:""
+              }
+            });
+          })
+          .then(({ data }) => {
+            console.log('发送图片成功', data);
+            const msgList = Array.isArray(data) ? data : [data];
+            this.$store.commit('pushCurrentMessageList', msgList);
+            this.updateConversationList()
+            this.$bus.$emit('scroll-bottom');
+            this.$refs.imagePicker.value = null;
+          })
+          .catch(({ errCode, errMsg }) => {
+            console.error('发送图片失败', errCode, errMsg);
+            this.$store.commit('showMessage', {
+              message: errMsg,
+              type: 'error',
+            });
+          });
+      });
+    },
+
+    sendFile() {
+      const input = document.getElementById('filePicker');
+      const file = input.files[0];
+      if (!file) return;
+
+      const customData = {
+        type: this.$store.state.conversation.type,
+        imType: this.$store.state.conversation.imType,
+        orderId: this.$store.state.conversation.orderId,
+        followId: this.$store.state.conversation.followId,
+        orderType: this.$store.state.conversation.orderType
+      };
+
+      // 1. 创建文件消息
+      this.OpenIM.createFileMessageByFile({
+        filePath: file.name,
+        fileName: file.name,
+        uuid: this.generateUUID(),
+        sourceUrl: '',
+        fileSize: file.size,
+        fileType: file.type,
+        file,
+        cloudCustomData: JSON.stringify(customData),
+        onProgress: percent => {
+          this.$set(file, 'progress', percent);
+        }
+      }).then(({ data }) => {
+        // 2. 发送文件消息
+        const sendFile = {
+          message: data,
+          recvID: this.$store.getters.toAccount||this.currentConversation.userID,
+          groupID: this.currentConversationType === 3 ? this.currentConversation.groupID : "",
+          offlinePushInfo : {
+            title:data.senderNickname,
+            desc:"[文件]",
+            iOSPushSound:"",
+            iOSBadgeCount:true,
+            operatorUserID:"",
+            ex:""
+          }
+        };
+
+        return this.OpenIM.sendMessage(sendFile);
+      }).then(({ data }) => {
+        const msgList = Array.isArray(data) ? data : [data];
+        this.$store.commit('pushCurrentMessageList', msgList);
+        this.updateConversationList()
+        this.$bus.$emit('scroll-bottom');
+        this.$refs.filePicker.value = null;
+      }).catch(({ errCode, errMsg }) => {
+        console.error("发送文件失败", errCode, errMsg);
+        this.$store.commit('showMessage', {
+          message: errMsg,
+          type: 'error'
+        });
+      });
+    },
+    sendVideo() {
+      const videoInput = document.getElementById('videoPicker');
+      const file = videoInput.files[0];
+      if (!file) return;
+
+      const customData = {
+        type: this.$store.state.conversation.type,
+        imType: this.$store.state.conversation.imType,
+        orderId: this.$store.state.conversation.orderId,
+        followId: this.$store.state.conversation.followId,
+        orderType: this.$store.state.conversation.orderType,
+      };
+
+      const videoURL = URL.createObjectURL(file);
+      const video = document.createElement('video');
+      video.preload = 'metadata';
+      video.src = videoURL;
+
+      video.onloadedmetadata = () => {
+        URL.revokeObjectURL(videoURL); // 清理临时URL
+        const duration = Math.ceil(video.duration); // 视频时长
+
+        // 等待第一帧加载
+        video.currentTime = 0;
+      };
+
+      video.onloadeddata = () => {
+        // 创建 canvas 截取视频第一帧
+        const canvas = document.createElement('canvas');
+        canvas.width = video.videoWidth;
+        canvas.height = video.videoHeight;
+        const ctx = canvas.getContext('2d');
+        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+
+        canvas.toBlob(snapshotBlob => {
+          const snapshotFile = new File([snapshotBlob], 'snapshot.png', { type: 'image/png' });
+
+          // 创建视频消息
+          this.OpenIM.createVideoMessageByFile({
+            cloudCustomData: JSON.stringify(customData),
+            videoPath: file.name,
+            duration: Math.ceil(video.duration),
+            videoType: file.type,
+            snapshotPath: snapshotFile.name,
+            videoUUID: '',
+            videoUrl: '',
+            videoSize: file.size,
+            snapshotUUID: '',
+            snapshotSize: snapshotFile.size,
+            snapshotUrl: '',
+            snapshotWidth: video.videoWidth,
+            snapshotHeight: video.videoHeight,
+            snapShotType: snapshotFile.type,
+            videoFile: file,
+            snapshotFile,
+            onProgress: percent => {
+              this.$set(this, 'videoProgress', percent);
+            }
+          }).then(({ data }) => {
+            const sendVideo = {
+              message: data,
+              recvID: this.$store.getters.toAccount||this.currentConversation.userID,
+              groupID: this.currentConversationType === 3 ? this.currentConversation.groupID : "",
+              offlinePushInfo : {
+                title:data.senderNickname,
+                desc:"[视频]",
+                iOSPushSound:"",
+                iOSBadgeCount:true,
+                operatorUserID:"",
+                ex:""
+              }
+            };
+
+            this.OpenIM.sendMessage(sendVideo).then(({ data }) => {
+              const msgList = Array.isArray(data) ? data : [data];
+              this.$store.commit('pushCurrentMessageList', msgList);
+              this.updateConversationList();
+              this.$bus.$emit('scroll-bottom');
+              this.$refs.videoPicker.value = null;
+            }).catch(({ errCode, errMsg }) => {
+              console.error("发送视频失败", errCode, errMsg);
+            });
+          });
+        }, 'image/png');
+      };
+    }
+
+
+  }
+}
+</script>
+<style lang="stylus" scoped>
+.at-member-box {
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+.at-member-item {
+  display: flex;
+  align-items: center;
+  padding: 6px 8px;
+  cursor: pointer;
+}
+
+.at-member-item:hover {
+  background: #f5f7fa;
+}
+
+.avatar-border {
+  width: 24px;
+  height: 24px;
+  margin: 0 12px 0 0;
+  vertical-align: middle;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  filter: grayscale(20%);
+}
+
+.avatar-border:hover {
+  transform: scale(1.2);
+  filter: grayscale(0%);
+}
+
+#message-send-box-wrapper {
+  box-sizing: border-box;
+  overflow: hidden;
+  padding: 3px 20px 20px 20px;
+  width : 100%;
+}
+
+.emojis {
+  height: 160px;
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  overflow-y: scroll;
+}
+
+.emoji {
+  height: 40px;
+  width: 40px;
+  box-sizing: border-box;
+}
+
+.send-header-bar {
+  box-sizing: border-box;
+  padding: 3px 0 0 0;
+  display: flex;
+  align-items: center;
+}
+
+.send-header-bar i {
+  cursor: pointer;
+  font-size: 24px;
+  color: gray;
+  margin: 0 12px 0 0;
+}
+
+.send-header-bar i:hover {
+  color: $black;
+}
+
+textarea {
+  resize: none;
+}
+
+.text-input {
+  font-size: 16px;
+  width: 100%;
+  box-sizing: box-sizing;
+  border: none;
+  outline: none;
+  background-color: transparent;
+}
+
+.block {
+  padding: 10px 0;
+  display: flex;
+  align-items: center;
+}
+
+.bottom {
+  padding-top: 10px;
+  position: relative;
+
+  .btn-send {
+    cursor: pointer;
+    position: absolute;
+    color: $primary;
+    font-size: 30px;
+    right: 0;
+    bottom: -5px;
+    padding: 6px 6px 4px 4px;
+    border-radius: 50%;
+  }
+}
+.group-live-icon-box {
+    display inline-block
+    position relative
+    top 3px
+    width 25px
+    height 25px
+    margin-right 12px
+    .group-live-icon {
+      display inline-block
+      position absolute
+      top 0
+      left 0
+      width 25px
+      height 25px
+      background url('../../assets/image/live-icon.png') center no-repeat
+      background-size cover
+      z-index 2
+    }
+    .group-live-icon-hover {
+      display inline-block
+      position absolute
+      top 0
+      left 0
+      width 25px
+      height 25px
+      background url('../../assets/image/live-icon-hover.png') center no-repeat
+      background-size cover
+      z-index 1
+    }
+}
+.group-live-icon-box:hover {
+  .group-live-icon {
+    z-index 1
+  }
+  .group-live-icon-hover{
+    z-index 2
+  }
+}
+.calling-member-list {
+  position absolute
+  top 50px
+  background #fff
+  margin-right 20px
+  .calling-list-btn {
+    width 140px
+    display flex
+    float right
+    margin 10px 0
+    .calling-btn {
+      cursor pointer
+      padding 6px 12px
+      background #00A4FF
+      color #ffffff
+      font-size 14px
+      border-radius 20px
+      margin-left 13px
+    }
+  }
+}
+.el-popover {
+  padding: 12px 0 0 0 !important;
+}
+</style>

+ 55 - 0
src/components/message/message-status-icon.vue

@@ -0,0 +1,55 @@
+<template>
+  <div
+    style="width:16px;height:16px;"
+    :class="messageIconClass"
+    @click="handleIconClick"
+  >{{messageIconClass==='message-send-fail'? '!':''}}</div>
+</template>
+
+<script>
+export default {
+  name: 'MessageStatusIcon',
+  props: {
+    message: {
+      type: Object,
+      required: true
+    }
+  },
+  computed: {
+    messageIconClass() {
+      switch (this.message.status) {
+        case 'unSend':
+          return 'el-icon-loading'
+        case 'fail':
+          return 'message-send-fail'
+        default:
+          return ''
+      }
+    }
+  },
+  methods: {
+    handleIconClick() {
+      if (this.messageIconClass === 'message-send-fail') {
+        this.tim.resendMessage(this.message).catch(imError => {
+          this.$store.commit('showMessage', {
+            message: imError.message,
+            type: 'error'
+          })
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.message-send-fail {
+  margin-right: 8px;
+  background-color: #f35f5f;
+  color: $white;
+  border-radius: 50%;
+  text-align: center;
+  line-height: 16px;
+  cursor: pointer;
+}
+</style>

+ 957 - 0
src/components/message/trtc-calling/calling-index.vue

@@ -0,0 +1,957 @@
+<template>
+  <div class="call-container" v-show="dialling || calling || isDialled">
+    <div class="choose" v-show="isDialled">
+      <div class="title">
+        {{ sponsor }}来通话啦
+      </div>
+      <div class="buttons">
+        <div class="accept" @click="handleDebounce(onAcceptClick,500)"></div>
+        <div class="refuse" @click="handleDebounce(reject,500)"></div>
+      </div>
+    </div>
+    <div class="call" v-show="dialling || calling">
+      <div class="title" v-if="dialling && currentConversationType==='1'">
+        正在呼叫{{toAccount}}...
+      </div>
+      <div class="title" v-if="dialling && currentConversationType==='3'">
+        正在呼叫...
+      </div>
+
+      <div v-show="callType === CALL_TYPE.VIDEO_CALL && calling">
+        <div class="small-group" id="small-group">
+          <!-- 本地视频容器 -->
+          <div class="video-box1" id="local">
+
+          </div>
+          <div
+            class="video-box"
+            v-for="userId in callingUserList"
+            :id="`video-${userId}`"
+            :key="`video-${userId}`"
+          ></div>
+           <!--动态创建远端视频容器 -->
+<!--          <span>{{"callingUserList:",callingUserList}}</span>-->
+
+        </div>
+      </div>
+
+      <div v-show="callType === CALL_TYPE.AUDIO_CALL && calling" class="audio-box">
+        <div v-show="calling" class="aduio-call">
+          <img :src="myAvatar" class="audio-img" />
+          <p style="text-align: center">
+            <span class="nick-text">{{ myNick }}</span>
+            <i v-if="isMicOn" class="el-icon-microphone micr-icon" style="color: #006FFF;"></i>
+            <i v-else class="el-icon-turn-off-microphone micr-icon"></i>
+          </p>
+        </div>
+        <div v-for="item in invitedUserInfo" class="aduio-call" :key="`video-${item.userID}`">
+          <img :src="item.avatar || defaultAvatar" class="audio-img" />
+          <p style="text-align: center">
+            <span class="nick-text">{{ item.nickname || item.userID }}</span>
+            <i
+              v-if="item.isInvitedMicOn === true || item.isInvitedMicOn == undefined"
+              class="el-icon-microphone micr-icon"
+              style="color: #006FFF;"
+            ></i>
+            <i v-else class="el-icon-turn-off-microphone micr-icon"></i>
+          </p>
+        </div>
+      </div>
+
+      <div class="duration" v-show="calling">{{ formatDurationStr }}</div>
+
+      <div class="buttons" v-if="callType === CALL_TYPE.VIDEO_CALL">
+        <div :class="isCamOn ? 'videoOn' : 'videoOff'" @click="toggleCamera"></div>
+        <div class="refuse" @click="handleDebounce(hangUp, 500)"></div>
+        <div :class="isMicOn ? 'micOn' : 'micOff'" @click="toggleMic"></div>
+      </div>
+
+      <div class="buttons" v-else>
+        <div class="refuse" @click="handleDebounce(hangUp, 500)"></div>
+        <div :class="isMicOn ? 'micOn' : 'micOff'" @click="toggleMic"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+
+<script>
+  import { v4 as uuidv4 } from 'uuid';
+  import { mapGetters, mapState } from 'vuex';
+  import { formatDuration } from '@/utils/formatDuration';
+  import { CALL_TYPE } from '@/constant/call';
+  import { getOpenIM } from '@/utils/openIM';
+  import Trtc,{createLocalAudioTrack,createLocalVideoTrack} from '@/utils/trtc';
+  import beCalledSound from '@/assets/audio/beCalled.mp3';
+  export default {
+    name: 'CallLayer',
+    data() {
+      return {
+        acceptToken: '',
+        acceptURL: '',
+        acceptRoomID: '',
+        faceImages:"",
+        nickName:"",
+        callType: '',
+        inviteID: '',
+        inviteData: {
+          inviterUserID: '',
+          inviteeUserIDList: '',
+          customData:null,
+          groupID: '',
+          roomID: '',
+          timeout: 0,
+          mediaType: '',
+          sessionType: 0,
+          platformID: 0,
+          initiateTime:null,
+          busyLineUserIDList:[],
+        },
+        sponsor: '',
+        callingUserList: [],
+        isCamOn: true,
+        isMicOn: true,
+        start: 0,
+        duration: 0,
+        dialling: false,
+        calling: false,
+        isDialled: false,
+        invitedUserInfo: [],
+        room: null,
+        localTracks: [],
+        remoteUsers: [],
+        OpenIM: null,
+        durationTimer: null,
+        defaultAvatar: 'https://imgcache.qq.com/open/qcloud/video/act/webim-avatar/avatar-3.png',
+        isInvitedMicOn: true,
+        ringtone: null,
+        ringtoneType: '',
+      };
+    },
+    computed: {
+      CALL_TYPE() {
+        return CALL_TYPE;
+      },
+      ...mapGetters(['toAccount', 'currentConversationType']),
+      ...mapState({
+        currentConversation: state => state.conversation.currentConversation,
+        currentUserProfile: state => state.imuser.currentUserProfile,
+        callingInfo: state => state.conversation.callingInfo,
+        userID: state => state.imuser.userID,
+      }),
+      formatDurationStr() {
+        return formatDuration(this.duration);
+      },
+      myAvatar() {
+        console.log("我的相关信息",this.currentUserProfile)
+        return this.currentUserProfile.faceURL || this.defaultAvatar
+      },
+      myNick() {
+        return this.currentUserProfile.nickname || this.userID
+      }
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+      //document.addEventListener('click', this.initAudio, { once: true })
+      this.setupEventListeners();
+    },
+    beforeDestroy() {
+      this.cleanup();
+    },
+    methods: {
+
+      micHandler() { // 是否打开麦克风
+        if (this.isMicOn) {
+          this.toggleMic()
+          this.isMicOn = false
+        } else {
+          this.toggleMic()
+          this.isMicOn = true
+        }
+      },
+      // 初始化事件监听
+      setupEventListeners() {
+        this.OpenIM.on('OnReceiveNewInvitation', this.handleNewInvitation);
+        this.OpenIM.on('OnInviteeAccepted', this.handleInviteeAccepted);
+        this.OpenIM.on('OnInviteeRejected', this.handleInviteeRejected);
+        this.OpenIM.on('OnInvitationTimeout', this.handleInvitationTimeout);
+        this.OpenIM.on('OnHangUp', this.handleHangUp);
+
+        this.$bus.$on('video-call', () => this.startCall('video'));
+        this.$bus.$on('audio-call', () => this.startCall('audio'));
+      },
+
+      // 清理资源
+      cleanup() {
+        //this.stopRingtone();
+        clearInterval(this.durationTimer);
+
+        if (this.room) {
+          this.room.disconnect();
+          this.room = null;
+        }
+
+        this.OpenIM.off('OnReceiveNewInvitation', this.handleNewInvitation);
+        this.OpenIM.off('OnInviteeAccepted', this.handleInviteeAccepted);
+        this.OpenIM.off('OnInviteeRejected', this.handleInviteeRejected);
+        this.OpenIM.off('OnInvitationTimeout', this.handleInvitationTimeout);
+        this.OpenIM.off('OnHangUp', this.handleHangUp);
+
+        this.$bus.$off('video-call');
+        this.$bus.$off('audio-call');
+      },
+
+      // 播放铃声
+      initAudio(type = 'callee') {
+        try {
+          const audio = new Audio(beCalledSound);
+          audio.play().catch(err => {
+            console.warn("播放声音失败", err);
+          });
+          this.ringtone = new Audio(beCalledSound);
+          this.ringtone.loop = true;
+          this.ringtone.preload = 'auto';
+          this.ringtoneType = type;
+
+          this.ringtone.addEventListener('error', (e) => {
+            console.error('音频加载错误:', e);
+          });
+
+          this.ringtone.play().catch((err) => {
+            console.warn('播放铃声失败,可能未解锁音频权限:', err);
+          });
+        } catch (e) {
+          console.error('音频初始化失败:', e);
+        }
+      },
+      playRingtone() {
+        if (!this.ringtone) {
+          this.initAudio()
+        }
+
+        this.ringtone.loop = true
+        this.ringtone.play().catch(e => {
+          console.error('播放被阻止:', e)
+          // 可以在这里显示"点击允许音频"的提示
+        })
+      },
+      stopRingtone() {
+        if (this.ringtone) {
+          this.ringtone.pause();
+          this.ringtone.currentTime = 0;
+          this.ringtone = null;
+          this.ringtoneType = '';
+        }
+      },
+
+      // 处理新邀请
+      async handleNewInvitation(data) {
+        console.log("返回的参数data", data.data)
+        this.inviteID = data.data.inviteID
+        this.inviteData = data.data.invitation
+        this.callType = data.data.invitation.mediaType
+        this.sponsor = data.data.participant.userInfo.nickname
+        this.isDialled = true
+
+        // ✅ 提前预热权限(耗时 1-2 秒,提前做)
+        this.prewarmMediaPermission()
+
+        // ✅ 提前执行 signalingAccept,拿到 token/url(非真正入会)
+        try {
+          const res = await this.OpenIM.signalingAccept({
+            opUserID: this.userID,
+            invitation: this.inviteData
+          });
+          this.acceptToken = res.data.token
+          this.acceptURL = res.data.liveURL
+          this.acceptRoomID = this.inviteData.roomID
+        } catch (err) {
+          console.error("提前 accept 接口失败", err)
+        }
+
+        document.addEventListener('click', () => {
+          this.initAudio('callee');
+        }, { once: true })
+
+        this.updateInvitedUserInfo(data.data.invitation.inviteeUserIDList)
+      },
+
+      // 更新被邀请用户信息
+      async updateInvitedUserInfo(userIDs) {
+        const users = await Promise.all(
+          userIDs.map(userID => this.OpenIM.getUsersInfo([userID]))
+        );
+        console.log("查询的用户信息",users)
+        this.invitedUserInfo = users.map(user => ({
+          userID: user.userID,
+          nickname: user.nickname,
+          avatar: user.faceURL,
+          isMicOn: true
+        }));
+      },
+
+      // 发起通话
+      async startCall(type) {
+        console.log("发起通话",this.toAccount)
+        if (this.calling || this.dialling) return;
+
+        try {
+          this.callType = type;
+          console.log("通话方式",this.callType)
+          this.dialling = true;
+          const roomId = uuidv4();
+
+          // 1. 先发送邀请,等待被叫方响应(这里需要监听被叫方的接受事件)
+          const { token, liveURL, roomID, inviteID } = await this.sendCallInvitation(roomId, type);
+          this.initAudio('caller');
+          this.inviteID = inviteID; // 保存 inviteID,等待对方接听后再 joinRoom
+          this.roomToken = token;
+          this.roomURL = liveURL;
+          this.roomID = roomID;
+          console.log("生成的token和url",this.roomToken, this.roomURL)
+        } catch (error) {
+          console.error('发起通话失败:', error);
+          this.resetCallState();
+        }
+      },
+      // 被叫方接受邀请后,主叫方和被叫方都调用
+      /*async joinRoom(roomID,roomToken,roomURL) {
+        console.log(roomID)
+        const callData = {
+          // 移除外层 operationID(SDK 会自动添加)
+          invitation: {
+            inviterUserID: this.userID,
+            inviteeUserIDList: ["D79"],
+            groupID: '',
+            roomID: roomID,
+            timeout: 60,
+            mediaType: this.callType,
+            sessionType: 1,
+            platformID: 5
+          },
+          offlinePushInfo: {
+            title: 'You have a call invitation',
+            desc: 'You have a call invitation',
+            ex: '',
+            iOSPushSound: '',
+            iOSBadgeCount: true
+          }
+        };
+        const res = await this.OpenIM.signalingAccept(callData);
+        console.log(res)
+        if (res.errCode !== 0) {
+          console.error("获取房间 token 失败:", res.errMsg);
+          return;
+        }
+        this.room = await Trtc.joinRoom(roomToken, roomURL, this.userID, roomID, {
+          video: this.callType === 'video',
+          audio: true
+        });
+
+        this.setupRoomListeners();
+        if (this.callType === 'video'){
+          await this.publishLocalVideo();
+          await this.publishLocalAudio();
+        }
+      },*/
+
+      // 发送通话邀请
+      async sendCallInvitation(roomId, type) {
+        console.log(" this.callingInfo.memberList", this.userID)
+        const invitation = {
+          inviterUserID: this.userID,
+          inviteeUserIDList: [this.toAccount],
+          groupID: '',
+          roomID: roomId,
+          timeout: 60,
+          mediaType: this.callType,
+          sessionType: 1,
+          platformID: 5,
+          customData:null,
+          initiateTime:null,
+          busyLineUserIDList:[],
+        };
+        this.inviteData = invitation;
+        console.log("this.inviteData",this.inviteData)
+        const callData = {
+          invitation: invitation,
+          offlinePushInfo: {
+            title: '来电提示',
+            desc: this.callType === 'video'? '视频通话':'语音通话',
+            ex: '',
+            iOSPushSound: '',
+            iOSBadgeCount: true
+          }
+        };
+        console.log("callData",callData)
+        const res = await this.OpenIM.signalingInvite(callData);
+        this.inviteID = res.data.inviteID;
+        console.log("sendCallInvitation返回值",res)
+        return {
+          token: res.data.token,
+          liveURL: res.data.liveURL,
+          roomID: res.data.roomID,
+          inviteID: res.data.inviteID,
+        };
+      },
+      // 设置房间事件监听
+      setupRoomListeners() {
+        console.log("触发setupRoomListeners")
+        /*this.room.on('participantConnected', participant => {
+          console.log('participantConnected:', participant.identity);
+          if (!this.callingUserList.includes(participant.identity)) {
+            this.callingUserList = [...this.callingUserList, participant.identity];
+            this.updateParticipantInfo(participant.identity);
+          }
+        });
+
+        this.room.on('trackSubscribed', (track, participant) => {
+          console.log('trackSubscribed:', track.kind, participant.identity);
+          this.attachRemoteTrack(track, participant);
+        });*/
+
+        this.room.on('trackPublished', publication => {
+          console.log('trackPublished:', publication.trackKind);
+        });
+      },
+      async prewarmMediaPermission() {
+        try {
+          const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: this.callType === 'video' });
+          stream.getTracks().forEach(track => track.stop()); // 不播放,只为触发授权
+          console.log("预热媒体权限成功");
+        } catch (err) {
+          console.warn("权限预热失败:", err);
+        }
+      },
+      async onAcceptClick() {
+        try {
+          await this.prewarmMediaPermission(); // 权限预热(防止卡住)
+          await this.accept();                 // 原来的接听逻辑
+        } catch (e) {
+          console.error("接听过程异常:", e);
+        }
+      },
+      // 发布本地视频
+      async publishLocalVideo() {
+        const localContainer = document.getElementById('local');
+        if (!localContainer) return;
+
+        // 确保容器有尺寸
+        localContainer.style.width = '320px';
+        localContainer.style.height = '240px';
+        localContainer.style.backgroundColor = 'black';
+        localContainer.innerHTML = '';
+
+        const devices = await navigator.mediaDevices.enumerateDevices();
+        const hasCam = devices.some(d => d.kind === 'videoinput');
+
+        if (!hasCam) {
+          console.warn('无可用摄像头,显示蓝色占位');
+          this.showBluePlaceholder(localContainer, '无摄像头');
+          return;
+        }
+
+        try {
+          const videoTrack = await createLocalVideoTrack();
+          await this.room.localParticipant.publishTrack(videoTrack);
+          this.localVideoTrack = videoTrack;
+
+          // 挂载本地视频轨道
+          const videoEl = document.createElement('video');
+          videoEl.autoplay = true;
+          videoEl.muted = true;
+          videoEl.playsInline = true;
+          videoEl.style.width = '100%';
+          videoEl.style.height = '100%';
+          localContainer.appendChild(videoEl);
+          videoTrack.attach(videoEl);
+        } catch (err) {
+          console.error('本地视频初始化失败:', err);
+          this.showBluePlaceholder(localContainer, '摄像头不可用');
+        }
+      },
+
+      async publishLocalAudio() {
+        const devices = await navigator.mediaDevices.enumerateDevices();
+        const hasMic = devices.some(d => d.kind === 'audioinput');
+        if (!hasMic) {
+          console.warn('无可用麦克风,跳过音频轨道发布');
+          return;
+        }
+        const audioTrack = await createLocalAudioTrack();
+        await this.room.localParticipant.publishTrack(audioTrack);
+        this.localAudioTrack = audioTrack;
+      },
+
+      // 接听通话
+      async accept() {
+        this.stopRingtone()
+        try {
+          console.time("joinRoom");
+
+          this.room = await Trtc.joinRoom(
+            this.acceptToken,
+            this.acceptURL,
+            this.userID,
+            (track, participant) => {
+              this.updateParticipantInfo(participant.identity)
+              this.attachRemoteTrack(track, participant)
+            }
+          )
+
+          console.timeEnd("joinRoom");
+          console.log("Room 对象:", this.room);
+
+          this.setupRoomListeners();
+          this.isDialled = false;
+          this.calling = true;
+          this.startCallTimer();
+          // 接听后挂载本地音视频
+          if (this.callType === 'video') {
+            await this.publishLocalVideo()
+          }
+          await this.publishLocalAudio()
+        } catch (error) {
+          console.error('接听失败:', error);
+          this.resetCallState();
+        }
+      },
+
+      // 拒绝通话
+      async reject() {
+        this.stopRingtone();
+        try {
+          console.log('接收到的邀请数据:', this.inviteData);
+          //this.stopRingtone();
+          const data = {
+            opUserID: this.userID,
+            invitation:this.inviteData
+          };
+          await this.OpenIM.signalingReject(data);
+          this.resetCallState();
+        } catch (error) {
+          console.error('拒绝失败:', error);
+          //message.error(t('toast.rejectFailed'));
+        }
+      },
+
+      // 挂断通话
+      async hangUp() {
+        this.stopRingtone();
+        console.log("this.inviteData==", this.inviteData)
+        try {
+          if (this.dialling) {
+            const data = {
+              opUserID: this.userID,
+              invitation:this.inviteData
+            };
+            console.log("取消邀请数据",data)
+            this.OpenIM.signalingCancel(data).catch(e => {
+              console.log("返回参数",e);
+            });
+          } else if (this.calling) {
+            await this.OpenIM.signalingHungUp({
+              opUserID: this.userID,      // 当前用户ID
+              invitation: this.inviteData //  完整 invitation 对象
+            });
+          }
+
+          // 离开房间
+          if (Trtc) {
+            await Trtc.leaveRoom();
+            await this.sleep(500)
+          }
+
+          this.resetCallState();
+
+        } catch (error) {
+          console.error('挂断失败:', error);
+          //message.error(t('toast.hangUpFailed'));
+        }
+      },
+      sleep(ms) {
+        return new Promise(resolve => setTimeout(resolve, ms))
+      },
+      // 防抖处理
+      handleDebounce(func, wait) {
+        if (this.timeout) clearTimeout(this.timeout)
+        this.timeout = setTimeout(() => {
+          func()
+        }, wait)
+      },
+      // 开始通话计时
+      startCallTimer() {
+        this.start = Date.now();
+        this.durationTimer = setInterval(() => {
+          this.duration = Math.floor((Date.now() - this.start) / 1000);
+        }, 1000);
+      },
+
+      // 重置通话状态
+      resetCallState() {
+        clearInterval(this.durationTimer);
+        this.duration = 0;
+        this.dialling = false;
+        this.calling = false;
+        this.isDialled = false;
+        this.localTracks = [];
+        this.remoteUsers = [];
+        this.callingUserList = [];
+        this.inviteID = '';
+        this.inviteData = {};
+        this.roomToken = '';
+        this.roomURL = '';
+        this.roomID = '';
+        this.room = null;
+        this.acceptToken = '';
+        this.acceptURL = '';
+        this.acceptRoomID = '';
+      },
+
+      // 切换摄像头状态
+      async toggleCamera() {
+        const videoTrack = Trtc.getLocalVideoTrack(); // 用 trtc 实例
+        if (!videoTrack) return;
+
+        const el = document.getElementById('local'); // 本地视频容器
+        if (!el) return;
+
+        try {
+          if (videoTrack.isMuted) {
+            // 打开摄像头
+            await videoTrack.unmute();
+            this.isCamOn = true;
+
+            // 先清空容器,再挂载视频
+            el.innerHTML = '';
+            const videoEl = videoTrack.attach(); // attach 会返回 video 元素
+            videoEl.style.width = '100%';
+            videoEl.style.height = '100%';
+            videoEl.style.objectFit = 'cover';
+            el.appendChild(videoEl);
+
+          } else {
+            // 关闭摄像头
+            await videoTrack.mute();
+            this.isCamOn = false;
+
+            // 清空容器并挂载黑屏占位
+            el.innerHTML = '';
+            const placeholder = document.createElement('div');
+            placeholder.style.width = '100%';
+            placeholder.style.height = '100%';
+            placeholder.style.backgroundColor = 'black';
+            el.appendChild(placeholder);
+          }
+
+          console.log('摄像头状态:', this.isCamOn);
+        } catch (err) {
+          console.error('切换摄像头失败:', err);
+        }
+      },
+
+      // 切换麦克风状态
+      async toggleMic() {
+        const audioTrack = Trtc.getLocalAudioTrack();
+        if (!audioTrack) return;
+
+        try {
+          if (audioTrack.isMuted) {
+            await audioTrack.unmute();
+            this.isMicOn = true;
+          } else {
+            await audioTrack.mute();
+            this.isMicOn = false;
+          }
+          console.log("麦克风状态:", this.isMicOn);
+        } catch (err) {
+          console.error("切换麦克风失败:", err);
+        }
+      },
+
+      // 处理参与者接受邀请
+      async handleInviteeAccepted(data) {
+        if (data.inviteID !== this.inviteID) return
+        this.stopRingtone()
+        this.inviteData = data.data.invitation
+        try {
+          // joinRoom,发布本地轨道 & 挂载远端轨道都在这里完成
+          this.room = await Trtc.joinRoom(
+            this.roomToken,
+            this.roomURL,
+            this.userID,
+            (track, participant) => {
+              this.updateParticipantInfo(participant.identity)
+              this.attachRemoteTrack(track, participant)
+            }
+          )
+          console.log("Room 对象:", this.room);
+          this.setupRoomListeners();
+          this.calling = true
+          this.dialling = false
+          this.isDialled = false
+          this.startCallTimer()
+          if (this.callType === 'video') {
+            await this.publishLocalVideo()
+          }
+          await this.publishLocalAudio()
+        } catch (err) {
+          console.error('主叫方 joinRoom 出错', err)
+        }
+      },
+
+
+      // 处理参与者拒绝邀请
+      handleInviteeRejected(data) {
+        console.log(123456789)
+        this.stopRingtone();
+        if (data.inviteID === this.inviteID) {
+          this.invitedUserInfo = this.invitedUserInfo.filter(
+            user => user.userID !== data.data.invitation.inviteeUserID
+          );
+        }
+      },
+
+      // 处理邀请超时
+      handleInvitationTimeout(data) {
+        this.stopRingtone();
+        if (data.inviteID === this.inviteID) {
+          //message.warning(t('toast.callTimeout'));
+          this.resetCallState();
+        }
+      },
+
+      // 处理挂断
+      handleHangUp(data) {
+
+        this.stopRingtone();
+        if (data.inviteID === this.inviteID) {
+          //message.info(t('toast.callEnded'));
+          this.resetCallState();
+        }
+      },
+
+      // 更新参与者信息
+      updateParticipantInfo(userID) {
+        if (!this.callingUserList.includes(userID)) {
+          this.callingUserList.push(userID); // ✅ 先 push
+        }
+        this.OpenIM.getUsersInfo([userID])
+          .then(({ data }) => {
+            console.log("查询用户信息",data)
+            this.faceImages = data[0].faceURL
+            this.nickName = data[0].nickname
+            console.log("更新参与者信息", this.invitedUserInfo)
+            const user = this.invitedUserInfo.find(u => u.userID === userID);
+            console.log(this.faceImages)
+            console.log(this.nickName)
+            if (!user) {
+              this.invitedUserInfo.push({
+                userID,
+                nickname: this.nickName,
+                avatar: this.faceImages,
+                isMicOn: true
+              });
+            }
+
+            // data: 查询到的用户信息列表
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败attachRemoteTrack
+          });
+
+      },
+
+      // 附加远端轨道
+      attachRemoteTrack(track, participant, retryCount = 0) {
+        const kind = track.kind;
+        const uid = participant.identity;
+
+        if (kind === 'audio') {
+          const audioEl = document.createElement('audio');
+          audioEl.autoplay = true;
+          audioEl.playsInline = true;
+          track.attach(audioEl);
+          document.body.appendChild(audioEl); // 直接挂到 body,避免容器找不到
+          return;
+        }
+
+        if (kind === 'video') {
+          const containerId = `video-${uid}`;
+          let container = document.getElementById(containerId);
+
+          if (!container) {
+            if (retryCount < 20) {
+              setTimeout(() => this.attachRemoteTrack(track, participant, retryCount + 1), 200);
+              return;
+            }
+            console.warn(`找不到容器: ${containerId}`);
+            return;
+          }
+
+          container.innerHTML = '';
+          const videoEl = document.createElement('video');
+          videoEl.autoplay = true;
+          videoEl.playsInline = true;
+          videoEl.style.width = '100%';
+          videoEl.style.height = '100%';
+          container.appendChild(videoEl);
+          track.attach(videoEl);
+        }
+      }
+    }
+  };
+</script>
+
+<style lang="stylus" scoped>
+  .call-container
+    background-size cover
+    position absolute
+    z-index 999
+
+  .accept, .refuse, .videoOn, .videoOff, .micOn, .micOff
+    height 50px
+    width 50px
+    box-sizing border-box
+    border-radius 50%
+    cursor: pointer
+
+  .accept
+    background center no-repeat url("../../../assets/image/call.png")
+    background-size 60%
+    background-color $success
+
+  .refuse
+    background center no-repeat url("../../../assets/image/hangup.png")
+    background-size 70%
+    background-color $danger
+
+  .videoOn
+    background center no-repeat url("../../../assets/image/big-camera-on.png")
+
+  .videoOff
+    background center no-repeat url("../../../assets/image/big-camera-off.png")
+
+  .micOn
+    background center no-repeat url("../../../assets/image/big-mic-on.png")
+
+  .micOff
+    background center no-repeat url("../../../assets/image/big-mic-off.png")
+
+  .buttons
+    position absolute
+    z-index 20
+    width 70%
+    top 75%
+    display flex
+    justify-content space-around
+    margin 0 15% 0 15%
+  .audio-box
+    position absolute
+    z-index 20
+    width 70%
+    top 200px
+    display flex
+    justify-content center
+    margin 0 15% 0 15%
+  .aduio-call
+    box-sizing border-box
+    width 140px
+    height 100px
+  .audio-img
+    display block
+    width 60px
+    height 60px
+    border-radius 50%
+    margin 0 auto 13px
+  .micr-icon
+    cursor pointer
+    font-size 28px
+  /*display block*/
+  /*text-align center*/
+  .nick-text
+    color #dddddd
+    font-size 12px
+    margin-right 5px
+    vertical-align super
+
+  .duration
+    color #fff
+    position absolute
+    z-index 20
+    width 100%
+    top 70%
+    display flex
+    justify-content center
+
+  .mask
+    position absolute
+    z-index 10
+    background #D8D8D8
+    height 100%
+    width 100%
+    display flex
+    align-items center
+    justify-content center
+
+    space
+    .image
+      margin-left 15%
+
+    .notice
+      color #888888
+
+  .choose, .call
+    color #fff
+    background-color rgba(0, 0, 0, 0.8)
+    height 100%
+    width 100%
+
+  .title
+    margin 25% 0 0 0
+    text-align center
+    width 100%
+    position absolute
+    z-index 10
+    color #fff
+    font-size 40px
+    font-weight 700
+
+  .big
+    position absolute
+    height 100%
+    width 100%
+
+  .small
+    position absolute
+    margin-left 74.8%
+    z-index 999
+    border-style solid
+    border-width 1px
+    border-color #808080
+    height 44.8%
+    width 25.2%
+  .big-group
+    height 60vh
+    width 100%
+
+  .small-group
+    display: flex
+    flex-wrap: wrap
+    position: absolute
+    width: 100%
+    height: 60vh
+
+  .video-box
+    width: 100%  // 根据参与者数量调整
+    height: 100%
+    background: #000
+    position: relative
+
+    video
+      width: 100%
+      height: 100%
+      object-fit: cover
+
+
+</style>

+ 177 - 0
src/components/message/trtc-calling/group-member-list.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="group-member-list-wrapper">
+    <div class="header">
+      <span class="member-count text-ellipsis">群成员:{{memberList.size}}</span>
+    </div>
+    <div class="scroll-content">
+      <div class="group-member-list">
+        <el-checkbox-group v-model="callingList" @change="handleCheckedMembersChange">
+          <!--                    <el-checkbox v-if="type === 'groupAt'"  label="所有人" >-->
+          <el-checkbox
+            v-if="type === 'groupAt'"
+            :label="JSON.stringify({ userID: 'AtAllTag', nick: '所有人' })"
+          >
+            <div class="group-member">
+              <avatar :src="''" />
+              <div class="member-name text-ellipsis">
+                <span>所有人</span>
+              </div>
+            </div>
+          </el-checkbox>
+
+          <!--                        <div class="group-member">-->
+          <!--                            <avatar  :src="''" />-->
+          <!--                            <div class="member-name text-ellipsis">-->
+          <!--                                <span >所有人</span>-->
+          <!--                            </div>-->
+          <!--                        </div>-->
+          <!--                    </el-checkbox>-->
+          <el-checkbox v-for="member in members" :disabled="member.userID===userID" :label="JSON.stringify({userID:member.userID,nick:member.nickname || member.userID})" :key="member.userID">
+            <div class="group-member">
+              <avatar  :src="member.faceURL" />
+              <div class="member-name text-ellipsis">
+                <span v-if="member.nameCard" >{{ member.nameCard }}</span>
+                <span v-else-if="member.nickname" >{{ member.nickname }}</span>
+                <span v-else >{{ member.userID }}</span>
+              </div>
+            </div>
+          </el-checkbox>
+        </el-checkbox-group>
+      </div>
+    </div>
+    <div class="more">
+      <el-button v-if="showLoadMore" type="text" @click="loadMore">查看更多</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+import { getOpenIM } from '@/utils/openIM';
+export default {
+  props:['type'],
+  data() {
+    return {
+      OpenIM:null,
+      callingList:[],
+      addGroupMemberVisible: false,
+      currentMemberID: '',
+      memberList:[],
+      count: 30 // 显示的群成员数量
+    }
+  },
+  components: {
+  },
+  created() {
+    this.OpenIM = getOpenIM();
+    this.fetchMemberList()
+  },
+  computed: {
+    ...mapState({
+      userID: state => state.imuser.userID,
+      currentConversation: state => state.conversation.currentConversation,
+      currentMemberList: state => state.group.currentMemberList
+    }),
+    showLoadMore() {
+      return this.members.length < this.memberList.length
+    },
+    members() {
+      return this.memberList.slice(0, this.count)
+    }
+  },
+  methods: {
+    async fetchMemberList() {
+      try {
+        const { data } = await this.OpenIM.getGroupMemberList({
+          groupID: this.currentConversation.groupID,
+          filter: 0,
+          offset: 0,
+          count: 1000,
+        })
+        this.memberList = data || []
+        console.log('群成员列表',this.memberList)
+      } catch (e) {
+        console.error('获取群成员失败', e)
+      }
+    },
+    handleCheckedMembersChange() {
+      this.$emit('getList',this.callingList)
+    },
+    getGroupMemberAvatarText(role) {
+      switch (role) {
+        case 'Owner':
+          return '群主'
+        case 'Admin':
+          return '管理员'
+        default:
+          return '群成员'
+      }
+    },
+    loadMore() {
+      this.$store
+        .dispatch('getGroupMemberList', this.groupProfile.groupID)
+        .then(() => {
+          this.count += 30
+        })
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+    .group-member-list-wrapper
+        .header
+            height 50px
+            padding 10px 16px 10px 20px
+            border-bottom 1px solid $border-base
+            .member-count
+                display inline-block
+                max-width 130px
+                line-height 30px
+                font-size 14px
+                vertical-align bottom
+            .btn-add-member
+                width 30px
+                height 30px
+                font-size 28px
+                text-align center
+                line-height 32px
+                cursor pointer
+                float right
+                &:hover
+                    color $light-primary
+        .scroll-content
+            max-height: 250px;
+            overflow-y: scroll;
+            padding 10px 15px 10px 15px
+            width 100%
+            .group-member-list
+                display flex
+                justify-content flex-start
+                flex-wrap wrap
+                width 100%
+            .group-member
+                width 100px
+                height 80px
+                display: flex;
+                justify-content center
+                align-content center
+                flex-direction: column;
+                text-align: center;
+                color: $black;
+                cursor: pointer;
+                .avatar
+                    width 40px
+                    height 40px
+                    border-radius 50%
+                    margin 0 auto
+                .member-name
+                    font-size 12px
+                    width: 100px;
+                    text-align center
+        .more
+            padding 0 20px
+            border-bottom 1px solid $border-base
+
+
+</style>

+ 294 - 0
src/components/user/login.vue

@@ -0,0 +1,294 @@
+
+<template>
+  <div class="login-wrapper">
+    <img class="logo" :src="logo" />
+
+    <el-button
+      type="primary"
+      @click="submit"
+      style="width:100%; margin-top: 6px;"
+      :loading="loading"
+    >登录即时通</el-button>
+  </div>
+</template>
+
+
+<script>
+  import logo from '../../assets/image/logo.png'
+  import { getOpenIM,getCbEvents } from '@/utils/openIM';
+  import { accountCheck } from '@/api/company/companyUser';
+  export default {
+    name: 'Login',
+    data() {
+      return {
+        hasBindReadyEvent : false,
+        logo: logo,
+        loading: false,
+        OpenIM: null
+      }
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+      this.initListener()
+    },
+    methods: {
+      submit() {
+        this.login()
+      },
+      initListener() {
+        // 登录成功后会触发 SDK_READY 事件,该事件触发后,可正常使用 SDK 接口
+        this.OpenIM.on(getCbEvents().OnConnectSuccess, () => {
+          console.log("OnConnectSuccess 事件触发!"); // 调试日志
+          this.$store.commit('toggleIsSDKReady', true);
+        });
+        this.OpenIM.on(getCbEvents().OnConnectFailed, this.onError);
+        this.OpenIM.on(getCbEvents().OnKickedOffline, this.onKickOut);
+        this.OpenIM.on(getCbEvents().OnSelfInfoUpdated, this.onSelfInfoUpdated);
+        /*this.OpenIM.on(getCbEvents().OnRecvNewMessage, (message) => {
+          console.log("收到单条消息", message);
+          this.onReceiveMessage({data: [message]}); // 包装成数组形式
+        });*/
+
+        this.OpenIM.on(getCbEvents().OnRecvNewMessages, (data) => {
+          console.log("收到多条消息", data);
+          const msgList = []
+          data.data.forEach(msg =>{
+            if (msg.contentType!==113){
+              msgList.push(msg)
+            }
+          })
+          if (msgList.length>0){
+            this.onReceiveMessage({data: msgList});
+
+          }
+        });
+      },
+      login() {
+        accountCheck(this.$store.getters.userID).then(response => {
+          console.log("response",response)
+          this.userToken = response.token
+          console.log("this.userToken",this.userToken)
+          console.log(this.$store.getters.userID);
+          const config = {
+            userID: this.$store.getters.userID,
+            token: this.userToken,
+            logLevel:0,
+            platformID: 5, // 使用配置的平台ID
+            apiAddr: 'https://web.im.cdwjyyh.com/api', // API地址
+            wsAddr: 'wss://web.im.cdwjyyh.com/msg_gateway', // WebSocket地址
+            dataDir: '/imdata' // 添加数据存储目录
+          }
+          console.log("config",config)
+          console.log("userToken",this.userToken)
+          this.checkSDKReadyState();
+          this.OpenIM.login(config).then(() => {
+            this.$store.commit('toggleIsLogin', true);
+            this.$store.commit('startComputeCurrent');
+            this.initListener()
+            this.$store.commit('showMessage', {
+              type: 'success',
+              message: 'IM 登录成功'
+            });
+          })
+            .catch((error) => {
+              this.loading = false;
+              console.error('登录失败:', error);
+              this.$store.commit('showMessage', {
+                message: `IM 登录失败:${error.message || error.errMsg || '未知错误'}`,
+                type: 'error',
+              });
+            });
+        });
+      },
+      // 添加SDK就绪状态检查
+      checkSDKReadyState() {
+        if (this.hasBindReadyEvent) return;
+        this.hasBindReadyEvent = true;
+
+        let isReady = false;
+
+        const timeout = setTimeout(() => {
+          if (!isReady) {
+            this.$store.commit('toggleIsSDKReady', false);
+            this.$store.commit('showMessage', {
+              message: 'SDK初始化超时',
+              type: 'error'
+            });
+          }
+        }, 10000);
+
+        this.OpenIM.on(getCbEvents().OnConnectSuccess, () => {
+          console.log("WebSocket连接成功");
+          clearTimeout(timeout);
+          isReady = true;
+          this.OpenIM.getSelfUserInfo().then(({ data }) => {
+            console.log("当前登录用户基本信息",data)
+            this.$store.commit('updateCurrentUserProfile', data)
+          })
+          this.$store.commit('toggleIsSDKReady', true);
+          this.$store.commit('showMessage', {
+            type: 'success',
+            message: 'SDK 初始化成功'
+          });
+          // 触发获取用户信息等操作
+          console.log("开始加载用户数据,获取会话列表")
+          console.log(this.OpenIM)
+          this.loadUserData();
+          console.log("结束加载用户数据,获取会话列表")
+        });
+
+        this.OpenIM.on(getCbEvents().OnConnectFailed, (error) => {
+          clearTimeout(timeout);
+          this.$store.commit('toggleIsSDKReady', false);
+          this.$store.commit('showMessage', {
+            message: `SDK 连接失败: ${error.message}`,
+            type: 'error'
+          });
+        });
+      },
+      onKickOut(event) {
+        // event.data.type 是踢出原因
+        //const reason = this.kickedOutReason(event.data.type);
+
+        // 弹窗提示用户
+        this.$alert(`即时通账号在其他设备登录,请重新登录。`, '提示', {
+          confirmButtonText: '确定',
+          type: 'error',
+          callback: () => {
+            // 用户确认后执行退出操作
+            this.$store.commit('toggleIsLogin', false);
+            this.$store.commit('reset');
+          }
+        });
+      },
+
+      // 返回踢出原因
+      kickedOutReason(type) {
+        switch(type) {
+          case 1: return '被管理员踢出';
+          case 2: return '账号在其他设备登录';
+          default: return '未知原因';
+        }
+      },
+      onReceiveMessage({ data: messageList }) {
+        // let totalUnreadCount = this.tim.getTotalUnreadMessageCount();
+
+        messageList.forEach(element => {
+          //过滤掉正在输入状态
+          if(element.sendID!=this.$store.getters.userID&&element.contentType!==113){
+            this.$notify({
+              title: '消息提示',
+              message: '您有一条新的消息',
+              type: 'success'
+            });
+          }
+        });
+        this.OpenIM.getAllConversationList()
+          .then(({ data }) => {
+            // 调用成功
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+        console.log(messageList)
+        //this.handleVideoMessage(messageList)
+        this.handleQuitGroupTip(messageList)
+        this.handleCloseGroupLive(messageList)
+        this.$store.commit('pushCurrentMessageList', messageList)
+        this.$store.commit('pushAvChatRoomMessageList', messageList)
+      },
+
+      // 添加加载用户数据方法
+      loadUserData() {
+        //查询会话列表
+        this.OpenIM.getAllConversationList()
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到会话列表",data)
+            this.conversationList= data
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+        //查询好友列表
+        this.OpenIM.getFriendListPage({ offset:0, count:100 })
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到好友列表",data)
+            //this.conversationList= data
+            this.$store.commit('updateFriendList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+        //查询群列表
+        this.OpenIM.getJoinedGroupList()
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到群聊列表",data)
+            //this.conversationList= data
+            this.$store.commit('updateGroupList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          });
+      },
+    }
+  }
+</script>
+
+<style lang="stylus" scoped>
+.login-wrapper
+  display flex
+  align-items center
+  flex-direction column
+  width 450px
+  background $white
+  color $black
+  border-radius 5px
+  box-shadow: 0 11px 20px 0 rgba(0,0,0,0.3)
+  .row-div
+    display flex
+    justify-content center
+    align-items center
+    flex-direction row
+  .logo
+    width 80px;
+    height 80px;
+    border-radius:15rpx;
+    margin-bottom:20px;
+  .loginBox
+    width 320px
+    margin 0 0 20px 0
+    .send-code
+      width 112px
+    .login-im-btn
+      width 100%
+  .loginFooter
+    color: #8c8a8ac7
+    text-align: center
+    padding: 0 0 20px 0
+    cursor: pointer
+.login-wrapper {
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  width: 400px;
+  padding: 20px 80px 50px;
+  background: $white;
+  color: $black;
+  border-radius: 5px;
+  box-shadow: 0 11px 20px 0 rgba(0, 0, 0, 0.3);
+
+  .register-button {
+    width: 100%;
+    margin: 6px 0 0 0;
+  }
+
+  .user-selector {
+    width: 100%;
+  }
+}
+</style>

+ 67 - 0
src/utils/ImSocket.js

@@ -0,0 +1,67 @@
+export class ImSocket {
+  /**
+   * @param {string} url - WebSocket 服务器地址
+   * @param {number} checkInterval - 检查连接状态的时间间隔,单位毫秒
+   */
+  constructor(url, checkInterval = 5000) {
+    this.url = url;
+    this.checkInterval = checkInterval;
+    this.ws = null;
+    this.onMessageCallback = null;
+    this.isConnecting = false;
+    this.connect();
+    this.startHeartbeat();
+  }
+
+  connect() {
+    if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
+      return;
+    }
+
+    if (this.isConnecting) {
+      return;
+    }
+
+    this.isConnecting = true;
+
+    this.ws = new WebSocket(this.url);
+
+    this.ws.onmessage = (event) => {
+      // 根据需要处理消息
+      if (this.onMessageCallback) this.onMessageCallback(event.data);
+    };
+
+    this.ws.onclose = (event) => {
+      this.isConnecting = false;
+    };
+  }
+
+  // 定时检查连接状态
+  startHeartbeat() {
+    if (this.heartbeatTimer) return;
+
+    this.heartbeatTimer = setInterval(() => {
+      if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+        this.connect();
+      }
+    }, this.checkInterval);
+  }
+
+   // 清除重连定时器的方法
+  stopHeartbeat() {
+    if (this.heartbeatTimer) {
+      clearInterval(this.heartbeatTimer);
+      this.heartbeatTimer = null;
+    }
+  }
+
+  // 主动关闭 WebSocket 连接,并清除定时任务
+  close() {
+    this.stopHeartbeat();
+    this.ws?.close();
+    this.ws = null;
+  }
+  onMessage(callback) {
+    this.onMessageCallback = callback;
+  }
+}

+ 68 - 0
src/utils/date.js

@@ -0,0 +1,68 @@
+/**
+ * 返回年月日
+ * @export
+ * @param {Date} date
+ * @param {string} [splitor='-']
+ * @returns
+ */
+export function getDate(date, splitor = '-') {
+  const year = date.getFullYear()
+  const month = date.getMonth() + 1
+  const day = date.getDate()
+  return `${year}${splitor}${addZeroPrefix(month)}${splitor}${addZeroPrefix(day)}`
+}
+
+/**
+ * 返回时分秒/时分
+ * @export
+ * @param {*} date
+ * @param {boolean} [withSecond=false]
+ * @returns
+ */
+export function getTime(date, withSecond = false) {
+  const hour = date.getHours()
+  const minute = date.getMinutes()
+  const second = date.getSeconds()
+  return withSecond ? `${addZeroPrefix(hour)}:${addZeroPrefix(minute)}:${addZeroPrefix(second)}` : `${hour}:${addZeroPrefix(minute)}`
+}
+
+export function getFullDate(date) {
+  return `${getDate(date)} ${getTime(date)}`
+}
+
+export function isToday(date) {
+  return date.toDateString() === new Date().toDateString()
+}
+
+
+/**
+ * 个位数,加0前缀
+ * @param {*} number
+ * @returns
+ */
+function addZeroPrefix(number) {
+  return number < 10 ? `0${number}`:number
+}
+
+export function formatTime(secondTime) {
+  let time = secondTime
+  let newTime, hour, minite, seconds
+  if (time >= 3600) {
+      hour = parseInt(time / 3600) < 10 ? '0' + parseInt(time / 3600) : parseInt(time / 3600)
+      minite = parseInt(time % 60 / 60) < 10 ? '0' + parseInt(time % 60 / 60) : parseInt(time % 60 / 60)
+      seconds = time % 3600 < 10 ? '0' + time % 3600 : time % 3600
+      if(seconds > 60) {
+        minite=parseInt(seconds / 60) < 10 ? '0' + parseInt(seconds / 60) : parseInt(seconds / 60)
+        seconds = seconds % 60 < 10 ? '0' + seconds % 60 : seconds % 60
+      }
+      newTime = hour + ':' + minite + ':' + seconds
+  } else if (time >= 60 && time < 3600) {
+      minite = parseInt(time / 60) < 10 ? '0' + parseInt(time / 60) : parseInt(time / 60)
+      seconds = time % 60 < 10 ? '0' + time % 60 : time % 60
+      newTime = '00:' + minite + ':' + seconds
+  } else if (time < 60) {
+      seconds = time < 10 ? '0' + time : time
+      newTime = '00:00:' + seconds
+  }
+  return newTime
+}

+ 61 - 0
src/utils/decodeText.js

@@ -0,0 +1,61 @@
+import { emojiMap, emojiUrl } from './emojiMap'
+/** 传入messageBody(群系统消息SystemMessage,群提示消息GroupTip除外)
+ * payload = {
+ *  msgType: 'TIMTextElem',
+ *  msgContent: {
+ *    text: 'AAA[龇牙]AAA[龇牙]AAA[龇牙AAA]'
+ *  }
+ *}
+ **/
+export function decodeText (payload) {
+  let renderDom = []
+  // 文本消息
+    let temp = payload.content
+    let left = -1
+    let right = -1
+    while (temp !== '') {
+      left = temp.indexOf('[')
+      right = temp.indexOf(']')
+      switch (left) {
+        case 0:
+          if (right === -1) {
+            renderDom.push({
+              name: 'text',
+              text: temp
+            })
+            temp = ''
+          } else {
+            let _emoji = temp.slice(0, right + 1)
+            if (emojiMap[_emoji]) {
+              renderDom.push({
+                name: 'img',
+                src: emojiUrl + emojiMap[_emoji]
+              })
+              temp = temp.substring(right + 1)
+            } else {
+              renderDom.push({
+                name: 'text',
+                text: '['
+              })
+              temp = temp.slice(1)
+            }
+          }
+          break
+        case -1:
+          renderDom.push({
+            name: 'text',
+            text: temp
+          })
+          temp = ''
+          break
+        default:
+          renderDom.push({
+            name: 'text',
+            text: temp.slice(0, left)
+          })
+          temp = temp.substring(left)
+          break
+      }
+    }
+  return renderDom
+}

+ 499 - 0
src/utils/emojiMap.js

@@ -0,0 +1,499 @@
+export const emojiUrl = 'https://web.sdk.qcloud.com/im/assets/emoji/'
+export const emojiMap = {
+  '[NO]': 'emoji_0@2x.png',
+  '[OK]': 'emoji_1@2x.png',
+  '[下雨]': 'emoji_2@2x.png',
+  '[么么哒]': 'emoji_3@2x.png',
+  '[乒乓]': 'emoji_4@2x.png',
+  '[便便]': 'emoji_5@2x.png',
+  '[信封]': 'emoji_6@2x.png',
+  '[偷笑]': 'emoji_7@2x.png',
+  '[傲慢]': 'emoji_8@2x.png',
+  '[再见]': 'emoji_9@2x.png',
+  '[冷汗]': 'emoji_10@2x.png',
+  '[凋谢]': 'emoji_11@2x.png',
+  '[刀]': 'emoji_12@2x.png',
+  '[删除]': 'emoji_13@2x.png',
+  '[勾引]': 'emoji_14@2x.png',
+  '[发呆]': 'emoji_15@2x.png',
+  '[发抖]': 'emoji_16@2x.png',
+  '[可怜]': 'emoji_17@2x.png',
+  '[可爱]': 'emoji_18@2x.png',
+  '[右哼哼]': 'emoji_19@2x.png',
+  '[右太极]': 'emoji_20@2x.png',
+  '[右车头]': 'emoji_21@2x.png',
+  '[吐]': 'emoji_22@2x.png',
+  '[吓]': 'emoji_23@2x.png',
+  '[咒骂]': 'emoji_24@2x.png',
+  '[咖啡]': 'emoji_25@2x.png',
+  '[啤酒]': 'emoji_26@2x.png',
+  '[嘘]': 'emoji_27@2x.png',
+  '[回头]': 'emoji_28@2x.png',
+  '[困]': 'emoji_29@2x.png',
+  '[坏笑]': 'emoji_30@2x.png',
+  '[多云]': 'emoji_31@2x.png',
+  '[大兵]': 'emoji_32@2x.png',
+  '[大哭]': 'emoji_33@2x.png',
+  '[太阳]': 'emoji_34@2x.png',
+  '[奋斗]': 'emoji_35@2x.png',
+  '[奶瓶]': 'emoji_36@2x.png',
+  '[委屈]': 'emoji_37@2x.png',
+  '[害羞]': 'emoji_38@2x.png',
+  '[尴尬]': 'emoji_39@2x.png',
+  '[左哼哼]': 'emoji_40@2x.png',
+  '[左太极]': 'emoji_41@2x.png',
+  '[左车头]': 'emoji_42@2x.png',
+  '[差劲]': 'emoji_43@2x.png',
+  '[弱]': 'emoji_44@2x.png',
+  '[强]': 'emoji_45@2x.png',
+  '[彩带]': 'emoji_46@2x.png',
+  '[彩球]': 'emoji_47@2x.png',
+  '[得意]': 'emoji_48@2x.png',
+  '[微笑]': 'emoji_49@2x.png',
+  '[心碎了]': 'emoji_50@2x.png',
+  '[快哭了]': 'emoji_51@2x.png',
+  '[怄火]': 'emoji_52@2x.png',
+  '[怒]': 'emoji_53@2x.png',
+  '[惊恐]': 'emoji_54@2x.png',
+  '[惊讶]': 'emoji_55@2x.png',
+  '[憨笑]': 'emoji_56@2x.png',
+  '[手枪]': 'emoji_57@2x.png',
+  '[打哈欠]': 'emoji_58@2x.png',
+  '[抓狂]': 'emoji_59@2x.png',
+  '[折磨]': 'emoji_60@2x.png',
+  '[抠鼻]': 'emoji_61@2x.png',
+  '[抱抱]': 'emoji_62@2x.png',
+  '[抱拳]': 'emoji_63@2x.png',
+  '[拳头]': 'emoji_64@2x.png',
+  '[挥手]': 'emoji_65@2x.png',
+  '[握手]': 'emoji_66@2x.png',
+  '[撇嘴]': 'emoji_67@2x.png',
+  '[擦汗]': 'emoji_68@2x.png',
+  '[敲打]': 'emoji_69@2x.png',
+  '[晕]': 'emoji_70@2x.png',
+  '[月亮]': 'emoji_71@2x.png',
+  '[棒棒糖]': 'emoji_72@2x.png',
+  '[汽车]': 'emoji_73@2x.png',
+  '[沙发]': 'emoji_74@2x.png',
+  '[流汗]': 'emoji_75@2x.png',
+  '[流泪]': 'emoji_76@2x.png',
+  '[激动]': 'emoji_77@2x.png',
+  '[灯泡]': 'emoji_78@2x.png',
+  '[炸弹]': 'emoji_79@2x.png',
+  '[熊猫]': 'emoji_80@2x.png',
+  '[爆筋]': 'emoji_81@2x.png',
+  '[爱你]': 'emoji_82@2x.png',
+  '[爱心]': 'emoji_83@2x.png',
+  '[爱情]': 'emoji_84@2x.png',
+  '[猪头]': 'emoji_85@2x.png',
+  '[猫咪]': 'emoji_86@2x.png',
+  '[献吻]': 'emoji_87@2x.png',
+  '[玫瑰]': 'emoji_88@2x.png',
+  '[瓢虫]': 'emoji_89@2x.png',
+  '[疑问]': 'emoji_90@2x.png',
+  '[白眼]': 'emoji_91@2x.png',
+  '[皮球]': 'emoji_92@2x.png',
+  '[睡觉]': 'emoji_93@2x.png',
+  '[磕头]': 'emoji_94@2x.png',
+  '[示爱]': 'emoji_95@2x.png',
+  '[礼品袋]': 'emoji_96@2x.png',
+  '[礼物]': 'emoji_97@2x.png',
+  '[篮球]': 'emoji_98@2x.png',
+  '[米饭]': 'emoji_99@2x.png',
+  '[糗大了]': 'emoji_100@2x.png',
+  '[红双喜]': 'emoji_101@2x.png',
+  '[红灯笼]': 'emoji_102@2x.png',
+  '[纸巾]': 'emoji_103@2x.png',
+  '[胜利]': 'emoji_104@2x.png',
+  '[色]': 'emoji_105@2x.png',
+  '[药]': 'emoji_106@2x.png',
+  '[菜刀]': 'emoji_107@2x.png',
+  '[蛋糕]': 'emoji_108@2x.png',
+  '[蜡烛]': 'emoji_109@2x.png',
+  '[街舞]': 'emoji_110@2x.png',
+  '[衰]': 'emoji_111@2x.png',
+  '[西瓜]': 'emoji_112@2x.png',
+  '[调皮]': 'emoji_113@2x.png',
+  '[象棋]': 'emoji_114@2x.png',
+  '[跳绳]': 'emoji_115@2x.png',
+  '[跳跳]': 'emoji_116@2x.png',
+  '[车厢]': 'emoji_117@2x.png',
+  '[转圈]': 'emoji_118@2x.png',
+  '[鄙视]': 'emoji_119@2x.png',
+  '[酷]': 'emoji_120@2x.png',
+  '[钞票]': 'emoji_121@2x.png',
+  '[钻戒]': 'emoji_122@2x.png',
+  '[闪电]': 'emoji_123@2x.png',
+  '[闭嘴]': 'emoji_124@2x.png',
+  '[闹钟]': 'emoji_125@2x.png',
+  '[阴险]': 'emoji_126@2x.png',
+  '[难过]': 'emoji_127@2x.png',
+  '[雨伞]': 'emoji_128@2x.png',
+  '[青蛙]': 'emoji_129@2x.png',
+  '[面条]': 'emoji_130@2x.png',
+  '[鞭炮]': 'emoji_131@2x.png',
+  '[风车]': 'emoji_132@2x.png',
+  '[飞吻]': 'emoji_133@2x.png',
+  '[飞机]': 'emoji_134@2x.png',
+  '[饥饿]': 'emoji_135@2x.png',
+  '[香蕉]': 'emoji_136@2x.png',
+  '[骷髅]': 'emoji_137@2x.png',
+  '[麦克风]': 'emoji_138@2x.png',
+  '[麻将]': 'emoji_139@2x.png',
+  '[鼓掌]': 'emoji_140@2x.png',
+  '[龇牙]': 'emoji_141@2x.png'
+}
+export const emojiCharMap = {
+  '[微笑]': '😀',
+  '[撇嘴]': '😒',
+  '[色]': '😍',
+  '[发呆]': '😳',
+  '[得意]': '😏',
+  '[流泪]': '😭',
+  '[害羞]': '😊',
+  '[闭嘴]': '🤐',
+  '[睡]': '😴',
+  '[大哭]': '😢',
+  '[尴尬]': '😅',
+  '[发怒]': '😡',
+  '[调皮]': '😜',
+  '[呲牙]': '😁',
+  '[惊讶]': '😲',
+  '[难过]': '😞',
+  '[酷]': '😎',
+  '[冷汗]': '😰',
+  '[抓狂]': '🤯',
+  '[吐]': '🤮',
+  '[偷笑]': '😏',
+  '[愉快]': '😄',
+  '[白眼]': '🙄',
+  '[傲慢]': '😤',
+  '[饥饿]': '😫',
+  '[困]': '😪',
+  '[惊恐]': '😱',
+  '[流汗]': '😓',
+  '[憨笑]': '😄',
+  '[大兵]': '💂‍♂️',
+  '[奋斗]': '💪',
+  '[咒骂]': '🖕',
+  '[疑问]': '❓',
+  '[嘘]': '🤫',
+  '[晕]': '😵',
+  '[折磨]': '😖',
+  '[衰]': '😞',
+  '[骷髅]': '💀',
+  '[敲打]': '👊',
+  '[再见]': '👋',
+  '[擦汗]': '😅',
+  '[抠鼻]': '🤭',
+  '[鼓掌]': '👏',
+  '[糗大了]': '😳',
+  '[坏笑]': '😈',
+  '[左哼哼]': '😤',
+  '[右哼哼]': '😤',
+  '[哈欠]': '🥱',
+  '[鄙视]': '😒',
+  '[委屈]': '😞',
+  '[快哭了]': '😢',
+  '[阴险]': '😏',
+  '[亲亲]': '😘',
+  '[吓]': '😱',
+  '[可怜]': '🥺',
+  '[菜刀]': '🔪',
+  '[西瓜]': '🍉',
+  '[啤酒]': '🍺',
+  '[篮球]': '🏀',
+  '[乒乓]': '🏓',
+  '[咖啡]': '☕',
+  '[饭]': '🍚',
+  '[猪头]': '🐷',
+  '[玫瑰]': '🌹',
+  '[凋谢]': '🥀',
+  '[示爱]': '💌',
+  '[爱心]': '❤️',
+  '[心碎]': '💔',
+  '[蛋糕]': '🎂',
+  '[闪电]': '⚡',
+  '[炸弹]': '💣',
+  '[刀]': '🔪',
+  '[足球]': '⚽',
+  '[瓢虫]': '🐞',
+  '[便便]': '💩',
+  '[月亮]': '🌙',
+  '[太阳]': '☀️',
+  '[礼物]': '🎁',
+  '[拥抱]': '🤗',
+  '[强]': '💪',
+  '[弱]': '🙌',
+  '[握手]': '🤝',
+  '[胜利]': '✌️',
+  '[抱拳]': '🙏',
+  '[勾引]': '😉',
+  '[拳头]': '👊',
+  '[差劲]': '👎',
+  '[爱你]': '❤️',
+  '[NO]': '🚫',
+  '[OK]': '👌',
+  '[爱情]': '💕',
+  '[飞吻]': '😘',
+  '[跳跳]': '🤸',
+  '[发抖]': '🤒',
+  '[怄火]': '😡',
+  '[转圈]': '🔄',
+  '[磕头]': '🙏',
+  '[回头]': '↩️',
+  '[跳绳]': '🤾',
+  '[投降]': '🏳️‍🌈',
+  '[激动]': '🤩',
+  '[乱舞]': '💃',
+  '[献吻]': '😘',
+  '[左太极]': '☯️',
+  '[右太极]': '☯️'
+};
+export const emojiName = [
+  '[微笑]',
+  '[撇嘴]',
+  '[色]',
+  '[发呆]',
+  '[得意]',
+  '[流泪]',
+  '[害羞]',
+  '[闭嘴]',
+  '[睡]',
+  '[大哭]',
+  '[尴尬]',
+  '[发怒]',
+  '[调皮]',
+  '[呲牙]',
+  '[惊讶]',
+  '[难过]',
+  '[酷]',
+  '[冷汗]',
+  '[抓狂]',
+  '[吐]',
+  '[偷笑]',
+  '[愉快]',
+  '[白眼]',
+  '[傲慢]',
+  '[饥饿]',
+  '[困]',
+  '[惊恐]',
+  '[流汗]',
+  '[憨笑]',
+  '[大兵]',
+  '[奋斗]',
+  '[咒骂]',
+  '[疑问]',
+  '[嘘]',
+  '[晕]',
+  '[折磨]',
+  '[衰]',
+  '[骷髅]',
+  '[敲打]',
+  '[再见]',
+  '[擦汗]',
+  '[抠鼻]',
+  '[鼓掌]',
+  '[糗大了]',
+  '[坏笑]',
+  '[左哼哼]',
+  '[右哼哼]',
+  '[哈欠]',
+  '[鄙视]',
+  '[委屈]',
+  '[快哭了]',
+  '[阴险]',
+  '[亲亲]',
+  '[吓]',
+  '[可怜]',
+  '[菜刀]',
+  '[西瓜]',
+  '[啤酒]',
+  '[篮球]',
+  '[乒乓]',
+  '[咖啡]',
+  '[饭]',
+  '[猪头]',
+  '[玫瑰]',
+  '[凋谢]',
+  '[示爱]',
+  '[爱心]',
+  '[心碎]',
+  '[蛋糕]',
+  '[闪电]',
+  '[炸弹]',
+  '[刀]',
+  '[足球]',
+  '[瓢虫]',
+  '[便便]',
+  '[月亮]',
+  '[太阳]',
+  '[礼物]',
+  '[拥抱]',
+  '[强]',
+  '[弱]',
+  '[握手]',
+  '[胜利]',
+  '[抱拳]',
+  '[勾引]',
+  '[拳头]',
+  '[差劲]',
+  '[爱你]',
+  '[NO]',
+  '[OK]',
+  '[爱情]',
+  '[飞吻]',
+  '[跳跳]',
+  '[发抖]',
+  '[怄火]',
+  '[转圈]',
+  '[磕头]',
+  '[回头]',
+  '[跳绳]',
+  '[投降]',
+  '[激动]',
+  '[乱舞]',
+  '[献吻]',
+  '[左太极]',
+  '[右太极]'
+]
+/*export const emojiName = [
+  '[龇牙]',
+  '[调皮]',
+  '[流汗]',
+  '[偷笑]',
+  '[再见]',
+  '[敲打]',
+  '[擦汗]',
+  '[猪头]',
+  '[玫瑰]',
+  '[流泪]',
+  '[大哭]',
+  '[嘘]',
+  '[酷]',
+  '[抓狂]',
+  '[委屈]',
+  '[便便]',
+  '[炸弹]',
+  '[菜刀]',
+  '[可爱]',
+  '[色]',
+  '[害羞]',
+  '[得意]',
+  '[吐]',
+  '[微笑]',
+  '[怒]',
+  '[尴尬]',
+  '[惊恐]',
+  '[冷汗]',
+  '[爱心]',
+  '[示爱]',
+  '[白眼]',
+  '[傲慢]',
+  '[难过]',
+  '[惊讶]',
+  '[疑问]',
+  '[困]',
+  '[么么哒]',
+  '[憨笑]',
+  '[爱情]',
+  '[衰]',
+  '[撇嘴]',
+  '[阴险]',
+  '[奋斗]',
+  '[发呆]',
+  '[右哼哼]',
+  '[抱抱]',
+  '[坏笑]',
+  '[飞吻]',
+  '[鄙视]',
+  '[晕]',
+  '[大兵]',
+  '[可怜]',
+  '[强]',
+  '[弱]',
+  '[握手]',
+  '[胜利]',
+  '[抱拳]',
+  '[凋谢]',
+  '[米饭]',
+  '[蛋糕]',
+  '[西瓜]',
+  '[啤酒]',
+  '[瓢虫]',
+  '[勾引]',
+  '[OK]',
+  '[爱你]',
+  '[咖啡]',
+  '[月亮]',
+  '[刀]',
+  '[发抖]',
+  '[差劲]',
+  '[拳头]',
+  '[心碎了]',
+  '[太阳]',
+  '[礼物]',
+  '[皮球]',
+  '[骷髅]',
+  '[挥手]',
+  '[闪电]',
+  '[饥饿]',
+  '[咒骂]',
+  '[折磨]',
+  '[抠鼻]',
+  '[鼓掌]',
+  '[糗大了]',
+  '[左哼哼]',
+  '[打哈欠]',
+  '[快哭了]',
+  '[吓]',
+  '[篮球]',
+  '[乒乓]',
+  '[NO]',
+  '[跳跳]',
+  '[怄火]',
+  '[转圈]',
+  '[磕头]',
+  '[回头]',
+  '[跳绳]',
+  '[激动]',
+  '[街舞]',
+  '[献吻]',
+  '[左太极]',
+  '[右太极]',
+  '[闭嘴]',
+  '[猫咪]',
+  '[红双喜]',
+  '[鞭炮]',
+  '[红灯笼]',
+  '[麻将]',
+  '[麦克风]',
+  '[礼品袋]',
+  '[信封]',
+  '[象棋]',
+  '[彩带]',
+  '[蜡烛]',
+  '[爆筋]',
+  '[棒棒糖]',
+  '[奶瓶]',
+  '[面条]',
+  '[香蕉]',
+  '[飞机]',
+  '[左车头]',
+  '[车厢]',
+  '[右车头]',
+  '[多云]',
+  '[下雨]',
+  '[钞票]',
+  '[熊猫]',
+  '[灯泡]',
+  '[风车]',
+  '[闹钟]',
+  '[雨伞]',
+  '[彩球]',
+  '[钻戒]',
+  '[沙发]',
+  '[纸巾]',
+  '[手枪]',
+  '[青蛙]'
+]*/

+ 24 - 0
src/utils/formatDuration.js

@@ -0,0 +1,24 @@
+/**
+ * 格式化video通话时间
+ * @export
+ * @param {number} int
+ * @returns
+ */
+function formatInt(int) {
+    return int < 10 ? `0${int}` : int
+}
+export function formatDuration(duration) {
+    if (duration < 60) {
+        return `00:00:${formatInt(duration)}`
+    }
+    if (duration < 60 * 60) {
+        const min = parseInt(duration / 60)
+        const sec = duration - min * 60
+        return `00:${formatInt(min)}:${formatInt(sec)}`
+    }
+    const hour = parseInt(duration / (60 * 60))
+    const remainder = duration - hour * (60 * 60)
+    const min = parseInt(remainder / 60)
+    const sec = remainder - min * 60
+    return `${formatInt(hour)}:${formatInt(min)}:${formatInt(sec)}`
+}

+ 39 - 0
src/utils/openIM.js

@@ -0,0 +1,39 @@
+import { getSDK, CbEvents } from '@openim/wasm-client-sdk';
+
+let sdkInstance = null;
+let cbEvents = null;
+
+export function getOpenIM() {
+  if (!sdkInstance) {
+    sdkInstance = getSDK(); // 只调用一次
+    console.log("OpenIM SDK 初始化完成");
+  }
+  return sdkInstance;
+}
+export const getCbEvents = () => {
+  if (!cbEvents) {
+    cbEvents = CbEvents;
+  }
+  return cbEvents;
+};
+
+// 设置全局监听(只需一次)
+/*export const setupListeners = (store) => {
+  if (isListenersSetup) return;
+
+  const im = getOpenIM();
+
+  im.on(CbEvents.OnConnectSuccess, () => {
+    store.commit('im/setReadyState', true);
+    console.log('[OpenIM] 连接成功');
+  });
+
+  im.on(CbEvents.OnConnectFailed, (error) => {
+    store.commit('im/setError', error.message);
+    console.error('[OpenIM] 连接失败:', error);
+  });
+
+  // 其他必要的事件监听...
+  isListenersSetup = true;
+  console.log('[OpenIM] 全局监听器已设置');
+};*/

+ 265 - 0
src/utils/rtc-client.js

@@ -0,0 +1,265 @@
+/* eslint-disable */
+//import TRTC from 'trtc-js-sdk'
+
+class RtcClient {
+  constructor(options) {
+    this.sdkAppId_ = options.sdkAppId;
+    this.userId_ = options.userId;
+    this.userSig_ = options.userSig;
+    this.roomId_ = options.roomId;
+
+    this.isJoined_ = false;
+    this.isPublished_ = false;
+    this.localStream_ = null;
+    this.remoteStreams_ = [];
+    this.ready = false
+
+    // check if browser is compatible with TRTC
+    /*TRTC.checkSystemRequirements().then(result => {
+      if (!result) {
+        alert('Your browser is not compatible with TRTC! Please download Chrome M72+');
+      }
+    });*/
+  }
+
+  async join() {
+    if (this.isJoined_) {
+      console.warn('duplicate RtcClient.join() observed');
+      return;
+    }
+
+    // create a client for RtcClient
+    // this.client_ = TRTC.createClient({
+    //   mode: 'videoCall', // 实时通话模式
+    //   sdkAppId: this.sdkAppId_,
+    //   userId: this.userId_,
+    //   userSig: this.userSig_
+    // });
+
+    // 处理 client 事件
+    this.handleEvents();
+
+    try {
+      // join the room
+      await this.client_.join({ roomId: this.roomId_ });
+      console.log('join room success');
+      this.isJoined_ = true;
+    } catch (error) {
+      window.dispatchEvent(new Event('leave'));
+      console.warn('RtcClient.join join room fail:', JSON.stringify(error))
+      alert('进房失败')
+      return;
+    }
+
+    this.localStream_.on('player-state-changed', event => {
+      console.log(`local stream ${event.type} player is ${event.state}`);
+      if (event.type === 'video' && event.state === 'PLAYING') {
+        // dismiss the remote user UI placeholder
+      } else if (event.type === 'video' && event.state === 'STOPPPED') {
+        // show the remote user UI placeholder
+      }
+    });
+
+    try {
+      this.localStream_.play('local') // 在id为 local 的 div 容器上播放本地音视频
+    } catch (e) {
+    }
+
+    // publish local stream by default after join the room
+    await this.publish();
+    console.log('发布本地流成功!')
+  }
+
+  async leave() {
+    if (!this.isJoined_) {
+      console.warn('leave() - leave without join()d observed');
+      return;
+    }
+
+    if (this.isPublished_) {
+      // ensure the local stream has been unpublished before leaving.
+      await this.unpublish(true);
+    }
+
+    try {
+      // leave the room
+      await this.client_.leave();
+      this.isJoined_ = false;
+    } catch (error) {
+      location.reload();
+    } finally {
+      // 停止本地流,关闭本地流内部的音视频播放器
+        this.localStream_.stop();
+        this.localStream_.close();
+        this.localStream_ = null;
+      // 关闭本地流,释放摄像头和麦克风访问权限
+    }
+  }
+
+  async publish() {
+    if (!this.isJoined_) {
+      console.warn('publish() - please join() firstly');
+      return;
+    }
+    if (this.isPublished_) {
+      console.warn('duplicate RtcClient.publish() observed');
+      return;
+    }
+    try {
+      // 发布本地流
+      await this.client_.publish(this.localStream_);
+      this.isPublished_ = true;
+    } catch (error) {
+      this.isPublished_ = false;
+    }
+  }
+
+  async unpublish(isLeaving) {
+    if (!this.isJoined_) {
+      console.warn('unpublish() - please join() firstly');
+      return;
+    }
+    if (!this.isPublished_) {
+      console.warn('RtcClient.unpublish() called but not published yet');
+      return;
+    }
+
+    try {
+      // 停止发布本地流
+      await this.client_.unpublish(this.localStream_);
+      this.isPublished_ = false;
+    } catch (error) {
+      if (!isLeaving) {
+        console.warn('leaving the room because unpublish failure observed');
+        this.leave();
+      }
+    }
+  }
+
+  muteLocalAudio() {
+    this.localStream_.muteAudio();
+  }
+
+  unmuteLocalAudio() {
+    this.localStream_.unmuteAudio();
+  }
+
+  muteLocalVideo() {
+    this.localStream_.muteVideo();
+  }
+
+  unmuteLocalVideo() {
+    this.localStream_.unmuteVideo();
+  }
+
+  // async createLocalStream(options) {
+  //   this.localStream_ = TRTC.createStream({
+  //     audio: options.audio, // 采集麦克风
+  //     video: options.video, // 采集摄像头
+  //     userId: this.userId_
+  //     // cameraId: getCameraId(),
+  //     // microphoneId: getMicrophoneId()
+  //   });
+  //   // 设置视频分辨率帧率和码率
+  //   this.localStream_.setVideoProfile('480p');
+  //
+  //   await this.localStream_.initialize();
+  // }
+
+  handleEvents() {
+    // 处理 client 错误事件,错误均为不可恢复错误,建议提示用户后刷新页面
+    this.client_.on('error', () => {
+      // alert(err);
+      // location.reload();
+    });
+
+    // 处理用户被踢事件,通常是因为房间内有同名用户引起,这种问题一般是应用层逻辑错误引起的
+    // 应用层请尽量使用不同用户ID进房
+    this.client_.on('client-banned', () => {
+      // location.reload();
+    });
+
+    // 远端用户进房通知 - 仅限主动推流用户
+    this.client_.on('peer-join', evt => {
+      const userId = evt.userId;
+      console.log('peer-join ' + userId);
+    });
+    // 远端用户退房通知 - 仅限主动推流用户
+    this.client_.on('peer-leave', evt => {
+      const userId = evt.userId;
+      window.dispatchEvent(new Event('leave'));
+      console.log('peer-leave ' + userId);
+    });
+
+    // 处理远端流增加事件
+    this.client_.on('stream-added', evt => {
+      const remoteStream = evt.stream;
+      const id = remoteStream.getId();
+      const userId = remoteStream.getUserId();
+      console.log(`remote stream added: [${userId}] ID: ${id} type: ${remoteStream.getType()}`);
+      console.log('subscribe to this remote stream');
+      // 远端流默认已订阅所有音视频,此处可指定只订阅音频或者音视频,不能仅订阅视频。
+      // 如果不想观看该路远端流,可调用 this.client_.unsubscribe(remoteStream) 取消订阅
+      this.client_.subscribe(remoteStream);
+    });
+
+    // 远端流订阅成功事件
+    this.client_.on('stream-subscribed', evt => {
+      const remoteStream = evt.stream;
+      const id = remoteStream.getId();
+      this.remoteStreams_.push(remoteStream);
+      try {
+        document.getElementsByName('remote')[0].setAttribute('id', id);
+        remoteStream.play(id); // 在指定的 div 容器上播放音视频
+      } catch(e) {
+      }
+      console.log('stream-subscribed ID: ', id);
+    });
+
+    // 处理远端流被删除事件
+    this.client_.on('stream-removed', evt => {
+      const remoteStream = evt.stream;
+      const id = remoteStream.getId();
+      // 关闭远端流内部的音视频播放器
+      remoteStream.stop();
+      this.remoteStreams_ = this.remoteStreams_.filter(stream => {
+        return stream.getId() !== id;
+      });
+      console.log(`stream-removed ID: ${id}  type: ${remoteStream.getType()}`);
+    });
+
+    // 处理远端流更新事件,在音视频通话过程中,远端流音频或视频可能会有更新
+    this.client_.on('stream-updated', evt => {
+      const remoteStream = evt.stream;
+      console.log(
+        'type: ' +
+        remoteStream.getType() +
+        ' stream-updated hasAudio: ' +
+        remoteStream.hasAudio() +
+        ' hasVideo: ' +
+        remoteStream.hasVideo()
+      );
+    });
+
+    // 远端流音频或视频mute状态通知
+    this.client_.on('mute-audio', evt => {
+      console.log(evt.userId + ' mute audio');
+    });
+    this.client_.on('unmute-audio', evt => {
+      console.log(evt.userId + ' unmute audio');
+    });
+    this.client_.on('mute-video', evt => {
+      console.log(evt.userId + ' mute video');
+    });
+    this.client_.on('unmute-video', evt => {
+      console.log(evt.userId + ' unmute video');
+    });
+
+    // 信令通道连接状态通知
+    this.client_.on('connection-state-changed', evt => {
+      console.log(`RtcClient state changed to ${evt.state} from ${evt.prevState}`);
+    });
+  }
+}
+
+export default RtcClient

+ 1 - 0
src/utils/testConnection.js

@@ -0,0 +1 @@
+ 

+ 176 - 0
src/utils/trtc.js

@@ -0,0 +1,176 @@
+import {
+  Room,
+  RoomEvent,
+  createLocalTracks,
+  createLocalAudioTrack,
+  createLocalVideoTrack,
+} from 'livekit-client'
+
+console.log("Room 构造函数:", Room)
+
+class Trtc {
+  constructor() {
+    this.room = null
+    this.localTracks = []
+  }
+
+  // 安全 attach 远端轨道
+  attachTrack(track, participant) {
+    const container = document.getElementById('video-' + participant.identity)
+    if (!container) return
+
+    // 已经 attach 过就跳过,避免闪屏
+    if (track.attachedElements.length > 0) return
+
+    const element = track.attach()
+    element.style.width = '100%'
+    element.style.height = '100%'
+    element.style.objectFit = 'cover'
+    container.appendChild(element)
+  }
+
+  async joinRoom(token, url, userId, onRemoteTrack) {
+    try {
+      // 如果已有房间,先退出
+      if (this.room) {
+        await this.leaveRoom()
+      }
+
+      this.room = new Room()
+
+      // 监听远端轨道
+      this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
+        console.log('📡 TrackSubscribed:', track.kind, participant.identity)
+        if (track.kind === 'video' || track.kind === 'audio') {
+          this.attachTrack(track, participant)
+          if (typeof onRemoteTrack === 'function') {
+            onRemoteTrack(track, participant)
+          }
+        }
+      })
+
+      // 检查设备
+      this.localTracks = []
+      if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
+        const devices = await navigator.mediaDevices.enumerateDevices()
+        const hasMic = devices.some(d => d.kind === 'audioinput')
+        const hasCam = devices.some(d => d.kind === 'videoinput')
+
+        if (hasMic || hasCam) {
+          this.localTracks = await createLocalTracks({
+            audio: hasMic,
+            video: hasCam,
+          })
+          console.log('本地轨道列表:', this.localTracks)
+        } else {
+          console.warn('未检测到摄像头或麦克风,将以静默方式加入房间')
+        }
+      } else {
+        console.warn('当前环境不支持 mediaDevices,将以纯接收模式加入房间')
+      }
+
+      console.log('发起连接到 LiveKit ...', url)
+
+      // 监听连接状态
+      const connectedPromise = new Promise((resolve, reject) => {
+        const timeout = setTimeout(() => {
+          reject(new Error('连接 LiveKit 超时'))
+        }, 15000)
+
+        try {
+          this.room.once(RoomEvent.Connected, () => {
+            clearTimeout(timeout)
+            console.log('成功连接到 LiveKit 房间')
+            resolve()
+
+            // 主动挂载已有轨道
+            if (this.room.participants && typeof this.room.participants.values === 'function') {
+              for (const participant of this.room.participants.values()) {
+                participant.tracks.forEach(publication => {
+                  if (publication.isSubscribed && publication.track) {
+                    this.attachTrack(publication.track, participant)
+                    if (typeof onRemoteTrack === 'function') {
+                      onRemoteTrack(publication.track, participant)
+                    }
+                  }
+                })
+              }
+            }
+          })
+
+          this.room.once(RoomEvent.ConnectionError, (err) => {
+            clearTimeout(timeout)
+            console.error('LiveKit 连接错误:', err)
+            reject(err)
+          })
+        } catch (e) {
+          console.error('LiveKit连接失败:', e)
+        }
+      })
+
+      this.room.on(RoomEvent.Disconnected, () => {
+        console.warn('与 LiveKit 断开连接')
+      })
+
+      // 发起连接
+      await this.room.connect(url, token, {
+        autoSubscribe: true,
+      })
+
+      await connectedPromise
+
+      // 发布本地轨道
+      for (const track of this.localTracks) {
+        console.log('正在发布轨道:', track.kind)
+        await this.room.localParticipant.publishTrack(track)
+      }
+
+      return this.room
+    } catch (err) {
+      console.error('joinRoom 出错:', err)
+      throw err
+    }
+  }
+
+  getLocalVideoTrack() {
+    return this.localTracks.find(t => t.kind === 'video') || null
+  }
+
+  getLocalAudioTrack() {
+    return this.localTracks.find(t => t.kind === 'audio') || null
+  }
+
+  async leaveRoom() {
+    if (this.room) {
+      console.log("Trtc 离开房间")
+
+      // 停止并释放本地轨道
+      this.localTracks.forEach(track => {
+        try {
+          track.stop()
+          track.detach()
+        } catch (e) {
+          console.warn("轨道释放失败:", e)
+        }
+      })
+
+      try {
+        await this.room.disconnect()
+      } catch (e) {
+        console.error("房间断开异常:", e)
+      }
+
+      this.room = null
+      this.localTracks = []
+    } else {
+      this.room = null
+      console.warn("离开房间 时 room 为 null")
+    }
+  }
+}
+
+export {
+  createLocalAudioTrack,
+  createLocalVideoTrack,
+}
+export default new Trtc()

+ 18 - 0
src/utils/trtcCustomMessageMap.js

@@ -0,0 +1,18 @@
+export const ACTION = {
+    VIDEO_AUDIO_CALL:1,
+    // VIDEO_CALL_ACTION_ERROR: -2,
+    // VIDEO_CALL_ACTION_UNKNOWN: -1,
+    VIDEO_CALL_ACTION_DIALING: 0, // 正在呼叫
+    VIDEO_CALL_ACTION_SPONSOR_CANCEL: 2, // 发起人取消
+    VIDEO_CALL_ACTION_REJECT: 4, // 拒接电话
+    VIDEO_CALL_ACTION_SPONSOR_TIMEOUT: 5, // 无人接听
+    VIDEO_CALL_ACTION_ACCEPTED: 3, // 连接进入通话
+    VIDEO_CALL_ACTION_HANGUP: 5, // 挂断
+    // VIDEO_CALL_ACTION_LINE_BUSY: 5 // 电话占线
+}
+/**
+ * 1: 仅仅是一个带链接的文本消息
+ * 2: iOS支持的视频通话版本,后续已经不兼容
+ * 3: Android/iOS/Web互通的视频通话版本
+ */
+export const VERSION = 3

+ 639 - 0
src/views/im/index.vue

@@ -0,0 +1,639 @@
+<template>
+  <div  >
+    <div class="container">
+      <div id="wrapper" v-if="!isLogin" >
+        <login />
+      </div>
+      <div
+        class="loading"
+        v-else
+        v-loading="showLoading"
+        element-loading-text="正在拼命初始化..."
+        element-loading-background="rgba(0, 0, 0, 0.8)"
+      >
+        <div class="chat-wrapper">
+          <el-row>
+            <el-col :xs="10" :sm="10" :md="8" :lg="8" :xl="7">
+              <side-bar />
+            </el-col>
+            <el-col :xs="14" :sm="14" :md="16" :lg="16" :xl="17">
+              <current-conversation />
+            </el-col>
+          </el-row>
+
+        </div>
+        <calling  ref="callLayer" class="chat-wrapper"/>
+        <image-previewer />
+        <group-live />
+      </div>
+      <div class="bg"></div>
+    </div>
+  </div>
+
+</template>
+
+<script>
+  import { Notification } from 'element-ui'
+  import { mapState } from 'vuex'
+  import CurrentConversation from '@/components/conversation/current-conversation'
+  import SideBar from '@/components/layout/side-bar'
+  import Login from '@/components/user/login'
+  import ImagePreviewer from '@/components/message/image-previewer.vue'
+  import QrCodeList from '@/components/qr-code-list'
+  import { translateGroupSystemNotice } from '@/utils/common'
+  import GroupLive from '@/components/group-live/index'
+  import Calling from '@/components/message/trtc-calling/calling-index'
+  import { ACTION } from '@/utils/trtcCustomMessageMap'
+  import { getOpenIM,getCbEvents } from '@/utils/openIM';
+  import { accountCheck } from '@/api/company/companyUser';
+  export default {
+    title: 'TIMSDK DEMO',
+    data () {
+      return {
+        loginType: 2, // github 登录只使用默认账号登录
+        OpenIM: null,
+        userToken:""
+      }
+    },
+    components: {
+      Login,
+      SideBar,
+      CurrentConversation,
+      ImagePreviewer,
+      QrCodeList,
+      GroupLive,
+      Calling,
+    },
+
+    computed: {
+      ...mapState({
+        currentUserProfile: state => state.user.currentUserProfile,
+        currentConversation: state => state.conversation.currentConversation,
+        videoCall: state => state.conversation.videoCall,
+        audioCall: state => state.conversation.audioCall,
+        isLogin: state => state.imuser.isLogin,
+        isSDKReady: state => state.imuser.isSDKReady,
+        isBusy: state => state.video.isBusy,
+        userID: state => state.imuser.userID,
+        token: state => state.imuser.token,
+        userSig: state => state.imuser.userSig,
+        sdkAppID: state => state.imuser.sdkAppID
+      }),
+      // 是否显示 Loading 状态
+      showLoading() {
+        return !this.isSDKReady
+      }
+    },
+    created() {
+      this.OpenIM = getOpenIM();
+      this.initListener()
+      //this.getTlsSig();
+    },
+    mounted() {
+      //this.getTlsSig()
+    },
+
+    watch: {
+    },
+
+    methods: {
+      // 修改 getTlsSig 方法
+      getTlsSig() {
+        accountCheck(this.$store.getters.userID).then(response => {
+          this.userToken = response.token
+          console.log("token",this.userToken)
+          const config = {
+            userID: this.$store.getters.userID,
+            token: this.userToken,
+            logLevel:6,
+            platformID: 5, // 使用配置的平台ID
+            apiAddr: 'https://web.im.cdwjyyh.com/api', // API地址
+            wsAddr: 'wss://web.im.cdwjyyh.com/msg_gateway', // WebSocket地址
+            dataDir: '/imdata' // 添加数据存储目录
+          }
+          console.log("登录 config:", config);
+
+          this.OpenIM.login(config).then(() => {
+            this.$nextTick(() => {
+              this.checkSDKReadyState();
+            });
+            this.$store.commit('toggleIsLogin', true);
+            this.$store.commit('startComputeCurrent');
+            this.$store.commit('showMessage', {
+              type: 'success',
+              message: 'IM 登录成功'
+            });
+          })
+            .catch((error) => {
+              this.loading = false;
+              console.error('登录失败:', error);
+              this.$store.commit('showMessage', {
+                message: `IM 登录失败:${error.message || error.errMsg || '未知错误'}`,
+                type: 'error',
+              });
+            });
+
+        });
+      },
+      // 添加SDK就绪状态检查
+      checkSDKReadyState() {
+        if (this.hasBindReadyEvent) return;
+        this.hasBindReadyEvent = true;
+
+        let isReady = false;
+
+        const timeout = setTimeout(() => {
+          if (!isReady) {
+            this.$store.commit('toggleIsSDKReady', false);
+            this.$store.commit('showMessage', {
+              message: 'SDK初始化超时',
+              type: 'error'
+            });
+          }
+        }, 10000);
+
+        this.OpenIM.on(getCbEvents().OnConnectSuccess, () => {
+          clearTimeout(timeout);
+          isReady = true;
+          console.log("this.OpenIM",this.OpenIM)
+          this.OpenIM.getSelfUserInfo().then(({ data }) => {
+            this.$store.commit('updateCurrentUserProfile', data)
+          })
+          this.$store.commit('toggleIsSDKReady', true);
+          this.$store.commit('showMessage', {
+            type: 'success',
+            message: 'SDK 初始化成功'
+          });
+          this.loadUserData();
+        });
+
+        this.OpenIM.on(getCbEvents().OnConnectFailed, (error) => {
+          clearTimeout(timeout);
+          this.$store.commit('toggleIsSDKReady', false);
+          this.$store.commit('showMessage', {
+            message: `SDK 连接失败: ${error.message}`,
+            type: 'error'
+          });
+        });
+      },
+      // 添加加载用户数据方法
+      loadUserData() {
+        //查询会话列表
+        this.OpenIM.getAllConversationList()
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到会话列表",data)
+            this.conversationList= data
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+        //查询好友列表
+        this.OpenIM.getFriendListPage({ offset:0, count:100 })
+          .then(({ data }) => {
+            // 调用成功
+            console.log("获取到好友列表",data)
+            //this.conversationList= data
+            this.$store.commit('updateFriendList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+      },
+
+      selfUpdateHandler({ data }) {
+        this.updateMessageNicknameAndFaceUrl({
+          sendID: data.userID,
+          senderNickname: data.nickname,
+          senderFaceUrl: data.faceURL,
+        });
+        this.updateSelfInfo(data);
+      },
+      // 更新昵称和头像
+      updateMessageNicknameAndFaceUrl({ sendID, senderNickname, senderFaceUrl }) {
+        // 更新消息昵称和头像的逻辑
+        console.log(sendID, senderNickname, senderFaceUrl);
+      },
+
+      // 更新个人信息
+      updateSelfInfo(data) {
+        // 更新个人信息的逻辑
+        console.log(data);
+      },
+      initListener() {
+        // 登录成功后会触发 SDK_READY 事件,该事件触发后,可正常使用 SDK 接口
+        this.OpenIM.on(getCbEvents().OnConnectSuccess, () => {
+          console.log("OnConnectSuccess 事件触发!"); // 调试日志
+          this.$store.commit('toggleIsSDKReady', true);
+        });
+        this.OpenIM.on(getCbEvents().OnConnectFailed, this.onError);
+        this.OpenIM.on(getCbEvents().OnKickedOffline, this.onKickOut);
+        this.OpenIM.on(getCbEvents().OnSelfInfoUpdated, this.onSelfInfoUpdated);
+        this.OpenIM.on(getCbEvents().OnFriendAdded,(data)=>{
+          console.log("新增好友事件触发",data)
+          this.onFriendListUpdated(data);
+        });
+        /*this.OpenIM.on(getCbEvents().OnRecvNewMessage, (message) => {
+          console.log("收到单条消息", message);
+          this.onReceiveMessage({data: [message]}); // 包装成数组形式
+        });*/
+
+        this.OpenIM.on(getCbEvents().OnRecvNewMessages, (data) => {
+          console.log("收到多条消息", data);
+          const msgList = []
+          data.data.forEach(msg =>{
+            if (msg.contentType!==113){
+              msgList.push(msg)
+            }
+          })
+          if (msgList.length>0){
+            this.onReceiveMessage({data: msgList});
+
+          }
+        });
+
+      },
+      onFriendApplicationListUpdated(data) {
+        this.$store.commit('updateApplicationList', data.data.friendApplicationList)
+        this.$store.commit('updateUnreadCount', data.data.unreadCount)
+      },
+      onFriendListUpdated(data) {
+        this.$store.commit('updateFriendList', data.data)
+      },
+      onFriendGroupListUpdated(data) {
+        this.$store.commit('updateFriendGroupList', data.data)
+      },
+
+      onReceiveMessage({ data: messageList }) {
+        // let totalUnreadCount = this.tim.getTotalUnreadMessageCount();
+
+        messageList.forEach(element => {
+          //过滤掉正在输入状态
+          if(element.sendID!=this.$store.getters.userID&&element.contentType!==113){
+            this.$notify({
+              title: '消息提示',
+              message: '您有一条新的消息',
+              type: 'success'
+            });
+          }
+
+        });
+        this.OpenIM.getAllConversationList()
+          .then(({ data }) => {
+            // 调用成功
+            this.$store.commit('updateConversationList', data)
+          })
+          .catch(({ errCode, errMsg }) => {
+            // 调用失败
+          })
+        console.log(messageList)
+        //this.handleVideoMessage(messageList)
+        this.handleQuitGroupTip(messageList)
+        this.handleCloseGroupLive(messageList)
+        this.$store.commit('pushCurrentMessageList', messageList)
+        this.$store.commit('pushAvChatRoomMessageList', messageList)
+      },
+
+      onError({ data }) {
+        if (data.message !== 'Network Error') {
+          this.$store.commit('showMessage', {
+            message: data.message,
+            type: 'error'
+          })
+        }
+      },
+      onMessageReadByPeer() {
+
+      },
+      onReadyStateUpdate({ name }) {
+        console.log("当前登录用户基本信息")
+        const isSDKReady = name === getCbEvents().OnConnectSuccess ? true : false
+        this.$store.commit('toggleIsSDKReady', isSDKReady)
+
+
+        // let totalUnreadCount = this.tim.getTotalUnreadMessageCount();
+        // console.log("收到消息数"+totalUnreadCount)
+
+        if (isSDKReady) {
+          this.OpenIM.getSelfUserInfo().then(({ data }) => {
+            console.log("当前登录用户基本信息",data)
+            this.$store.commit('updateCurrentUserProfile', data)
+          })
+            .catch(error => {
+              this.$store.commit('showMessage', {
+                type: 'error',
+                message: error.message
+              })
+            })
+          /*this.$store.dispatch('getBlacklist')
+          // 登录trtc calling
+          console.log(this.sdkAppID)
+          this.trtcCalling.login({
+            sdkAppID: this.sdkAppID,
+            userID: this.userID,
+            userSig:this.userSig
+          })*/
+        }
+      },
+      kickedOutReason(type) {
+        /*switch (type) {
+          case this.OpenIM.TYPES.KICKED_OUT_MULT_ACCOUNT:
+            return '由于多实例登录'
+          case this.OpenIM.TYPES.KICKED_OUT_MULT_DEVICE:
+            return '由于多设备登录'
+          case this.OpenIM.TYPES.KICKED_OUT_USERSIG_EXPIRED:
+            return '由于 userSig 过期'
+          default:
+            return ''
+        }*/
+      },
+      checkoutNetState(state) {
+        /*switch (state) {
+          case this.OpenIM.TYPES.NET_STATE_CONNECTED:
+            return { message: '已接入网络', type: 'success' }
+          case this.OpenIM.TYPES.NET_STATE_CONNECTING:
+            return { message: '当前网络不稳定', type: 'warning' }
+          case this.OpenIM.TYPES.NET_STATE_DISCONNECTED:
+            return { message: '当前网络不可用', type: 'error' }
+          default:
+            return ''
+        }*/
+      },
+      onNetStateChange(event) {
+        this.$store.commit('showMessage', this.checkoutNetState(event.data.state))
+      },
+
+      onKickOut(event) {
+        this.$alert(`即时通账号在其他设备登录,请重新登录。`, '提示', {
+          confirmButtonText: '确定',
+          type: 'error',
+          callback: () => {
+            // 用户确认后执行退出操作
+            this.$store.commit('toggleIsLogin', false);
+            this.$store.commit('reset');
+          }
+        });
+      },
+      onUpdateConversationList(event) {
+        if(this.isSDKReady){
+          this.$store.commit('updateConversationList', event.data)
+        }
+      },
+
+      onUpdateGroupList(event) {
+        this.$store.commit('updateGroupList', event.data)
+      },
+      onReceiveGroupSystemNotice(event) {
+        const isKickedout = event.data.type === 4
+        const isCurrentConversation =
+          `GROUP${event.data.message.payload.groupProfile.groupID}` ===
+          this.currentConversation.conversationID
+        // 在当前会话被踢,需reset当前会话
+        if (isKickedout && isCurrentConversation) {
+          this.$store.commit('resetCurrentConversation')
+        }
+        Notification({
+          title: '新系统通知',
+          message: translateGroupSystemNotice(event.data.message),
+          duration: 3000,
+          onClick: () => {
+            const SystemConversationID = '@TIM#SYSTEM'
+            this.$store.dispatch('checkoutConversation', SystemConversationID)
+          }
+        })
+      },
+      selectConversation(conversationID) {
+        if (conversationID !== this.currentConversation.conversationID) {
+          this.$store.dispatch('checkoutConversation',conversationID)
+        }
+      },
+      isJsonStr(str) {
+        try{
+          JSON.parse(str)
+          return true
+        }catch {
+          return false
+        }
+      },
+      handleVideoMessage(messageList) {
+        const videoMessageList = messageList.filter(
+          message => message.contentType === 110 && this.isJsonStr(message.payload.data)
+        )
+        if (videoMessageList.length === 0) return
+        const videoPayload = JSON.parse(videoMessageList[0].payload.data)
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_DIALING) {
+          if (this.isBusy) {
+            this.$bus.$emit('busy', videoPayload, videoMessageList[0])
+            return
+          }
+          this.$store.commit('GENERATE_VIDEO_ROOM', videoPayload.room_id)
+          this.selectConversation(videoMessageList[0].conversationID) // 切换当前会话页
+          if (videoMessageList[0].from !== this.userID) {
+            this.$bus.$emit('isCalled')
+          }
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_SPONSOR_CANCEL) {
+          this.$bus.$emit('missCall')
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_REJECT) {
+          this.$bus.$emit('isRefused')
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_SPONSOR_TIMEOUT) {
+          this.$bus.$emit('missCall')
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_ACCEPTED) {
+          this.$bus.$emit('isAccept')
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_HANGUP) {
+          this.$bus.$emit('isHungUp')
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_LINE_BUSY) {
+          this.$bus.$emit('isRefused')
+        }
+        if (videoPayload.action === ACTION.VIDEO_CALL_ACTION_ERROR) {
+          this.$bus.$emit('isRefused')
+        }
+      },
+      /**
+       * 使用 window.Notification 进行全局的系统通知
+       * @param {Message} message
+       */
+      notifyMe(message) {
+        // 需检测浏览器支持和用户授权
+        if (!('Notification' in window)) {
+          return
+        } else if (window.Notification.permission === 'granted') {
+          this.handleNotify(message)
+        } else if (window.Notification.permission !== 'denied') {
+          window.Notification.requestPermission().then(permission => {
+            // 如果用户同意,就可以向他们发送通知
+            if (permission === 'granted') {
+              this.handleNotify(message)
+            }
+          })
+        }
+      },
+      handleNotify(message) {
+        const notification = new window.Notification('有人提到了你', {
+          icon: 'https://web.sdk.qcloud.com/im/assets/images/logo.png',
+          body: message.payload.text
+        })
+        notification.onclick = () => {
+          window.focus()
+          this.$store.dispatch('checkoutConversation', message.conversationID)
+          notification.close()
+        }
+      },
+      /**
+       * 收到有群成员退群/被踢出的groupTip时,需要将相关群成员从当前会话的群成员列表中移除
+       * @param {Message[]} messageList
+       */
+      handleQuitGroupTip(messageList) {
+        // 筛选出当前会话的退群/被踢群的 groupTip
+        const groupTips = messageList.filter(message => {
+          return this.currentConversation.conversationID === message.conversationID &&
+            message.contentType === 1501 &&
+            (message.payload.operationType === 1504 ||
+              message.payload.operationType === 1508)
+        })
+        // 清理当前会话的群成员列表
+        if (groupTips.length > 0) {
+          groupTips.forEach(groupTip => {
+            if (Array.isArray(groupTip.payload.userIDList) || groupTip.payload.userIDList.length > 0) {
+              this.$store.commit('deleteGroupMemberList', groupTip.payload.userIDList)
+            }
+          })
+        }
+      },
+      /**
+       * 收到结束直播自定义消息,派发事件关闭组件
+       * @param {Message[]} messageList
+       */
+      handleCloseGroupLive(messageList) {
+        messageList.forEach(message => {
+          if (this.currentConversation.conversationID === message.conversationID && message.contentType === 110) {
+            let data = {}
+            try {
+              data = JSON.parse(message.payload.data)
+            } catch(e) {
+              data = {}
+            }
+            if (data.roomId && Number(data.roomStatus) === 0) {
+              this.$bus.$emit('close-group-live')
+            }
+          }
+        })
+      },
+    }
+  }
+</script>
+
+<style lang="stylus">
+  body {
+    overflow: hidden;
+    margin: 0;
+    font-family: 'Microsoft YaHei', '微软雅黑', 'MicrosoftJhengHei', 'Lantinghei SC', 'Open Sans', Arial, 'Hiragino Sans GB', 'STHeiti', 'WenQuanYi Micro Hei', SimSun, sans-serif;
+    // font-family  "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Heiti SC", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif
+    // text-shadow: $regular 0 0 0.05em
+    background-color: $bg;
+    -ms-scroll-chaining: chained;
+    -ms-overflow-style: none;
+    -ms-content-zooming: zoom;
+    -ms-scroll-rails: none;
+    -ms-content-zoom-limit-min: 100%;
+    -ms-content-zoom-limit-max: 500%;
+    -ms-scroll-snap-type: proximity;
+    -ms-scroll-snap-points-x: snapList(100%, 200%, 300%, 400%, 500%);
+    -ms-overflow-style: none;
+    overflow: auto;
+
+    div {
+      box-sizing: border-box;
+
+      &::before, &::after {
+        box-sizing: border-box;
+      }
+    }
+  }
+
+  #wrapper {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+    margin-top: 100px;
+  }
+
+
+  .container
+    position relative
+  // height 100vh
+  .bg {
+    position: absolute;
+    // width: 100%;
+    // height: 100%;
+    top: 0;
+    left: 0;
+    z-index: -1;
+    // background: url('~@/./assets/image/bg.jpg') no-repeat 0 0;
+    // background-size: cover;
+    // filter blur(67px)
+  }
+
+  .loading {
+    width: 80%;
+    height: $height;
+    display: flex;
+    justify-content: center;
+    align-items:center;
+  }
+
+  .text-ellipsis {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .chat-wrapper {
+    // margin-top: 8vh;
+    width: 100%;
+    height: 80vh;
+    // max-width: 1280px;
+    box-shadow: 0 11px 20px 0 rgba(0, 0, 0, 0.3);
+
+    .official-link {
+      display: flex;
+      text-decoration: none;
+      color: #38c9ff;
+      width: fit-content;
+      float: right;
+      height: 45px;
+      align-items: center;
+    }
+  }
+
+  /* 设置滚动条的样式 */
+  ::-webkit-scrollbar {
+    width: 12px;
+    height: 13px;
+  }
+
+  /* 滚动槽 */
+  ::-webkit-scrollbar-track {
+    border-radius: 10px;
+  }
+
+  /* 滚动条滑块 */
+  ::-webkit-scrollbar-thumb {
+    border-radius: 10px;
+    background: rgba(81, 80, 80, 0.54)
+  }
+  /deep/ .el-popover {
+    width 800px
+    position fixed
+    left 0
+    right 0
+    margin auto
+  }
+</style>