9.16下午更新 包含im

This commit is contained in:
zoujiandong
2025-09-16 16:19:29 +08:00
parent 215b3298d0
commit b884a8f6fc
127 changed files with 59972 additions and 182 deletions
@@ -0,0 +1,112 @@
<template>
<div
class="p2p-msg-receipt-wrapper"
v-if="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P &&
p2pMsgReceiptVisible
"
>
<div v-if="p2pMsgRotateDeg == 360" class="icon-read-wrapper">
<Icon type="icon-read" :size="16"></Icon>
</div>
<div v-else class="sector">
<span
class="cover-1"
:style="`transform: rotate(${p2pMsgRotateDeg}deg)`"
></span>
<span
:class="p2pMsgRotateDeg >= 180 ? 'cover-2 cover-3' : 'cover-2'"
></span>
</div>
</div>
</template>
<script lang="ts" setup>
/** 会话列表已读未读组件 */
import { computed } from 'vue'
import Icon from '@/components/Icon.vue'
import { V2NIMConst } from '@/utils/im/nim'
import {
V2NIMConversationForUI,
V2NIMLocalConversationForUI,
} from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMConversationType } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMConversationService'
const props = withDefaults(
defineProps<{
conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI
}>(),
{}
)
/** 是否需要显示 p2p 消息、p2p会话列表消息已读未读,默认 false*/
const p2pMsgReceiptVisible = uni.$UIKitStore.localOptions.p2pMsgReceiptVisible
/** 会话类型 */
const conversationType =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationType(
props.conversation.conversationId
) as unknown as V2NIMConversationType
const p2pMsgRotateDeg = computed(() => {
return (props?.conversation?.msgReceiptTime || 0) >=
(props?.conversation?.lastMessage?.messageRefer?.createTime || 0)
? 360
: 0
})
</script>
<style scoped lang="scss">
.p2p-msg-receipt-wrapper {
width: 22px;
height: 22px;
overflow: hidden;
line-height: 18px;
vertical-align: bottom;
display: flex;
align-items: center;
justify-content: center;
}
.icon-read-wrapper {
margin: 0px 3px 0px 0;
width: 18px;
height: 18px;
overflow: hidden;
display: flex;
align-items: center;
}
.sector {
display: inline-block;
position: relative;
overflow: hidden;
border: 2px solid #4c84ff;
width: 12px;
height: 12px;
background-color: #ffffff;
border-radius: 50%;
margin: 0px 3px 0 0;
.cover-1,
.cover-2 {
position: absolute;
top: 0;
width: 50%;
height: 100%;
background-color: #ffffff;
}
.cover-1 {
background-color: #4c84ff;
transform-origin: right;
}
.cover-3 {
right: 0;
background-color: #4c84ff;
}
}
</style>
@@ -0,0 +1,266 @@
<template>
<div>
<div
v-if="
props.lastMessage.lastMessageState ===
V2NIMConst.V2NIMLastMessageState.V2NIM_MESSAGE_STATUS_REVOKE
"
>
{{ t('recall') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_NOTIFICATION
"
>
{{ t('conversationNotificationText') }}
</div>
<div
v-else-if="
props.lastMessage.sendingState ===
V2NIMConst.V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_FAILED
"
>
{{ t('conversationSendFailText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE
"
>
{{ translateMsg('fileMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE
"
>
{{ translateMsg('imgMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM
"
>
{{ props.lastMessage.text || translateMsg('customMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
"
>
{{ translateMsg('audioMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
>
{{ translateMsg('callMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_LOCATION
"
>
{{ translateMsg('geoMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_ROBOT
"
>
{{ translateMsg('robotMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TIPS
"
>
{{ translateMsg('tipMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO
"
>
{{ translateMsg('videoMsgText') }}
</div>
<div
v-else-if="
props.lastMessage.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
class="msg-conversation-text-wrap"
>
<template v-for="item in textArr" :key="item.key">
<template v-if="item.type === 'text'">
<span class="msg-conversation-text">{{ item.value }}</span>
</template>
<template v-else-if="item.type === 'emoji'">
<span
:class="
isWxApp
? 'msg-conversation-text-emoji-wx'
: 'msg-conversation-text-emoji'
"
>
<Icon :type="EMOJI_ICON_MAP_CONFIG[item.value]" :size="16" />
</span>
</template>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
/** 会话列表Item 外漏消息组件 */
import { computed } from 'vue'
import Icon from '@/components/Icon.vue'
import { V2NIMConst } from '@/utils/im/nim'
import { t } from '@/utils/im/i18n'
import { V2NIMLastMessage } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMConversationService'
import { EMOJI_ICON_MAP_CONFIG, emojiRegExp } from '@/utils/im/emoji'
import { isWxApp } from '@/utils/im/index'
const props = withDefaults(
defineProps<{
lastMessage: V2NIMLastMessage
}>(),
{}
)
/** 筛选出文本和表情 */
const parseTextWithEmoji = (text: string) => {
if (!text) return []
const matches: {
type: 'emoji' | 'text'
value: string
index: number
}[] = []
let match
const regexEmoji = emojiRegExp
while ((match = regexEmoji.exec(text)) !== null) {
matches.push({
type: 'emoji',
value: match[0],
index: match.index,
})
const fillText = ' '.repeat(match[0].length)
text = text.replace(match[0], fillText)
}
text = text.replace(regexEmoji, ' ')
if (text) {
text
.split(' ')
.filter((item) => item.trim())
.map((item) => {
const index = text?.indexOf(item)
matches.push({
type: 'text',
value: item,
index,
})
const fillText = ' '.repeat(item.length)
text = text.replace(item, fillText)
})
}
return matches
.sort((a, b) => a.index - b.index)
.map((item, index) => {
return {
...item,
key: index + item.type,
}
})
}
/** 解析的消息数组 */
const textArr = computed(() => {
return parseTextWithEmoji(props.lastMessage.text as string)
})
/** 消息映射 */
const translateMsg = (key: string): string => {
const text =
{
textMsgText: t('textMsgText'),
customMsgText: t('customMsgText'),
audioMsgText: t('audioMsgText'),
videoMsgText: t('videoMsgText'),
fileMsgText: t('fileMsgText'),
callMsgText: t('callMsgText'),
geoMsgText: t('geoMsgText'),
imgMsgText: t('imgMsgText'),
notiMsgText: t('notiMsgText'),
robotMsgText: t('robotMsgText'),
tipMsgText: t('tipMsgText'),
unknowMsgText: t('unknowMsgText'),
}[key] || ''
return `[${text}]`
}
</script>
<style scoped lang="scss">
.wrapper {
flex: 1;
font-size: 13px;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.msg-conversation-text {
font-size: 13px !important;
height: 22px;
line-height: 22px;
width: 100%;
display: inline;
}
.msg-conversation-text-wrap {
width: 80%;
line-height: 22px;
height: 22px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 14px;
overflow: hidden;
}
.msg-conversation-text-emoji {
display: inline-flex;
width: 18px;
height: 18px;
box-sizing: border-box;
align-items: center;
justify-content: center;
}
.msg-conversation-text-emoji-wx {
display: inline-flex;
width: 18px;
height: 18px;
box-sizing: border-box;
align-items: center;
justify-content: center;
position: relative;
bottom: 4px;
}
</style>
@@ -0,0 +1,496 @@
<template>
<view
:class="[
'conversation-item-container',
{
'show-action-list': showMoreActions,
'stick-on-top': conversation.stickTop,
},
]"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@click="handleConversationItemClick()"
>
<view class="conversation-item-content">
<view class="conversation-item-left">
<!-- 会话Item未读数 -->
<view class="unread" v-if="unread">
<view class="dot" v-if="isMute"></view>
<view class="badge" v-else>{{ unread }}</view>
</view>
<!-- 会话头像 -->
<Avatar :account="to" :avatar="teamAvatar" />
<!-- 用户在线离线状态 -->
<view
class="login-state-icon"
v-if="
loginStateVisible &&
!isAiUser &&
conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P &&
status
"
></view>
<view
class="unlogin-state-icon"
v-if="
loginStateVisible &&
!isAiUser &&
conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P &&
!status
"
></view>
</view>
<view class="conversation-item-right">
<!-- 会话名称 -->
<view class="conversation-item-top">
<Appellation
class="conversation-item-title"
v-if="
conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
"
:account="to"
/>
<span v-else class="conversation-item-title">
{{ sessionName }}
</span>
<span class="conversation-item-time">{{ date }}</span>
</view>
<view class="conversation-item-desc">
<!-- 是否有人@ -->
<span v-if="beMentioned" class="beMentioned">
{{ '[' + t('someoneText') + '@' + t('meText') + ']' }}
</span>
<!-- 会话最后一条消息是否已读 -->
<!-- <ConversationItemIsRead
v-if="showConversationUnread"
:conversation="props.conversation"
></ConversationItemIsRead> -->
<!-- 会话最后一条消息外露 -->
<span
v-if="props.conversation.lastMessage"
class="conversation-item-desc-content"
>
<LastMsgContent :lastMessage="props.conversation.lastMessage" />
</span>
<!-- 消息免打扰 -->
<span class="conversation-item-desc-ait">
<Icon
v-if="isMute"
iconClassName="conversation-item-desc-state"
type="icon-xiaoximiandarao"
color="#ccc"
/>
</span>
</view>
</view>
</view>
<!-- 消息右键操作列表 -->
<view class="right-action-list">
<view
v-for="action in moreActions"
:key="action.type"
:class="['right-action-item', action.class]"
@click="() => handleClick(action.type)"
>
{{ action.name }}
</view>
</view>
</view>
</template>
<script lang="ts" setup>
/** 会话列表Item组件 */
import Avatar from '@/components/Avatar.vue'
import Appellation from '@/components/Appellation.vue'
import Icon from '@/components/Icon.vue'
import { computed, onUnmounted, withDefaults } from 'vue'
import dayjs from 'dayjs'
import { t } from '@/utils/im/i18n'
import { V2NIMConst } from '@/utils/im/nim'
import {
V2NIMConversationForUI,
V2NIMLocalConversationForUI,
} from '@xkit-yx/im-store-v2/dist/types/types'
import ConversationItemIsRead from './conversation-item-isRead.vue'
import LastMsgContent from './conversation-item-last-msg-content.vue'
import { ref } from 'vue'
import { autorun } from 'mobx'
const props = withDefaults(
defineProps<{
conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI
showMoreActions?: boolean
}>(),
{ showMoreActions: false }
)
const emit = defineEmits(['click', 'delete', 'stickyToTop', 'leftSlide'])
/** 右滑操作列表 */
const moreActions = computed(() => {
return [
{
name: props.conversation.stickTop
? t('deleteStickTopText')
: t('addStickTopText'),
class: 'action-top',
type: 'action-top',
},
{
name: t('deleteSessionText'),
class: 'action-delete',
type: 'action-delete',
},
]
})
/** 对话方 */
const to = computed(() => {
const res = uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationTargetId(
props.conversation.conversationId
)
return res
})
/** 全局配置的是否需要展示在线离线状态 */
const loginStateVisible = uni.$UIKitStore.localOptions.loginStateVisible
/** 当前会话方在线离线状态 */
const status = ref<boolean>(false)
/** 右滑操作点击 */
const handleClick = (type: string) => {
if (type === 'action-top') {
emit('stickyToTop', props.conversation)
} else {
emit('delete', props.conversation)
}
}
/** 群头像 */
const teamAvatar = computed(() => {
if (
props.conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
) {
const { avatar } = props.conversation
return avatar
}
})
/** 会话昵称 */
const sessionName = computed(() => {
if (props.conversation.name) {
return props.conversation.name
}
return props.conversation.conversationId
})
/** 是否是机器人 */
const isAiUser = ref(false)
/** 时间 */
const date = computed(() => {
const time =
props.conversation.lastMessage?.messageRefer.createTime ||
props.conversation.updateTime
// 如果最后一条消息时间戳不存在,则会话列表不显示
if (!time) {
return ''
}
const _d = dayjs(time)
const isCurrentDay = _d.isSame(dayjs(), 'day')
const isCurrentYear = _d.isSame(dayjs(), 'year')
return _d.format(
isCurrentDay ? 'HH:mm' : isCurrentYear ? 'MM-DD HH:mm' : 'YYYY-MM-DD HH:mm'
)
})
const max = 99
/** 未读数 */
const unread = computed(() => {
return props.conversation.unreadCount > 0
? props.conversation.unreadCount > max
? `${max}+`
: props.conversation.unreadCount + ''
: ''
})
/** 是否免打扰 */
const isMute = computed(() => {
return !!props.conversation.mute
})
/** 是否被@ */
const beMentioned = computed(() => {
return !!props.conversation.aitMsgs?.length
})
/** 是否展示 未读数 */
const showConversationUnread = computed(() => {
const myUserAccountId = uni.$UIKitNIM.V2NIMLoginService.getLoginUser()
if (
props.conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
) {
return (
props?.conversation?.lastMessage?.messageRefer.senderId ===
myUserAccountId &&
props?.conversation?.lastMessage?.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL &&
props?.conversation?.lastMessage?.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_NOTIFICATION &&
props?.conversation?.lastMessage?.sendingState ===
V2NIMConst.V2NIMMessageSendingState
.V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED &&
props?.conversation?.lastMessage?.lastMessageState !==
V2NIMConst.V2NIMLastMessageState.V2NIM_MESSAGE_STATUS_REVOKE
)
} else {
return false
}
})
// 左滑显示 action 动画
let startX = 0,
startY = 0
// 开始左滑
function handleTouchStart(event: TouchEvent) {
startX = event.changedTouches[0].pageX
startY = event.changedTouches[0].pageY
}
// 左滑
function handleTouchMove(event: TouchEvent) {
const moveEndX = event.changedTouches[0].pageX
const moveEndY = event.changedTouches[0].pageY
const X = moveEndX - startX + 20
const Y = moveEndY - startY
if (Math.abs(X) > Math.abs(Y) && X > 0) {
emit('leftSlide', null)
} else if (Math.abs(X) > Math.abs(Y) && X < 0) {
emit('leftSlide', props.conversation)
}
}
/** 会话列表点击事件 */
function handleConversationItemClick() {
if (props.showMoreActions) {
emit('leftSlide', null)
return
}
emit('click', props.conversation)
}
/** 监听是否是Ai 数字人 */
const isAiUserWatch = autorun(() => {
isAiUser.value = uni.$UIKitStore.aiUserStore.isAIUser(to.value)
})
/** 监听会话方在线离线状态 */
const statusWatch = autorun(() => {
if (
props.conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
) {
const stateMap = uni.$UIKitStore?.subscriptionStore.stateMap
if (
stateMap.get(to.value) &&
uni.$UIKitStore.localOptions.loginStateVisible
) {
status.value =
stateMap.get(to.value)?.statusType ===
V2NIMConst.V2NIMUserStatusType.V2NIM_USER_STATUS_TYPE_LOGIN
} else {
status.value = false
}
}
})
onUnmounted(() => {
isAiUserWatch()
statusWatch()
})
</script>
<style lang="scss" scoped>
$cellHeight: 72px;
.conversation-item-container {
position: relative;
transition: transform 0.3s;
&.show-action-list {
transform: translateX(-200px);
}
&.stick-on-top {
background: #f3f5f7;
}
.beMentioned {
color: #ff4d4f;
}
.content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.right-action-list {
position: absolute;
top: 0;
right: -200px;
bottom: 0;
width: 200px;
white-space: nowrap;
.right-action-item {
width: 100px;
display: inline-block;
color: #fff;
text-align: center;
height: $cellHeight;
line-height: $cellHeight;
}
.action-top {
background: #337eff;
}
.action-delete {
background: #a8abb6;
}
}
.conversation-item-content {
display: flex;
align-items: center;
padding: 10px 20px;
height: $cellHeight;
box-sizing: border-box;
}
.conversation-item-left {
position: relative;
.conversation-item-badge {
position: absolute;
top: 0px;
right: 0px;
z-index: 10;
}
}
.conversation-item-right {
flex: 1;
width: 0;
margin-left: 10px;
}
.conversation-item-top {
display: flex;
justify-content: space-between;
align-items: center;
.conversation-item-title {
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
white-space: nowrap; //溢出不换行
}
.conversation-item-time {
font-size: 12px;
color: #cccccc;
text-align: right;
width: 90px;
flex-shrink: 0;
}
}
.conversation-item-desc {
font-size: 13px;
color: #999999;
display: flex;
align-items: center;
justify-content: space-between;
height: 22px;
.conversation-item-desc-content {
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
white-space: nowrap; //溢出不换行
flex: 1;
}
.conversation-item-desc-state {
margin-left: 10px;
}
}
.dot {
background-color: #ff4d4f;
color: #fff;
width: 10px;
height: 10px;
border-radius: 5px;
box-sizing: border-box;
z-index: 99;
}
.badge {
background-color: #ff4d4f;
color: #fff;
font-size: 12px;
min-width: 20px;
height: 20px;
line-height: 19px;
border-radius: 10px;
padding: 0 5px;
box-sizing: border-box;
text-align: center;
z-index: 99;
position: relative;
}
.unread {
position: absolute;
right: -4px;
top: -2px;
z-index: 99;
}
.conversation-item-desc-ait {
display: inline-block;
}
.login-state-icon {
width: 8px;
height: 8px;
box-sizing: content-box;
background-color: #84ed85;
border: 2px solid #fff;
position: absolute;
right: -2px;
bottom: -2px;
border-radius: 50%;
}
.unlogin-state-icon {
width: 8px;
height: 8px;
box-sizing: content-box;
background-color: #d4d9da;
border: 2px solid #fff;
position: absolute;
right: -2px;
bottom: -2px;
border-radius: 50%;
}
</style>
@@ -0,0 +1,570 @@
<template>
<div class="conversation-wrapper">
<div
v-if="addDropdownVisible"
class="dropdown-mark"
@touchstart="hideAddDropdown"
></div>
<div class="navigation-bar">
<div :class="isWxApp ? 'button-box-mp' : 'button-box'">
<!-- #ifdef MP -->
<image
src="https://yx-web-nosdn.netease.im/common/9ae07d276ba2833b678a4077960e2d1e/Group 1899.png"
class="button-icon"
@tap="showAddDropdown"
/>
<!-- #endif -->
<!-- #ifndef MP -->
<div class="button-icon-add" @tap="showAddDropdown">
<Icon type="icon-More" />
</div>
<!-- #endif -->
<div v-if="addDropdownVisible" class="dropdown-container">
<div class="add-menu-list">
<div class="add-menu-item" @tap="onDropdownClick('addFriend')">
<Icon type="icon-tianjiahaoyou" :style="{ marginRight: '5px' }" />
{{ t('addFriendText') }}
</div>
<div class="add-menu-item" @tap="onDropdownClick('createGroup')">
<Icon
type="icon-chuangjianqunzu"
:style="{ marginRight: '5px' }"
/>
{{ t('createTeamText') }}
</div>
<div
class="add-menu-item"
@tap="onDropdownClick('createDiscussion')"
>
<Icon
type="icon-chuangjianqunzu"
:style="{ marginRight: '5px' }"
/>
{{ t('createDiscussionText') }}
</div>
</div>
</div>
</div>
</div>
<div class="block"></div>
<NetworkAlert />
<div v-if="!conversationList || conversationList.length === 0">
<div class="conversation-search" @tap="goToSearchPage">
<div class="search-input-wrapper">
<div class="search-icon-wrapper">
<Icon
iconClassName="search-icon"
:size="16"
color="#A6ADB6"
type="icon-sousuo"
></Icon>
</div>
<div class="search-input">{{ t('searchText') }}</div>
</div>
</div>
<!-- 页面初始化的过程中sessionList编译到小程序和h5出现sessionList为undefined的情况即使给了默认值为空数组故在此处进行判断 -->
<Empty
v-if="!conversationList || conversationList.length === 0"
:text="t('conversationEmptyText')"
/>
</div>
<div v-else class="conversation-list-wrapper">
<div class="security-tip">
<div>
{{ t('securityTipText') }}
</div>
</div>
<div class="conversation-search" @click="goToSearchPage">
<div class="search-input-wrapper">
<div class="search-icon-wrapper">
<Icon
iconClassName="search-icon"
:size="16"
color="#A6ADB6"
type="icon-sousuo"
></Icon>
</div>
<div class="search-input">{{ t('searchText') }}</div>
</div>
</div>
<!-- 此处的key如果用conversationId会在ios上渲染存在问题会出现会话列表显示undefined -->
<div
v-for="conversation in conversationList"
:key="conversation.renderKey"
>
<ConversationItem
:key="conversation.renderKey"
:showMoreActions="
currentMoveSessionId === conversation.conversationId
"
:conversation="conversation"
@delete="handleSessionItemDeleteClick"
@stickyToTop="handleSessionItemStickTopChange"
@click="handleSessionItemClick"
@leftSlide="handleSessionItemLeftSlide"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/** 会话列表主界面 */
import { onUnmounted, ref, watch } from 'vue'
import { autorun } from 'mobx'
import { onShow, onHide } from '@dcloudio/uni-app'
import Icon from '@/components/Icon.vue'
import NetworkAlert from '@/components/NetworkAlert.vue'
import Empty from '@/components/Empty.vue'
import ConversationItem from './conversation-item.vue'
import { setContactTabUnread, setTabUnread } from '@/utils/im/msg'
import { t } from '@/utils/im/i18n'
import { customNavigateTo } from '@/utils/im/customNavigate'
import { V2NIMConst } from '@/utils/im/nim'
import { isWxApp } from '@/utils/im/index'
import { trackInit } from '@/utils/im/reporter'
import {
V2NIMConversationForUI,
V2NIMLocalConversationForUI,
} from '@xkit-yx/im-store-v2/dist/types/types'
/**会话列表 */
const conversationList = ref<
(
| (V2NIMConversationForUI & { renderKey: string })
| (V2NIMLocalConversationForUI & { renderKey: string })
)[]
>([])
/** 右上角更多 */
const addDropdownVisible = ref(false)
/** 当前左滑会话ID */
const currentMoveSessionId = ref('')
/**是否是云端会话 */
const enableV2CloudConversation =
uni.$UIKitStore?.sdkOptions?.enableV2CloudConversation
/** 会话左滑 */
const handleSessionItemLeftSlide = (
conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI | null
) => {
// 微信小程序点击也会触发左滑事件,但此时 conversation 为 null
if (conversation) {
currentMoveSessionId.value = conversation.conversationId
} else {
currentMoveSessionId.value = ''
}
}
let flag = false
// 点击会话
const handleSessionItemClick = async (
conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI
) => {
console.log(conversation)
if (flag) return
currentMoveSessionId.value = ''
try {
flag = true
// 处理@消息相关
if (
conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM ||
conversation.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_SUPER_TEAM
) {
if (enableV2CloudConversation) {
await uni.$UIKitStore.conversationStore?.markConversationReadActive(
conversation.conversationId
)
} else {
await uni.$UIKitStore.localConversationStore?.markConversationReadActive(
conversation.conversationId
)
}
}
await uni.$UIKitStore.uiStore.selectConversation(
conversation.conversationId
)
customNavigateTo({
url: '/pages_chat/chat/index',
})
} catch {
uni.showToast({
title: t('selectSessionFailText'),
icon: 'error',
})
} finally {
flag = false
}
}
// 删除会话
const handleSessionItemDeleteClick = async (
conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI
) => {
try {
if (enableV2CloudConversation) {
await uni.$UIKitStore.conversationStore?.deleteConversationActive(
conversation.conversationId
)
} else {
await uni.$UIKitStore.localConversationStore?.deleteConversationActive(
conversation.conversationId
)
}
currentMoveSessionId.value = ''
} catch {
uni.showToast({
title: t('deleteSessionFailText'),
icon: 'error',
})
}
}
// 置顶会话
const handleSessionItemStickTopChange = async (
conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI
) => {
if (conversation.stickTop) {
try {
if (enableV2CloudConversation) {
await uni.$UIKitStore?.conversationStore?.stickTopConversationActive(
conversation.conversationId,
false
)
} else {
await uni.$UIKitStore?.localConversationStore?.stickTopConversationActive(
conversation.conversationId,
false
)
}
} catch {
uni.showToast({
title: t('deleteStickTopFailText'),
icon: 'error',
})
}
} else {
try {
if (enableV2CloudConversation) {
await uni.$UIKitStore?.conversationStore?.stickTopConversationActive(
conversation.conversationId,
true
)
} else {
await uni.$UIKitStore?.localConversationStore?.stickTopConversationActive(
conversation.conversationId,
true
)
}
} catch {
uni.showToast({
title: t('addStickTopFailText'),
icon: 'error',
})
}
}
}
/** 显示添加好友、群聊 Dropdown */
const showAddDropdown = () => {
addDropdownVisible.value = true
}
/** 隐藏添加好友、群聊 Dropdown */
const hideAddDropdown = () => {
addDropdownVisible.value = false
}
/** 点击Dropdown */
const onDropdownClick = (
urlType: 'addFriend' | 'createGroup' | 'createDiscussion'
) => {
const urlMap = {
// 添加好友
addFriend: '/pages/User/friend/add-friend',
// 创建群聊
createGroup: '/pages/Team/team-create/index',
// 创建讨论组和创建群聊复用一个页面,仅在创建群接口时,群扩展字段添加im_ui_kit_group参数区分,讨论组本质也是群,只是少了群的一些能力,旨在于快速创建讨论
createDiscussion: `/pages/Team/team-create/index?createDiscussion=${true}`,
}
addDropdownVisible.value = false
customNavigateTo({
url: urlMap[urlType],
})
}
/** 跳转至搜索页面 */
const goToSearchPage = () => {
customNavigateTo({
url: '/pages/Conversation/conversation-search/index',
})
}
/** 订阅当前会话方在线离线状态 */
const subscribeUserStatus = (
conversations: (V2NIMConversationForUI | V2NIMLocalConversationForUI)[]
) => {
const loginStateVisible = uni.$UIKitStore.localOptions.loginStateVisible
if (loginStateVisible) {
// 订阅会话列表中 单聊的在线离线状态
const accounts = conversations
.filter(
(item) =>
item.type ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
)
.map((item) => {
return uni.$UIKitNIM?.V2NIMConversationIdUtil.parseConversationTargetId(
item.conversationId
)
})
// 将 accounts 拆分成多个长度不超过 100 的子数组
const chunkSize = 100
const length = accounts.length
for (let i = 0; i < length; i += chunkSize) {
const chunk = accounts.slice(i, i + chunkSize)
if (chunk.length > 0) {
uni.$UIKitStore.subscriptionStore.subscribeUserStatusActive(chunk)
}
}
}
}
trackInit('ConversationUIKit')
/** 监听会话列表数据变更,实时更新 conversationList */
const conversationListWatch = autorun(() => {
const _conversationList = enableV2CloudConversation
? uni.$UIKitStore?.uiStore?.conversations
: uni.$UIKitStore?.uiStore?.localConversations
conversationList.value = _conversationList
?.map(
(conversation: V2NIMConversationForUI | V2NIMLocalConversationForUI) => {
return {
...conversation,
// 为什么要加一个renderKey 直接在渲染的时候写 :key = conversation.conversationId 不就行了吗?
// 如果不加,在渲染的时候,就会出现会话列表显示undefined,uniapp 很奇怪的问题
renderKey: conversation.conversationId,
}
}
)
.sort(
(
a: V2NIMConversationForUI | V2NIMLocalConversationForUI,
b: V2NIMConversationForUI | V2NIMLocalConversationForUI
) => b.sortOrder - a.sortOrder
)
setTabUnread()
})
/** 连接状态监听 断网重连后重新订阅 */
const connectWatch = autorun(() => {
if (
uni.$UIKitStore?.connectStore.loginStatus ===
V2NIMConst.V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED &&
uni.$UIKitStore?.connectStore.connectStatus ===
V2NIMConst.V2NIMConnectStatus.V2NIM_CONNECT_STATUS_CONNECTED
) {
subscribeUserStatus(conversationList?.value)
}
})
/** 监听系统消息未读 */
const getTotalUnreadMsgsCountWatch = autorun(() => {
// 为了监听会触发
uni.$UIKitStore?.sysMsgStore?.getTotalUnreadMsgsCount()
setContactTabUnread()
})
// 监听数组长度变化
watch(
() => conversationList?.value?.length, // 监听 length 属性
() => {
subscribeUserStatus(conversationList?.value)
}
)
// 监听会话列表数据变更,实时订阅在线离线状态
onShow(() => {
if (conversationList.value?.length) {
subscribeUserStatus(conversationList?.value)
}
})
onHide(() => {
addDropdownVisible.value = false
currentMoveSessionId.value = ''
})
/**卸载监听 */
onUnmounted(() => {
conversationListWatch()
getTotalUnreadMsgsCountWatch()
connectWatch()
})
</script>
<style lang="scss" scoped>
@import '@/styles/common.scss';
.conversation-wrapper {
height: 100vh;
overflow: hidden;
}
.navigation-bar {
position: fixed;
height: 60px;
border-bottom: 1rpx solid #e9eff5;
padding: 0 20px;
display: none;
align-items: center;
justify-content: space-between;
padding-top: var(--status-bar-height);
background-color: #fff;
width: 100%;
opacity: 1;
z-index: 999;
}
.conversation-search {
display: none;
align-items: center;
height: 54px;
box-sizing: border-box;
overflow: hidden;
padding: 10px;
}
.security-tip {
padding: 0 10px;
background: #fff5e1;
height: 50px;
width: 100%;
box-sizing: border-box;
font-size: 14px;
text-align: center;
white-space: wrap;
color: #eb9718;
text-align: left;
display: none;
align-items: center;
}
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
height: 34px;
overflow: hidden;
box-sizing: border-box;
padding: 8px 10px;
background: #f3f5f7;
border-radius: 5px;
}
.search-input {
margin-left: 5px;
color: #999999;
font-size: 14px;
}
.search-icon-wrapper {
height: 22px;
display: flex;
align-items: center;
}
.block {
height: 60px;
width: 100%;
display: none;
padding-top: var(--status-bar-height);
}
.conversation-list-wrapper {
height: calc(100% - 60px - var(--status-bar-height));
box-sizing: border-box;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.logo-box {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 500;
.logo-img {
width: 32px;
height: 32px;
margin-right: 10px;
}
}
.button-icon-add {
position: relative;
right: 20px;
}
.dropdown-mark {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.dropdown-container {
position: absolute;
// #ifdef MP
top: -105px;
// #endif
// #ifndef MP
top: 100%;
// #endif
right: 30px;
min-width: 122px;
min-height: 40px;
background-color: #fff;
border: 1px solid #e6e6e6;
box-shadow: 0px 4px 7px rgba(133, 136, 140, 0.25);
border-radius: 8px;
z-index: 99;
}
.add-menu-list {
padding: 15px 10px;
.add-menu-item {
white-space: nowrap;
font-size: 16px;
padding-left: 5px;
margin-bottom: 10px;
height: 30px;
line-height: 30px;
display: flex;
align-items: center;
&:last-child {
margin-bottom: 0;
}
}
}
.conversation-block {
width: 100%;
height: 72px;
}
</style>