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
+329
View File
@@ -0,0 +1,329 @@
<template>
<!-- 处理滚动穿透 此为官方推荐做法 https://uniapp.dcloud.net.cn/component/uniui/uni-popup.html#%E4%BB%8B%E7%BB%8D -->
<page-meta
:page-style="'overflow:' + (moveThrough ? 'hidden' : 'visible')"
></page-meta>
<div>
<NavBar
:title="
forwardConversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? t('teamChooseText')
: t('chooseText')
"
:showLeft="true"
>
<template v-slot:left>
<div @tap="backToChat">
<Icon type="icon-zuojiantou" :size="22"></Icon>
</div>
</template>
</NavBar>
<div
v-if="
forwardConversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
"
>
<div class="group-list-content">
<Empty v-if="teamList.length === 0" :text="t('TeamEmptyText')" />
<div v-else>
<div
class="group-item"
v-for="team in teamList"
:key="team.teamId"
@click="() => handleItemClick(team.teamId)"
>
<Avatar :account="team.teamId" :avatar="team.avatar" />
<span class="group-name">{{ team.name }}</span>
</div>
</div>
</div>
</div>
<div v-else>
<div v-if="friendGroupList.length > 0" class="friend-list-container">
<div class="friend-group-list">
<div
class="friend-group-item"
v-for="friendGroup in friendGroupList"
:key="friendGroup.key"
>
<div class="friend-group-title">
{{ friendGroup.key }}
</div>
<div
class="friend-item"
v-for="friend in friendGroup.data"
:key="friend.account"
@click="() => handleItemClick(friend.account)"
>
<Avatar :account="friend.account" size="36" />
<div class="friend-name">{{ friend.appellation }}</div>
</div>
</div>
</div>
</div>
<Empty v-else :text="t('noFriendText')" />
</div>
<!-- 转发弹窗 -->
<ForwardModal
:forward-modal-visible="forwardModalVisible"
:forward-to="forwardTo"
:forward-msg="forwardMsg"
:forward-conversation-type="forwardConversationType"
:forward-to-team-info="forwardToTeamInfo"
@confirm="handleForwardConfirm"
@cancel="handleForwardCancel"
/>
</div>
</template>
<script lang="ts" setup>
/**消息转发页面 */
import { ref, computed, onUnmounted } from 'vue'
import NavBar from '@/components/NavBar.vue'
import { t } from '@/utils/im/i18n'
import { onLoad } from '@dcloudio/uni-app'
import Avatar from '@/components/Avatar.vue'
import { friendGroupByPy } from '@/utils/im/friend'
import { autorun } from 'mobx'
import Empty from '@/components/Empty.vue'
import Icon from '@/components/Icon.vue'
import ForwardModal from './message/message-forward-modal.vue'
import { V2NIMConst } from 'nim-web-sdk-ng/dist/esm/nim'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMTeam } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMTeamService'
/** 好友列表 */
const friendGroupList = ref<
{ key: string; data: { account: string; appellation: string }[] }[]
>([])
/** 转发类型 */
const forwardConversationType = ref<V2NIMConst.V2NIMConversationType>(
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
)
/** 群列表 */
const teamList = ref<V2NIMTeam[]>([])
/** 会话ID */
const conversationId = uni.$UIKitStore?.uiStore.selectedConversation
/** 转发消息idClient*/
let msgIdClient = ''
/** 转发消息来源 */
let origin = ''
/** 转发相关 */
const forwardModalVisible = ref(false)
/** 转发到 */
const forwardTo = ref('')
/** 转发消息内容 */
const forwardMsg = ref<V2NIMMessageForUI>()
/** 转发到的群信息 */
const forwardToTeamInfo = ref<V2NIMTeam>()
const moveThrough = computed(() => {
return forwardModalVisible.value
})
/**转发消息确认 */
const handleForwardConfirm = (forwardComment: string) => {
forwardModalVisible.value = false
if (!forwardMsg.value) {
uni.showToast({
title: t('getForwardMessageFailed'),
icon: 'error',
})
setTimeout(() => {
backToChat()
}, 1000)
return
}
const forwardConversationId = uni.$UIKitNIM.V2NIMConversationIdUtil[
forwardConversationType.value ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
? 'p2pConversationId'
: 'teamConversationId'
](forwardTo.value)
uni.$UIKitStore.msgStore
.forwardMsgActive(forwardMsg.value, forwardConversationId, forwardComment)
.then(() => {
uni.showToast({
title: t('forwardSuccessText'),
icon: 'none',
duration: 1000,
})
setTimeout(() => {
backToChat()
}, 1000)
})
.catch(() => {
uni.showToast({
title: t('forwardFailedText'),
icon: 'error',
duration: 1000,
})
})
}
/**
* 取消转发弹窗
*/
const handleForwardCancel = () => {
forwardModalVisible.value = false
}
onLoad((props) => {
forwardConversationType.value = Number(props?.forwardConversationType)
msgIdClient = props?.msgIdClient
origin = props?.origin
})
/**群监听 */
const teamListWatch = autorun(() => {
teamList.value = uni.$UIKitStore.uiStore.teamList
})
/**好友监听 */
const friendsWatch = autorun(() => {
const friendsWithoutBlacklist = uni.$UIKitStore.uiStore.friends
.filter(
(item) =>
!uni.$UIKitStore.relationStore.blacklist.includes(item.accountId)
)
.map((item) => ({
account: item.accountId,
appellation: uni.$UIKitStore.uiStore.getAppellation({
account: item.accountId,
teamId: forwardTo.value,
}),
}))
friendGroupList.value = friendGroupByPy(
friendsWithoutBlacklist,
{
firstKey: 'appellation',
},
false
)
})
/**回到聊天 */
const backToChat = () => {
uni.navigateBack({
delta: 1,
})
}
/**点击转发选择列表 */
const handleItemClick = (_forwardTo: string) => {
if (_forwardTo && msgIdClient) {
forwardTo.value = _forwardTo
forwardMsg.value = uni.$UIKitStore.msgStore.getMsg(conversationId, [
msgIdClient,
])?.[0]
if (origin === 'pin') {
const curPinMsgsMap = uni.$UIKitStore.msgStore.pinMsgs.get(conversationId)
//@ts-ignore
const pinInfo = [...curPinMsgsMap.values()].find((pinInfo) => {
if (pinInfo.message) {
return pinInfo.message.messageClientId === msgIdClient
} else {
return false
}
})
if (pinInfo) {
forwardMsg.value = pinInfo.message
}
} else if (origin === 'collection') {
const msg = uni.$UIKitStore.msgStore.collectionMsgs.get(msgIdClient)
if (msg) {
forwardMsg.value = msg
}
}
forwardModalVisible.value = true
if (
forwardConversationType.value ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
) {
forwardToTeamInfo.value = uni.$UIKitStore.teamStore.teams.get(_forwardTo)
}
}
}
onUnmounted(() => {
teamListWatch()
friendsWatch()
})
</script>
<style lang="scss" scoped>
@import '../styles/common.scss';
.nav-bar-text {
color: #337eff;
margin-right: 20px;
}
.group-list-content {
height: calc(100% - 60px - var(--status-bar-height));
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.group-item {
display: flex;
align-items: center;
height: 60px;
padding: 0 20px;
}
.group-name {
margin-left: 10px;
font-size: 16px;
padding-right: 20px;
color: #333333;
flex: 1;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
white-space: nowrap; //溢出不换行
}
.friend-group-item {
padding-left: 20px;
}
.friend-group-title {
height: 40px;
line-height: 40px;
font-size: 14px;
color: #b3b7bc;
border-bottom: 1rpx solid #e1e6e8;
}
.friend-item {
margin-top: 16px;
display: flex;
align-items: center;
.friend-name {
margin-left: 12px;
padding-right: 20px;
font-size: 14px;
color: #333333;
flex: 1;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
white-space: nowrap; //溢出不换行
}
}
</style>
+683
View File
@@ -0,0 +1,683 @@
<template>
<!-- 处理滚动穿透 此为官方推荐做法 https://uniapp.dcloud.net.cn/component/uniui/uni-popup.html#%E4%BB%8B%E7%BB%8D -->
<page-meta
:page-style="'overflow:' + (moveThrough ? 'hidden' : 'visible')"
></page-meta>
<div :class="isH5 ? 'msg-page-wrapper-h5' : 'msg-page-wrapper'">
<navBar :title="title"></navBar>
<!-- <NavBar :title="title" :subTitle="subTitle" :showLeft="true">
<template v-slot:left>
<div @click="backToConversation">
<Icon type="icon-zuojiantou" :size="22"></Icon>
</div>
</template>
</NavBar> -->
<div class="msg-alert">
<NetworkAlert />
</div>
<div :class="isH5 ? 'msg-wrapper-h5' : 'msg-wrapper'">
<MessageList
:conversationType="conversationType"
:to="to"
:msgs="msgs"
:loading-more="loadingMore"
:no-more="noMore"
:reply-msgs-map="replyMsgsMap"
/>
</div>
<div style="height: 'auto'">
<MessageInput
:reply-msgs-map="replyMsgsMap"
:conversation-type="conversationType"
:to="to"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { onShow, onHide } from '@dcloudio/uni-app'
import { events } from '@/utils/im/constants'
import { trackInit } from '@/utils/im/reporter'
import { autorun } from 'mobx'
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { getUniPlatform } from '@/utils/im/index'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { customSwitchTab } from '@/utils/im/customNavigate'
import NetworkAlert from '@/components/NetworkAlert.vue'
//import NavBar from './message/nav-bar.vue'
import navBar from "@/components/navBar/navBar.vue"
import Icon from '@/components/Icon.vue'
import MessageList from './message/message-list.vue'
import MessageInput from './message/message-input.vue'
import { HISTORY_LIMIT } from '@/utils/im/constants'
import { t } from '@/utils/im/i18n'
import { V2NIMMessage } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMMessageService'
import { V2NIMConst } from '@/utils/im/nim'
import { V2NIMConversationType } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMConversationService'
import { V2NIMMessageRefer } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMMessageService'
export interface YxReplyMsg {
messageClientId: string
scene: V2NIMConst.V2NIMConversationType
from: string
receiverId: string
to: string
idServer: string
time: number
}
const fromPage=ref('')
trackInit('ChatUIKit')
const title = ref('')
const subTitle = ref('')
/**会话ID */
const conversationId = uni.$UIKitStore.uiStore.selectedConversation
/**会话类型 */
const conversationType =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationType(
conversationId
) as unknown as V2NIMConversationType
/**对话方 */
const to =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationTargetId(
conversationId
)
const isH5 = getUniPlatform() === 'web'
/**处理uni-popup 引起的滚动穿透 */
const moveThrough = ref(false)
/**回到会话列表 */
const backToConversation = () => {
uni.navigateBack()
}
/**读取是否需要显示群组消息已读未读的全局配置,默认 false */
const teamManagerVisible = uni.$UIKitStore.localOptions.teamMsgReceiptVisible
/**读取是否需要显示 p2p 消息、p2p会话列表消息已读未读的全局配置,默认 false */
const p2pMsgReceiptVisible = uni.$UIKitStore.localOptions.p2pMsgReceiptVisible
/** 读取是否需要显示在线离线的全局配置,默认true*/
const loginStateVisible = uni.$UIKitStore.localOptions.loginStateVisible
let isMounted = false
const loadingMore = ref(false)
/**是否还有更多历史消息 */
const noMore = ref(false)
/**消息列表 */
const msgs = ref<V2NIMMessage[]>([])
/**回复消息map,用于回复消息的解析处理 */
const replyMsgsMap = ref<Record<string, V2NIMMessage>>()
/** 解散群组回调 */
const onTeamDismissed = (data: any) => {
if (data.teamId === to) {
uni.showModal({
content: t('onDismissTeamText'),
showCancel: false,
success(data) {
if (data.confirm) {
backToConversation()
}
},
})
}
}
/** 自己主动离开群组或被管理员踢出回调 */
const onTeamLeft = (data: any) => {
uni
.showToast({
title: t('onRemoveTeamText'),
icon: 'none',
duration: 1000,
})
.then(() => {
backToConversation()
})
}
/** 收到新消息 */
const onReceiveMessages = (msgs: V2NIMMessage[]) => {
const routes = getCurrentPages()
const curRoute = routes[routes.length - 1].route
// 不是当前用户的其他端发送的消息且是当前会话的未读消息,才发送已读回执
if (
msgs.length &&
!msgs[0]?.isSelf &&
msgs[0].conversationId == conversationId &&
curRoute?.includes('Chat/index')
) {
handleMsgReceipt(msgs)
}
uni.$emit(events.ON_SCROLL_BOTTOM, msgs)
}
/** 处理收到消息的已读回执 */
const handleMsgReceipt = (msg: V2NIMMessage[]) => {
if (
msg[0].conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P &&
p2pMsgReceiptVisible
) {
uni.$UIKitStore.msgStore.sendMsgReceiptActive(msg[0])
} else if (
msg[0].conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM &&
teamManagerVisible
) {
uni.$UIKitStore.msgStore.sendTeamMsgReceiptActive(msg)
}
}
/** 处理历史消息的已读未读 */
const handleHistoryMsgReceipt = (msgs: V2NIMMessage[]) => {
/** 如果是单聊 */
if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P &&
p2pMsgReceiptVisible
) {
const myUserAccountId = uni.$UIKitNIM.V2NIMLoginService.getLoginUser()
const othersMsgs = msgs
.filter(
(item: V2NIMMessage) =>
// @ts-ignore
!['beReCallMsg', 'reCallMsg'].includes(item.recallType || '')
)
.filter((item: V2NIMMessage) => item.senderId !== myUserAccountId)
/** 发送单聊消息已读回执 */
if (othersMsgs.length > 0) {
uni.$UIKitStore.msgStore.sendMsgReceiptActive(othersMsgs?.[0])
}
/** 如果是群聊 */
} else if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM &&
teamManagerVisible
) {
const myUserAccountId = uni.$UIKitNIM.V2NIMLoginService.getLoginUser()
const myMsgs = msgs
.filter(
(item: V2NIMMessage) =>
// @ts-ignore
!['beReCallMsg', 'reCallMsg'].includes(item.recallType || '')
)
.filter((item: V2NIMMessage) => item.senderId === myUserAccountId)
uni.$UIKitStore.msgStore.getTeamMsgReadsActive(myMsgs, conversationId)
// 发送群消息已读回执
// sdk 要求 一次最多传入 50 个消息对象
const othersMsgs = msgs
.filter(
(item: V2NIMMessage) =>
// @ts-ignore
!['beReCallMsg', 'reCallMsg'].includes(item.recallType || '')
)
.filter((item: V2NIMMessage) => item.senderId !== myUserAccountId)
if (othersMsgs.length > 0 && othersMsgs.length < 50) {
uni.$UIKitStore.msgStore.sendTeamMsgReceiptActive(othersMsgs)
}
}
}
/** 拉取历史消息 */
const getHistory = async (endTime: number, lastMsgId?: string) => {
try {
if (noMore.value) {
return []
}
if (loadingMore.value) {
return []
}
loadingMore.value = true
if (conversationId) {
const historyMsgs = await uni.$UIKitStore.msgStore.getHistoryMsgActive({
conversationId,
endTime,
lastMsgId,
limit: HISTORY_LIMIT,
})
// 在点击会话时,去获取并更新 pin 和 msg 信息。
await uni.$UIKitStore.msgStore.getPinnedMessageListActive(conversationId)
loadingMore.value = false
if (historyMsgs.length < HISTORY_LIMIT) {
noMore.value = true
}
// 消息已读未读相关
handleHistoryMsgReceipt(historyMsgs)
return historyMsgs
}
} catch (error) {
//@ts-ignore
// 云端会话下,离线状态时,解散群聊,仍然可以拉到会话,但此时群已经解散
if (error.code === 109404) {
uni.showModal({
content: t('onDismissTeamText'),
showCancel: false,
success(data) {
if (data.confirm) {
backToConversation()
}
},
})
}
loadingMore.value = false
throw error
}
}
/** 加载更多消息 */
const loadMoreMsgs = (lastMsg: V2NIMMessage) => {
if (lastMsg) {
getHistory(lastMsg.createTime, lastMsg.messageServerId)
} else {
getHistory(Date.now())
}
}
/** 设置页面标题 */
const setNavTitle = () => {
// 如果是单聊
if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
) {
if (loginStateVisible) {
subTitle.value =
uni.$UIKitStore?.subscriptionStore.stateMap.get(to)?.statusType ===
V2NIMConst.V2NIMUserStatusType.V2NIM_USER_STATUS_TYPE_LOGIN
? `(${t('userOnlineText')})`
: `(${t('userOfflineText')})`
}
console.log('to:'+to);
if(!fromPage.value){
title.value = uni.$UIKitStore.uiStore.getAppellation({ account: to })
}
;
// 如果是群聊
} else if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
) {
const team = uni.$UIKitStore.teamStore.teams.get(to)
subTitle.value = `(${team?.memberCount || 0})`
title.value = team?.name || ''
}
}
/** 监听当前聊天页面的会话类型 */
const conversationTypeWatch = autorun(() => {
setNavTitle()
})
/** 监听连接状态 */
const connectedWatch = autorun(() => {
if (
uni.$UIKitStore.connectStore.connectStatus ===
V2NIMConst.V2NIMConnectStatus.V2NIM_CONNECT_STATUS_CONNECTED
) {
if (
uni.$UIKitStore.connectStore.loginStatus ==
V2NIMConst.V2NIMLoginStatus.V2NIM_LOGIN_STATUS_LOGINED
) {
getHistory(Date.now()).then(() => {
if (!isMounted) {
uni.$emit(events.ON_SCROLL_BOTTOM)
isMounted = true
}
})
}
}
})
/** 处理回复消息 */
const handleReplyMsg = (messages: V2NIMMessage[]) => {
// 遍历所有消息,找出被回复消息,储存在map中
if (messages.length !== 0) {
const replyMsgsMapForExt: any = {}
const replyMsgsMapForThreadReply: any = {}
const extReqMsgs: YxReplyMsg[] = []
const threadReplyReqMsgs: V2NIMMessageRefer[] = []
const messageClientIds: Record<string, string> = {}
msgs.value.forEach((msg) => {
if (msg.serverExtension) {
try {
// yxReplyMsg 存储着被回复消息的相关消息
const { yxReplyMsg } = JSON.parse(msg.serverExtension)
if (yxReplyMsg) {
// 从消息列表中找到被回复消息,replyMsg 为被回复的消息
const replyMsg = msgs.value.find(
(item) => item.messageClientId === yxReplyMsg.idClient
)
// 如果直接找到,存储在map中
if (replyMsg) {
replyMsgsMapForExt[msg.messageClientId] = replyMsg
// 如果没找到,说明被回复的消息可能有三种情况:1.被删除 2.被撤回 3.不在当前消息列表中(一次性没拉到,在之前的消息中)
} else {
replyMsgsMapForExt[msg.messageClientId] = {
messageClientId: 'noFind',
}
const {
scene,
from,
to,
idServer,
messageClientId,
time,
receiverId,
} = yxReplyMsg
if (
scene &&
from &&
to &&
idServer &&
messageClientId &&
time &&
receiverId
) {
extReqMsgs.push({
scene,
from,
to,
idServer,
messageClientId,
time,
receiverId,
})
messageClientIds[idServer] = msg.messageClientId
}
}
}
} catch {}
}
if (msg.threadReply) {
//找到被回复的消息
const beReplyMsg = msgs.value.find(
(item) => item.messageClientId === msg.threadReply?.messageClientId
)
if (beReplyMsg) {
replyMsgsMapForThreadReply[msg.messageClientId] = beReplyMsg
} else {
replyMsgsMapForThreadReply[msg.messageClientId] = {
messageClientId: 'noFind',
}
messageClientIds[msg.threadReply.messageServerId] =
msg.messageClientId
threadReplyReqMsgs.push(msg.threadReply)
}
}
})
if (extReqMsgs.length > 0) {
// 从服务器拉取被回复消息, 但是有频率控制
uni.$UIKitNIM.V2NIMMessageService.getMessageListByRefers(
//@ts-ignore
extReqMsgs.map((item) => ({
senderId: item.from,
receiverId: item.receiverId,
messageClientId: item.messageClientId,
messageServerId: item.idServer,
createTime: item.time,
conversationType: item.scene,
conversationId: item.to,
}))
)
.then((res) => {
if (res?.length > 0) {
res.forEach((item) => {
if (item.messageServerId) {
replyMsgsMapForExt[messageClientIds[item.messageServerId]] =
item
}
})
}
replyMsgsMap.value = { ...replyMsgsMapForExt }
})
.catch(() => {
replyMsgsMap.value = { ...replyMsgsMapForExt }
})
}
replyMsgsMap.value = {
...replyMsgsMap.value,
...replyMsgsMapForThreadReply,
}
if (threadReplyReqMsgs.length > 0) {
uni.$UIKitNIM.V2NIMMessageService.getMessageListByRefers(
//@ts-ignore
threadReplyReqMsgs
)
.then((res) => {
if (res?.length > 0) {
res.forEach((item) => {
if (item.messageServerId) {
replyMsgsMapForThreadReply[
messageClientIds[item.messageServerId]
] = item
}
})
}
replyMsgsMap.value = {
...replyMsgsMap.value,
...replyMsgsMapForThreadReply,
}
})
.catch((error) => {
replyMsgsMap.value = {
...replyMsgsMap.value,
...replyMsgsMapForThreadReply,
}
})
}
}
}
/** 动态更新消息 */
const msgsWatch = autorun(() => {
// 这里需要 Clone,否则 pinState 更新了,对应的消息展示不会重新渲染
const messages = [...uni.$UIKitStore.msgStore.getMsg(conversationId)]
if (messages.length !== 0) {
msgs.value = messages
}
// 处理回复消息
handleReplyMsg(messages)
// 当聊天消息小于6条时,由于页面被键盘撑起,导致已经发出的消息不可见,所以需要隐藏键盘
if (messages.length < 6) {
uni.hideKeyboard()
}
})
/** 监听会话方在线离线状态 */
const statusWatch = autorun(() => {
const stateMap = uni.$UIKitStore?.subscriptionStore.stateMap
if (
uni.$UIKitStore.localOptions.loginStateVisible &&
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
) {
subTitle.value =
stateMap.get(to)?.statusType ===
V2NIMConst.V2NIMUserStatusType.V2NIM_USER_STATUS_TYPE_LOGIN
? `(${t('userOnlineText')})`
: `(${t('userOfflineText')})`
}
})
/** 滚动到底部*/
const scrollToBottom = () => {
const timer = setTimeout(() => {
uni.$emit(events.ON_SCROLL_BOTTOM)
clearTimeout(timer)
}, 300)
}
/** 订阅在线离线状态 */
const subscribeUserStatus = () => {
if (
loginStateVisible &&
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
) {
uni.$UIKitStore.subscriptionStore.subscribeUserStatusActive([to])
}
}
onShow(()=>{
setNavTitle();
//console.log(uni.$UIKitStore);
console.log(3333);
setTimeout(()=>{
console.log(1111);
uni.$UIKitStore?.userStore._getUserInfo(to).then(res=>{
console.log(res)
title.value=res.name;
});
console.log(22222);
})
// 从其他页面返回到聊天页时,可能使用的是 uni.navigateBack,此时不会触发onload等事件,但此时需要将收到的新消息发送已读未读
if (msgs.value.length) {
const _msgs = [...msgs.value].reverse()
handleHistoryMsgReceipt(_msgs)
}
})
onLoad((options) => {
fromPage.value=options.from;
uni.$on(events.HANDLE_MOVE_THROUGH, (flag) => {
moveThrough.value = flag
})
})
onMounted(() => {
setNavTitle()
scrollToBottom()
subscribeUserStatus()
/** 收到消息 */
uni.$UIKitNIM.V2NIMMessageService.on(
'onReceiveMessages',
//@ts-ignore
onReceiveMessages
)
/** 解散群组回调 */
uni.$UIKitNIM.V2NIMTeamService.on('onTeamDismissed', onTeamDismissed)
/** 自己主动离开群组或被管理员踢出回调 */
uni.$UIKitNIM.V2NIMTeamService.on('onTeamLeft', onTeamLeft)
/** 加载更多消息 */
uni.$on(events.GET_HISTORY_MSG, loadMoreMsgs)
})
//卸载相关事件监听
onUnmounted(() => {
uni.$UIKitNIM.V2NIMTeamService.off('onTeamDismissed', onTeamDismissed)
uni.$UIKitNIM.V2NIMTeamService.off('onTeamLeft', onTeamLeft)
uni.$UIKitNIM.V2NIMMessageService.off(
'onReceiveMessages',
//@ts-ignore
onReceiveMessages
)
uni.$off(events.GET_HISTORY_MSG, loadMoreMsgs)
/** 移除store的数据监听 */
connectedWatch()
msgsWatch()
statusWatch()
conversationTypeWatch()
})
onHide(() => {
uni.hideKeyboard()
})
onUnload(() => {
uni.$off(events.CONFIRM_FORWARD_MSG)
uni.$off(events.CANCEL_FORWARD_MSG)
})
</script>
<style scoped lang="scss">
page {
height: 100%;
overflow: hidden;
}
.msg-page-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.msg-page-wrapper-h5 {
width: 100%;
height: 100%;
max-width: 100%;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
}
.msg-alert {
display: flex;
flex-direction: column;
width: 100%;
height: auto;
z-index: 1;
}
.msg-wrapper {
width: 100%;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
flex: 1;
}
.msg-wrapper-h5 {
width: 100%;
height: 100%;
max-width: 100%;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
}
.msg-wrapper > message-list {
height: 100%;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="msg-page-wrapper">
<div class="msg-nav">
<NavBar :title="t('msgReadPageTitleText')" :showLeft="true">
<template v-slot:left>
<div @click="backToConversation">
<Icon type="icon-zuojiantou" :size="22"></Icon>
</div>
</template>
</NavBar>
</div>
<div class="msg-alert">
<NetworkAlert />
</div>
<div class="msg-read-header">
<div
class="msg-read-header-item"
:class="selectedType === 'read' ? 'active' : ''"
@click="selectedType = 'read'"
>
{{ `${t('readText')}(${readCount})` }}
</div>
<div
class="msg-read-header-item"
:class="selectedType === 'unread' ? 'active' : ''"
@click="selectedType = 'unread'"
>
{{ `${t('unreadText')}(${unReadCount})` }}
</div>
</div>
<div v-show="selectedType === 'read'" class="list-wrapper">
<div
v-if="readList.length"
class="list-item"
v-for="item in readList"
:key="item"
>
<div class="avatar-wrapper">
<Avatar
size="40"
:account="item"
:goto-user-card="true"
:teamId="teamId"
:goto-team-card="false"
/>
</div>
<Appellation :account="item" :teamId="teamId"></Appellation>
</div>
<div v-else>
<Empty :text="t('allUnReadText')"></Empty>
</div>
</div>
<div v-show="selectedType === 'unread'" class="list-wrapper">
<div
v-if="unReadList.length"
class="list-item"
v-for="item in unReadList"
:key="item"
>
<div class="avatar-wrapper">
<Avatar
size="40"
:account="item"
:goto-user-card="true"
:teamId="teamId"
:goto-team-card="false"
/>
</div>
<Appellation :account="item" :teamId="teamId"></Appellation>
</div>
<div v-else>
<Empty :text="t('allReadText')"></Empty>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/** 消息已读未读详情页面 */
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { customNavigateTo } from '@/utils/im/customNavigate'
import NetworkAlert from '@/components/NetworkAlert.vue'
import NavBar from './message/nav-bar.vue'
import Icon from '@/components/Icon.vue'
import { t } from '@/utils/im/i18n'
import Avatar from '@/components/Avatar.vue'
import Appellation from '@/components/Appellation.vue'
import Empty from '@/components/Empty.vue'
/** 已读人数 */
const readCount = ref(0)
/** 未读人数 */
const unReadCount = ref(0)
/** 已读列表 */
const readList = ref<string[]>([])
/** 未读列表 */
const unReadList = ref<string[]>([])
/** 已读未读类型 */
const selectedType = ref<string>('read')
/** 群ID */
const teamId = ref<string>('')
/** 返回会话列表 */
const backToConversation = () => {
uni.navigateBack({
delta: 1,
})
}
onLoad((props) => {
const messageClientId = props?.messageClientId
const conversationId = props?.conversationId
if (messageClientId && conversationId) {
teamId.value =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationTargetId(
conversationId
)
const msg = uni.$UIKitStore.msgStore.getMsg(conversationId, [
messageClientId,
])
if (msg.length) {
// 获取当前消息的已读未读详情
uni.$UIKitStore.msgStore
.getTeamMessageReceiptDetailsActive(msg[0])
.then((res) => {
readCount.value = res?.readReceipt.readCount
unReadCount.value = res?.readReceipt.unreadCount
readList.value = res?.readAccountList
setTimeout(() => {
unReadList.value = res?.unreadAccountList
})
})
}
}
})
</script>
<style scoped lang="scss">
.msg-page-wrapper {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
.msg-nav {
flex-basis: 45px;
}
.msg-read-header {
display: flex;
align-items: center;
width: 100%;
height: 40px;
margin-top: 5px;
margin-bottom: 10px;
.msg-read-header-item {
flex: 1;
text-align: center;
line-height: 40px;
}
.active {
border-bottom: 1px solid #007aff;
}
}
.list-wrapper {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
}
.list-item {
height: 50px;
width: 100%;
overflow: hidden;
display: flex;
align-items: center;
padding: 0 20px;
}
.avatar-wrapper {
margin-right: 10px;
}
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="msg-face-wrapper">
<div class="msg-face">
<div class="msg-face-row" v-for="(emojiRow, rowIndex) in emojiMatrix">
<div
@tap.stop="
() => {
handleEmojiClick({ key, type: emojiMap[key] })
}
"
v-for="key in emojiRow"
:key="key"
class="msg-face-item"
>
<Icon :size="27" :type="emojiMap[key]"></Icon>
</div>
<!-- 下面放三个看不到的 Icon 占个位 -->
<Icon
v-if="rowIndex + 1 === Math.ceil(emojiArr.length / emojiColNum)"
class="msg-face-delete"
:size="27"
type="icon-tuigejian"
></Icon>
<Icon
v-if="rowIndex + 1 === Math.ceil(emojiArr.length / emojiColNum)"
class="msg-face-delete"
:size="27"
type="icon-tuigejian"
></Icon>
<Icon
v-if="rowIndex + 1 === Math.ceil(emojiArr.length / emojiColNum)"
class="msg-face-delete"
:size="27"
type="icon-tuigejian"
></Icon>
</div>
</div>
<div class="emoji-block"></div>
<div class="msg-face-control">
<div @tap="handleEmojiDelete" class="msg-delete-btn">
<Icon type="icon-tuigejian" :size="25" :color="'#333'" />
</div>
<div @tap="handleEmojiSend" class="msg-send-btn">{{ t('sendText') }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
/** 表情组件 */
import { emojiMap } from '@/utils/im/emoji'
import { calculateMatrix } from '@/utils/im/matrix'
import Icon from '@/components/Icon.vue'
import { t } from '@/utils/im/i18n'
// 七个一行
const emojiArr = Object.keys(emojiMap)
const emojiColNum = 7
// 处理表情需要网格布局
const emojiMatrix = calculateMatrix(emojiArr, emojiColNum)
const emit = defineEmits(['emojiClick', 'emojiSend', 'emojiDelete'])
// 点击表情
const handleEmojiClick = (emoji: any) => {
emit('emojiClick', emoji)
}
// 删除表情
const handleEmojiDelete = () => {
emit('emojiDelete')
}
// 发送表情
const handleEmojiSend = () => {
emit('emojiSend')
}
</script>
<style scoped lang="scss">
.msg-face-wrapper {
box-sizing: border-box;
}
.msg-face-control {
position: fixed;
bottom: 8px;
right: 10px;
z-index: 8;
}
.emoji-block {
width: 100%;
height: 40px;
background-color: transparent;
}
.msg-face {
display: flex;
flex-direction: column;
padding-bottom: 10px;
// flex-wrap: wrap;
&-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 5px 12px;
&:last-child {
flex-basis: 57.14%;
}
}
&-item {
font-size: 27px;
}
&-delete {
font-size: 27px;
visibility: hidden;
}
}
.msg-face-control {
display: flex;
flex-direction: row;
align-items: center;
}
.msg-send-btn {
padding: 6px 16px;
color: #fff;
background-color: #337eff;
}
.msg-delete-btn {
background-color: #fff;
margin-right: 10px;
padding: 0 16px;
}
</style>
@@ -0,0 +1,293 @@
<template>
<div class="mention-member-list-wrapper">
<div class="header">
<div @tap="onClosePopup" class="close">
<Icon color="#000" type="icon-jiantou" />
</div>
<div class="title">{{ t('chooseMentionText') }}</div>
</div>
<div class="member-list-content">
<div style="display: none">{{ teamExt }}</div>
<div
v-if="allowAtAll"
class="member-item"
@tap="
() =>
handleItemClick({
accountId: AT_ALL_ACCOUNT,
appellation: t('teamAll'),
})
"
>
<Icon :size="42" type="icon-team2" color="#fff" />
<span class="member-name">
{{ t('teamAll') }}
</span>
</div>
<div
class="member-item"
v-for="member in teamMembersWithoutSelf"
:key="member.accountId"
@tap="() => handleItemClick(member)"
>
<Avatar :account="member.accountId" />
<div class="member-name">
<Appellation
:account="member.accountId"
:teamId="member.teamId"
></Appellation>
</div>
<div
v-if="
member.memberRole ===
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_OWNER
"
class="owner"
>
{{ t('teamOwner') }}
</div>
<div
v-else-if="
member.memberRole ===
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_MANAGER
"
class="manager"
>
{{ t('teamManager') }}
</div>
</div>
<div class="member-item-block"></div>
</div>
</div>
</template>
<script lang="ts" setup>
/**@ 列表组件,用于在群里@ 成员列表 */
import { ref, computed, onUnmounted, withDefaults } from 'vue'
import { t } from '@/utils/im/i18n'
import { autorun } from 'mobx'
import Avatar from '@/components/Avatar.vue'
import Icon from '@/components/Icon.vue'
import { ALLOW_AT, AT_ALL_ACCOUNT } from '@/utils/im/constants'
import { events } from '@/utils/im/constants'
import Appellation from '@/components/Appellation.vue'
import {
V2NIMTeam,
V2NIMTeamMember,
} from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMTeamService'
import { V2NIMConst } from '@/utils/im/nim'
import { MentionedMember } from './message-input.vue'
const props = withDefaults(
defineProps<{
teamId: string
}>(),
{}
)
const team = ref<V2NIMTeam>()
const teamMembers = ref<V2NIMTeamMember[]>([])
const teamExt = ref('')
/** 群成员 不包括当前登录用户 */
const teamMembersWithoutSelf = computed(() => {
return teamMembers.value.filter(
(item) => item.accountId !== uni.$UIKitStore.userStore.myUserInfo.accountId
)
})
/** 是否是群主 */
const isGroupOwner = computed(() => {
const myUser = uni.$UIKitStore.userStore.myUserInfo
return (
(team.value ? team.value.ownerAccountId : '') ===
(myUser ? myUser.accountId : '')
)
})
/** 是否是群管理员 */
const isGroupManager = computed(() => {
const myUser = uni.$UIKitStore.userStore.myUserInfo
return teamMembers.value
.filter(
(item) =>
item.memberRole ===
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_MANAGER
)
.some((member) => member.accountId === (myUser ? myUser.accountId : ''))
})
/** 是否允许@ 所有人 */
const allowAtAll = computed(() => {
let ext: any = {}
try {
ext = JSON.parse(teamExt.value || '{}')
} catch (error) {
//
}
if (ext[ALLOW_AT] === 'manager') {
return isGroupOwner.value || isGroupManager.value
}
return true
})
/** 群成员排序 群主 > 管理员 > 成员 */
const sortGroupMembers = (members: V2NIMTeamMember[], teamId: string) => {
const owner = members.filter(
(item) =>
item.memberRole ===
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_OWNER
)
const manager = members
.filter(
(item) =>
item.memberRole ===
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_MANAGER
)
.sort((a, b) => a.joinTime - b.joinTime)
const other = members
.filter(
(item) =>
![
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_OWNER,
V2NIMConst.V2NIMTeamMemberRole.V2NIM_TEAM_MEMBER_ROLE_MANAGER,
].includes(item.memberRole)
)
.sort((a, b) => a.joinTime - b.joinTime)
const result = [...owner, ...manager, ...other].map((item) => {
return {
...item,
name: uni.$UIKitStore.uiStore.getAppellation({
account: item.accountId,
teamId,
}),
}
})
return result
}
/**
* 群成员点击函数
*/
const handleItemClick = (member: V2NIMTeamMember | MentionedMember) => {
const _member: MentionedMember =
member.accountId === AT_ALL_ACCOUNT
? (member as MentionedMember)
: {
accountId: member.accountId,
appellation: uni.$UIKitStore.uiStore.getAppellation({
account: member.accountId,
teamId: (member as V2NIMTeamMember).teamId,
ignoreAlias: true,
}),
}
uni.$emit(events.HANDLE_AIT_MEMBER, _member)
}
const onClosePopup = () => {
uni.$emit(events.CLOSE_AIT_POPUP)
}
/** 监听群成员 */
const teamMemberWatch = autorun(() => {
if (props.teamId) {
teamMembers.value = sortGroupMembers(
//@ts-ignore
uni.$UIKitStore.teamMemberStore.getTeamMember(props.teamId),
props.teamId
)
// @ts-ignore
const _team: V2NIMTeam = uni.$UIKitStore.teamStore.teams.get(props.teamId)
if (team) {
team.value = _team
teamExt.value = _team?.serverExtension || ''
}
}
})
onUnmounted(() => {
/** 移除监听 */
teamMemberWatch()
})
</script>
<style scoped lang="scss">
@import '@/styles/common.scss';
.mention-member-list-wrapper {
z-index: 9999999;
touch-action: none;
}
.title {
text-align: center;
font-weight: 500;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
height: 60px;
}
.close {
transform: rotate(90deg);
margin-left: 15px;
}
.member-list-content {
height: 70vh;
box-sizing: border-box;
overflow-y: auto;
}
.member-item {
display: flex;
align-items: center;
height: 50px;
padding: 8px 20px;
}
.member-item-block {
height: 100px;
}
.member-name {
margin-left: 10px;
font-size: 16px;
padding-right: 20px;
color: #333333;
flex: 1;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
white-space: nowrap; //溢出不换行
}
.contact-item-icon {
height: 42px;
width: 42px;
border-radius: 50%;
text-align: center;
line-height: 39px;
font-size: 20px;
color: #fff;
background-color: #53c3f4;
}
.owner,
.manager {
color: rgb(6, 155, 235);
background-color: rgb(210, 229, 246);
height: 20px;
line-height: 20px;
border-radius: 4px;
font-size: 14px;
text-align: center;
padding: 2px 4px;
position: relative;
right: 10px;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
<template>
<div
:class="!msg.isSelf || mode === 'audio-in' ? 'audio-in' : 'audio-out'"
:style="{ width: audioContainerWidth + 'px' }"
@tap="handlePlayAudio"
>
<div class="audio-dur">{{ duration }}s</div>
<div class="audio-icon-wrapper">
<Icon :size="24" :key="audioIconType" :type="audioIconType" />
</div>
</div>
</template>
<script lang="ts" setup>
/** 音频消息组件 */
import { ref, onUnmounted, computed, watch, withDefaults } from 'vue'
import Icon from '@/components/Icon.vue'
import { events } from '@/utils/im/constants'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMMessageAudioAttachment } from 'nim-web-sdk-ng/dist/v2/NIM_UNIAPP_SDK/V2NIMMessageService'
import { onHide, onUnload } from '@dcloudio/uni-app'
import { isHarmonyOs } from '@/utils/im/index'
const props = withDefaults(
defineProps<{
msg: V2NIMMessageForUI
mode?: 'audio-in' | 'audio-out'
broadcastNewAudioSrc?: string
}>(),
{}
)
const audioIconType = ref('icon-yuyin3')
const animationFlag = ref(false)
const isAudioPlaying = ref<boolean>(false)
const audioMap = new Map<string, any>()
const emits = defineEmits(['getGlobalAudioContext'])
/** 格式化音频时长 */
const formatDuration = (duration: number) => {
return Math.round(duration / 1000) || 1
}
/** 音频消息宽度 */
const audioContainerWidth = computed(() => {
//@ts-ignore
const duration = formatDuration(props.msg.attachment?.duration)
const maxWidth = 180
return 50 + 8 * (duration - 1) > maxWidth ? maxWidth : 50 + 8 * (duration - 1)
})
/** 音频时长 */
const duration = computed(() => {
return formatDuration(
(props.msg.attachment as V2NIMMessageAudioAttachment)?.duration
)
})
/**播放音频 */
const handlePlayAudio = () => {
//@ts-ignore
uni.$emit(events.AUDIO_URL_CHANGE, props.msg?.attachment?.url)
const audioContext = getAudio()
if (!audioContext) {
const globalAudioContext = uni.createInnerAudioContext()
audioMap.set('audio', globalAudioContext)
initAudioSrc()
}
toggleAudioPlayState()
}
/** 监听当前的音频播放 是不是当前点击url,如果不是,就停止 */
watch(
() => props.broadcastNewAudioSrc,
(newSrc: string) => {
//@ts-ignore
if (newSrc !== props.msg?.attachment?.url && isAudioPlaying.value) {
stopAudio()
isAudioPlaying.value = false
}
}
)
/** 播放 */
const toggleAudioPlayState = () => {
if (!isAudioPlaying.value) {
playAudio()
} else {
stopAudio()
}
}
/**停止播放音频 */
const stopAudio = () => {
const audioContext = getAudio()
if (!audioContext) {
return
}
try {
audioContext.stop()
isAudioPlaying.value = false
} catch {
console.log('stop audio error')
}
}
/** 初始化音频实例 */
function initAudioSrc() {
const audioContext = getAudio()
if (!audioContext) {
return
}
//@ts-ignore
audioContext.src = props.msg?.attachment?.url
isAudioPlaying.value = false
audioContext.onPlay(onAudioPlay)
audioContext.onStop(onAudioStop)
audioContext.onEnded(onAudioEnded)
audioContext.onError(onAudioError)
}
/**播放音频 */
function playAudio() {
const audioContext = getAudio()
console.log('audio played', audioContext)
if (!audioContext) {
return
}
try {
audioContext.play()
} catch (error) {
console.log('audio played error', error)
}
}
/** 音频开始播放 */
function onAudioPlay() {
isAudioPlaying.value = true
playAudioAnimation()
}
/** 音频停止播放 */
function onAudioStop() {
animationFlag.value = false
isAudioPlaying.value = false
if (isHarmonyOs) {
const audioContext = getAudio()
audioContext?.destroy?.()
audioMap.delete('audio')
}
}
/** 音频播放结束 */
function onAudioEnded() {
animationFlag.value = false
isAudioPlaying.value = false
}
/** 音频播放失败 */
function onAudioError(error: any) {
animationFlag.value = false
console.warn('audio played error', error)
}
/**获取音频实例 */
const getAudio = () => {
return audioMap.get('audio')
}
/** 播放音频动画 */
const playAudioAnimation = () => {
try {
animationFlag.value = true
let audioIcons = ['icon-yuyin1', 'icon-yuyin2', 'icon-yuyin3']
const handler = () => {
const icon = audioIcons.shift()
if (icon) {
audioIconType.value = icon
if (!audioIcons.length && animationFlag.value) {
audioIcons = ['icon-yuyin1', 'icon-yuyin2', 'icon-yuyin3']
}
if (audioIcons.length) {
setTimeout(handler, 300)
}
}
}
handler()
} catch (error) {
console.log('playAudioAnimation error', error)
}
}
/** 离开当前页面时,停止播放音频 */
const stopAudioOnHide = () => {
const audioContext = getAudio()
if (isAudioPlaying.value) {
stopAudio()
}
audioContext?.destroy?.()
animationFlag.value = false
audioMap.delete('audio')
}
onUnmounted(() => {
stopAudioOnHide()
})
onHide(() => {
stopAudioOnHide()
})
onUnload(() => {
stopAudioOnHide()
})
</script>
<style scoped lang="scss">
.audio-dur {
height: 24px;
line-height: 24px;
}
.audio-in,
.audio-out {
width: 50px;
display: flex;
cursor: pointer;
justify-content: flex-end;
align-items: center;
}
.audio-in {
flex-direction: row-reverse;
.audio-icon-wrapper {
transform: rotate(180deg);
}
}
.audio-icon-wrapper {
height: 24px;
display: flex;
align-items: center;
}
</style>
@@ -0,0 +1,45 @@
<template>
<Avatar
:account="account"
:teamId="teamId"
:size="size"
:gotoUserCard="gotoUserCard"
:onLongpress="handleAitTeamMember"
/>
</template>
<script lang="ts" setup>
import { defineProps } from 'vue'
import { events } from '@/utils/im/constants'
import Avatar from '@/components/Avatar.vue'
const props = defineProps<{
account: string
teamId?: string
avatar?: string
size?: string
gotoUserCard?: boolean
fontSize?: string
}>()
const store = uni.$UIKitStore
const handleAitTeamMember = () => {
const isSelf = props.account === store.userStore.myUserInfo.account
if (props.teamId && props.account && !isSelf) {
uni.$emit(events.AIT_TEAM_MEMBER, {
account: props.account,
appellation: store.uiStore.getAppellation({
account: props.account,
teamId: props.teamId,
ignoreAlias: true,
}),
})
}
}
</script>
<style scoped lang="scss">
</style>
+883
View File
@@ -0,0 +1,883 @@
<template>
<Tooltip
v-if="!props.msg.isSelf"
:placement="placement"
ref="tooltipRef"
color="white"
>
<template #content>
<div class="msg-action-groups" v-if="!isUnknownMsg">
<div
class="msg-action-btn"
v-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
@tap="handleCopy"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-fuzhi1"
></Icon>
<text class="msg-action-btn-text">{{ t('copyText') }}</text>
</div>
<div
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
class="msg-action-btn"
@tap="handleReplyMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-huifu"
></Icon>
<text class="msg-action-btn-text">{{ t('replyText') }}</text>
</div>
<div
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO &&
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
class="msg-action-btn"
@tap="handleForwardMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-zhuanfa"
></Icon>
<text class="msg-action-btn-text">{{ t('forwardText') }}</text>
</div>
<div
class="msg-action-btn"
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
@tap="handlePinMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-pin"
></Icon>
<!-- pinState 为 0 或者 undefined,显示“标记”,其他显示“取消标记” -->
<text class="msg-action-btn-text">{{
props.msg.pinState ? t('unpinText') : t('pinText')
}}</text>
</div>
<div class="msg-action-btn" @tap="handleDeleteMsg">
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-shanchu"
></Icon>
<text class="msg-action-btn-text">{{ t('deleteText') }}</text>
</div>
<div
class="msg-action-btn"
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
@tap="handleCollectionMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-collection"
></Icon>
<text class="msg-action-btn-text">{{ t('collectionText') }}</text>
</div>
</div>
<!-- 未知消息体 -->
<div class="msg-action-groups-unknown" v-else>
<div class="msg-action-btn" @tap="handleDeleteMsg">
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-shanchu"
></Icon>
<text class="msg-action-btn-text">{{ t('deleteText') }}</text>
</div>
</div>
</template>
<div v-if="bgVisible" class="msg-bg msg-bg-in">
<slot></slot>
</div>
<slot v-else></slot>
</Tooltip>
<div
v-else-if="
props.msg.sendingState ===
V2NIMConst.V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SENDING
"
class="msg-status-wrapper"
>
<Icon
:size="21"
color="#337EFF"
class="msg-status-icon icon-loading"
type="icon-a-Frame8"
></Icon>
<Tooltip
:placement="placement"
ref="tooltipRef"
color="white"
:align="props.msg.isSelf"
>
<template #content>
<div class="msg-action-groups">
<div
class="msg-action-btn"
v-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
@tap="handleCopy"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-fuzhi1"
></Icon>
<text class="msg-action-btn-text">{{ t('copyText') }}</text>
</div>
<div class="msg-action-btn" @tap="handleDeleteMsg">
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-shanchu"
></Icon>
<text class="msg-action-btn-text">{{ t('deleteText') }}</text>
</div>
</div>
</template>
<div v-if="bgVisible" class="msg-bg msg-bg-out">
<slot></slot>
</div>
<slot v-else></slot>
</Tooltip>
</div>
<div
v-else-if="
props.msg.sendingState ===
V2NIMConst.V2NIMMessageSendingState
.V2NIM_MESSAGE_SENDING_STATE_FAILED ||
props.msg.messageStatus.errorCode === 102426 ||
props.msg.messageStatus.errorCode === 104404
"
class="msg-failed-wrapper"
>
<div class="msg-failed">
<div class="msg-status-wrapper" @tap="handleResendMsg">
<div class="icon-fail">!</div>
</div>
<Tooltip
:placement="placement"
ref="tooltipRef"
color="white"
:align="props.msg.isSelf"
>
<template #content>
<div
class="msg-action-groups"
:style="{
width:
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
? '112px'
: '56px',
}"
>
<div
class="msg-action-btn"
v-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
@tap="handleCopy"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-fuzhi1"
></Icon>
<text class="msg-action-btn-text">{{ t('copyText') }}</text>
</div>
<div class="msg-action-btn" @tap="handleDeleteMsg">
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-shanchu"
></Icon>
<text class="msg-action-btn-text">{{ t('deleteText') }}</text>
</div>
</div>
</template>
<div v-if="bgVisible" class="msg-bg msg-bg-out">
<slot></slot>
</div>
<slot v-else></slot>
</Tooltip>
</div>
<div
class="in-blacklist"
v-if="props.msg.messageStatus.errorCode === 102426"
>
{{ t('sendFailWithInBlackText') }}
</div>
<div
class="friend-delete"
v-else-if="props.msg.messageStatus.errorCode === 104404"
>
{{ t('sendFailWithDeleteText') }}
<span @tap="addFriend" class="friend-verification">{{
t('friendVerificationText')
}}</span>
</div>
</div>
<Tooltip
v-else-if="tooltipVisible"
:placement="placement"
ref="tooltipRef"
color="white"
:align="props.msg.isSelf"
>
<template #content>
<div class="msg-action-groups" v-if="!isUnknownMsg">
<div
class="msg-action-btn"
v-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
@tap="handleCopy"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-fuzhi1"
></Icon>
<text class="msg-action-btn-text">{{ t('copyText') }}</text>
</div>
<div
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
class="msg-action-btn"
@tap="handleReplyMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-huifu"
></Icon>
<text class="msg-action-btn-text">{{ t('replyText') }}</text>
</div>
<div
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO &&
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
class="msg-action-btn"
@tap="handleForwardMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-zhuanfa"
></Icon>
<text class="msg-action-btn-text">{{ t('forwardText') }}</text>
</div>
<div
class="msg-action-btn"
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
@tap="handlePinMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-pin"
></Icon>
<!-- pinState 为 0 或者 undefined,显示“标记”,其他显示“取消标记” -->
<text class="msg-action-btn-text">{{
props.msg.pinState ? t('unpinText') : t('pinText')
}}</text>
</div>
<div class="msg-action-btn" @tap="handleDeleteMsg">
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-shanchu"
></Icon>
<text class="msg-action-btn-text">{{ t('deleteText') }}</text>
</div>
<div
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
class="msg-action-btn"
@tap="handleRecallMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-chehui"
></Icon>
<text class="msg-action-btn-text">{{ t('recallText') }}</text>
</div>
<div
class="msg-action-btn"
v-if="
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
@tap="handleCollectionMsg"
>
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-collection"
></Icon>
<text class="msg-action-btn-text">{{ t('collectionText') }}</text>
</div>
</div>
<!-- 未知消息体 -->
<div class="msg-action-groups-unknown" v-else>
<div class="msg-action-btn" @tap="handleDeleteMsg">
<Icon
:size="18"
color="#656A72"
class="msg-action-btn-icon"
type="icon-shanchu"
></Icon>
<text class="msg-action-btn-text">{{ t('deleteText') }}</text>
</div>
</div>
</template>
<div v-if="bgVisible" class="msg-bg msg-bg-out">
<slot></slot>
</div>
<slot v-else></slot>
</Tooltip>
<div v-else-if="bgVisible" class="msg-bg msg-bg-out">
<slot></slot>
</div>
<div v-else>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
/** 消息操作组件 */
import { onMounted, onUnmounted, ref } from 'vue'
// @ts-ignore
import Tooltip from '@/components/Tooltip.vue'
import Icon from '@/components/Icon.vue'
import { customNavigateTo } from '@/utils/im/customNavigate'
import { events } from '@/utils/im/constants'
import { autorun } from 'mobx'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
//@ts-ignore
import { V2NIMConst } from '@/utils/im/nim'
import { msgRecallTime } from '@/utils/im/constants'
import { t } from '@/utils/im/i18n'
import { V2NIMMessage } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMMessageService'
const tooltipRef = ref(null)
const props = withDefaults(
defineProps<{
msg: V2NIMMessageForUI
tooltipVisible?: boolean
bgVisible?: boolean
placement?: string
}>(),
{}
)
/**会话类型 */
const conversationType =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationType(
props.msg.conversationId
) as unknown as V2NIMConst.V2NIMConversationType
onMounted(() => {
/** 当前版本仅支持文本、图片、文件、语音、视频 话单消息,其他消息类型统一为未知消息 */
isUnknownMsg.value = !(
props.msg.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT ||
props.msg.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE ||
props.msg.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE ||
props.msg.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO ||
props.msg.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO ||
props.msg.messageType == V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
)
})
/** 是否是好友 */
const isFriend = ref(true)
/** 未知消息 */
const isUnknownMsg = ref(false)
const closeTooltip = () => {
// @ts-ignore
tooltipRef.value.close()
}
/** 复制消息 */
const handleCopy = () => {
uni.setClipboardData({
data: props.msg.text || '',
showToast: false,
success: () => {
uni.showToast({
title: t('copySuccessText'),
icon: 'none',
})
},
fail: () => {
uni.showToast({
title: t('copyFailText'),
icon: 'none',
})
},
complete() {
closeTooltip()
},
})
}
/** 页面滚动到底部 */
const scrollBottom = () => {
setTimeout(() => {
uni.$emit(events.ON_SCROLL_BOTTOM)
}, 100)
}
/** 重发消息 */
const handleResendMsg = async () => {
const _msg = props.msg as V2NIMMessageForUI
uni.$UIKitStore.msgStore.removeMsg(_msg.conversationId, [
_msg.messageClientId,
])
try {
if (_msg.threadReply) {
const beReplyMsg =
await uni.$UIKitNIM.V2NIMMessageService.getMessageListByRefers([
//@ts-ignore
_msg.threadReply,
])
if (beReplyMsg.length > 0) {
//@ts-ignore
uni.$UIKitStore.msgStore.replyMsgActive(beReplyMsg[0])
}
}
uni.$UIKitStore.msgStore.sendMessageActive({
msg: _msg,
conversationId: _msg.conversationId,
progress: () => true,
sendBefore: () => {
scrollBottom()
},
})
scrollBottom()
} catch (error) {
console.log(error)
}
}
/** 转发消息 */
const handleForwardMsg = () => {
uni.showActionSheet({
itemList: [t('forwardToTeamText'), t('forwardToFriendText')],
success(data) {
if (data.tapIndex === 0) {
customNavigateTo({
url: `/pages/Chat/forward?forwardConversationType=${V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM}&msgIdClient=${props.msg.messageClientId}`,
})
} else if (data.tapIndex === 1) {
customNavigateTo({
url: `/pages/Chat/forward?forwardConversationType=${V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P}&msgIdClient=${props.msg.messageClientId}`,
})
}
},
complete() {
closeTooltip()
},
})
}
/** pin消息 */
const handlePinMsg = () => {
const _msg = props.msg
if (_msg.pinState) {
// 取消标记
uni.$UIKitStore.msgStore.unpinMessageActive(_msg).catch((err: any) => {
if (err?.code && typeof t(`${err.code}`) !== 'undefined') {
uni.showToast({
title: t(`${err.code}`),
icon: 'error',
duration: 1000,
})
} else {
uni.showToast({
title: t('unpinFailedText'),
icon: 'error',
duration: 1000,
})
}
})
} else {
/** 显示标记 */
uni.$UIKitStore.msgStore.pinMessageActive(_msg).catch((err: any) => {
if (err?.code && typeof t(`${err.code}`) !== 'undefined') {
uni.showToast({
title: t(`${err.code}`),
icon: 'error',
duration: 1000,
})
} else {
uni.showToast({
title: t('pinFailedText'),
icon: 'error',
duration: 1000,
})
}
})
}
closeTooltip()
}
/**是否是云端会话 */
const enableV2CloudConversation =
uni.$UIKitStore?.sdkOptions?.enableV2CloudConversation
/** 收藏消息 */
const handleCollectionMsg = () => {
const _msg = props.msg
const conversation = enableV2CloudConversation
? uni.$UIKitStore.conversationStore?.conversations.get(_msg.conversationId)
: uni.$UIKitStore.localConversationStore?.conversations.get(
_msg.conversationId
)
const collectionDataObj = {
//@ts-expect-error
message: uni.$UIKitNIM.V2NIMMessageConverter.messageSerialization(_msg), // 序列化
avatar: uni.$UIKitStore.userStore.users.get(_msg.senderId)?.avatar,
conversationName: conversation?.name,
senderName: uni.$UIKitStore.uiStore.getAppellation({
account: _msg.senderId,
teamId:
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? _msg.receiverId
: '',
}),
}
const addCollectionParams = {
collectionType: 1000 + _msg.messageType, // 和移动端对齐
collectionData: JSON.stringify(collectionDataObj),
uniqueId: _msg.messageServerId,
}
uni.$UIKitStore.msgStore
.addCollectionActive(addCollectionParams)
.then(() => {
uni.showToast({
title: t('addCollectionSuccessText'),
icon: 'none',
})
})
.catch((err: any) => {
if (err?.code && typeof t(`${err.code}`) !== 'undefined') {
uni.showToast({
title: t(`${err.code}`),
icon: 'error',
duration: 1000,
})
} else {
uni.showToast({
title: t('addCollectionFailedText'),
icon: 'error',
duration: 1000,
})
}
})
closeTooltip()
}
/** 回复消息 */
const handleReplyMsg = async () => {
const _msg = props.msg
uni.$UIKitStore.msgStore.replyMsgActive(_msg)
closeTooltip()
uni.$emit(events.REPLY_MSG, props.msg)
// 在群里回复其他人的消息,也是@被回复人
if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM &&
!props.msg.isSelf
) {
uni.$emit(events.AIT_TEAM_MEMBER, {
accountId: props.msg.senderId,
appellation: uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.senderId,
teamId: props.msg.receiverId,
ignoreAlias: true,
}),
})
}
}
/** 撤回消息 */
const handleRecallMsg = () => {
const diff = Date.now() - props.msg.createTime
if (diff > msgRecallTime) {
uni.showToast({
title: t('msgRecallTimeErrorText'),
icon: 'none',
})
closeTooltip()
return
}
uni.showModal({
title: t('recallText'),
content: t('recall3'),
showCancel: true,
confirmText: t('recallText'),
confirmColor: '#1861df',
success(data) {
if (data.confirm) {
const _msg = props.msg
uni.$UIKitStore.msgStore.reCallMsgActive(_msg).catch(() => {
uni.showToast({
title: t('recallMsgFailText'),
icon: 'error',
})
})
}
},
complete() {
closeTooltip()
},
})
}
/** 删除消息 */
const handleDeleteMsg = () => {
const _msg = props.msg
uni.showModal({
title: t('deleteText'),
content: t('delete'),
showCancel: true,
confirmText: t('deleteText'),
confirmColor: '#1861df',
success(data) {
if (data.confirm) {
uni.$UIKitStore.msgStore
.deleteMsgActive([_msg])
.then(() => {
uni.showToast({
title: t('deleteMsgSuccessText'),
icon: 'none',
})
})
.catch((error: any) => {
uni.showToast({
title: t('deleteMsgFailText'),
icon: 'error',
})
})
}
},
complete() {
closeTooltip()
},
})
}
/** 添加好友 */
const addFriend = () => {
customNavigateTo({
url: `/pages/User/friend/index?account=${props.msg.receiverId}`,
})
}
/** 监听好友列表 */
const friendsWatch = autorun(() => {
const _isFriend = uni.$UIKitStore.uiStore.friends
.filter(
(item) =>
!uni.$UIKitStore.relationStore.blacklist.includes(item.accountId)
)
.map((item) => item.accountId)
.some((item: any) => item.account === props.msg.receiverId)
isFriend.value = _isFriend
})
/** 卸载监听 */
onUnmounted(() => {
friendsWatch()
})
</script>
<style scoped lang="scss">
@import '@/styles/common.scss';
.msg-bg {
max-width: 360rpx;
overflow: hidden;
padding: 12px 16px;
&-in {
border-radius: 0 8px 8px 8px;
background-color: #e8eaed;
margin-left: 8px;
}
&-out {
border-radius: 8px 0 8px 8px;
background-color: #d6e5f6;
margin-right: 8px;
}
}
.msg-action-groups {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 224px;
width: max-content;
}
.msg-action-btn {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 10px;
width: 56px;
&-icon {
color: #656a72;
font-size: 18px;
}
&-text {
color: #333;
font-size: 14px;
word-break: keep-all;
}
}
.msg-failed-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
.in-blacklist {
color: #b3b7bc;
font-size: 14px;
position: relative;
right: 20%;
margin: 10px 0;
}
.friend-delete {
color: #b3b7bc;
font-size: 14px;
margin: 10px 0;
.friend-verification {
color: #337eff;
font-size: 14px;
}
}
}
.msg-status-wrapper {
// max-width: 450rpx;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
margin-right: 8px;
box-sizing: border-box;
.msg-bg-out {
margin-right: 0;
flex: 1;
}
}
.msg-status-icon {
margin-right: 8px;
font-size: 21px;
position: absolute;
bottom: 2px;
left: -30px;
&.icon-loading {
color: #337eff;
animation: loadingCircle 1s infinite linear;
}
}
.icon-fail {
background: #fc596a;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
margin-right: 5px;
font-size: 15px;
}
.msg-failed {
display: flex;
flex-direction: row;
align-items: center;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<template>
<uni-link
v-if="!isWxApp"
class="msg-file-wrapper"
:href="downloadUrl"
:download="name"
:showUnderLine="false"
>
<div
:class="!msg.isSelf ? 'msg-file msg-file-in' : 'msg-file msg-file-out'"
@click="() => openInBrowser(downloadUrl)"
>
<Icon :type="iconType" :size="32"></Icon>
<div class="msg-file-content">
<div class="msg-file-title">
<div class="msg-file-title-prefix">{{ prefixName }}</div>
<div class="msg-file-title-suffix">{{ suffixName }}</div>
</div>
<div class="msg-file-size">{{ parseFileSize(size) }}</div>
</div>
</div>
</uni-link>
<div v-else @click="mpDownload">
<div
:class="!msg.isSelf ? 'msg-file msg-file-in' : 'msg-file msg-file-out'"
>
<Icon :type="iconType" :size="32"></Icon>
<div class="msg-file-content">
<div class="msg-file-title">
<div class="msg-file-title-prefix">{{ prefixName }}</div>
<div class="msg-file-title-suffix">{{ suffixName }}</div>
<!-- <text class="msg-file-name" v-text="name"></text> -->
</div>
<div class="msg-file-size">{{ parseFileSize(size) }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/** 文件消息组件 */
import { getFileType, parseFileSize } from '@xkit-yx/utils'
import Icon from '@/components/Icon.vue'
// @ts-ignore
import UniLink from '@/components/uni-components/uni-link/components/uni-link/uni-link.vue'
import { isWxApp } from '@/utils/im/index'
import { t } from '@/utils/im/i18n'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMMessageFileAttachment } from 'nim-web-sdk-ng/dist/v2/NIM_UNIAPP_SDK/V2NIMMessageService'
const props = withDefaults(defineProps<{ msg: V2NIMMessageForUI }>(), {})
/** 文件图标映射 */
const fileIconMap = {
pdf: 'icon-PPT',
word: 'icon-Word',
excel: 'icon-Excel',
ppt: 'icon-PPT',
zip: 'icon-RAR1',
txt: 'icon-qita',
img: 'icon-tupian2',
audio: 'icon-yinle',
video: 'icon-shipin',
}
const {
name = '',
url = '',
ext = '',
size = 0,
} = (props.msg.attachment as V2NIMMessageFileAttachment) || {}
//@ts-ignore
const iconType = fileIconMap[getFileType(ext)] || 'icon-weizhiwenjian'
const index = name.lastIndexOf('.') > -1 ? name.lastIndexOf('.') : name.length
/** 文件名前缀 */
const prefixName = name.slice(0, Math.max(index - 5, 0))
/** 文件名后缀 */
const suffixName = name.slice(Math.max(index - 5, 0))
/** 下载地址 */
const downloadUrl =
url + ((url as string).includes('?') ? '&' : '?') + `download=${name}`
/** 小程序不支持直接下载文件,复制链接到剪切板,浏览器打开 */
const mpDownload = () => {
uni.setClipboardData({
data: downloadUrl,
})
uni.showModal({
content: t('wxAppFileCopyText'),
showCancel: false,
})
}
/** 打开浏览器 */
const openInBrowser = (url: string) => {
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.showToast({
title: t('openUrlText'),
icon: 'none',
})
},
})
}
</script>
<style scoped lang="scss">
.msg-file {
height: 56px;
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 15px;
border-radius: 8px;
border: 1px solid #dee0e2;
&-in {
margin-left: 8px;
}
&-out {
margin-right: 8px;
}
&-content {
margin-left: 15px;
max-width: 300rpx;
min-width: 0;
}
&-title {
color: #333;
font-size: 14px;
font-weight: 400;
display: flex;
&-prefix {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-suffix {
white-space: nowrap;
}
}
&-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-size {
color: #999;
font-size: 10px;
margin-top: 4px;
}
}
</style>
@@ -0,0 +1,146 @@
<template>
<Modal
:title="t('sendToText')"
:visible="forwardModalVisible"
:confirmText="t('sendText')"
:cancelText="t('cancelText')"
@cancel="handleCancel"
@confirm="handleConfirm"
>
<div
v-if="
props.forwardConversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
"
class="avatar-wrapper"
>
<Avatar
:account="
(props.forwardToTeamInfo && props.forwardToTeamInfo.teamId) || ''
"
:avatar="props.forwardToTeamInfo && props.forwardToTeamInfo.avatar"
size="36"
/>
<div class="name">
{{ (props.forwardToTeamInfo && props.forwardToTeamInfo.name) || '' }}
</div>
</div>
<div v-else class="avatar-wrapper">
<Avatar :account="forwardTo" size="36" />
<div class="name">
<span>{{ forwardToNick }}</span>
</div>
</div>
<div class="description">
{{ '[' + t('forwardText') + ']' }}
{{ forwardFromNick }}
{{ t('sessionRecordText') }}
</div>
<input
class="forward-input"
@input="handleForwardInputChange"
:placeholder="t('forwardComment')"
/>
</Modal>
</template>
<script lang="ts" setup>
/** 消息转发弹窗组件 */
import { t } from '@/utils/im/i18n'
import { ref, computed } from 'vue'
import Modal from '@/components/Modal.vue'
import Avatar from '@/components/Avatar.vue'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMConst } from '@/utils/im/nim'
interface ForwardToTeamInfo {
teamId: string
name: string
avatar: string
}
const props = withDefaults(
defineProps<{
forwardModalVisible: boolean
forwardTo: string
forwardMsg: V2NIMMessageForUI
forwardConversationType: V2NIMConst.V2NIMConversationType
forwardToTeamInfo?: ForwardToTeamInfo
}>(),
{}
)
const emit = defineEmits(['confirm', 'cancel'])
/** 留言 */
const forwardComment = ref('')
/** 转发弹窗 Input */
const handleForwardInputChange = (event: any) => {
forwardComment.value = event.detail.value
}
/** 取消转发 */
const handleCancel = () => {
emit('cancel')
}
/** 确认转发 */
const handleConfirm = () => {
emit('confirm', forwardComment.value)
}
/** 转发消息的接收方昵称 */
const forwardToNick = computed(() => {
return uni.$UIKitStore.uiStore.getAppellation({
account: props.forwardTo,
})
})
/** 转发消息的发送方昵称 */
const forwardFromNick = computed(() => {
return uni.$UIKitStore.uiStore.getAppellation({
account: props.forwardMsg?.senderId,
})
})
</script>
<style lang="scss" scoped>
.description {
font-size: 14px;
height: 32px;
color: #000000;
background-color: #f2f4f5;
margin: 16px;
padding: 0 16px;
line-height: 32px;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.forward-input {
height: 32px;
border: 1px solid #e1e6e8;
border-radius: 4px;
margin: 10px 16px 0 16px;
padding: 5px 8px;
box-sizing: border-box;
}
.avatar-wrapper {
display: flex;
align-items: center;
height: 36px;
margin: 13px 16px;
.name {
margin-left: 10px;
font-size: 14px;
color: #333333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="g2-message-wrapper" @click="handleCall">
<Icon :type="iconType" :size="28"></Icon>
<div class="g2-message-status">{{ status }}</div>
<div v-if="duration" class="g2-message-duration">{{ duration }}</div>
</div>
</template>
<script lang="ts" setup>
/** 音视频消息组件 */
import Icon from '@/components/Icon.vue'
import { t } from '@/utils/im/i18n'
import { convertSecondsToTime, startCall, isApp } from '@/utils/im/index'
import { g2StatusMap } from '@/utils/im/constants'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
const props = withDefaults(defineProps<{ msg: V2NIMMessageForUI }>(), {})
/** 通话时长 */
const duration = convertSecondsToTime(
//@ts-ignore
props.msg.attachment?.durations[0]?.duration
)
/** 通话状态 */
//@ts-expect-error
const status = g2StatusMap[props.msg.attachment?.status]
const iconType =
//@ts-expect-error
props.msg.attachment?.type == 1 ? 'icon-yuyin8' : 'icon-shipin8'
/** 发起呼叫 */
const handleCall = () => {
if (isApp) {
//@ts-ignore
const callType = props.msg.attachment?.type
const myAccount = uni.$UIKitStore.userStore.myUserInfo.accountId
const isSelfMsg = props.msg.senderId === myAccount
if (isSelfMsg) {
const remoteShowName = uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.receiverId,
})
startCall({
remoteUserAccid: props.msg.receiverId,
currentUserAccid: myAccount,
type: callType,
remoteShowName: remoteShowName,
})
} else {
const remoteShowName = uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.senderId,
})
startCall({
remoteUserAccid: props.msg.senderId,
currentUserAccid: myAccount,
type: callType,
remoteShowName: remoteShowName,
})
}
} else {
uni.showToast({
title: t('callFailedText'),
icon: 'none',
})
}
}
</script>
<style scoped lang="scss">
.g2-message-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.g2-message-status {
margin: 0 7px;
}
</style>
File diff suppressed because it is too large Load Diff
+664
View File
@@ -0,0 +1,664 @@
<template>
<div
:class="`msg-item-wrapper ${
props.msg.pinState &&
!(
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
props.msg.timeValue !== undefined
) &&
!props.msg.recallType
? 'msg-pin'
: ''
}`"
:id="MSG_ID_FLAG + props.msg.messageClientId"
:key="props.msg.createTime"
>
<!-- 消息时间 -->
<div
class="msg-time"
v-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
props.msg.timeValue !== undefined
"
>
{{ props.msg.timeValue }}
</div>
<!-- 撤回消息 可重新编辑 -->
<div
class="msg-common"
:style="{
flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse',
}"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
props.msg.recallType === 'reCallMsg' &&
props.msg.canEdit
"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<MessageBubble :msg="props.msg" :bg-visible="true">
{{ t('recall2') }}
<text
class="msg-recall-btn"
@tap="
() => {
handleReeditMsg(props.msg)
}
"
>
{{ t('reeditText') }}
</text>
</MessageBubble>
</div>
<!-- 撤回消息 不可重新编辑 主动撤回 -->
<div
class="msg-common"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
props.msg.recallType === 'reCallMsg' &&
!props.msg.canEdit
"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<MessageBubble :msg="props.msg" :bg-visible="true">
<div class="recall-text">{{ t('you') + t('recall') }}</div>
</MessageBubble>
</div>
<!-- 撤回消息 对方撤回-->
<div
class="msg-common"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
props.msg.recallType === 'beReCallMsg'
"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<div :class="props.msg.isSelf ? 'self-msg-recall' : 'msg-recall'">
<text class="msg-recall2">
{{ !props.msg.isSelf ? t('recall2') : `${t('you') + t('recall')}` }}
</text>
</div>
</div>
</div>
<!-- 文本消息-->
<div
class="msg-common"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="true"
>
<ReplyMessage v-if="!!replyMsg" :replyMsg="replyMsg"></ReplyMessage>
<MessageText :msg="props.msg"></MessageText>
</MessageBubble>
</div>
<MessageIsRead v-if="props.msg?.isSelf" :msg="props.msg"></MessageIsRead>
</div>
<!-- 图片消息-->
<div
class="msg-common"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE
"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="true"
style="cursor: pointer"
>
<div
@tap="
() => {
//@ts-ignore
handleImageTouch(props.msg.attachment?.url)
}
"
>
<image
class="msg-image"
:lazy-load="true"
mode="aspectFill"
:src="imageUrl"
></image>
</div>
</MessageBubble>
</div>
<MessageIsRead v-if="props.msg?.isSelf" :msg="props.msg"></MessageIsRead>
</div>
<!-- 视频消息-->
<div
class="msg-common"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO
"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="true"
style="cursor: pointer"
>
<div
class="video-msg-wrapper"
@tap="() => handleVideoTouch(props.msg)"
>
<div class="video-play-button">
<div class="video-play-icon"></div>
</div>
<image
class="msg-image"
:lazy-load="true"
mode="aspectFill"
:src="videoFirstFrameDataUrl"
></image>
</div>
</MessageBubble>
</div>
<MessageIsRead v-if="props.msg?.isSelf" :msg="props.msg"></MessageIsRead>
</div>
<!-- 音视频消息-->
<div
class="msg-common"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CALL
"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="true"
>
<MessageG2 :msg="props.msg" />
</MessageBubble>
</div>
</div>
<!-- 文件消息-->
<div
class="msg-common"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE
"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="false"
>
<MessageFile :msg="props.msg" />
</MessageBubble>
</div>
<MessageIsRead v-if="props.msg?.isSelf" :msg="props.msg"></MessageIsRead>
</div>
<!-- 语音消息-->
<div
class="msg-common"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
"
:style="{
flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse',
}"
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="true"
style="cursor: pointer"
>
<MessageAudio
:msg="props.msg"
:broadcastNewAudioSrc="broadcastNewAudioSrc"
/>
</MessageBubble>
</div>
<!-- <MessageIsRead v-if="props.msg?.isSelf" :msg="props.msg"></MessageIsRead> -->
</div>
<!-- 通知消息-->
<MessageNotification
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_NOTIFICATION
"
:msg="props.msg"
/>
<div
class="msg-common"
:style="{ flexDirection: !props.msg.isSelf ? 'row' : 'row-reverse' }"
v-else
>
<Avatar
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
:goto-user-card="true"
/>
<div class="msg-content">
<div class="msg-name" v-if="!props.msg.isSelf">
{{ appellation }}
</div>
<MessageBubble
:msg="props.msg"
:tooltip-visible="true"
:bg-visible="true"
>
[{{ t('unknowMsgText') }}]
</MessageBubble>
</div>
</div>
<!-- 消息标记 不展示 pinState 为 0 、时间消息以及撤回消息的标记样式 -->
<div
class="msg-pin-tip"
v-if="
props.msg.pinState &&
!(
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
props.msg.timeValue !== undefined
) &&
!props.msg.recallType
"
:style="{ justifyContent: !props.msg.isSelf ? 'flex-start' : 'flex-end' }"
>
<Icon :size="11" type="icon-green-pin"></Icon>&nbsp;<span
v-if="props.msg.operatorId === accountId"
>{{ `${t('you')}` }}</span
>
<Appellation
v-else
:account="props.msg.operatorId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
color="#3EAF96"
fontSize="11"
></Appellation
>&nbsp;{{ `${t('pinThisText')}` }}
</div>
</div>
</template>
<script lang="ts" setup>
/** 消息组件 */
import { ref, computed, onUnmounted } from 'vue'
import Avatar from '@/components/Avatar.vue'
import MessageBubble from './message-bubble.vue'
import ReplyMessage from './message-reply.vue'
import MessageFile from './message-file.vue'
import MessageText from './message-text.vue'
import MessageAudio from './message-audio.vue'
import MessageNotification from './message-notification.vue'
import MessageG2 from './message-g2.vue'
import { customNavigateTo } from '@/utils/im/customNavigate'
import MessageIsRead from './message-read.vue'
import Icon from '@/components/Icon.vue'
import Appellation from '@/components/Appellation.vue'
import { events, MSG_ID_FLAG } from '@/utils/im/constants'
import { autorun } from 'mobx'
import { stopAllAudio } from '@/utils/im/index'
import { t } from '@/utils/im/i18n'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMConst } from '@/utils/im/nim'
const props = withDefaults(
defineProps<{
msg: V2NIMMessageForUI & { timeValue?: number }
index: number
replyMsgsMap?: {
[key: string]: V2NIMMessageForUI
}
broadcastNewAudioSrc: string
}>(),
{}
)
/** 回复消息 */
const replyMsg = computed(() => {
return props.replyMsgsMap && props.replyMsgsMap[props.msg.messageClientId]
})
/** 昵称 */
const appellation = ref('')
/** 当前用户账号 */
const accountId = uni.$UIKitStore?.userStore?.myUserInfo.accountId
/** 会话类型 */
const conversationType =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationType(
props.msg.conversationId
) as unknown as V2NIMConst.V2NIMConversationType
/** 会话对象 */
const to = uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationTargetId(
props.msg.conversationId
)
/** 获取视频首帧 */
const videoFirstFrameDataUrl = computed(() => {
//@ts-ignore
const url = props.msg.attachment?.url
return url ? `${url}${url.includes('?') ? '&' : '?'}vframe&offset=1` : ''
})
/** 图片地址 */
const imageUrl = computed(() => {
/** 被拉黑 */
if (props.msg.messageStatus.errorCode == 102426) {
return 'https://yx-web-nosdn.netease.im/common/c1f278b963b18667ecba4ee9a6e68047/img-fail.png'
}
/** 非好友关系 */
if (props.msg.messageStatus.errorCode == 104404) {
return 'https://yx-web-nosdn.netease.im/common/c1f278b963b18667ecba4ee9a6e68047/img-fail.png'
}
//@ts-ignore
return props.msg?.attachment?.url || props.msg.attachment?.file
})
/** 点击图片预览 */
const handleImageTouch = (url: string) => {
if (url) {
uni.previewImage({
urls: [url],
})
}
}
/** 点击视频播放 */
const handleVideoTouch = (msg: V2NIMMessageForUI) => {
stopAllAudio()
//@ts-ignore
const url = msg.attachment?.url
if (url) {
customNavigateTo({
url: `/pages/Chat/video-play?videoUrl=${encodeURIComponent(url)}`,
})
}
}
/** 重新编辑消息 */
const handleReeditMsg = (msg: V2NIMMessageForUI) => {
uni.$emit(events.ON_REEDIT_MSG, msg)
}
/** 监听昵称变化 */
const appellationWatch = autorun(() => {
/** 昵称展示顺序 群昵称 > 备注 > 个人昵称 > 帐号 */
appellation.value = uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.senderId,
teamId:
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: '',
})
})
onUnmounted(() => {
appellationWatch()
})
</script>
<style scoped lang="scss">
.msg-item-wrapper {
padding: 0 15px 15px;
}
.msg-common {
padding-top: 8px;
display: flex;
align-items: flex-start;
font-size: 16px;
message-is-read {
align-self: flex-end;
}
}
.msg-pin {
opacity: 1;
background: #fffbea;
}
.msg-pin-tip {
font-size: 11px;
font-weight: normal;
color: #3eaf96;
margin: 8px 50px 0 50px;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
}
.msg-content {
display: flex;
flex-direction: column;
}
.msg-name {
font-size: 12px;
color: #999;
text-align: left;
margin-bottom: 4px;
max-width: 300rpx;
padding-left: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-image {
max-width: 100%;
}
.msg-time {
margin-top: 8px;
text-align: center;
color: #b3b7bc;
font-size: 12px;
}
.msg-recall-btn {
margin-left: 5px;
color: #1861df;
}
.msg-recall2 {
font-size: 16px;
}
.self-msg-recall {
max-width: 360rpx;
overflow: hidden;
padding: 12px 16px;
border-radius: 8px 0px 8px 8px;
margin-right: 8px;
background-color: #d6e5f6;
color: #666666;
}
.msg-recall {
max-width: 360rpx;
overflow: hidden;
padding: 12px 16px;
border-radius: 0px 8px 8px 8px;
margin-left: 8px;
background-color: #e8eaed;
color: #666666;
}
.recall-text {
color: #666666;
}
.video-play-button {
width: 50px;
height: 50px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
z-index: 9;
}
.video-play-icon {
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 18px solid #fff;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-40%, -50%);
}
.video-msg-wrapper {
box-sizing: border-box;
max-width: 360rpx;
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<template>
<div class="msg-list-wrapper" @touchstart="handleTapMessageList">
<scroll-view
id="message-scroll-list"
scroll-y="true"
:scroll-top="scrollTop"
class="message-scroll-list"
>
<!-- 查看更多 -->
<div v-show="!noMore" @click="onLoadMore" class="view-more-text">
{{ t('viewMoreText') }}
</div>
<view class="msg-tip" v-show="noMore">{{ t('noMoreText') }}</view>
<div v-for="(item, index) in finalMsgs" :key="item.renderKey">
<MessageItem
:msg="item"
:index="index"
:key="item.renderKey"
:reply-msgs-map="replyMsgsMap"
:broadcastNewAudioSrc="broadcastNewAudioSrc"
>
</MessageItem>
</div>
</scroll-view>
</div>
</template>
<script lang="ts" setup>
/** 消息列表组件 */
import { ref, computed, onBeforeMount, onUnmounted, withDefaults } from 'vue'
import MessageItem from './message-item.vue'
import { events } from '@/utils/im/constants'
import { caculateTimeago } from '@/utils/im/date'
import { t } from '@/utils/im/i18n'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMConst } from '@/utils/im/nim'
import { V2NIMTeam } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMTeamService'
import { autorun } from 'mobx'
const props = withDefaults(
defineProps<{
msgs: V2NIMMessageForUI[]
conversationType: V2NIMConst.V2NIMConversationType
to: string
loadingMore?: boolean
noMore?: boolean
replyMsgsMap?: {
[key: string]: V2NIMMessageForUI
}
}>(),
{}
)
/** 群信息监听 */
let teamWatch = () => {}
onBeforeMount(() => {
let team: V2NIMTeam | undefined = undefined
/** 群监听 */
teamWatch = autorun(() => {
team = uni.$UIKitStore.teamStore.teams.get(props.to) as unknown as V2NIMTeam
})
if (
props.conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
) {
uni.$UIKitStore.teamMemberStore.getTeamMemberActive({
teamId: props.to,
queryOption: {
limit: Math.max((team as unknown as V2NIMTeam)?.memberLimit || 0, 200),
roleQueryType: 0,
},
})
}
/** 全局播放音频url */
uni.$on(events.AUDIO_URL_CHANGE, (url) => {
broadcastNewAudioSrc.value = url
})
/** 滚动到底部 */
uni.$on(events.ON_SCROLL_BOTTOM, () => {
scrollToBottom()
})
/** 加载更多 */
uni.$on(events.ON_LOAD_MORE, () => {
const msg = finalMsgs.value.filter(
(item) =>
!(
item.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
['beReCallMsg', 'reCallMsg'].includes(item.recallType || '')
)
)[0]
if (msg) {
uni.$emit(events.GET_HISTORY_MSG, msg)
}
})
})
/** 滚动条位置距离 */
const scrollTop = ref(99999)
/** 消息列表 */
const finalMsgs = computed(() => {
const res: (V2NIMMessageForUI & { renderKey: string })[] = []
props.msgs.forEach((item, index) => {
// 如果两条消息间隔超过5分钟,插入一条自定义时间消息
if (
index > 0 &&
item.createTime - props.msgs[index - 1].createTime > 5 * 60 * 1000
) {
res.push({
...item,
messageClientId: 'time-' + item.createTime,
messageType: V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM,
sendingState:
V2NIMConst.V2NIMMessageSendingState
.V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED,
// @ts-ignore
timeValue: caculateTimeago(item.createTime),
renderKey: `${item.createTime + 1}`,
})
}
res.push({
...item,
// @ts-ignore
renderKey: `${item.createTime}`,
})
})
return res
})
/** 全局播放音频url */
const broadcastNewAudioSrc = ref<string>('')
/** 消息滑动到底部
* 不建议查询当前的消息列表dom高度进行滚动,在个别机型会不生效,并有卡顿问题,使用极大值,进行滚动,无该问题
*/
const scrollToBottom = () => {
scrollTop.value += 9999999
const timer = setTimeout(() => {
scrollTop.value += 1
clearTimeout(timer)
}, 300)
}
/** 加载更多消息 */
const onLoadMore = () => {
const msg = finalMsgs.value.filter(
(item) =>
!(
item.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM &&
['beReCallMsg', 'reCallMsg'].includes(item.recallType || '')
)
)[0]
uni.$emit(events.GET_HISTORY_MSG, msg)
}
/** 点击消息列表 */
const handleTapMessageList = () => {
uni.$emit(events.CLOSE_PANEL)
setTimeout(() => {
uni.$emit(events.CLOSE_PANEL)
}, 300)
}
onUnmounted(() => {
uni.$off(events.ON_SCROLL_BOTTOM)
uni.$off(events.ON_LOAD_MORE)
uni.$off(events.AUDIO_URL_CHANGE)
teamWatch()
})
</script>
<style scoped lang="scss">
.msg-list-wrapper {
flex: 1;
overflow: hidden;
display: flex;
height: 100%;
box-sizing: border-box;
padding: 16px 0;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.msg-tip {
text-align: center;
color: #b3b7bc;
font-size: 12px;
margin-top: 10px;
}
.block {
width: 100%;
height: 40px;
}
.message-scroll-list {
height: 100%;
box-sizing: border-box;
padding-bottom: 1px;
}
.view-more-text {
text-align: center;
color: #b3b7bc;
font-size: 15px;
margin-top: 20px;
}
page > view > message > view > message-list {
height: 100%;
}
</style>
@@ -0,0 +1,223 @@
<template>
<div v-if="notificationContent" class="msg-noti">
{{ notificationContent }}
</div>
</template>
<script lang="ts" setup>
/** 通知消息组件 */
import { ALLOW_AT } from '@/utils/im/constants'
import { t } from '@/utils/im/i18n'
import { V2NIMConst } from '@/utils/im/nim'
import { V2NIMTeam } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMTeamService'
import {
V2NIMMessageForUI,
YxServerExt,
} from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMMessageNotificationAttachment } from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMMessageService'
import { onUnmounted, ref } from 'vue'
import { autorun } from 'mobx'
const props = withDefaults(defineProps<{ msg: V2NIMMessageForUI }>(), {})
/** 群ID */
const teamId =
props.msg.conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? props.msg.receiverId
: ''
/** 通知消息内容 */
const notificationContent = ref('')
/** 通知消息监听 */
const notificationContentWatch = autorun(() => {
const getNotificationContent = () => {
const attachment = props.msg
.attachment as V2NIMMessageNotificationAttachment
switch (attachment?.type) {
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_UPDATE_TINFO: {
const team = (attachment?.updatedTeamInfo || {}) as V2NIMTeam
const content: string[] = []
if (team.avatar !== undefined) {
content.push(t('updateTeamAvatar'))
}
if (team.name !== undefined) {
content.push(`${t('updateTeamName')}${team.name}`)
}
if (team.intro !== undefined) {
content.push(t('updateTeamIntro'))
}
if (team.inviteMode !== undefined) {
content.push(
`${t('updateTeamInviteMode')}${
team.inviteMode ===
V2NIMConst.V2NIMTeamInviteMode.V2NIM_TEAM_INVITE_MODE_ALL
? t('teamAll')
: t('teamOwnerAndManagerText')
}`
)
}
if (team.updateInfoMode !== undefined) {
content.push(
`${t('updateTeamUpdateTeamMode')}${
team.updateInfoMode ===
V2NIMConst.V2NIMTeamUpdateInfoMode.V2NIM_TEAM_UPDATE_INFO_MODE_ALL
? t('teamAll')
: t('teamOwnerAndManagerText')
}`
)
}
if (team.chatBannedMode !== void 0) {
content.push(
`${t('updateTeamMute')}${
team.chatBannedMode ===
V2NIMConst.V2NIMTeamChatBannedMode
.V2NIM_TEAM_CHAT_BANNED_MODE_UNBAN
? t('closeText')
: t('openText')
}`
)
}
if (team.serverExtension) {
let ext: YxServerExt = {}
try {
ext = JSON.parse(team.serverExtension)
} catch (error) {
//
}
if (ext[ALLOW_AT] !== undefined) {
content.push(
`${t('updateAllowAt')}${
ext[ALLOW_AT] === 'manager'
? t('teamOwnerAndManagerText')
: t('teamAll')
}`
)
}
}
return content.length
? `${uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.senderId,
teamId,
})} ${content.join('、')}`
: ''
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_APPLY_PASS:
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_INVITE_ACCEPT: {
return `${uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.senderId,
teamId,
})} ${t('joinTeamText')}`
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_INVITE: {
const accounts: string[] = attachment?.targetIds || []
accounts.map(async (item) => {
await uni.$UIKitStore.userStore.getUserActive(item)
})
const nicks = accounts
.map((item) => {
return uni.$UIKitStore.uiStore.getAppellation({
account: item,
teamId,
})
})
.filter((item) => !!item)
.join('、')
return `${nicks} ${t('joinTeamText')}`
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_KICK: {
const accounts: string[] = attachment?.targetIds || []
accounts.map(async (item) => {
await uni.$UIKitStore.userStore.getUserActive(item)
})
const nicks = accounts
.map((item) => {
return uni.$UIKitStore.uiStore.getAppellation({
account: item,
teamId,
})
})
.filter((item) => !!item)
.join('、')
return `${nicks} ${t('beRemoveTeamText')}`
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_ADD_MANAGER: {
const accounts: string[] = attachment?.targetIds || []
accounts.map(async (item) => {
await uni.$UIKitStore.userStore.getUserActive(item)
})
const nicks = accounts
.map((item) => {
return uni.$UIKitStore.uiStore.getAppellation({
account: item,
teamId,
})
})
.filter((item) => !!item)
.join('、')
return `${nicks} ${t('beAddTeamManagersText')}`
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_REMOVE_MANAGER: {
const accounts: string[] = attachment?.targetIds || []
accounts.map(async (item) => {
await uni.$UIKitStore.userStore.getUserActive(item)
})
const nicks = accounts
.map((item) => {
return uni.$UIKitStore.uiStore.getAppellation({
account: item,
teamId,
})
})
.filter((item) => !!item)
.join('、')
return `${nicks} ${t('beRemoveTeamManagersText')}`
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_LEAVE: {
return `${uni.$UIKitStore.uiStore.getAppellation({
account: props.msg.senderId,
teamId,
})} ${t('leaveTeamText')}`
}
case V2NIMConst.V2NIMMessageNotificationType
.V2NIM_MESSAGE_NOTIFICATION_TYPE_TEAM_OWNER_TRANSFER: {
return `${uni.$UIKitStore.uiStore.getAppellation({
account: (attachment?.targetIds || [])[0],
teamId,
})} ${t('newGroupOwnerText')}`
}
default:
return ''
}
}
notificationContent.value = getNotificationContent()
})
onUnmounted(() => {
notificationContentWatch()
})
</script>
<style scoped lang="scss">
.msg-noti {
margin: 8px auto 0;
text-align: center;
font-size: 14px;
color: #b3b7bc;
max-width: 70%;
}
</style>
@@ -0,0 +1,382 @@
<template>
<div class="pincard-wrapper">
<div class="info-wrapper">
<div class="info" @tap="gotoChat">
<div class="info-left">
<Avatar size="32" :account="props.msg.senderId"></Avatar>
</div>
<div class="info-right">
<div class="name">
<Appellation
:account="props.msg.senderId"
:teamId="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
? to
: ''
"
></Appellation>
</div>
<div class="createtime">
{{ timeFormat() }}
</div>
</div>
</div>
<Icon type="icon-More" @tap="handlePinMsg" />
</div>
<div class="content-wrapper">
<div
class="msg-text"
v-if="
msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
>
<MessageText :msg="props.msg"></MessageText>
</div>
<div class="file-wrapper">
<div
v-if="
msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE
"
>
<div
@tap="
() => {
handleImageTouch(props.msg.attachment.url)
}
"
>
<image
class="msg-image"
:lazy-load="true"
mode="aspectFill"
:src="imageUrl"
></image>
</div>
</div>
<div
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO
"
>
<div
class="video-msg-wrapper"
@tap="() => handleVideoTouch(props.msg)"
>
<div class="video-play-button">
<div class="video-play-icon"></div>
</div>
<image
class="msg-image"
:lazy-load="true"
mode="aspectFill"
:src="videoFirstFrameDataUrl"
></image>
</div>
</div>
<div
class="extra"
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE
"
>
<MessageFile :msg="props.msg" />
</div>
<div
v-else-if="
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
"
>
<div class="audio-wrapper">
<MessageAudio :msg="props.msg" mode="audio-in" />
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, withDefaults } from 'vue'
import { stopAllAudio } from '@/utils/index'
import Avatar from '@/components/Avatar.vue'
import Icon from '@/components/Icon.vue'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMConst } from '@/utils/im/nim'
import Appellation from '@/components/Appellation.vue'
import dayjs from 'dayjs'
import { customNavigateTo } from '@/utils/im/customNavigate'
import MessageFile from './message-file.vue'
import MessageAudio from './message-audio.vue'
import { t } from '@/utils/im/i18n'
import { customRedirectTo } from '@/utils/im/customNavigate'
import MessageText from './message-text.vue'
const props = withDefaults(
defineProps<{
msg: V2NIMMessageForUI
handleUnPinMsg: any
}>(),
{}
)
/** 会话类型 */
const conversationType =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationType(
props.msg.conversationId
)
/** 会话对象 */
const to = uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationTargetId(
props.msg.conversationId
)
/** 调整至聊天页面 */
const gotoChat = async () => {
await uni.$UIKitStore.uiStore.selectConversation(props.msg.conversationId)
customRedirectTo({
url: '/pages/Chat/index',
})
}
/** 复制 */
const handleCopy = () => {
uni.setClipboardData({
data: props.msg.text || '',
showToast: false,
success: () => {
uni.showToast({
title: t('copySuccessText'),
icon: 'none',
})
},
fail: () => {
uni.showToast({
title: t('copyFailText'),
icon: 'none',
})
},
})
}
// pin 消息操作
const handlePinMsg = () => {
let itemList = [t('unpinText')]
if (
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
) {
itemList = itemList.concat([
t('copyText'),
t('forwardToTeamText'),
t('forwardToFriendText'),
])
} else if (
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
) {
itemList = itemList.concat([
t('forwardToTeamText'),
t('forwardToFriendText'),
])
}
uni.showActionSheet({
itemList,
success(data) {
if (
props.msg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
) {
if (data.tapIndex === 0) {
props.handleUnPinMsg()
} else if (data.tapIndex === 1) {
handleCopy()
} else if (data.tapIndex === 2) {
customNavigateTo({
url: `/pages/Chat/forward?forwardConversationType=${V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM}&msgIdClient=${props.msg.messageClientId}&origin=pin`,
})
} else if (data.tapIndex === 3) {
customNavigateTo({
url: `/pages/Chat/forward?forwardConversationType=${V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P}&msgIdClient=${props.msg.messageClientId}&origin=pin`,
})
}
} else if (
props.msg.messageType !==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
) {
if (data.tapIndex === 0) {
props.handleUnPinMsg()
} else if (data.tapIndex === 1) {
customNavigateTo({
url: `/pages/Chat/forward?forwardConversationType=${V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM}&msgIdClient=${props.msg.messageClientId}&origin=pin`,
})
} else if (data.tapIndex === 2) {
customNavigateTo({
url: `/pages/Chat/forward?forwardConversationType=${V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P}&msgIdClient=${props.msg.messageClientId}&origin=pin`,
})
}
} else {
if (data.tapIndex === 0) {
props.handleUnPinMsg()
}
}
},
complete() {},
})
}
/** 图片Url */
const imageUrl = computed(() => {
//@ts-ignore
return props.msg?.attachment?.url || props.msg.attachment?.file
})
// 获取视频首帧
const videoFirstFrameDataUrl = computed(() => {
//@ts-ignore
const url = props.msg.attachment?.url
return url ? `${url}${url.includes('?') ? '&' : '?'}vframe&offset=1` : ''
})
// 点击图片预览
const handleImageTouch = (url: string) => {
if (url) {
uni.previewImage({
urls: [url],
})
}
}
// 点击视频播放
const handleVideoTouch = (msg: V2NIMMessageForUI) => {
stopAllAudio()
//@ts-ignore
const url = msg.attachment?.url
if (url) {
customNavigateTo({
url: `/pages/Chat/video-play?videoUrl=${encodeURIComponent(url)}`,
})
}
}
/** 时间格式化 */
const isToday = (time: number) => {
const createTime = new Date(time)
const now = new Date()
return (
createTime.getFullYear() === now.getFullYear() &&
createTime.getMonth() === now.getMonth() &&
createTime.getDate() === now.getDate()
)
}
/** 时间格式化 */
const isThisYear = (time: number) => {
const createTime = new Date(time)
const now = new Date()
return createTime.getFullYear() === now.getFullYear()
}
/** 时间格式化 */
const timeFormat = () => {
const createTime = props.msg.createTime
if (isToday(createTime)) {
return dayjs(createTime).format('HH:mm')
} else if (isThisYear(createTime)) {
return dayjs(createTime).format('MM月DD日 HH:mm')
} else {
return dayjs(createTime).format('YYYY年MM月DD日 HH:mm')
}
}
</script>
<style lang="scss" scoped>
.pincard-wrapper {
background-color: #fff;
margin: 0 20px;
margin-top: 12px;
overflow: hidden;
border-radius: 8px;
.info-wrapper {
display: flex;
margin: 0 16px;
padding: 16px 0 12px 0;
border-bottom: 1px solid #e4e9f2;
justify-content: space-between;
align-items: center;
.info {
display: flex;
flex-grow: 1;
.info-left {
margin-right: 8px;
}
.info-right {
font-size: 12px;
.createtime {
color: #999999;
}
}
}
}
.content-wrapper {
padding: 12px 16px 16px 16px;
word-break: break-all;
word-wrap: break-word;
white-space: break-spaces;
.file-wrapper {
position: relative;
width: 50%;
.extra {
width: 200%;
}
.msg-image {
width: 100%;
}
.video-msg-wrapper {
box-sizing: border-box;
max-width: 360rpx;
}
.video-play-button {
width: 50px;
height: 50px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
z-index: 9;
}
.video-play-icon {
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 18px solid #fff;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-40%, -50%);
}
.audio-wrapper {
width: fit-content;
opacity: 1;
background: #d6e5f6;
overflow: hidden;
padding: 10px 12px;
border-radius: 0 8px 8px 8px;
background-color: #e8eaed;
}
}
.msg-text {
word-break: break-all;
word-wrap: break-word;
white-space: break-spaces;
}
}
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<template>
<div
v-if="
props.msg.sendingState ==
V2NIMConst.V2NIMMessageSendingState.V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED
"
class="msg-read-wrapper"
>
<div
v-if="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P &&
p2pMsgReceiptVisible
"
>
<div v-if="p2pMsgRotateDeg == 360" class="icon-read-wrapper">
<Icon type="icon-read" :size="18"></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>
<div
v-if="
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM &&
teamManagerVisible
"
>
<div class="icon-read-wrapper" v-if="teamMsgRotateDeg == 360">
<Icon type="icon-read" :size="18"></Icon>
</div>
<div v-else class="sector" @click="jumpToTeamMsgReadInfo">
<span
class="cover-1"
:style="`transform: rotate(${teamMsgRotateDeg}deg)`"
></span>
<span
:class="teamMsgRotateDeg >= 180 ? 'cover-2 cover-3' : 'cover-2'"
></span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/** 消息已读未读组件 */
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import Icon from '@/components/Icon.vue'
import { V2NIMConst } from '@/utils/im/nim'
import { customNavigateTo } from '@/utils/im/customNavigate'
import { t } from '@/utils/im/i18n'
import { autorun } from 'mobx'
const props = withDefaults(
defineProps<{
msg: V2NIMMessageForUI
}>(),
{}
)
/** 是否需要显示群组消息已读未读,默认 false */
const teamManagerVisible = uni.$UIKitStore.localOptions.teamMsgReceiptVisible
/** 是否需要显示 p2p 消息、p2p会话列表消息已读未读,默认 false */
const p2pMsgReceiptVisible = uni.$UIKitStore.localOptions.p2pMsgReceiptVisible
/** 会话类型 */
const conversationType =
uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationType(
props.msg.conversationId
) as unknown as V2NIMConst.V2NIMConversationType
/** 单聊消息已读未读,用于UI变更 */
const p2pMsgRotateDeg = ref(0)
/**是否是云端会话 */
const enableV2CloudConversation =
uni.$UIKitStore?.sdkOptions?.enableV2CloudConversation
/** 设置单聊消息已读未读 */
const setP2pMsgRotateDeg = () => {
/**如果是单聊 */
if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_P2P
) {
const conversation = enableV2CloudConversation
? uni.$UIKitStore.conversationStore?.conversations.get(
props.msg.conversationId
)
: uni.$UIKitStore.localConversationStore?.conversations.get(
props.msg.conversationId
)
p2pMsgRotateDeg.value =
props?.msg?.createTime <= (conversation?.msgReceiptTime || 0) ? 360 : 0
}
}
/** 监听单聊消息已读未读 */
const p2pMsgReadWatch = autorun(() => {
setP2pMsgRotateDeg()
})
/** 跳转到已读未读详情 */
const jumpToTeamMsgReadInfo = () => {
if (
uni.$UIKitStore.connectStore.connectStatus !==
V2NIMConst.V2NIMConnectStatus.V2NIM_CONNECT_STATUS_CONNECTED
) {
uni.showToast({
title: t('offlineText'),
icon: 'none',
})
return
}
// 跳转到消息已读未读详情页
if (props?.msg?.messageClientId && props?.msg?.conversationId) {
customNavigateTo({
url: `/pages/Chat/message-read-info?messageClientId=${props.msg.messageClientId}&conversationId=${props.msg.conversationId}`,
})
}
}
/** 群消息已读未读,用于UI变更 */
const teamMsgRotateDeg = computed(() => {
if (
conversationType ===
V2NIMConst.V2NIMConversationType.V2NIM_CONVERSATION_TYPE_TEAM
) {
const percentage =
(props?.msg?.yxRead || 0) /
((props?.msg?.yxUnread || 0) + (props?.msg?.yxRead || 0)) || 0
return percentage * 360
}
return 0
})
onMounted(() => {
setP2pMsgRotateDeg()
})
onUnmounted(() => {
p2pMsgReadWatch()
})
</script>
<style scoped lang="scss">
.msg-read-wrapper {
align-self: flex-end;
display: none;
}
.icon-read-wrapper {
margin: 0px 10px 5px 0;
}
.sector {
display: inline-block;
position: relative;
overflow: hidden;
border: 2px solid #4c84ff;
width: 14px;
height: 14px;
background-color: #eeeeee;
border-radius: 50%;
margin: 0px 10px 0 0;
.cover-1,
.cover-2 {
position: absolute;
top: 0;
width: 50%;
height: 100%;
background-color: #eeeeee;
}
.cover-1 {
background-color: #4c84ff;
transform-origin: right;
}
.cover-3 {
right: 0;
background-color: #4c84ff;
}
}
</style>
+334
View File
@@ -0,0 +1,334 @@
<template>
<div>
<div v-if="props.replyMsg?.messageClientId" class="reply-msg-wrapper">
<!-- replyMsg 不存在 说明回复的消息被删除或者撤回 -->
<div v-if="!isReplyMsgExist">
<span>{{ t('replyNotFindText') }}</span>
</div>
<div v-else class="reply-msg" @tap="showFullReplyMsg">
<div class="reply-msg-name-wrapper">
<div class="reply-msg-name-line">|</div>
<div class="reply-msg-name-content">
<Appellation
:account="props.replyMsg?.senderId"
:teamId="props.replyMsg?.receiverId"
color="#929299"
:fontSize="13"
></Appellation>
</div>
<div class="reply-msg-name-to">:</div>
</div>
<message-one-line
v-if="
props.replyMsg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
:text="props.replyMsg.text"
></message-one-line>
<div
v-else-if="
props.replyMsg.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_FILE
"
class="other-msg-wrapper"
>
<uni-link
v-if="!isHarmonyOs"
:href="downloadUrl"
:download="name"
:showUnderLine="false"
>
{{ t('fileMsgTitleText') }}
</uni-link>
<span
v-else
class="other-msg-wrapper"
@click="() => openInBrowser(downloadUrl)"
>
{{ t('fileMsgTitleText') }}
</span>
</div>
<div class="other-msg-wrapper" v-else>
{{ '[' + REPLY_MSG_TYPE_MAP[props.replyMsg.messageType] + ']' }}
</div>
</div>
</div>
<!-- 点击被回复的消息需要全屏显示 -->
<div v-if="isFullScreen" class="reply-full-screen" @tap="closeFullReplyMsg">
<!-- #ifdef MP -->
<div class="reply-message-close-mp" @tap="closeFullReplyMsg">
<Icon
color="#929299"
:iconStyle="{ fontWeight: '200' }"
:size="18"
type="icon-guanbi"
/>
</div>
<!-- #endif -->
<!-- #ifndef MP -->
<div class="reply-message-close" @tap="closeFullReplyMsg">
<Icon
color="#929299"
:iconStyle="{ fontWeight: '200' }"
:size="18"
type="icon-guanbi"
/>
</div>
<!-- #endif -->
<div
v-if="
props.replyMsg?.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
"
class="reply-message-content"
>
<message-text :msg="replyMsg" :fontSize="22"></message-text>
</div>
<div
v-else-if="
props.replyMsg?.messageType ==
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
"
class="msg-common"
:style="{
flexDirection: props.replyMsg?.isSelf ? 'row-reverse' : 'row',
backgroundColor: props.replyMsg?.isSelf ? '#d6e5f6' : '#e8eaed',
borderRadius: props.replyMsg?.isSelf
? '8px 0px 8px 8px'
: '0 8px 8px',
}"
@click.stop="() => {}"
>
<MessageAudio :msg="replyMsg" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/** 回复消息组件 */
import { t } from '@/utils/im/i18n'
import MessageOneLine from '@/components/MessageOneLine.vue'
import { ref, onMounted, computed, onUnmounted } from 'vue'
import MessageText from './message-text.vue'
// @ts-ignore
import UniLink from '@/components/uni-components/uni-link/components/uni-link/uni-link.vue'
import { REPLY_MSG_TYPE_MAP } from '@/utils/im/constants'
import { events } from '@/utils/im/constants'
import { isHarmonyOs, stopAllAudio } from '@/utils/im/index'
import { autorun } from 'mobx'
import { customNavigateTo } from '@/utils/im/customNavigate'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { V2NIMConst } from '@/utils/im/nim'
import MessageAudio from './message-audio.vue'
import Icon from '@/components/Icon.vue'
import Appellation from '@/components/Appellation.vue'
const props = withDefaults(
defineProps<{ replyMsg: V2NIMMessageForUI | undefined }>(),
{}
)
/**是否全屏展示 */
const isFullScreen = ref(false)
/** 回复对象 */
const repliedTo = ref('')
//@ts-ignore
const { name = '', url = '' } = props.replyMsg?.attachment || {}
/**下载地址 */
const downloadUrl = computed(() => {
//@ts-ignore
const { name = '', url = '' } = props.replyMsg?.attachment || {}
return url + ((url as string).includes('?') ? '&' : '?') + `download=${name}`
})
/**被回复消息是否存在 */
const isReplyMsgExist = computed(() => {
return props.replyMsg?.messageClientId !== 'noFind'
})
/**回复消息昵称 */
const repliedToWatch = autorun(() => {
repliedTo.value = uni.$UIKitStore.uiStore.getAppellation({
account: props.replyMsg?.senderId as string,
teamId: props.replyMsg?.receiverId,
})
})
/**全屏展示回复消息 */
const showFullReplyMsg = () => {
if (
props.replyMsg?.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_IMAGE
) {
uni.previewImage({
//@ts-ignore
urls: [props.replyMsg?.attachment?.url as string],
})
} else if (
props.replyMsg?.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_TEXT
) {
isFullScreen.value = true
uni.$emit(events.HANDLE_MOVE_THROUGH, true)
} else if (
props.replyMsg?.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_VIDEO
) {
//@ts-ignore
const url = props.replyMsg?.attachment?.url
stopAllAudio()
if (url) {
customNavigateTo({
url: `/pages/Chat/video-play?videoUrl=${encodeURIComponent(url)}`,
})
}
} else if (
props.replyMsg?.messageType ===
V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_AUDIO
) {
isFullScreen.value = true
}
}
/**点击全屏的回复消息,关闭全屏 */
const closeFullReplyMsg = () => {
isFullScreen.value = false
stopAllAudio()
uni.$emit(events.HANDLE_MOVE_THROUGH, false)
}
/**复制下载链接 */
const openInBrowser = (url: string) => {
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.showToast({
title: t('openUrlText'),
icon: 'none',
})
},
})
}
onMounted(() => {
repliedTo.value = uni.$UIKitStore.uiStore.getAppellation({
account: props.replyMsg?.senderId as string,
teamId: props.replyMsg?.receiverId,
})
})
onUnmounted(() => {
repliedToWatch()
})
</script>
<style lang="scss" scoped>
.reply-msg-wrapper {
display: flex;
align-items: center;
color: #929299;
font-size: 13px;
white-space: nowrap;
.reply-msg {
display: flex;
align-items: center;
width: 100%;
message-one-line {
flex: 1;
font-size: 13px;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.reply-msg-name-wrapper {
margin-right: 5px;
max-width: 125px;
flex: 0 0 auto;
display: flex;
white-space: nowrap;
}
.reply-msg-name-line {
flex-basis: 0 0 3px;
margin-right: 2px;
}
.reply-msg-name-to {
flex-basis: 0 0 3px;
}
.reply-msg-name-content {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.reply-msg-content {
// display: flex;
// align-items: center;
flex: 1;
}
}
.reply-full-screen {
position: fixed;
right: 0;
bottom: 0;
top: 0;
left: 0;
height: 100vh;
overflow: hidden;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
font-size: 24px;
justify-content: center;
touch-action: none;
z-index: 999999999;
box-sizing: border-box;
}
.reply-message-content {
height: 85vh;
overflow-y: auto;
box-sizing: border-box;
padding: 30px 30px 100px 30px;
touch-action: none;
display: flex;
align-items: center;
}
.other-msg-wrapper {
flex-wrap: nowrap;
}
.reply-message-close {
position: fixed;
right: 20px;
z-index: 999999;
top: 60px;
}
.reply-message-close-mp {
position: fixed;
right: 20px;
top: 100px;
z-index: 999999;
}
.msg-common {
display: flex;
align-items: flex-start;
font-size: 16px;
max-width: 360rpx;
overflow: hidden;
padding: 16px 20px;
}
</style>
@@ -0,0 +1,80 @@
<template>
<div v-for="(item, index) in sliceMsgs">
<text>{{ start + index }}</text>
<messageItem
:id="MSG_ID_FLAG + item.idClient"
:scene="scene"
:to="to"
:msg="item"
:key="item.idClient"
:msg-index="start + index"
>
</messageItem>
</div>
</template>
<script lang="ts" setup>
import messageItem from './message-item.vue'
import { computed } from '../../../utils/transformVue'
import { MSG_ID_FLAG } from '../../../utils/constants'
import { caculateTimeago } from '../../../utils/date'
import { V2NIMMessage } from 'nim-web-sdk-ng/dist/v2/NIM_UNIAPP_SDK/V2NIMMessageService'
import { V2NIMConst } from 'nim-web-sdk-ng/dist/v2/NIM_UNIAPP_SDK/index'
const props = defineProps({
msgs: {
type: Array,
required: true,
},
scene: {
type: Object, // Assuming TMsgScene is a custom object type
required: true,
},
to: {
type: String,
required: true,
},
start: {
type: Number,
required: true,
},
end: {
type: Number,
required: true,
},
})
const sliceMsgs = computed(() => {
const res: V2NIMMessage[] = []
const msgs = props.msgs as V2NIMMessage[]
const _slice = msgs.slice(props.start, props.end)
_slice.forEach((item, index) => {
const msgIndex = props.start + index
// 如果两条消息间隔超过5分钟,插入一条自定义时间消息
if (
msgIndex > 0 &&
item.createTime - msgs[msgIndex - 1].createTime > 5 * 60 * 1000
) {
res.push({
messageClientId: 'time-' + item.createTime,
messageType: V2NIMConst.V2NIMMessageType.V2NIM_MESSAGE_TYPE_CUSTOM,
attachment: {
type: 'time',
value: caculateTimeago(item.createTime),
},
sendingState:
V2NIMConst.V2NIMMessageSendingState
.V2NIM_MESSAGE_SENDING_STATE_SUCCEEDED,
})
}
res.push(item)
})
return res.filter((item) => item.type !== 'notification')
})
</script>
<style lang="scss" scoped></style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="msg-text" :style="{ fontSize: (fontSize || 16) + 'px' }">
<template v-for="item in textArr" :key="item.key">
<template v-if="item.type === 'text'">
<span class="msg-text">{{ item.value }}</span>
</template>
<template v-else-if="item.type === 'Ait'">
<text class="msg-text" :style="{ color: '#1861df' }">
{{ ' ' + item.value + ' ' }}
</text>
</template>
<template v-else-if="item.type === 'emoji'">
<Icon
:type="EMOJI_ICON_MAP_CONFIG[item.value]"
:size="fontSize || 22"
:style="{ margin: '0 2px 2px 2px', verticalAlign: 'bottom' }"
/>
</template>
<template v-else-if="item.type === 'link'">
<UniLink
v-if="!isHarmonyOs"
:href="item.value"
:style="{ color: '#1861df', fontSize: (fontSize || 16) + 'px' }"
:showUnderLine="false"
>
{{ item.value }}
</UniLink>
<span
v-else
:style="{ color: '#1861df', fontSize: (fontSize || 16) + 'px' }"
@click="() => openInBrowser(item.value)"
>
{{ item.value }}
</span>
</template>
</template>
</div>
</template>
<script lang="ts" setup>
/**文本消息组件 */
import Icon from '@/components/Icon.vue'
// @ts-ignore
import UniLink from '@/components/uni-components/uni-link/components/uni-link/uni-link.vue'
import { parseText } from '@/utils/im/parseText'
import { EMOJI_ICON_MAP_CONFIG } from '@/utils/im/emoji'
import { V2NIMMessageForUI } from '@xkit-yx/im-store-v2/dist/types/types'
import { t } from '@/utils/im/i18n'
import { isHarmonyOs } from '@/utils/im/index'
const props = withDefaults(
defineProps<{
msg: V2NIMMessageForUI
fontSize?: number
}>(),
{}
)
/**解析文本 */
const textArr = parseText(props.msg?.text || '', props.msg?.serverExtension)
/**unilink 不支持鸿蒙 故提示在浏览器打开链接 */
const openInBrowser = (url: string) => {
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
uni.showToast({
title: t('openUrlText'),
icon: 'none',
})
},
})
}
</script>
<style lang="scss" scoped>
.msg-text {
color: #333;
text-align: left;
overflow-y: auto;
word-break: break-all;
word-wrap: break-word;
white-space: break-spaces;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<!-- 样式兼容微信小程序 -->
<div>
<div
class="nav-bar-wrapper"
:style="{
backgroundColor: backgroundColor || '#ffffff',
backgroundImage: `url(${title})`,
height: isWxApp ? '55px' : '40px',
alignItems: isWxApp ? 'flex-end' : 'center',
}"
>
<slot v-if="showLeft" name="left"></slot>
<div v-else @tap="back">
<Icon type="icon-zuojiantou" :size="22"></Icon>
</div>
<div class="title-container">
<div class="title">{{ title }}</div>
<div class="subTitle" v-if="subTitle">{{ subTitle }}</div>
<slot name="icon"></slot>
</div>
<div>
<slot name="right"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { isWxApp } from '@/utils/im/index'
import Icon from '@/components/Icon.vue'
withDefaults(
defineProps<{
title: string
subTitle?: string
backgroundColor?: string
showLeft?: boolean
}>(),
{
subTitle: '',
backgroundColor: '',
showLeft: true,
}
)
const back = () => {
uni.navigateBack({
delta: 1,
})
}
</script>
<style lang="scss" scoped>
@import '@/styles/common.scss';
.nav-bar-wrapper {
display: flex;
justify-content: space-between;
padding: var(--status-bar-height) 10px 5px 10px;
z-index: 9999;
.title-container {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 300px;
display: flex;
justify-content: center;
align-items: center;
}
.title {
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
white-space: nowrap;
font-weight: 500;
max-width: 230px;
}
.subTitle {
white-space: nowrap;
font-weight: 500;
}
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<template>
<div>
<NavBar :title="t('setText')" />
<div class="p2p-set-container">
<div class="p2p-set-card">
<div class="p2p-set-item">
<div class="p2p-set-my-info">
<Avatar :account="account" />
<div class="p2p-set-my-nick">{{ myNick }}</div>
</div>
<div class="member-add" @tap="addTeamMember">
<Icon type="icon-tianjiaanniu" />
</div>
</div>
</div>
<div class="p2p-set-card">
<div class="p2p-set-item p2p-set-item-flex-sb" @tap="goPinInP2p">
<div>{{ t('pinText') }}</div>
<Icon iconClassName="more-icon" color="#999" type="icon-jiantou" />
</div>
<div class="p2p-set-item p2p-set-item-flex-sb">
<div>{{ t('sessionMuteText') }}</div>
<switch :checked="!isMute" @change="changeSessionMute" />
</div>
<div class="p2p-set-item p2p-set-item-flex-sb">
<div>{{ t('stickTopText') }}</div>
<switch :checked="isStickTop" @change="changeStickTopInfo" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/**单聊设置组件 */
import NavBar from '@/components/NavBar.vue'
import Avatar from '@/components/Avatar.vue'
import Icon from '@/components/Icon.vue'
import { onLoad } from '@dcloudio/uni-app'
import { onUnmounted, ref } from 'vue'
import { autorun } from 'mobx'
import { t } from '@/utils/im/i18n'
import { customNavigateTo } from '@/utils/im/customNavigate'
import { V2NIMConst } from '@/utils/im/nim'
const myNick = ref('')
const conversation = ref()
const isMute = ref(false)
const isStickTop = ref(false)
const account = ref('')
const conversationId = ref('')
let p2pSetWatch: () => void
/**是否是云端会话 */
const enableV2CloudConversation =
uni.$UIKitStore?.sdkOptions?.enableV2CloudConversation
onLoad((option) => {
const _account = option?.id
account.value = _account
const _conversationId =
uni.$UIKitNIM.V2NIMConversationIdUtil.p2pConversationId(_account)
conversationId.value = _conversationId
p2pSetWatch = autorun(() => {
conversation.value = enableV2CloudConversation
? uni.$UIKitStore.conversationStore?.conversations.get(_conversationId)
: uni.$UIKitStore.localConversationStore?.conversations.get(
_conversationId
)
myNick.value = uni.$UIKitStore.uiStore.getAppellation({ account: _account })
isMute.value = uni.$UIKitStore.relationStore.mutes.includes(_account)
isStickTop.value = !!conversation.value?.stickTop
})
})
/**添加群成员 */
const addTeamMember = () => {
const to = uni.$UIKitNIM.V2NIMConversationIdUtil.parseConversationTargetId(
conversationId.value
)
customNavigateTo({
url: `/pages/Team/team-create/index?p2pConversationId=${to}`,
})
}
/**跳转至pin页面 */
const goPinInP2p = () => {
customNavigateTo({
url: `/pages/Chat/message/pin-list?conversationId=${conversationId.value}`,
})
}
/**修改会话免打扰 */
const changeSessionMute = async (e: any) => {
const checked = !e.detail.value
try {
await uni.$UIKitStore.relationStore.setP2PMessageMuteModeActive(
account.value,
checked
? V2NIMConst.V2NIMP2PMessageMuteMode.V2NIM_P2P_MESSAGE_MUTE_MODE_ON
: V2NIMConst.V2NIMP2PMessageMuteMode.V2NIM_P2P_MESSAGE_MUTE_MODE_OFF
)
} catch (error) {
uni.showToast({
title: checked ? t('sessionMuteFailText') : t('sessionUnMuteFailText'),
icon: 'error',
})
}
}
/**修改置顶 */
const changeStickTopInfo = async (e: any) => {
const checked = e.detail.value
try {
if (enableV2CloudConversation) {
await uni.$UIKitStore.conversationStore?.stickTopConversationActive(
conversationId.value,
checked
)
} else {
await uni.$UIKitStore.localConversationStore?.stickTopConversationActive(
conversationId.value,
checked
)
}
} catch (error) {
uni.showToast({
title: checked ? t('addStickTopFailText') : t('deleteStickTopFailText'),
icon: 'error',
})
}
}
onUnmounted(() => {
p2pSetWatch()
})
</script>
<style lang="scss" scoped>
@import '../../styles/common.scss';
page {
padding-top: var(--status-bar-height);
height: 100vh;
overflow: hidden;
}
.p2p-set-container {
height: 100vh;
box-sizing: border-box;
background-color: #eff1f4;
padding: 10px 20px;
}
.p2p-set-card {
background: #ffffff;
border-radius: 8px;
padding-left: 16px;
margin-bottom: 10px;
}
.p2p-set-button {
text-align: center;
background: #ffffff;
border-radius: 8px;
color: #e6605c;
height: 40px;
line-height: 40px;
}
.p2p-set-item {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 10px 0;
&:not(:last-child) {
border-bottom: 1rpx solid #f5f8fc;
}
}
.p2p-set-item-flex-sb {
justify-content: space-between;
}
.p2p-set-my-info {
margin-right: 12px;
display: flex;
flex-direction: column;
align-items: center;
}
.p2p-set-my-nick {
margin-top: 5px;
color: #333;
font-size: 12px;
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more-icon {
margin: 0 16px;
color: #999999;
}
.p2p-info-item {
height: 70px;
display: flex;
align-items: center;
.p2p-info-title {
font-size: 16px;
margin-left: 10px;
width: 0;
flex: 1;
overflow: hidden; //超出的文本隐藏
text-overflow: ellipsis; //溢出用省略号显示
white-space: nowrap; //溢出不换行
}
}
.p2p-members-item {
height: 90px;
}
.p2p-members-info-item {
display: flex;
align-items: center;
}
.p2p-members-info {
height: 40px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
.p2p-info-subtitle {
color: #999999;
}
}
.member-list {
white-space: nowrap;
overflow-x: hidden;
margin-right: 30px;
height: 50px;
display: flex;
align-items: center;
}
.member-add {
width: 40px;
height: 40px;
border-radius: 100%;
border: 1px dashed #999999;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.member-item {
margin-right: 10px;
display: inline-block;
flex-shrink: 0;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<div :class="`wrapper ${pinInfos.length === 0 ? 'bg-white' : ''}`">
<NavBar :title="t('pinText')">
<template v-slot:left>
<div class="nav-bar-text" @tap="back">{{ t('pinText') }}</div>
</template>
</NavBar>
<div class="pinCard-item-wrapper">
<Empty v-if="pinInfos.length === 0" :text="t('noPinListText')" />
<div
v-else
v-for="(item, index) in pinInfos"
:key="item.message.messageClientId"
>
<PinCard
:msg="item.message"
:index="index"
:key="item.message.messageClientId"
:handleUnPinMsg="handleUnPinMsg(item.message)"
>
</PinCard>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app'
import { autorun } from 'mobx'
import { onUnmounted, ref } from '@/utils/im/transformVue'
import Icon from '@/components/Icon.vue'
import NavBar from '@/components/NavBar.vue'
import PinCard from '@/components/PinCard.vue'
import { t } from '@/utils/im/i18n'
import { getUniPlatform } from '@/utils/index'
import { deepClone } from '@/utils/index'
import { V2NIMTeamMember } from 'nim-web-sdk-ng/dist/v2/NIM_UNIAPP_SDK/V2NIMTeamService'
import Empty from '@/components/Empty.vue'
const inputValue = ref('')
const showClearIcon = ref(false)
const myMemberInfo = ref<V2NIMTeamMember>()
let teamId = ''
let conversationId = ''
let pinInfos = ref([])
let uninstallTeamMemberWatch = () => {}
onLoad((option) => {
conversationId = option?.conversationId
getPinnedMessageList()
})
const getPinnedMessageList = () => {
uni.$UIKitStore.msgStore
.getPinnedMessageListActive(conversationId)
.then((data) => {
pinInfos.value = data
})
}
const handleUnPinMsg = (msg) => {
return () => {
// 不用进行 catch 处理,因为 store 里面的 pin 相关方法处理过了,并且会将错误日志输出到控制台
return uni.$UIKitStore.msgStore
.unpinMessageActive(msg)
.then(() => {
return getPinnedMessageList()
})
.catch(() => {
uni.showToast({
title: t('unpinFailedText'),
icon: 'none',
duration: 1000,
})
})
}
}
onUnmounted(() => {
uninstallTeamMemberWatch()
})
</script>
<style lang="scss" scoped>
@import '../../styles/common.scss';
page {
padding-top: var(--status-bar-height);
height: 100vh;
background-color: rgb(245, 246, 247);
}
.wrapper {
width: 100%;
height: 100vh;
box-sizing: border-box;
background-color: rgb(245, 246, 247);
.nav-bar-text {
color: rgb(20, 146, 209);
}
}
.bg-white {
background: #fff;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div :class="`wrapper ${pinInfos.length === 0 ? 'bg-white' : ''}`">
<NavBar :title="t('pinText')">
<template v-slot:left>
<div class="nav-bar-text" @tap="back">{{ t('pinText') }}</div>
</template>
</NavBar>
<div class="pinCard-item-wrapper">
<Empty v-if="pinInfos.length === 0" :text="t('noPinListText')" />
<div
v-else
v-for="(item, index) in pinInfos"
:key="item.message.messageClientId"
>
<PinCard
:msg="item.message"
:index="index"
:key="item.message.messageClientId"
:handleUnPinMsg="handleUnPinMsg(item.message)"
>
</PinCard>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/**pin 消息组件 */
import { onLoad } from '@dcloudio/uni-app'
import { autorun } from 'mobx'
import { onUnmounted, ref } from 'vue'
import NavBar from '@/components/NavBar.vue'
import PinCard from './message-pin-card.vue'
import { t } from '@/utils/im/'
import Empty from '@/components/Empty.vue'
import {
V2NIMMessage,
V2NIMMessagePin,
} from 'nim-web-sdk-ng/dist/esm/nim/src/V2NIMMessageService'
export type PinInfo = V2NIMMessagePin & {
operatorId?: string
pinState: number
message?: V2NIMMessage
}
let conversationId = ''
let pinInfos = ref<PinInfo[]>([])
let pinInfosWatch = () => {}
onLoad((option) => {
conversationId = option?.conversationId
pinInfosWatch = autorun(() => {
const curPinMsgsMap =
uni.$UIKitStore.msgStore.pinMsgs.map.get(conversationId)
//@ts-ignore
pinInfos.value = [...curPinMsgsMap.values()]
.filter((pinInfo) => pinInfo.pinState > 0 && pinInfo.message)
.sort((a, b) => b.message!.createTime - a.message!.createTime)
})
})
/**取消pin */
const handleUnPinMsg = (msg: V2NIMMessage) => {
return () => {
// 不用进行 catch 处理,因为 store 里面的 pin 相关方法处理过了,并且会将错误日志输出到控制台
return uni.$UIKitStore.msgStore
.unpinMessageActive(msg)
.catch((err: any) => {
if (err?.code && typeof t(`${err.code}`) !== 'undefined') {
uni.showToast({
title: t(`${err.code}`),
icon: 'error',
duration: 1000,
})
} else {
uni.showToast({
title: t('unpinFailedText'),
icon: 'error',
duration: 1000,
})
}
})
}
}
onUnmounted(() => {
pinInfosWatch()
})
</script>
<style lang="scss" scoped>
@import '../../styles/common.scss';
page {
padding-top: var(--status-bar-height);
background-color: rgb(245, 246, 247);
}
.wrapper {
width: 100%;
box-sizing: border-box;
padding-bottom: 30px;
background-color: rgb(245, 246, 247);
min-height: 100vh;
.nav-bar-text {
color: rgb(20, 146, 209);
}
}
.bg-white {
background: #fff;
}
</style>
+228
View File
@@ -0,0 +1,228 @@
<template>
<div class="voice-panel-wrapper">
<div class="audio-remind-text">{{ t('audioRemindText') }}</div>
<div class="voice-panel">
<div
class="voice-panel-circel"
@touchstart="onStartRecord"
@touchend="onStopRecord"
>
<div class="img-mask"></div>
<Icon :width="24" :height="30" type="audio-btn"></Icon>
</div>
<div
:style="{ display: recordState == 'stop' ? 'none' : 'block' }"
class="big-circle"
></div>
<div
:style="{ display: recordState == 'stop' ? 'none' : 'block' }"
class="small-circle"
></div>
</div>
<div class="audio-btn-text">{{ t('audioBtnText') }}</div>
</div>
</template>
<script lang="ts" setup>
/**语音消息面板组件 */
import { ref } from 'vue'
import Icon from '@/components/Icon.vue'
import { t } from '@/utils/im/i18n'
import { isWxApp, stopAllAudio } from '@/utils/im/index'
const $emit = defineEmits(['handleSendAudioMsg'])
/**录音实例 */
const recorderManager = uni.getRecorderManager()
let startRecordStamp = 0
/**录音状态 */
const recordState = ref('stop')
/**开始录音 */
const onStartRecord = () => {
console.log('开始录音')
try {
stopAllAudio()
recorderManager.start({
format: 'mp3',
duration: 60000,
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 192000,
frameSize: 50,
})
} catch (error) {
console.log('录音失败', error)
}
}
/**结束录音 */
const onStopRecord = () => {
console.log('结束录音')
recordState.value = 'stop'
recorderManager.stop()
}
/** 开始录音 */
recorderManager.onStart((res) => {
console.log('recorder start' + JSON.stringify(res))
console.log('recorder start')
recordState.value = 'recording'
startRecordStamp = new Date().getTime()
})
/** 结束录音 */
recorderManager.onStop((res) => {
console.log('recorder stop' + JSON.stringify(res))
recordState.value = 'stop'
const duration = new Date().getTime() - startRecordStamp
if (duration < 1000) {
uni.showToast({
title: '录音时间太短',
icon: 'none',
})
return
}
$emit('handleSendAudioMsg', res.tempFilePath, duration)
})
/** 录音出错 */
recorderManager.onError((res) => {
console.log('recorder error', res)
recordState.value = 'stop'
if (!isWxApp) {
uni.showToast({
title: t('audioErrorText'),
icon: 'none',
})
} else {
if (res.errMsg == 'operateRecorder:fail auth deny') {
uni.showToast({
title: t('audioErrorText'),
icon: 'none',
})
}
}
})
</script>
<style scoped lang="scss">
.voice-panel-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
}
.voice-panel {
flex: 1;
width: 100%;
overflow: hidden;
margin-bottom: 30px;
}
.audio-remind-text {
margin-top: 30px;
height: 30px;
text-align: center;
font-size: 14px;
color: #666;
line-height: 30px;
}
.voice-panel-circel {
z-index: 3;
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(163deg, #4883ea 11%, #2561c9 121%);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
}
.big-circle {
z-index: 1;
position: absolute;
width: 230px;
height: 230px;
left: 20%;
top: 10%;
border-radius: 50%;
transform: translate(-50%, -50%);
background: #cce6f8 38.14%;
animation: circleSmall 1.5s ease-out;
animation-iteration-count: infinite;
}
.small-circle {
z-index: 2;
position: absolute;
width: 130px;
height: 130px;
border-radius: 50%;
background: #a0d2f0;
top: 28%;
left: 33%;
transform: translate(-50%, -50%);
animation: circleSmall 1.5s ease-out;
animation-iteration-count: infinite;
}
@keyframes circleSmall {
0% {
transform: scale(1);
opacity: 0;
}
25% {
transform: scale(1);
opacity: 0.4;
}
50% {
transform: scale(1.2);
opacity: 0.6;
}
75% {
transform: scale(1.3);
opacity: 0.4;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.img-mask {
position: absolute;
z-index: 10;
left: 0;
right: 0;
top: 0;
bottom: 0;
opacity: 0;
}
.audio-btn-text {
position: absolute;
bottom: 20%;
left: 50%;
transform: translateX(-50%);
color: #666;
font-size: 14px;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="video-wrapper">
<NavBar :title="t('videoPlayText')" :showLeft="false"></NavBar>/>
<div class="video-box">
<video
v-if="show"
class="video"
:src="videoUrl"
id="videoEle"
controls
autoplay
></video>
</div>
</div>
</template>
<script lang="ts" setup>
/** 视频播放界面 */
import { ref } from 'vue'
import { onLoad, onReady } from '@dcloudio/uni-app'
import NavBar from '@/components/NavBar.vue'
import { t } from '@/utils/im/i18n'
/** 视频地址 */
const videoUrl = ref()
/** 是否显示视频 */
const show = ref(false)
onLoad((option: any) => {
videoUrl.value = decodeURIComponent(option.videoUrl)
show.value = true
})
onReady(() => {
show.value = true
})
</script>
<style lang="scss" scoped>
.video-wrapper {
overflow: hidden;
width: 100vw;
height: 100vh;
background: #000000;
display: flex;
flex-direction: column;
}
.video-box {
width: 100vw;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
.video {
width: 100%;
height: 95%;
}
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<view class="page">
<!-- 顶部导航 -->
<view class="nav">
<view class="back" @click="onBack">
<uni-icons type="left" color="#8B2316" size="24"></uni-icons>
</view>
<view class="title">群发消息</view>
<view class="right"></view>
</view>
<!-- 时间展示 -->
<view class="time-row">{{ currentTime }}</view>
<!-- 卡片区域 -->
<view class="card">
<view class="card-header">
<text class="label">{{ patientCount }}位患者</text>
<view class="close" @click="onClear">
<text>×</text>
</view>
</view>
<view class="card-body">
<view class="line">{{ patientName }}</view>
<view class="line phone">{{ patientPhone }}</view>
</view>
<view class="card-footer">
<view class="btn-outline" @click="onResend">再发一条</view>
</view>
</view>
<!-- 占位滚动区域 -->
<view class="spacer" />
<!-- 底部按钮 -->
<view class="bottom-bar">
<view class="btn-primary" @click="onSendGroup">群发消息</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const currentTime = ref('')
const patientCount = ref(1)
const patientName = ref('测试')
const patientPhone = ref('155555555')
const pad = (n) => (n < 10 ? `0${n}` : `${n}`)
const updateTime = () => {
const d = new Date()
currentTime.value = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
onMounted(() => {
updateTime()
// 每秒刷新一次时间(如不需要可删除)
setInterval(updateTime, 1000)
})
const onBack = () => {
uni.navigateBack()
}
const onClear = () => {
// 清空选择的患者(占位)
patientCount.value = 0
patientName.value = ''
patientPhone.value = ''
}
const onResend = () => {
uni.showToast({ title: '已触发再发一条', icon: 'none' })
}
const onSendGroup = () => {
uni.showToast({ title: '已触发群发', icon: 'none' })
}
</script>
<style lang="scss" scoped>
$page-bg: #f5f5f5;
$brand: #8B2316;
$brand-deep: #8B2316;
$primary: #00cbc0;
$red: #D32F2F;
.page {
background: $page-bg;
min-height: 100vh;
position: relative;
}
.nav {
height: 88rpx;
padding: 0 24rpx;
display: flex;
align-items: center;
background: #fff;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
.back { width: 80rpx; display: flex; align-items: center; }
.title { flex: 1; text-align: center; font-size: 34rpx; color: $red; font-weight: 600; }
.right { width: 80rpx; }
}
.time-row {
text-align: center;
font-size: 32rpx;
padding: 32rpx 0;
color: #333;
background: #fff;
}
.card {
margin: 24rpx;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
overflow: hidden;
.card-header {
position: relative;
padding: 28rpx 28rpx 12rpx 28rpx;
.border { height: 2rpx; background: #eee; }
.label { font-size: 32rpx; color: #333; }
.close {
position: absolute;
right: 0;
top: 0;
width: 100rpx;
height: 100rpx;
border-bottom-left-radius: 100rpx;
background: #8B2316;
display: flex; align-items: center; justify-content: center;
text { color: #fff; font-size: 40rpx; font-weight: 500; }
}
}
.card-body {
padding: 20rpx 28rpx 8rpx 28rpx;
.line { font-size: 32rpx; color: #333; padding: 16rpx 0; }
.phone { font-size: 36rpx; }
}
.card-footer {
display: flex;
justify-content: flex-end;
padding: 16rpx 28rpx 28rpx 28rpx;
.btn-outline {
border: 2rpx solid $red;
color: $red;
padding: 10rpx 24rpx;
border-radius: 28rpx;
font-size: 28rpx;
}
}
}
.spacer { height: 200rpx; }
.bottom-bar {
position: fixed;
left: 0; right: 0; bottom: 0;
background: #00cbc0;
height: 100rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-primary {
color: #fff;
font-size: 34rpx;
font-weight: 600;
}
}
</style>