chat-pc/src/views/message/inner/panel/PanelContent.vue

1222 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { watch, onMounted, ref, nextTick, onUnmounted } from 'vue'
import { NDropdown, NCheckbox, NPopover, NInfiniteScroll } from 'naive-ui'
import { Loading, MoreThree, ToTop } from '@icon-park/vue-next'
import { bus } from '@/utils/event-bus'
import { useDialogueStore } from '@/store'
import { formatTime, parseTime } from '@/utils/datetime'
import { clipboard, htmlDecode, clipboardImage } from '@/utils/common'
import { downloadImage } from '@/utils/functions'
import { MessageComponents, ForwardableMessageType } from '@/constant/message'
import { useMenu } from './menu'
import SkipBottom from './SkipBottom.vue'
import { ITalkRecord } from '@/types/chat'
import { EditorConst } from '@/constant/event-bus'
import { useInject, useTalkRecord, useUtil } from '@/hooks'
import { ExclamationCircleFilled } from '@ant-design/icons-vue'
import { useUserStore, useUploadsStore } from '@/store'
import RevokeMessage from '@/components/talk/message/RevokeMessage.vue'
import { voiceToText, ServeMessageReadDetail } from '@/api/chat.js'
import { confirmBox } from '@/components/confirm-box/service.js'
import ws from '@/connect'
import avatarModule from '@/components/avatar-module/index.vue'
// 定义消息已读状态接口
interface ReadStatus {
msg_ids: string[]
talk_type: number
receiver_id: number
user_id?: number
}
// 定义状态接口
interface State {
visibleElements: Set<HTMLElement>
visibleOutElements: Set<HTMLElement>
tempWaitDoRead: ReadStatus[]
tempWaitDoCheck: ReadStatus[]
setMessageReadInterval: number | null
setOutMessageReadInterval: number | null
lastUpdateTime: number
isScrolling: boolean
scrollTimer: number | null
lastTriggerTime: number
talkReadListDetail: any[]
readDetailIsUnread: number
currentMsgReadDetail: any | null
currentReadDetailPage: number
hasMoreReadListDetail: boolean
loadingReadListDetail: boolean
lastScrollTop: number
}
const props = defineProps({
uid: {
type: Number,
default: 0
},
talk_type: {
type: Number,
default: 0
},
receiver_id: {
type: Number,
default: 0
},
index_name: {
type: String,
default: ''
},
specifiedMsg: {
type: String,
default: ''
},
num: {
type: Number,
default: 0
}
})
const { loadConfig, records, onLoad, onRefreshLoad, onJumpMessage, onLoadMoreDown } = useTalkRecord(props.uid)
const uploadsStore = useUploadsStore()
const { useMessage } = useUtil()
const { dropdown, showDropdownMenu, closeDropdownMenu } = useMenu()
const { showUserInfoModal } = useInject()
const dialogueStore = useDialogueStore()
const userStore = useUserStore()
// const showUserInfoModal = (uid: number) => {
// userStore.getUserInfo(uid)
// }
// 置底按钮
const skipBottom = ref(false)
// 是否显示消息时间
const isShowTalkTime = (index: number, datetime: string) => {
if (datetime == undefined) {
return false
}
if (records.value[index].is_revoke == 1) {
return false
}
datetime = datetime.replace(/-/g, '/')
let time = Math.floor(Date.parse(datetime) / 1000)
let currTime = Math.floor(new Date().getTime() / 1000)
// 当前时间5分钟内时间不显示
if (currTime - time < 300) return false
// 判断是否是最后一条消息,最后一条消息默认显示时间
if (index == records.value.length - 1) {
return true
}
let nextDate = records.value[index + 1].created_at.replace(/-/g, '/')
return !(
parseTime(new Date(datetime), '{y}-{m}-{d} {h}:{i}') ==
parseTime(new Date(nextDate), '{y}-{m}-{d} {h}:{i}')
)
}
// 窗口滚动事件
const onPanelScroll = (e: any) => {
if (e.target.scrollTop == 0) {
onRefreshLoad()
}
const height = e.target.scrollTop + e.target.clientHeight
const scrollHeight = e.target.scrollHeight
const isScrollingDown = e.target.scrollTop > state.value.lastScrollTop
state.value.lastScrollTop = e.target.scrollTop
// 触底且必须是向下滚动
if (height === scrollHeight && isScrollingDown) {
onLoadMoreDown()
}
skipBottom.value = height < scrollHeight - 200
if (!skipBottom.value && dialogueStore.unreadBubble) {
dialogueStore.setUnreadBubble(0)
}
// 设置滚动状态
state.value.isScrolling = true
if (state.value.scrollTimer) {
clearTimeout(state.value.scrollTimer)
}
state.value.scrollTimer = window.setTimeout(() => {
state.value.isScrolling = false
// 滚动停止,强制触发一次
checkVisibleOutElements()
// 重置节流时间戳,保证下一次变化能立刻触发
lastVisibleOutTriggerTime = Date.now()
}, 300) // 300ms 的防抖时间
// 检测是否到达底部
if (skipBottom.value == false) {
let len = dialogueStore.records.length
if (len > 100) {
// 为了优化数据过多页面卡顿页面最多只显示100条数据
// 目前没有用虚拟列表只能这么干
dialogueStore.records.splice(0, len - 100)
let minSequence = 0
dialogueStore.records.forEach((item: ITalkRecord) => {
if (minSequence == 0 || item.sequence < minSequence) {
minSequence = item.sequence
}
})
loadConfig.cursor = minSequence
loadConfig.status = 1
}
}
}
// 复制文本信息
const onCopyText = (data: ITalkRecord) => {
if (data.msg_type == 1) {
if (data.extra.content && data.extra.content.length > 0) {
return clipboard(htmlDecode(data.extra.content), () => useMessage.success('复制成功'))
}
}
if (data.extra?.url) {
return clipboardImage(data.extra.url, () => {
useMessage.success('复制成功')
})
}
}
// 删除对话消息
const onDeleteTalk = (data: ITalkRecord) => {
dialogueStore.ApiDeleteRecord([data.msg_id])
}
// 撤销对话消息
const onRevokeTalk = (data: ITalkRecord) => {
dialogueStore.ApiRevokeRecord(data.msg_id)
}
// 多选事件
const onMultiSelect = (data: ITalkRecord) => {
dialogueStore.updateDialogueRecord({
msg_id: data.msg_id,
isCheck: true
})
dialogueStore.isOpenMultiSelect = true
}
const onDownloadFile = (data: ITalkRecord) => {
if (data.msg_type == 3) {
return downloadImage(data.extra.url, `${data.msg_id}.${data.extra.suffix}`)
}
if (data.msg_type == 4) {
return useMessage.info('音频暂不支持下载!')
}
return useMessage.info('视频暂不支持下载!')
}
const onQuoteMessage = (data: ITalkRecord) => {
let item = {
id: data.msg_id,
title: `${data.nickname} ${data.created_at}`,
describe: '',
image: ''
}
switch (data.msg_type) {
case 1:
item.describe = data?.extra?.content
break // 文本消息
case 2:
item.describe = '[代码消息]'
break // 代码消息
case 3:
item.image = data.extra.url
break // 图片文件
case 4:
item.describe = '[语音文件]'
break // 语音文件
case 5:
item.describe = '[视频文件]'
break // 视频文件
case 6:
item.describe = '[其它文件]'
break // 其它文件
case 7:
item.describe = '[位置消息]'
break // 位置消息
case 8:
item.describe = '[名片消息]'
break // 名片消息
case 9:
item.describe = '[转发消息]'
break // 转发消息
case 10:
item.describe = '[登录消息]'
break // 登录消息
case 11:
item.describe = '[投票消息]'
break // 投票消息
case 12:
item.describe = '[图文消息]'
break // 图文消息
}
bus.emit('editor:quote', item)
}
const onCollectImage = (data: ITalkRecord) => {
if (data.msg_type == 3) {
dialogueStore.ApiCollectImage({
msg_id: data.msg_id
})
}
}
const onClickNickname = (data: ITalkRecord) => {
bus.emit(EditorConst.Mention, {
id: data.user_id,
value: data.nickname
})
}
// 会话列表右键显示菜单
const onContextMenu = (e: any, item: ITalkRecord) => {
if (!dialogueStore.isShowEditor || dialogueStore.isOpenMultiSelect) {
return e.preventDefault()
}
showDropdownMenu(e, props.uid, item)
e.preventDefault()
}
const onConvertText = async (data: ITalkRecord) => {
data.is_convert_text = 1
const res = await voiceToText({ msgId: data.msg_id, voiceUrl: data.extra.url })
if (res.code == 200) {
data.extra.content = res.data.convText
}
}
const onloseConvertText = (data: ITalkRecord) => {
data.is_convert_text = 0
}
const evnets = {
copy: onCopyText,
revoke: onRevokeTalk,
delete: onDeleteTalk,
multiSelect: onMultiSelect,
download: onDownloadFile,
quote: onQuoteMessage,
collect: onCollectImage,
convertText: onConvertText,
closeConvertText: onloseConvertText
}
// 会话列表右键菜单回调事件
const onContextMenuHandle = (key: string) => {
// 触发事件
evnets[key] && evnets[key](dropdown.item)
closeDropdownMenu()
}
const onRowClick = (item: ITalkRecord) => {
if (dialogueStore.isOpenMultiSelect) {
if (ForwardableMessageType.includes(item.msg_type)) {
item.isCheck = !item.isCheck
} else {
useMessage.info('此类消息不支持转发')
}
}
}
const lastParams = ref('')
// 监听整个 props 对象的变化
watch(
() => props,
async (newProps) => {
await nextTick()
let specialParams = undefined
console.error(newProps, 'newProps')
if (newProps.specifiedMsg) {
try {
const parsed = JSON.parse(decodeURIComponent(newProps.specifiedMsg))
// 只有会话id和参数都匹配才进入特殊模式
if (
parsed.talk_type === newProps.talk_type &&
parsed.receiver_id === newProps.receiver_id
) {
specialParams = parsed
}
} catch (e) {}
}
onLoad(
{
receiver_id: newProps.receiver_id,
talk_type: newProps.talk_type,
limit: 30
},
specialParams ? { specifiedMsg: specialParams } : undefined
)
},
{ immediate: true, deep: true }
)
// onMounted(() => {
// onLoad({ ...props, limit: 30 })
// })
const retry = (item: any) => {
confirmBox({
content: '确定重发吗'
}).then(() => {
uploadsStore.retryCommonUpload(item.extra.upload_id)
})
}
const onContextMenuAvatar = (e: any, item: any) => {
e.preventDefault()
if (item.float !== 'right') {
bus.emit(EditorConst.Mention, {
id: item.user_id,
value: item.nickname
})
}
}
const state = ref<State>({
visibleElements: new Set(),
visibleOutElements: new Set(),
tempWaitDoRead: [],
tempWaitDoCheck: [],
setMessageReadInterval: null,
setOutMessageReadInterval: null,
lastUpdateTime: 0,
isScrolling: false,
scrollTimer: null,
lastTriggerTime: 0,
talkReadListDetail: [],
readDetailIsUnread: 1,
currentMsgReadDetail: null,
currentReadDetailPage: 1,
hasMoreReadListDetail: true,
loadingReadListDetail: false,
lastScrollTop: 0
})
// 定义观察者变量
let observer: IntersectionObserver | null = null
// 检查需要发送已读回执的元素
const checkVisibleElements = () => {
if (state.value.visibleElements.size > 0) {
let waitDoRead: ReadStatus[] = []
state.value.visibleElements.forEach((el: HTMLElement) => {
const msgId = el.dataset.msgid
const talkType = Number(el.dataset.talktype)
const receiverId = Number(el.dataset.receiverid)
if (!msgId) return
if (waitDoRead.length === 0) {
waitDoRead.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId
})
} else {
const existingItem = waitDoRead.find(
(item) => item.talk_type === talkType && item.receiver_id === receiverId
)
if (existingItem) {
existingItem.msg_ids.push(msgId)
} else {
waitDoRead.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId
})
}
}
})
if (waitDoRead.length > 0) {
waitDoRead.forEach((doReadItem) => {
const prevItem = state.value.tempWaitDoRead.find(
(prev) =>
prev.talk_type === doReadItem.talk_type && prev.receiver_id === doReadItem.receiver_id
)
if (!prevItem || !doReadItem.msg_ids.every((id) => prevItem.msg_ids.includes(id))) {
console.error('====发送了新版已读回执=====', doReadItem)
ws.emit('im.message.new.read', doReadItem)
}
})
}
state.value.tempWaitDoRead = JSON.parse(JSON.stringify(waitDoRead))
}
}
// 检查需要获取别人发送的已读回执列表的元素
const checkVisibleOutElements = () => {
if (state.value.visibleOutElements.size > 0) {
let waitDoCheck: ReadStatus[] = []
state.value.visibleOutElements.forEach((el: HTMLElement) => {
const msgId = el.dataset.msgid
const talkType = Number(el.dataset.talktype)
const receiverId = Number(el.dataset.receiverid)
if (!msgId) return
if (waitDoCheck.length === 0) {
waitDoCheck.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId,
user_id: props.uid
})
} else {
const existingItem = waitDoCheck.find(
(item) => item.talk_type === talkType && item.receiver_id === receiverId
)
if (existingItem) {
existingItem.msg_ids.push(msgId)
} else {
waitDoCheck.push({
msg_ids: [msgId],
talk_type: talkType,
receiver_id: receiverId,
user_id: props.uid
})
}
}
})
if (waitDoCheck.length > 0) {
waitDoCheck.forEach((doCheckItem) => {
console.error('====组装了新版已读回执参数需要发送socket=====', doCheckItem)
ws.emit('im.message.listen.read', doCheckItem)
})
}
state.value.tempWaitDoCheck = JSON.parse(JSON.stringify(waitDoCheck))
}
}
// 定义节流时间戳
let lastVisibleOutTriggerTime = 0
//新版采用socket监听已读回执不轮询接口
watch(
() => state.value.visibleOutElements,
(newVal) => {
const now = Date.now()
if (now - lastVisibleOutTriggerTime < 1000) {
return
}
lastVisibleOutTriggerTime = now
checkVisibleOutElements()
},
{
deep: true,
immediate: true
}
)
// 观察者函数
const handleIntersection = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
let elData = entry.target.dataset
const msgType = elData.msgtype
const userId = elData.userid
if (Number(msgType) < 1000 && Number(userId) !== Number(props.uid)) {
//我读别人发的消息,需要发送已读回执
state.value.visibleElements.add(entry.target)
}
if (Number(msgType) < 1000 && Number(userId) === Number(props.uid)) {
//我发的消息,需要获取别人发送的已读回执列表
state.value.visibleOutElements.add(entry.target)
}
} else {
// 元素离开视口,从集合中移除
state.value.visibleElements.delete(entry.target)
state.value.visibleOutElements.delete(entry.target)
}
})
}
// 监听消息列表变化
watch(
() => records.value,
() => {
nextTick(() => {
// 断开旧的观察者
if (observer) {
observer.disconnect()
}
// 重新初始化观察者
const options = {
root: null,
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
rootMargin: '50px 0px'
}
observer = new IntersectionObserver(handleIntersection, options)
// 重新观察所有消息元素
const messageElements = document.querySelectorAll('.message-item')
messageElements.forEach((el) => {
if (observer) {
observer.observe(el)
}
})
})
},
{ deep: true }
)
// 事件总线防抖处理
let eventBusDebounceTimer: number | null = null
onMounted(() => {
// 事件总线监听
bus.subscribe('check-visible-out-elements', (type) => {
if (eventBusDebounceTimer) {
clearTimeout(eventBusDebounceTimer)
}
eventBusDebounceTimer = window.setTimeout(() => {
checkVisibleOutElements()
eventBusDebounceTimer = null
}, 500)
})
//设置观察者前设置定时器
if (state.value.setMessageReadInterval) {
clearInterval(state.value.setMessageReadInterval)
state.value.setMessageReadInterval = null
}
state.value.setMessageReadInterval = setInterval(() => {
checkVisibleElements()
}, 2000)
if (state.value.setOutMessageReadInterval) {
clearInterval(state.value.setOutMessageReadInterval)
state.value.setOutMessageReadInterval = null
}
// 旧版采用定时器来轮询已读回执新版采用socket监听已读回执
// state.value.setOutMessageReadInterval = setInterval(() => {
// checkVisibleOutElements()
// }, 2000)
//初始化设置观察者
const options = {
root: null,
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
rootMargin: '50px 0px'
}
observer = new IntersectionObserver(handleIntersection, options)
// 观察所有消息元素
nextTick(() => {
const messageElements = document.querySelectorAll('.message-item')
messageElements.forEach((el) => {
if (observer) {
observer.observe(el)
}
})
})
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
if (state.value.setMessageReadInterval) {
clearInterval(state.value.setMessageReadInterval)
state.value.setMessageReadInterval = null
checkVisibleElements()
}
if (state.value.setOutMessageReadInterval) {
clearInterval(state.value.setOutMessageReadInterval)
state.value.setOutMessageReadInterval = null
checkVisibleOutElements()
}
// 清理事件总线防抖定时器
if (eventBusDebounceTimer) {
clearTimeout(eventBusDebounceTimer)
eventBusDebounceTimer = null
}
// 事件总线移除监听
bus.unsubscribe('check-visible-out-elements', checkVisibleOutElements)
})
//点击显示对应消息的已读回执详情
const toShowMessageReadDetail = async (item?: ITalkRecord) => {
if (item) {
state.value.currentMsgReadDetail = item
onReadTabChange('unread-tab')
return
}
let params = {
page: state.value.currentReadDetailPage,
pageSize: 10,
type: 'detail', //list是列表detail是详情
talkType: state?.value?.currentMsgReadDetail?.talk_type, //1私聊2群聊
receiverId: state?.value?.currentMsgReadDetail?.receiver_id, //私聊的时候是对方用户id群聊的时候是对方群id
msgId: state?.value?.currentMsgReadDetail?.msg_id,
isUnread: state?.value?.readDetailIsUnread //不送或者送0代表看已读送1看未读
}
console.log(params)
const resp = await ServeMessageReadDetail(params)
console.log(resp)
if (resp.code === 200) {
console.log(resp?.data?.data?.length)
if (resp?.data?.data?.length > 0) {
state.value.hasMoreReadListDetail = true
if (state.value.currentReadDetailPage === 1) {
state.value.talkReadListDetail = resp.data.data
} else {
state.value.talkReadListDetail = [...state.value.talkReadListDetail, ...resp.data.data]
}
} else {
if (state.value.currentReadDetailPage === 1) {
state.value.talkReadListDetail = []
}
state.value.hasMoreReadListDetail = false
}
}
}
//已读回执tab切换
const onReadTabChange = (value) => {
if (value === 'unread-tab') {
//未读
state.value.readDetailIsUnread = 1
} else if (value === 'read-tab') {
//已读
state.value.readDetailIsUnread = 0
}
state.value.currentReadDetailPage = 1
toShowMessageReadDetail()
}
//加载更多已读回执
const loadMoreReadListDetail = () => {
console.log('loadMoreReadListDetail')
if (!state.value.hasMoreReadListDetail || state.value.loadingReadListDetail) {
return
}
state.value.loadingReadListDetail = true
state.value.currentReadDetailPage++
toShowMessageReadDetail().finally(() => {
state.value.loadingReadListDetail = false
})
}
</script>
<template>
<section class="section">
<div
id="imChatPanel"
class="me-scrollbar me-scrollbar-thumb talk-container"
@scroll="onPanelScroll($event)"
>
<!-- 数据加载状态栏 -->
<div class="load-toolbar pointer">
<span v-if="loadConfig.status == 0"> 正在加载数据中 ... </span>
<span v-else-if="loadConfig.status == 1" @click="onRefreshLoad"> 查看更多消息 ... </span>
<span v-else class="no-more"> 没有更多消息了 </span>
</div>
<div
class="message-item"
v-for="(item, index) in records"
:key="item.msg_id"
:id="item.msg_id"
:data-msgid="item.msg_id"
:data-msgtype="item.msg_type"
:data-userid="item.user_id"
:data-talktype="props?.talk_type"
:data-receiverid="props?.receiver_id"
>
<!-- 系统消息 -->
<div v-if="item.msg_type >= 1000" class="message-box">
<component
:is="MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
/>
</div>
<!-- 撤回消息 -->
<div v-else-if="item.is_revoke == 1" class="message-box">
<revoke-message
:login_uid="uid"
:data="item"
:user_id="item.user_id"
:nickname="item.nickname"
:talk_type="item.talk_type"
:datetime="item.created_at"
/>
</div>
<div
v-else
class="message-box record-box"
:class="{
'direction-rt': item.float == 'right',
'multi-select': dialogueStore.isOpenMultiSelect,
'multi-select-check': item.isCheck
}"
>
<!-- 多选按钮 -->
<aside v-if="dialogueStore.isOpenMultiSelect" class="checkbox-column shrink-0">
<n-checkbox
size="small"
:checked="item.isCheck"
@update:checked="item.isCheck = !item.isCheck"
/>
</aside>
<!-- 头像信息 -->
<aside class="avatar-column">
<im-avatar
class="pointer"
:src="item.avatar"
:size="42"
:username="item.nickname"
@contextmenu.prevent="onContextMenuAvatar($event, item)"
@click="showUserInfoModal(item.erp_user_id, item.user_id)"
/>
</aside>
<!-- 主体信息 -->
<main class="main-column">
<!-- <div class="talk-title">
<span
class="nickname pointer"
v-show="talk_type == 2 && item.float == 'left'"
@click="onClickNickname(item)"
>
<span class="at">@</span>{{ item.nickname }}
</span>
<span>{{ parseTime(item.created_at, '{y}/{m}/{d} {h}:{i}') }}</span>
</div> -->
<div class="talk-title">
<span class="mr-7px" v-show="talk_type == 2 && item.float == 'left'"
>{{ item.nickname }}
</span>
<span>{{ parseTime(item.created_at, '{y}/{m}/{d} {h}:{i}') }}</span>
</div>
<div
class="talk-content"
:class="{ pointer: dialogueStore.isOpenMultiSelect }"
@click="onRowClick(item)"
>
<component
:is="MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
:max-width="true"
:source="'panel'"
@contextmenu.prevent="onContextMenu($event, item)"
/>
<div
v-if="
item.float === 'right' && item.extra.percentage === -1 && item.extra.is_uploading
"
class="mr-10px"
>
<n-button text style="font-size: 20px;" @click="retry(item)">
<n-icon color="#CF3050">
<ExclamationCircleFilled />
</n-icon>
</n-button>
</div>
<!-- <div class="talk-tools">
<template v-if="talk_type == 1 && item.float == 'right'">
<loading
theme="outline"
size="19"
fill="#000"
:strokeWidth="1"
class="icon-rotate"
v-show="item.send_status == 1"
/>
<span v-show="item.send_status == 1"> 正在发送... </span>
<span v-show="item.send_status != 1"> 已送达 </span>
</template>
<n-icon class="more-tools pointer" :component="MoreThree" @click="onContextMenu($event, item)" />
</div> -->
</div>
<div
v-if="item.extra.reply"
class="talk-reply pointer"
@click="onJumpMessage(item.extra?.reply?.msg_id)"
>
<n-icon :component="ToTop" size="14" class="icon-top" />
<span class="ellipsis">
回复 {{ item.extra?.reply?.nickname }}:
{{ item.extra?.reply?.content }}
</span>
</div>
<!-- 已读回执 -->
<div class="talk_read_num" v-if="item.user_id === props.uid">
<n-popover trigger="click" placement="bottom-end" style="height: 382px; padding: 0;">
<template #trigger>
<span v-if="props.talk_type === 1">{{
item.read_total_num > 0 ? '已读' : '未读'
}}</span>
<span v-if="props.talk_type === 2" @click="toShowMessageReadDetail(item)">
已读 ({{ item?.read_total_num || 0 }}/{{
props.num - 1 > 0 ? props.num - 1 : 0
}})
</span>
</template>
<div class="talk-read-list-detail">
<n-tabs
type="line"
animated
justify-content="space-around"
@update:value="onReadTabChange"
>
<n-tab name="unread-tab">
{{ `未读(${props.num - 1 - (item.read_total_num || 0) || 0})` }}
</n-tab>
<n-tab name="read-tab">
{{ `已读(${item.read_total_num || 0})` }}
</n-tab>
</n-tabs>
<div class="talk-read-list">
<n-infinite-scroll style="height: 340px;" @load="loadMoreReadListDetail">
<div
class="talk-read-list-item"
v-for="(talkReadDetailItem,
talkReadDetailIndex) in state.talkReadListDetail"
:key="talkReadDetailIndex"
>
<avatarModule
:mode="1"
:avatar="talkReadDetailItem.avatar"
:userName="talkReadDetailItem.nickName"
:groupType="0"
:customStyle="{
width: '36px',
height: '36px'
}"
:customTextStyle="{
fontSize: '12px',
fontWeight: 'bold',
color: '#fff',
lineHeight: '17px'
}"
></avatarModule>
<div class="talk-read-list-item-info">
<span style="font-size: 12px; font-weight: 600; line-height: 17px;">{{
talkReadDetailItem.nickName
}}</span>
<span style="font-size: 12px; color: #999; line-height: 14px;">{{
talkReadDetailItem.jobNum
}}</span>
</div>
</div>
</n-infinite-scroll>
</div>
</div>
</n-popover>
</div>
</main>
</div>
<div class="datetime" v-show="isShowTalkTime(index, item.created_at)">
{{ formatTime(item.created_at) }}
</div>
</div>
</div>
<!-- 置底按钮 -->
<SkipBottom v-model="skipBottom" />
</section>
<!-- 右键菜单 -->
<n-dropdown
:show="dropdown.show"
:x="dropdown.x"
:y="dropdown.y"
style="width: 142px;"
:options="dropdown.options"
@select="onContextMenuHandle"
@clickoutside="closeDropdownMenu"
/>
</template>
<style lang="less" scoped>
.section {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
}
.talk-container {
height: 100%;
width: 100%;
box-sizing: border-box;
padding: 10px 15px 30px;
overflow-y: auto;
overflow-x: hidden;
.load-toolbar {
height: 38px;
color: #409eff;
text-align: center;
line-height: 38px;
font-size: 13px;
.no-more {
color: #b9b3b3;
}
}
.message-item {
&.border {
border-radius: 10px;
border: 1px solid var(--im-primary-color);
}
}
.message-box {
width: 100%;
min-height: 30px;
margin-bottom: 5px;
}
.datetime {
height: 30px;
line-height: 30px;
color: #ccc9c9;
font-size: 12px;
text-align: center;
margin: 5px 0;
}
.record-box {
display: flex;
flex-direction: row;
.checkbox-column {
display: flex;
justify-content: center;
width: 35px;
order: 1;
user-select: none;
padding-top: 12px;
}
.avatar-column {
width: 47px;
display: flex;
align-items: center;
order: 2;
user-select: none;
padding-top: 10px;
flex-direction: column;
}
.main-column {
flex: 1 auto;
order: 3;
position: relative;
box-sizing: border-box;
padding: 5px;
overflow: hidden;
min-height: 30px;
.talk-title {
display: flex;
align-items: center;
height: 24px;
margin-bottom: 2px;
font-size: 12px;
user-select: none;
color: #a7a0a0;
opacity: 1;
&.show {
opacity: 1;
}
.nickname {
color: var(--im-text-color);
margin-right: 5px;
font-size: 12px;
.at {
display: none;
}
&:hover {
color: var(--im-primary-color);
.at {
display: inline-block;
}
}
}
span {
transform: scale(0.88);
transform-origin: left center;
}
}
.talk-content {
display: flex;
justify-content: flex-start;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
.talk-tools {
display: flex;
margin: 0 8px;
color: #a79e9e;
font-size: 12px;
user-select: none;
align-items: center;
justify-content: space-around;
.more-tools {
visibility: hidden;
margin-left: 5px;
}
}
}
.talk-reply {
display: flex;
align-items: flex-start;
align-items: center;
width: fit-content;
padding: 4px;
margin-top: 3px;
margin-right: auto;
font-size: 12px;
color: #8f8f8f;
word-break: break-all;
background-color: var(--im-message-left-bg-color);
border-radius: 5px;
max-width: 300px;
overflow: hidden;
user-select: none;
.icon-top {
margin-right: 3px;
}
.ellipsis {
display: -webkit-inline-box;
text-overflow: ellipsis;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
.talk_read_num {
text-align: right;
color: #7a58de;
font-size: 12px;
font-weight: 400;
line-height: 17px;
margin: 5px 0 0;
span {
cursor: pointer;
}
}
&:hover {
.talk-title {
opacity: 1;
}
.more-tools {
visibility: visible !important;
}
}
}
&.direction-rt {
.avatar-column {
order: 3;
}
.main-column {
order: 2;
.talk-title {
justify-content: flex-end;
span {
transform-origin: right center;
}
}
.talk-content {
flex-direction: row-reverse;
}
.talk-reply {
margin-right: 0;
margin-left: auto;
}
}
}
&.multi-select {
border-radius: 5px;
&:hover,
&.multi-select-check {
background-color: var(--im-active-bg-color);
}
}
}
}
.talk-read-list-detail {
width: 341px;
padding: 0 14px;
.talk-read-list {
.talk-read-list-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #f1f1f1;
.talk-read-list-item-info {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
span {
}
}
}
}
}
</style>