chat-app/src/pages/dialog/index.vue

1997 lines
54 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.

<template>
<div class="dialog-page">
<ZPaging
use-chat-record-mode
use-virtual-list
cell-height-mode="dynamic"
:refresher-enabled="true"
:show-scrollbar="false"
:loading-more-enabled="true"
:hide-empty-view="true"
height="100%"
ref="zpagingRef"
v-model="virtualList"
:loading-more-custom-style="{ display: 'none', height: '0' }"
@scrolltolower="onScrollToLower"
@scrolltoupper="onScrollToUpper"
>
<template #top>
<customNavbar :title="talkParams.username" id="navBarArea">
<template
#subTitle
v-if="talkStore?.findItem(talkParams.index_name)?.group_type === 4"
>
<div class="text-[24rpx] text-[#999999]">公司群</div>
</template>
<template #right>
<div
class="mr-[36rpx] toChatSetting_btn"
v-if="!talkParams.isDismiss && !talkParams.isQuit"
>
<tm-icon
color="rgb(51, 51, 51)"
:font-size="36"
name="tmicon-gengduo"
@click="toChatSettingsPage"
></tm-icon>
</div>
</template>
</customNavbar>
</template>
<!-- <template #top>
<div class="load-toolbar pointer">
<span v-if="loadConfig.status == 0"> 正在加载数据中 ... </span>
<span v-else-if="loadConfig.status == 1" @click="onScrollToLower"> 查看更多消息 ... </span>
<span v-else class="no-more"> 没有更多消息了 </span>
</div>
</template> -->
<!-- 数据加载状态栏 -->
<div class="dialog-list" @touchstart="handleHidePanel">
<div
class="message-item"
v-for="item in virtualList"
:id="`zp-id-${item.msg_id}`"
:key="item.zp_index"
style="transform: scaleY(-1);"
>
<!-- 系统消息 -->
<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="userStore.uid"
:user_id="item.user_id"
:nickname="item.nickname"
:talk_type="item.talk_type"
:datetime="item.created_at"
:msg_id="item.msg_id"
:revokeInfo="item.revokeInfo"
:extra="item.extra"
>
<template
v-if="
canEditRevokedMessage(item) && item.user_id === userStore.uid
"
>
<span
class="edit-revoked-message"
@click="restoreRevokedMessage(item)"
>
重新编辑
</span>
</template>
</revoke-message>
</div>
<div
v-else
class="message-box record-box"
:class="{
'direction-rt': item.user_id === talkParams.uid,
'multi-select': dialogueStore.isOpenMultiSelect,
'multi-select-check': item.isCheck,
}"
>
<!-- 多选按钮 -->
<aside
v-if="dialogueStore.isOpenMultiSelect"
class="checkbox-column"
>
<!-- <n-checkbox size="small" :checked="item.isCheck" @update:checked="item.isCheck = !item.isCheck" /> -->
<tm-checkbox
:round="10"
:defaultChecked="item.isCheck"
@update:modelValue="item.isCheck = !item.isCheck"
:size="42"
color="#46299D"
></tm-checkbox>
</aside>
<!-- 头像信息 -->
<aside
class="avatar-column"
@click="toUserDetailPage(item)"
@touchstart="() => handleAvatarTouchStart(item)"
@touchend="handleAvatarTouchEnd"
>
<im-avatar
class="pointer"
:src="item.avatar"
:size="80"
:username="item.nickname"
@click="showUserInfoModal(item.user_id)"
/>
</aside>
<!-- 主体信息 -->
<main class="main-column">
<div class="talk-title">
<span
class="nickname pointer"
v-show="
talkParams.type == 2 && item.user_id !== talkParams.uid
"
>
<!-- <span class="at">@</span> -->
{{ item.nickname }}
</span>
<span>
{{ parseTime(item.created_at, '{m}/{d} {h}:{i}') }}
</span>
</div>
<div
class="talk-content"
:class="{ pointer: dialogueStore.isOpenMultiSelect }"
>
<deepBubble
@clickMenu="(menuType) => onContextMenu(menuType, item)"
:isShowCopy="isShowCopy(item)"
:isShowWithdraw="isRevoke(talkParams.uid, item) || isLeader"
>
<component
class="component-content"
:key="item.zp_index"
:is="MessageComponents[item.msg_type] || 'unknown-message'"
:extra="item.extra"
:data="item"
:max-width="true"
:source="'panel'"
/>
</deepBubble>
<!-- <div class="talk-tools">
<template v-if="talkParams.type == 1 && item.float == 'right'">
<loading theme="outline" size="19" fill="#000" :sxtrokeWidth="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>
</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>
</main>
</div>
</div>
<div class="load-toolbar pointer" style="transform: scaleY(-1);">
<span v-if="loadConfig.status == 0">正在加载数据中 ...</span>
<span v-if="loadConfig.status == 1" @click="onScrollToLower">
查看更多消息 ...
</span>
<span v-if="loadConfig.status == 2" class="no-more">
没有更多消息了
</span>
</div>
</div>
<template #bottom>
<div class="footBox" id="footBoxArea">
<div v-if="!dialogueStore.isOpenMultiSelect">
<div
class="pt-[16rpx] ml-[32rpx] mr-[32rpx] flex items-start justify-between"
>
<div class="flex-1 quillBox">
<QuillEditor
ref="editor"
id="editor"
:options="editorOption"
@editorChange="onEditorChange"
style="width: 100%; flex: 1; height: 100%; border: none;"
@click="onEditorClick"
/>
<!-- <tm-input type=textarea autoHeight focusColor="#F9F9F9" color="#F9F9F9" :inputPadding="[12]"
placeholder=""></tm-input> -->
<div class="quote-area" v-if="state?.quoteInfo">
<span
v-if="state?.quoteInfo?.msg_type === 1"
class="text-[28rpx] text-[#999]"
>
{{
state?.quoteInfo?.nickname +
'' +
state?.quoteInfo?.extra?.content
}}
</span>
<span
v-if="state?.quoteInfo"
class="text-[28rpx] text-[#999]"
>
{{
state?.quoteInfo?.nickname +
'' +
ChatMsgTypeMapping[state?.quoteInfo?.msg_type]
}}
</span>
<img
@click="clearQuoteInfo"
style="width: 30rpx; height: 30rpx;"
src="/src/static/image/login/check-circle-filled@3x.png"
/>
</div>
</div>
<div class="flex items-center justify-end h-[72rpx]">
<tm-image
:margin="[10, 0]"
@click="handleEmojiPanel"
:width="52"
:height="52"
:round="12"
:src="state.isOpenEmojiPanel ? keyboard : smile"
></tm-image>
<tm-image
@click="handleFilePanel"
:margin="[10, 0]"
:width="52"
:height="52"
:round="12"
:src="addCircleGray"
></tm-image>
<tm-button
@click="onSendMessageClick"
:margin="[0, 0]"
:padding="[0, 30]"
color="#46299D"
:fontSize="28"
size="mini"
:shadow="0"
label="发送"
></tm-button>
</div>
</div>
<div v-if="state.isOpenEmojiPanel" class="mt-[50rpx]">
<emojiPanel @on-select="onEmoticonEvent" />
</div>
<div v-if="state.isOpenFilePanel" class="mt-[16rpx]">
<filePanel
@selectImg="handleSelectImg"
:talkParams="talkParams"
/>
</div>
</div>
<div v-else class="h-[232rpx]">
<div
class="flex items-center justify-center mt-[12rpx] text-[24rpx] text-[#747474] leading-[44rpx]"
>
<div class="mr-[8rpx]">已选中:</div>
<div>{{ selectedMessage.length }}条消息</div>
</div>
<div
class="flex items-center justify-around pl-[128rpx] pr-[128rpx] mt-[18rpx] text-[20rpx] text-[#737373]"
>
<div
@click="handleMergeForward"
class="flex flex-col items-center justify-center"
>
<tm-image :width="68" :height="68" :src="zu6050"></tm-image>
<div class="mt-[6rpx]">合并转发</div>
</div>
<div
@click="handleSingleForward"
class="flex flex-col items-center justify-center"
>
<tm-image :width="68" :height="68" :src="zu6051"></tm-image>
<div class="mt-[6rpx]">逐条转发</div>
</div>
<div
@click="handleWechatForward"
class="flex flex-col items-center justify-center"
>
<tm-image :width="68" :height="68" :src="zu6052"></tm-image>
<div class="mt-[6rpx]">微信</div>
</div>
<div
@click="handleDelete"
class="flex flex-col items-center justify-center"
>
<tm-image :width="68" :height="68" :src="zu6053"></tm-image>
<div class="mt-[6rpx]">删除</div>
</div>
</div>
</div>
<!--底部安全区-->
<div class="content-placeholder"></div>
<tm-drawer
placement="bottom"
v-model:show="state.showWin"
:hideHeader="true"
:height="416"
:round="6"
>
<div class="w-full h-full flex flex-col items-center">
<div
class="mt-[46rpx] mb-[44rpx] leading-[48rpx] text-[#747474] text-[24rpx]"
>
撤回该条消息
</div>
<div class="divider"></div>
<div
@click="withdrawerConfirm"
class="mt-[32rpx] mb-[32rpx] text-[32rpx] text-[#CF3050] leading-[48rpx]"
>
撤回
</div>
<div class="divider"></div>
<div
@click="state.showWin = false"
class="mt-[32rpx] mb-[32rpx] text-[32rpx] text-[#000000] leading-[48rpx]"
>
取消
</div>
</div>
</tm-drawer>
</div>
</template>
</ZPaging>
<tm-drawer
placement="bottom"
v-model:show="state.isShowMentionSelect"
:hideHeader="true"
:round="5"
:height="state.mentionSelectHeight"
>
<div
class="mention-select-drawer flex flex-row flex-1 flex-row flex-row-center-between"
>
<div
class="cancel-btns flex-row flex flex-row-center-start"
style="width: 210rpx;"
>
<div
class="hide-btn"
v-if="!state.mentionIsMulSelect"
@click="hideMentionSelect"
>
<img
style="width: 40rpx; height: 40rpx;"
src="/src/static/image/chatList/mention_select_hide_bg.png"
/>
<img
style="
position: absolute;
top: 50%;
left: 50%;
margin-left: -9rpx;
margin-top: -5rpx;
"
src="/src/static/image/chatList/mention_select_hide_icon.png"
/>
</div>
<span
style="flex-shrink: 0; display: block;"
class="text-[32rpx] font-regular text-[#191919]"
v-if="state.mentionIsMulSelect"
@click="changeMentionSelectMul(false)"
>
{{ $t('cancel') }}
</span>
</div>
<div
class="flex flex-row-center-center flex-col"
style="padding: 6rpx 0;"
>
<text>{{ $t('chat.mention.select') }}</text>
</div>
<div class="flex-row flex flex-row-center-end" style="width: 210rpx;">
<div
class="mention-edit-btn"
v-if="!state.mentionIsMulSelect"
@click="changeMentionSelectMul(true)"
>
<span class="text-[32rpx] font-regular text-[#191919]">
{{ $t('button.multiple.choice') }}
</span>
</div>
<div
class="mention-done-btn"
:class="
state?.selectedMembersNum > 0 ? 'mention-done-btn-can-do' : ''
"
v-if="state.mentionIsMulSelect"
@click="confirmMentionSelect"
>
<span class="text-[32rpx] font-regular text-[#191919]">
{{ $t('button.text.done') }}
</span>
<span
class="text-[32rpx] font-regular text-[#191919]"
v-if="state?.selectedMembersNum > 0"
>
{{ '(' + state?.selectedMembersNum + ')' }}
</span>
</div>
</div>
</div>
<selectMemberByAlphabet
:manageType="'mention'"
ref="selectMemberByAlphabetRef"
:selectAreaHeight="state.selectAreaHeight"
@updateSelectedMembersNum="updateSelectedMembersNum"
:isMulSelect="state.mentionIsMulSelect"
@getSelectResult="getSelectResult"
@getMentionSelectLists="getMentionSelectLists"
></selectMemberByAlphabet>
</tm-drawer>
</div>
</template>
<script setup>
import selectMemberByAlphabet from '../chatSettings/components/selectMemberByAlphabet.vue'
import {
ref,
reactive,
watch,
computed,
onMounted,
onUnmounted,
nextTick,
} from 'vue'
import { QuillEditor, Quill } from '@vueup/vue-quill'
import EmojiBlot from './formats/emoji'
import { useChatList } from '@/store/chatList/index.js'
import { useAuth } from '@/store/auth'
import {
useUserStore,
useDialogueStore,
useUploadsStore,
useEditorDraftStore,
useTalkStore,
useSettingsStore,
useDialogueListStore,
} from '@/store'
import addCircleGray from '@/static/image/chatList/addCircleGray.png'
import {
MessageComponents,
ForwardableMessageType,
ChatMsgTypeMapping,
} from '@/constant/message'
import { formatTime, parseTime } from '@/utils/datetime'
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
import smile from '@/static/image/chatList/smile@2x.png'
import keyboard from '@/static/image/chatList/keyboard@2x.png'
import { useInject, useTalkRecord } from '@/hooks'
import { emitCall } from '@/utils/common'
import ZPaging from '@/uni_modules/z-paging/components/z-paging/z-paging.vue'
import useZPaging from '@/uni_modules/z-paging/components/z-paging/js/hooks/useZPaging.js'
import emojiPanel from './components/emojiPanel.vue'
import filePanel from './components/filePanel.vue'
import lodash from 'lodash'
import {
ServePublishMessage,
detailGetRecordsContext,
ServeClearTalkUnreadNum,
ServeTalkRecords,
} from '@/api/chat'
import copy07 from '@/static/image/chatList/copy07@2x.png'
import multipleChoices from '@/static/image/chatList/multipleChoices@2x.png'
import cite from '@/static/image/chatList/cite@2x.png'
import withdraw from '@/static/image/chatList/withdraw@2x.png'
import delete07 from '@/static/image/chatList/delete@2x.png'
import zu6050 from '@/static/image/chatList/zu6050@2x.png'
import zu6051 from '@/static/image/chatList/zu6051@2x.png'
import zu6052 from '@/static/image/chatList/zu6052@2x.png'
import zu6053 from '@/static/image/chatList/zu6053@2x.png'
import deepBubble from '@/components/deep-bubble/deep-bubble.vue'
import { isRevoke } from './menu'
import useConfirm from '@/components/x-confirm/useConfirm.js'
import { onLoad as uniOnload, onUnload as uniOnUnload } from '@dcloudio/uni-app'
Quill.register('formats/emoji', EmojiBlot)
import 'quill-mention'
const selectMemberByAlphabetRef = ref(null)
const {
getDialogueList,
updateZpagingRef,
virtualList,
} = useDialogueListStore()
const talkStore = useTalkStore()
const { showConfirm } = useConfirm()
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const dialogueStore = useDialogueStore()
const editorDraftStore = useEditorDraftStore()
const editor = ref()
const zpagingRef = ref()
useZPaging(zpagingRef)
const indexName = computed(() => dialogueStore.index_name)
const talkParams = reactive({
uid: computed(() => userStore.uid),
index_name: computed(() => dialogueStore.index_name),
type: computed(() => dialogueStore.talk.talk_type),
receiver_id: computed(() => dialogueStore.talk.receiver_id),
username: computed(() => dialogueStore.talk.username),
online: computed(() => dialogueStore.online),
keyboard: computed(() => dialogueStore.keyboard),
num: computed(() => dialogueStore.members.length),
isDismiss: computed(() => dialogueStore.isDismiss),
isQuit: computed(() => dialogueStore.isQuit),
adminList: computed(() => dialogueStore.getAdminList),
})
const state = ref({
isOpenEmojiPanel: false,
isOpenFilePanel: false,
showWin: false,
onfocusItem: null,
sessionId: '', //会话Id
localPageLoadDone: true, //分页加载缓存中的聊天记录是否完毕
quoteInfo: null, //引用信息
mentionIsMulSelect: false, //是否是多选提醒的人
selectedMembersNum: 0, //选中的要提醒的人数
mentionSelectHeight: 0, //选择要提醒人的区域高度
selectAreaHeight: 0, //选择要提醒人的可选人员列表区域高度
isShowMentionSelect: false, //是否显示要提醒人的选择区域
useCustomLoadMore: false, //是否使用自定义加载更多事件(下拉刷新、上拉加载)
recordDate: '', //按日期查询聊天记录的开始日期
serveFindRecord: [], //调用接口查找到的聊天记录
middleMsg: {}, //缓存中没有时,调用接口初次使用的依据记录
keepDialogInfo: false, //是否保存会话信息
lastCursorIndex: undefined, //在失焦前保存光标位置
mentionUserIds: [], //存储所有被@的用户ID
isInsertingMention: false, //是否正在插入mention
})
uniOnload(async (options) => {
console.log('onLoad' + JSON.stringify(options))
if (options.sessionId) {
state.value.sessionId = options.sessionId
}
if (options.keepDialogInfo) {
state.value.keepDialogInfo = options.keepDialogInfo === '1' ? true : false
}
if (options.msgInfo) {
const msgInfo = JSON.parse(decodeURIComponent(options.msgInfo))
queryRecordsByMsgInfo(msgInfo)
state.value.useCustomLoadMore = true
return
}
if (options.recordDate) {
state.value.recordDate = options.recordDate
const msgInfo = await findTalkRecords(options.recordDate, true)
queryRecordsByMsgInfo(msgInfo)
state.value.useCustomLoadMore = true
return
}
initData()
})
uniOnUnload(() => {
console.log('onUnload')
ServeClearTalkUnreadNum({
talk_type: Number(talkParams.type),
receiver_id: Number(talkParams.receiver_id),
}).then(() => {
talkStore.updateItem({
index_name: talkParams.index_name,
unread_num: 0,
})
})
})
const handleEmojiPanel = () => {
state.value.isOpenFilePanel = false
state.value.isOpenEmojiPanel = !state.value.isOpenEmojiPanel
}
const handleFilePanel = () => {
state.value.isOpenEmojiPanel = false
state.value.isOpenFilePanel = !state.value.isOpenFilePanel
}
//点击隐藏表情/文件上传 面板
const handleHidePanel = () => {
state.value.isOpenFilePanel = false
state.value.isOpenEmojiPanel = false
}
//点击编辑区聚焦输入框
const onEditorClick = () => {
handleHidePanel()
}
const onSendMessage = (data = {}, callBack) => {
let message = {
...data,
receiver: {
receiver_id: talkParams.receiver_id,
talk_type: talkParams.type,
},
}
ServePublishMessage(message)
.then(({ code, message }) => {
if (code == 200) {
if (callBack) {
callBack(true)
}
} else {
message.warning(message)
}
})
.catch(() => {
message.warning('网络繁忙,请稍后重试!')
})
}
const onSendMessageClick = () => {
// 发送前确保 mentionUserIds 是最新的
updateMentionUserIds()
let delta = getQuill().getContents()
if (state.value.quoteInfo) {
delta.ops.unshift({
insert: {
quote: {
id: state.value.quoteInfo.msg_id,
},
},
})
}
let data = deltaToMessage(delta)
if (data.items.length === 0) {
return
}
switch (data.msgType) {
case 1: // 文字消息
if (data.items[0].content.length > 1024) {
return message.info('发送内容超长,请分条发送')
}
onSendTextEvent({
data,
callBack: (ok) => {
if (!ok) return
getQuill().setContents([], Quill.sources.USER)
if (state.value.quoteInfo) {
state.value.quoteInfo = null
}
},
})
break
}
}
// 发送文本消息
const onSendTextEvent = lodash.throttle((value) => {
let { data, callBack } = value
let message = {
type: 'text',
content: data.items[0].content,
quote_id: data.quoteId,
mentions: state.value.mentionUserIds, // 使用最终的用户ID数组
}
console.log(message)
onSendMessage(message, callBack)
}, 1000)
// 编辑器输入事件
const onInputEvent = ({ data }) => {
talkStore.updateItem({
index_name: indexName.value,
draft_text: data,
})
// 判断对方是否在线和是否需要推送
// 3秒时间内推送一次
if (settingsStore.isKeyboard && props.online) {
onKeyboardPush()
}
}
// 发送表情消息
const onSendEmoticonEvent = ({ data }) => {
onSendMessage({ type: 'emoticon', emoticon_id: data })
}
// 注册事件
const evnets = {
text_event: onSendTextEvent,
input_event: onInputEvent,
emoticon_event: onSendEmoticonEvent,
history_event: () => {
isShowHistory.value = true
},
}
const {
loadConfig,
records,
onLoad,
onRefreshLoad,
onJumpMessage,
} = useTalkRecord(talkParams.uid)
const getQuill = () => {
return editor.value?.getQuill()
}
const isShowCopy = (item) => {
switch (item.msg_type) {
case 1:
return true
case 3:
return true
case 5:
return true
case 6:
return true
default:
return false
}
}
const selectedMessage = computed(() => {
return virtualList.value.filter((item) => item.isCheck)
})
// 编辑器事件
const onEditorEvent = (msg) => {
evnets[msg.event] && evnets[msg.event](msg)
}
const getQuillSelectionIndex = () => {
let quill = getQuill()
return (quill.getSelection() || {}).index
}
const onEmoticonEvent = (data) => {
if (data.type == 1) {
const quill = getQuill()
let index = getQuillSelectionIndex()
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
quill.deleteText(0, 1)
index = 0
}
if (data.img) {
quill.insertEmbed(index, 'emoji', {
alt: data.value,
src: data.img,
})
} else {
quill.insertText(index, data.value)
}
quill.setSelection(index + 1, 0, 'user')
} else {
let fn = emitCall('emoticon_event', data.value, () => {})
emit('editor-event', fn)
}
}
const onEditorChange = () => {
let delta = getQuill().getContents()
let text = deltaToString(delta)
// 如果正在插入mention不进行mention的检查和更新
if (!state.value.isInsertingMention) {
updateMentionUserIds()
}
if (!isEmptyDelta(delta)) {
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text,
ops: delta.ops,
})
} else {
// 删除 editorDraftStore.items 下的元素
delete editorDraftStore.items[indexName.value || '']
}
onEditorEvent(emitCall('input_event', text))
// emit('editor-event', emitCall('input_event', text))
// 清理过期的撤回消息超过5分钟
const now = new Date().getTime()
Object.keys(state.value.revokedMessages || {}).forEach((msgId) => {
if (now - state.value.revokedMessages[msgId].revokeTime > 5 * 60 * 1000) {
delete state.value.revokedMessages[msgId]
}
})
}
const onClipboardMatcher = (node, Delta) => {
const ops = []
Delta.ops.forEach((op) => {
// 如果粘贴了图片,这里会是一个对象,所以可以这样处理
if (op.insert && typeof op.insert === 'string') {
ops.push({
insert: op.insert, // 文字内容
attributes: {}, //文字样式(包括背景色和文字颜色等)
})
} else {
ops.push(op)
}
})
Delta.ops = ops
return Delta
}
const editorOption = {
debug: false,
modules: {
toolbar: false,
clipboard: {
// 粘贴版,处理粘贴时候的自带样式
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]],
},
keyboard: {
bindings: {
enter: {
key: 13,
handler: onSendMessageClick,
},
},
},
// imageUploader: {
// upload: onEditorUpload
// },
mention: {
allowedChars: /^[\u4e00-\u9fa5]*$/,
mentionDenotationChars: ['@'],
positioningStrategy: 'fixed',
renderItem: (data) => {
const el = document.createElement('div')
el.className = 'ed-member-item'
el.innerHTML += `<span class="nickname">${data.nickname}</span>`
return el
},
source: function (searchTerm, renderList, mentionChar) {
if (talkParams.type === 1) {
return
}
// 在失焦前保存光标位置
const quill = getQuill()
const range = quill.getSelection()
if (range) {
state.value.lastCursorIndex = range.index
}
state.value.isShowMentionSelect = true
// 失去焦点
quill.blur()
},
mentionContainerClass: '',
},
},
placeholder: '',
}
const handleSelectImg = (data, file_num) => {
onSendMessage({ ...data, file_num })
}
const virtualListChange = (vList) => {
virtualList.value = vList
}
const onContextMenu = (menuType, item) => {
console.log(menuType, item, 'item')
switch (menuType) {
case 'actionCopy':
actionCopy(item)
break
case 'multipleChoose':
multipleChoose(item)
break
case 'actionCite':
actionCite(item)
break
case 'actionWithdraw':
actionWithdraw(item)
break
case 'actionDelete':
actionDelete(item)
break
default:
break
}
}
const actionCopy = (item) => {
console.log('复制')
let content = ''
switch (item.msg_type) {
case 1:
content = item.extra.content
break
case 3:
content = item.extra.url
break
case 5:
content = item.extra.url
break
default:
break
}
uni.setClipboardData({
data: content,
})
}
const multipleChoose = (item) => {
item.isCheck = true
dialogueStore.setMultiSelect(true)
}
const actionCite = (item) => {
console.log('引用')
state.value.quoteInfo = item
}
//清除引用信息
const clearQuoteInfo = () => {
state.value.quoteInfo = null
}
const actionWithdraw = (item) => {
console.log('撤回')
state.value.onfocusItem = item
state.value.showWin = true
}
const withdrawerConfirm = () => {
dialogueStore.ApiRevokeRecord(state.value.onfocusItem.msg_id)
state.value.onfocusItem = null
state.value.showWin = false
}
// 添加检查是否可以重新编辑撤回消息的函数
const canEditRevokedMessage = (item) => {
// console.log(item)
if (item.is_revoke === 1 && item.msg_type === 1) {
const now = new Date().getTime()
const revokeTime = new Date(item.created_at).getTime()
// console.log(now)
// 检查是否在5分钟内
return now - revokeTime <= 5 * 60 * 1000
}
return false
}
// 添加恢复撤回消息到输入框的函数
const restoreRevokedMessage = async (item) => {
// 接口拿数据,之后把查询的内容给输入框
const res = await detailGetRecordsContext({
msgId: item.msg_id,
})
console.log(res)
if (res.code == 200) {
const content = res.data.item?.extra?.content
const quill = getQuill()
quill.setText(content)
// 将光标设置到文本末尾
quill.setSelection(content.length, 0)
quill.focus()
}
/* const revokedMsg = state.value.revokedMessages[msgId]
// 根据消息类型处理
if (revokedMsg.msgType === 1) { // 文本消息
const quill = getQuill()
quill.setText(revokedMsg.content)
quill.focus()
} */
// 可以根据需要添加其他类型消息的处理
}
const actionDelete = (item) => {
console.log('删除')
item.isCheck = true
handleDelete()
}
const handleMergeForward = () => {
if (selectedMessage.value.length == 0) {
return message.warning('未选择消息')
}
console.log('合并转发')
dialogueStore.setForwardType(2)
dialogueStore.setForwardMessages(selectedMessage.value)
uni.navigateTo({
url: '/pages/chooseChat/index',
success: function (res) {
clearMultiSelect()
},
})
}
const handleSingleForward = () => {
if (selectedMessage.value.length == 0) {
return message.warning('未选择消息')
}
console.log('逐条转发')
dialogueStore.setForwardType(1)
dialogueStore.setForwardMessages(selectedMessage.value)
uni.navigateTo({
url: '/pages/chooseChat/index',
success: function (res) {
clearMultiSelect()
},
})
}
const handleWechatForward = () => {
if (selectedMessage.value.length == 0) {
return message.warning('未选择消息')
}
console.log('微信转发')
}
const handleDelete = () => {
if (selectedMessage.value.length == 0) {
return message.warning('未选择消息')
}
console.log('删除')
showConfirm({
content: '确定删除聊天记录',
confirmText: '删除',
confirmColor: '#CF3050',
onConfirm: async () => {
const msgIds = selectedMessage.value.map((item) => item.msg_id)
virtualList.value = virtualList.value.filter(
(item) => !msgIds.includes(item.msg_id),
)
dialogueStore.ApiDeleteRecord(msgIds)
clearMultiSelect()
},
onCancel: () => {},
})
}
//更新选中的要提醒的人数
const updateSelectedMembersNum = (numChange) => {
state.value.selectedMembersNum = state.value.selectedMembersNum + numChange
}
watch(
() => zpagingRef.value,
(newValue, oldValue) => {
if (newValue) {
updateZpagingRef(newValue)
}
},
)
watch(
() => virtualList.value,
(newValue, oldValue) => {
if (newValue) {
const dialogueList = getDialogueList(talkParams.index_name)
// console.log(newValue[newValue.length - 1]?.sequence, dialogueList?.records?.[0]?.sequence)
if (!dialogueList || dialogueList?.length === 0) {
state.value.localPageLoadDone = true
return
}
// 只在正常加载模式下检查是否加载完缓存数据
if (!state.value.useCustomLoadMore) {
if (
newValue[newValue.length - 1]?.sequence ===
dialogueList?.records?.[0]?.sequence
) {
if (
dialogueList?.records?.[0]?.sequence !== 1 &&
!state.value.localPageLoadDone
) {
loadConfig.status = 1
}
//相同意味着分页加载缓存中的聊天记录完毕
state.value.localPageLoadDone = true
} else {
state.value.localPageLoadDone = false
}
}
}
},
{
deep: true,
},
)
const onScrollToLower = async () => {
if (state.value.useCustomLoadMore) {
const tempVirtualList = lodash.cloneDeep(virtualList.value).reverse()
const dialogueList = getDialogueList(talkParams.index_name)
const recordIndex = dialogueList?.records?.findIndex(
(record) => record.msg_id === tempVirtualList[0].msg_id,
)
if (!recordIndex || recordIndex === -1) {
const moreRecords = await findTalkRecords(
'',
false,
tempVirtualList[0].sequence,
{
direction: 'up',
sort_sequence: '',
},
)
console.log(moreRecords)
// 格式化新加载的消息
const formattedMoreRecords = moreRecords.map((item) => ({
...item,
float: item.user_id === talkParams.uid ? 'right' : 'left',
}))
virtualList.value = formattedMoreRecords.concat(tempVirtualList).reverse()
loadConfig.status =
dialogueList?.records?.[0]?.sequence > 1 && moreRecords.length > 0
? 1
: 2
} else {
if (tempVirtualList[0].sequence > dialogueList.records[0].sequence) {
virtualList.value = dialogueList.records
.slice(Math.max(0, recordIndex - 10), recordIndex)
.concat(tempVirtualList)
.reverse()
// zpagingRef.value.setLocalPaging(
// dialogueList.records
// .slice(0, recordIndex)
// .concat(tempVirtualList)
// .reverse(),
// // zpagingRef.value.scrollIntoViewById('zp-id-' + virtualList.value[virtualList.value.length - 1].msg_id)
// )
console.log(virtualList.value)
}
}
return
}
if (state.value.localPageLoadDone) {
//本地缓存的聊天记录分页加载完之后,才可以请求接口
onRefreshLoad()
}
}
//本来的下拉刷新——列表倒置后为上拉加载
const onScrollToUpper = async () => {
if (state.value.useCustomLoadMore) {
const tempVirtualList = lodash.cloneDeep(virtualList.value).reverse()
const dialogueList = getDialogueList(talkParams.index_name)
const recordIndex = dialogueList?.records?.findIndex(
(record) =>
record.msg_id === tempVirtualList[tempVirtualList.length - 1].msg_id,
)
console.log(recordIndex)
if (!recordIndex || recordIndex === -1) {
// 记住加载更多前消息的ID
const currentMsgId = tempVirtualList[tempVirtualList.length - 1].msg_id
const moreRecords = await findTalkRecords(
'',
false,
tempVirtualList[tempVirtualList.length - 1].sequence,
)
console.log(moreRecords)
// 格式化新加载的消息
const formattedMoreRecords = moreRecords.map((item) => ({
...item,
float: item.user_id === talkParams.uid ? 'right' : 'left',
}))
virtualList.value = tempVirtualList
.concat(formattedMoreRecords.reverse())
.reverse()
console.log(virtualList.value)
// 数据更新后,滚动到之前的位置
nextTick(() => {
zpagingRef.value?.scrollIntoViewById('zp-id-' + currentMsgId)
})
} else {
if (
tempVirtualList[tempVirtualList.length - 1].sequence <
dialogueList.records[dialogueList.records.length - 1].sequence
) {
// 记住加载更多前消息的ID
const currentMsgId = tempVirtualList[tempVirtualList.length - 1].msg_id
virtualList.value = tempVirtualList
.concat(
dialogueList.records.slice(
recordIndex + 1,
Math.min(recordIndex + 11, dialogueList.records.length),
),
)
.reverse()
// 数据更新后,滚动到之前的位置
nextTick(() => {
zpagingRef.value?.scrollIntoViewById('zp-id-' + currentMsgId)
})
}
}
}
}
const clearMultiSelect = () => {
dialogueStore.setMultiSelect(false)
virtualList.value.forEach((item) => {
item.isCheck = false
})
}
const initData = async () => {
const dialogueList = getDialogueList(talkParams.index_name)
let objT = {
uid: talkParams.uid,
talk_type: talkParams.type,
receiver_id: talkParams.receiver_id,
index_name: talkParams.index_name,
direction: dialogueList ? 'down' : 'up',
no_limit: dialogueList ? 1 : 0,
}
await onLoad({ ...objT })
zpagingRef.value?.setLocalPaging(records.value)
}
//点击跳转到聊天设置页面
const toChatSettingsPage = () => {
uni.navigateTo({
url:
'/pages/chatSettings/index?groupId=' +
talkParams?.receiver_id +
'&sessionId=' +
state.value.sessionId,
})
}
//点击跳转到用户详情页面
const toUserDetailPage = (userItem) => {
uni.navigateTo({
url:
'/pages/dialog/dialogDetail/userDetail?erpUserId=' + userItem.erp_user_id,
})
}
//切换提醒的人选择弹窗多选状态
const changeMentionSelectMul = (status) => {
state.value.mentionIsMulSelect = status
}
//隐藏要提醒人的选择
const hideMentionSelect = () => {
state.value.isShowMentionSelect = false
// 清除保存的光标位置
state.value.lastCursorIndex = undefined
// 获得焦点
const quill = getQuill()
quill.focus()
}
//确认要提醒人的选择
const confirmMentionSelect = () => {
if (state?.value.selectedMembersNum > 0) {
if (selectMemberByAlphabetRef.value) {
selectMemberByAlphabetRef.value.confirmSelectMembers()
}
hideMentionSelect()
}
}
//获取选择的结果
const getSelectResult = (mentionSelect) => {
console.log(mentionSelect)
getMentionSelectLists(mentionSelect)
}
//处理要提醒人的消息样式
const getMentionSelectLists = (mentionSelectList) => {
const quill = getQuill()
const mention = quill.getModule('mention')
// 使用之前保存的光标位置
if (state.value.lastCursorIndex !== undefined) {
// 删除光标前的 @ 符号
quill.deleteText(state.value.lastCursorIndex - 1, 1)
}
// 设置正在插入mention的标记
state.value.isInsertingMention = true
// 逐个插入mention
mentionSelectList.forEach((mentionSelectItem) => {
mention.insertItem(
{
id: mentionSelectItem?.id,
denotationChar: '@',
value: mentionSelectItem?.nickname + ' ',
},
true,
)
})
// 将光标设置到文本末尾
const length = quill.getLength()
quill.setSelection(length, 0)
// 清除保存的光标位置
state.value.lastCursorIndex = undefined
// 延迟清除插入mention的标记并手动触发一次内容更新
setTimeout(() => {
state.value.isInsertingMention = false
// 手动触发一次内容更新,确保 mentionUserIds 是最新的
updateMentionUserIds()
}, 100)
hideMentionSelect()
}
// 新增一个更新 mentionUserIds 的函数
const updateMentionUserIds = () => {
const delta = getQuill().getContents()
const ops = delta.ops || []
const currentMentions = new Set()
// 收集当前所有@的用户ID并转换为number类型
ops.forEach((op) => {
if (op.insert && op.insert.mention) {
currentMentions.add(Number(op.insert.mention.id))
}
})
// 直接使用当前编辑器中的mentions作为mentionUserIds
state.value.mentionUserIds = Array.from(currentMentions)
}
//根据msg信息找到对应的聊天记录并根据sequence等查看上下文
const queryRecordsByMsgInfo = async (msgInfo) => {
console.log(msgInfo)
state.value.middleMsg = msgInfo
const dialogueList = getDialogueList(talkParams.index_name)
const recordIndex = dialogueList?.records?.findIndex(
(record) => record.msg_id === msgInfo.msg_id,
)
let recordsList = []
console.log(recordIndex)
if (!recordIndex || recordIndex === -1) {
recordsList = await findTalkRecords('', true, msgInfo.sequence)
} else {
// console.log(recordIndex)
const startRecordIndex = Math.max(0, recordIndex - 10)
const endRecordIndex = Math.max(0, recordIndex + 10)
// console.log(dialogueList.records.slice(startRecordIndex, endRecordIndex))
// console.log(recordIndex-startRecordIndex)
recordsList = dialogueList.records.slice(startRecordIndex, endRecordIndex)
}
// 格式化消息,确保每条消息都有正确的 float
recordsList = recordsList.map((item) => {
return {
...item,
float: item.user_id === talkParams.uid ? 'right' : 'left',
}
})
nextTick(() => {
zpagingRef.value.complete(recordsList.reverse())
loadConfig.status =
dialogueList?.records?.[0]?.sequence > 1 && recordsList.length > 0 ? 1 : 2
nextTick(() => {
let offset = uni.getSystemInfoSync().windowHeight
const navBarAreaQuery = uni.createSelectorQuery()
navBarAreaQuery
.select('#navBarArea')
.boundingClientRect((res) => {
if (res) {
// console.log('元素高度:', res.height)
offset = offset - res.height
}
})
.exec()
const footBoxAreaQuery = uni.createSelectorQuery()
footBoxAreaQuery
.select('#footBoxArea')
.boundingClientRect((res) => {
if (res) {
// console.log('元素高度:', res.height)
offset = offset - res.height
}
})
.exec()
setTimeout(() => {
zpagingRef.value.scrollIntoViewById(
'zp-id-' + msgInfo.msg_id,
offset - 60,
)
}, 1000)
})
})
}
//查找聊天记录
const findTalkRecords = (record, isMiddle, sequence, appointParams) => {
return new Promise((resolve, reject) => {
let params = {
talk_type: talkParams.type, //1私聊2群聊
receiver_id: talkParams.receiver_id, //目标用户id或群聊id
no_limit: '', //1不限制
file_name: '',
msg_type: 0, //消息类型0:全部;2:代码;3:图片;4:音频;5:视频;6:文件;7:位置;9:会话;11群投票;12图文混合
cursor: sequence || 0, //上次查询的游标
limit: 10, //数据行数
direction: 'down', //down向下查最新up向上查老数据
start_time: '',
end_time: '',
group_member_user_id: 0, //群成员id当查询群历史消息的时候需要指定群成员的时候送
sort_sequence: 'asc',
create_time: state.value.middleMsg.created_at,
}
if (record) {
params = Object.assign({}, params, {
start_time: record,
end_time: record,
limit: 1,
direction: '',
})
}
if (appointParams) {
params = Object.assign({}, params, appointParams)
}
console.log(params)
const resp = ServeTalkRecords(params)
console.log(resp)
resp.then(({ code, data }) => {
console.log(data)
if (code == 200) {
if (data?.items.length > 0) {
if (record) {
resolve(data?.items[0])
return
}
if (isMiddle) {
state.value.serveFindRecord = JSON.parse(
JSON.stringify(data?.items),
)
return findTalkRecords('', false, sequence + 1, {
direction: 'up',
sort_sequence: '',
}).then((finalResult) => {
console.log(finalResult)
resolve(finalResult)
})
} else {
state.value.serveFindRecord = data?.items
.reverse()
.concat(state.value.serveFindRecord)
resolve(JSON.parse(JSON.stringify(state.value.serveFindRecord)))
state.value.serveFindRecord = []
}
} else {
resolve([])
}
} else {
resolve([])
}
})
resp.catch(() => {})
})
}
//是否是管理员
const isLeader = computed(() => {
if (talkParams.adminList.length > 0) {
return (
talkParams.adminList.filter(
(adminItem) => adminItem.erp_user_id === useAuth()?.userInfo?.value?.ID,
).length > 0
)
}
return false
})
//长按头像@用户
const doMentionUser = (mentionSelect) => {
console.log(mentionSelect)
// 构造正确的 mention 对象
const mentionObj = {
id: mentionSelect.user_id, // 使用 user_id 而不是 erp_user_id
nickname: mentionSelect.nickname,
}
getMentionSelectLists([mentionObj])
}
let avatarPressTimer = null
let currentPressItem = null
const handleAvatarTouchStart = (item) => {
currentPressItem = item
avatarPressTimer = setTimeout(() => {
doMentionUser(item)
}, 500)
}
const handleAvatarTouchEnd = () => {
if (avatarPressTimer) {
clearTimeout(avatarPressTimer)
avatarPressTimer = null
}
currentPressItem = null
}
onMounted(async () => {
if (typeof plus !== 'undefined') {
const webview = plus.webview.currentWebview()
webview.setStyle({
bottom: 0,
})
} else {
document.addEventListener('plusready', () => {
const webview = plus.webview.currentWebview()
webview.setStyle({
bottom: 0,
})
})
}
nextTick(() => {
state.value.mentionSelectHeight = pxTorPx(
uni.getSystemInfoSync().windowHeight * 0.86,
)
state.value.selectAreaHeight =
rpxToPx(state.value.mentionSelectHeight) - rpxToPx(90) + 'px'
})
})
const pxTorPx = (px) => {
const sysInfo = uni.getSystemInfoSync()
const rpx = px / (sysInfo.screenWidth / 750)
return rpx
}
const rpxToPx = (rpx) => {
const sysInfo = uni.getSystemInfoSync()
const px = (sysInfo.screenWidth / 750) * rpx
return px
}
onUnmounted(() => {
if (!state.value.keepDialogInfo) {
dialogueStore.setDialogue({})
}
clearMultiSelect()
if (avatarPressTimer) {
clearTimeout(avatarPressTimer)
}
})
</script>
<style scoped lang="less">
.dialog-page {
flex: 1;
background-image: url('@/static/image/clockIn/z3280@3x.png');
background-size: cover;
background-position: bottom center;
background-attachment: scroll;
width: 100%;
.dialog-list {
padding: 20rpx 32rpx;
}
.toChatSetting_btn {
::v-deep .tmicon-gengduo {
line-height: unset !important;
}
}
}
.edit-revoked-message {
margin-left: 10rpx;
color: #46299d;
cursor: pointer;
font-size: 24rpx;
&:hover {
text-decoration: underline;
}
}
.searchRoot {
background-color: #fff;
padding: 22rpx 18rpx;
}
.contentRoot {
margin-top: 20rpx;
background-color: #fff;
}
.footBox {
min-height: 162rpx;
background-color: #fff;
.quote-area {
margin: 4rpx 0 0 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
overflow: hidden;
width: 100%;
span {
display: -webkit-inline-box;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
width: 100%;
}
img {
margin: 0 0 0 30rpx;
flex-shrink: 0;
}
}
}
.load-toolbar {
height: 50rpx;
color: #409eff;
text-align: center;
line-height: 50rpx;
font-size: 24rpx;
.no-more {
color: #b9b3b3;
}
}
.message-item {
&.border {
border-radius: 16rpx;
}
}
.message-box {
width: 100%;
min-height: 30rpx;
margin-bottom: 5rpx;
}
.record-box {
display: flex;
flex-direction: row;
align-items: flex-start;
.checkbox-column {
display: flex;
justify-content: center;
width: 42rpx;
order: 1;
user-select: none;
margin-top: 20rpx;
margin-right: 20rpx;
}
.avatar-column {
width: 80rpx;
display: flex;
align-items: center;
order: 2;
user-select: none;
margin-top: 16rpx;
flex-direction: column;
}
.main-column {
flex: 1 auto;
order: 3;
position: relative;
box-sizing: border-box;
padding: 16rpx 20rpx 14rpx 20rpx;
// overflow: hidden;
min-height: 30px;
.talk-title {
display: flex;
align-items: center;
margin-bottom: 6rpx;
font-size: 24rpx;
user-select: none;
color: #bababa;
opacity: 1;
&.show {
opacity: 1;
}
.nickname {
color: var(--im-text-color);
margin-right: 5rpx;
.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 16rpx;
color: #a79e9e;
font-size: 24rpx;
user-select: none;
align-items: center;
justify-content: space-around;
.more-tools {
visibility: hidden;
margin-left: 5rpx;
}
}
}
.talk-reply {
display: flex;
align-items: flex-start;
align-items: center;
width: fit-content;
padding: 8rpx;
margin-top: 6rpx;
margin-right: auto;
font-size: 24rpx;
color: #8f8f8f;
word-break: break-all;
background-color: var(--im-message-left-bg-color);
border-radius: 10rpx;
max-width: 450rpx;
overflow: hidden;
user-select: none;
.icon-top {
margin-right: 6rpx;
}
.ellipsis {
display: -webkit-inline-box;
text-overflow: ellipsis;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
&: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);
}
}
}
.content-placeholder {
height: 58rpx;
}
.quillBox {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
:deep(.ql-clipboard) {
position: relative;
opacity: 0;
height: 1rpx;
overflow: auto;
}
:deep(.ql-editor) {
padding: 14rpx 22rpx;
background-color: #f9f9f9;
border-radius: 8rpx;
outline: none !important;
max-height: 294rpx;
overflow: auto;
line-height: 44rpx;
font-size: 32rpx;
p {
display: inline-flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
white-space: normal;
word-break: break-all;
.ed-emoji {
width: 44rpx;
height: 44rpx;
display: inline-block;
}
span {
user-select: all;
}
}
}
}
:deep(.wd-action-sheet) {
background-color: #8b8b8b !important;
}
:deep(.wd-action-sheet__panel-title) {
color: #fff !important;
}
.component-content {
position: relative;
z-index: 1;
/* 确保 z-index 低于 deepBubble */
}
.divider {
width: 100%;
height: 1rpx;
background-color: #e7e7e7;
}
.mention-select-drawer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 36rpx 32rpx 0;
.cancel-btns {
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.hide-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
position: relative;
text {
}
img {
width: 18rpx;
height: 10rpx;
}
}
}
.mention-done-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 6rpx 24rpx;
background-color: #f3f3f3;
border-radius: 8rpx;
flex-shrink: 0;
span {
color: #bababa;
line-height: 40rpx;
flex-shrink: 0;
}
}
.mention-done-btn-can-do {
background-color: #46299d;
span {
color: #fff;
}
}
.mention-edit-btn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
span {
flex-shrink: 0;
}
}
}
:deep(.mention) {
color: #1890ff;
}
</style>