msg.content消息结构重构

This commit is contained in:
bob
2025-05-17 12:21:50 +08:00
parent ecfa623a72
commit a2017be96a
26 changed files with 1080 additions and 948 deletions

View File

@@ -11,17 +11,23 @@ export const proto = {
// 和服务端约定好的第一个消息都是从10001开始的
export const BEGIN_MSG_ID = 10001
// 消息内容类型
/**
* 消息内容类型
* MIX类型为TEXT,EMOJI,SCREENSHOT,AT,QUOTE的组合
*/
export const msgContentType = {
MIX: 0, // 组合,包含多种类型
TEXT: 1, // 文本
IMAGE: 2, // 图
RECORDING: 3, // 语音
AUDIO: 4, // 音频文件
EMOJI: 5, // 表情
VIDEO: 6, // 视频
DOCUMENT: 7, // 文档
FORWARD_TOGETHER: 10 // 合并转发消息
TEXT: 0b0000000000000001, // 文本
EMOJI: 0b0000000000000010, // 表情
SCREENSHOT: 0b0000000000000100, //
AT: 0b0000000000001000, // @
QUOTE: 0b0000000000010000, // 引用
IMAGE: 0b0000001000000000, // 图片
RECORDING: 0b0000010000000000, // 语音
AUDIO: 0b0000100000000000, // 音频文件
VIDEO: 0b0001000000000000, // 视频
DOCUMENT: 0b0010000000000000, // 文档
FORWARD_TOGETHER: 0b0100000000000000 // 合并转发消息
}
// 消息发送状态

View File

@@ -164,6 +164,16 @@ export const showTimeFormatDay = (datatime) => {
return `${year}-${month}-${day}`
}
export const showDurationFormat = (duration) => {
if (!duration) {
return '0:00'
}
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
export const combineId = (fromId, toId) => {
if (fromId < toId) {
return fromId + '@' + toId

188
src/js/utils/message.js Normal file
View File

@@ -0,0 +1,188 @@
import { msgContentType } from '@/const/msgConst'
import { jsonParseSafe, showDurationFormat } from './common'
import { useImageStore, useAudioStore, useVideoStore, useDocumentStore } from '@/stores'
import { emojis } from './emojis'
const imageData = useImageStore()
const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
export const showSimplifyMsgContent = (content) => {
const arr = jsonParseSafe(content)
if (!arr) {
return content
}
let simplifyContent = ''
for (const item of arr) {
if (!item.type || !item.value) {
return content
}
switch (item.type) {
case msgContentType.TEXT:
case msgContentType.EMOJI:
simplifyContent = simplifyContent + item.value
break
case msgContentType.AT:
simplifyContent = simplifyContent + `@${item.value.nickName} `
break
case msgContentType.SCREENSHOT:
simplifyContent = simplifyContent + `[截图]`
break
case msgContentType.QUOTE:
simplifyContent = simplifyContent + '[引用]'
break
case msgContentType.RECORDING:
simplifyContent =
simplifyContent + `[语音] ${showDurationFormat(audioData.audio[item.value].duration)}`
break
case msgContentType.IMAGE:
simplifyContent = simplifyContent + `[图片] ${imageData.image[item.value].fileName}`
break
case msgContentType.AUDIO:
simplifyContent = simplifyContent + `[音频] ${audioData.audio[item.value].fileName}`
break
case msgContentType.VIDEO:
simplifyContent = simplifyContent + `[视频] ${videoData.video[item.value].fileName}`
break
case msgContentType.DOCUMENT:
simplifyContent = simplifyContent + `[文件] ${documentData.document[item.value].fileName}`
break
case msgContentType.FORWARD_TOGETHER:
simplifyContent = simplifyContent + '[聊天记录]'
break
default:
simplifyContent = simplifyContent + item.value
break
}
}
return simplifyContent
}
/**
* 内容字符串是否匹配消息结构
*/
export const isMatchMsgStruct = (contentStr) => {
const contentArr = jsonParseSafe(contentStr)
if (!contentArr || !Array.isArray(contentArr) || contentArr.length === 0) {
return false
}
for (const item of contentArr) {
const { type, value } = item
if (!type || !value) {
return false
}
switch (type) {
case msgContentType.TEXT:
break
case msgContentType.EMOJI:
if (!(value in emojis)) {
return false
}
break
case msgContentType.SCREENSHOT:
case msgContentType.IMAGE:
case msgContentType.RECORDING:
case msgContentType.AUDIO:
case msgContentType.VIDEO:
case msgContentType.DOCUMENT:
if (!/^\d+$/.test(value)) {
return false
}
break
case msgContentType.AT: {
const { account, nickName } = value
if (!account || !nickName) {
return false
}
break
}
case msgContentType.QUOTE: {
const { msgId, nickName } = value
if (!msgId || !nickName || !/^\d+$/.test(msgId)) {
return false
}
break
}
case msgContentType.FORWARD_TOGETHER: {
const { sessionId, data } = value
if (!sessionId || !data) {
return false
}
if (Array.isArray(data)) {
return
}
for (const item of data) {
const { msgId, nickName } = item
if (!msgId || !nickName || !/^\d+$/.test(msgId)) {
return false
}
}
break
}
default:
return false
}
}
return true
}
/**
* 是否为 MIX 类型
* @param {*} type
* @returns
*/
export const isMixType = (type) => {
const MIX_CANDIDATES =
msgContentType.TEXT |
msgContentType.EMOJI |
msgContentType.SCREENSHOT |
msgContentType.AT |
msgContentType.QUOTE
return type <= MIX_CANDIDATES
}
/**
* 所有包含图片的type集合
* @returns
*/
export const imageTypes = () => {
return [
msgContentType.IMAGE,
msgContentType.SCREENSHOT,
msgContentType.SCREENSHOT || msgContentType.TEXT,
msgContentType.SCREENSHOT || msgContentType.EMOJI,
msgContentType.SCREENSHOT || msgContentType.AT,
msgContentType.SCREENSHOT || msgContentType.QUOTE,
msgContentType.SCREENSHOT || msgContentType.TEXT || msgContentType.EMOJI,
msgContentType.SCREENSHOT || msgContentType.TEXT || msgContentType.AT,
msgContentType.SCREENSHOT || msgContentType.TEXT || msgContentType.QUOTE,
msgContentType.SCREENSHOT || msgContentType.EMOJI || msgContentType.AT,
msgContentType.SCREENSHOT || msgContentType.EMOJI || msgContentType.QUOTE,
msgContentType.SCREENSHOT || msgContentType.AT || msgContentType.QUOTE,
msgContentType.SCREENSHOT || msgContentType.TEXT || msgContentType.EMOJI || msgContentType.AT,
msgContentType.SCREENSHOT || msgContentType.TEXT || msgContentType.AT || msgContentType.QUOTE,
msgContentType.SCREENSHOT || msgContentType.EMOJI || msgContentType.AT || msgContentType.QUOTE,
msgContentType.SCREENSHOT ||
msgContentType.TEXT ||
msgContentType.EMOJI ||
msgContentType.QUOTE,
msgContentType.SCREENSHOT ||
msgContentType.TEXT ||
msgContentType.EMOJI ||
msgContentType.AT ||
msgContentType.QUOTE
]
}

View File

@@ -3,7 +3,7 @@ import { proto } from '@/const/msgConst'
import { useUserStore } from '@/stores'
import { v4 as uuidv4 } from 'uuid'
export const chatConstructor = ({ sessionId, remoteId, content, sequence }) => {
export const chatConstructor = ({ sessionId, remoteId, content, contentType, sequence }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
@@ -18,6 +18,7 @@ export const chatConstructor = ({ sessionId, remoteId, content, sequence }) => {
toId: remoteId,
sessionId: sessionId,
content: content,
contentType: contentType,
seq: sequence
})
const chatMsg = Msg.create({ header: header, body: body })
@@ -27,7 +28,7 @@ export const chatConstructor = ({ sessionId, remoteId, content, sequence }) => {
return data
}
export const groupChatConstructor = ({ sessionId, remoteId, content, sequence }) => {
export const groupChatConstructor = ({ sessionId, remoteId, content, contentType, sequence }) => {
const header = Header.create({
magic: proto.magic,
version: proto.version,
@@ -42,6 +43,7 @@ export const groupChatConstructor = ({ sessionId, remoteId, content, sequence })
sessionId: sessionId,
groupId: remoteId,
content: content,
contentType: contentType,
seq: sequence
})
const msg = Msg.create({ header: header, body: body })

View File

@@ -343,9 +343,15 @@ class WsConnect {
* @param {*} before 发送前的处理,用于展示发送前状态
* @param {*} after 发送后(接收MsgType.DELIVERED时)的处理,用于展示发送后状态
*/
sendMsg(sessionId, remoteId, msgType, content, seq, before, after) {
sendMsg(sessionId, remoteId, msgType, content, contentType, seq, before, after) {
const sequence = seq || uuidv4()
const data = this.dataConstructor[msgType]({ sessionId, remoteId, content, sequence })
const data = this.dataConstructor[msgType]({
sessionId,
remoteId,
content,
contentType,
sequence
})
before(data)
this.msgIdRefillCallback[sequence] = after
this.sendAgent(data)

View File

@@ -64,6 +64,7 @@ class Message {
fromId,
msgType,
content,
contentType,
msgTime,
sendTime = undefined,
msgId = undefined,
@@ -78,6 +79,7 @@ class Message {
this.fromId = fromId
this.msgType = msgType
this.content = content
this.contentType = contentType
this.status = status
this.msgTime = msgTime
this.sendTime = sendTime

View File

@@ -819,6 +819,7 @@ export const Body = ($root.Body = (() => {
* @property {string|null} [groupId] Body groupId
* @property {number|Long|null} [msgId] Body msgId
* @property {string|null} [content] Body content
* @property {number|null} [contentType] Body contentType
* @property {string|null} [seq] Body seq
* @property {string|null} [sessionId] Body sessionId
*/
@@ -836,8 +837,9 @@ export const Body = ($root.Body = (() => {
* | 5 | groupId | - | - | - | - | M | M | - | M | - | todo | todo |
* | 6 | msgId | - | - | - | M | - | M | O | O | M | todo | todo |
* | 7 | content | - | - | M | M | M | M | M | M | - | todo | todo |
* | 8 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
* | 9 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
* | 8 | contentType | - | - | M | M | M | M | - | - | - | todo | todo |
* | 9 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
* |10 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
* +---+--------------+------+-----------+---------|-----------+---------------+-----------------+----------+----------------+----------+-------------------+---------------------+
* NO filed STATUS_REQ STATUS_RES STATUS_SYNC SYS_GROUP_XXX AT(up) AT(down) REVOKE DELETE
* +---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
@@ -848,8 +850,9 @@ export const Body = ($root.Body = (() => {
* | 5 | groupId | - | - | - | M | M | M | o | - |
* | 6 | msgId | - | - | - | M | - | M | M | M |
* | 7 | content | M | M | M | M | M | M | M | - |
* | 8 | seq | - | - | - | - | M | M | - | - |
* | 9 | sessionId | - | - | - | M | M | M | M | M |
* | 8 | contentType | - | - | - | - | - | - | - | - |
* | 9 | seq | - | - | - | - | M | M | - | - |
* |10 | sessionId | - | - | - | M | M | M | M | M |
* +---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
* @implements IBody
* @constructor
@@ -917,6 +920,14 @@ export const Body = ($root.Body = (() => {
*/
Body.prototype.content = null
/**
* Body contentType.
* @member {number|null|undefined} contentType
* @memberof Body
* @instance
*/
Body.prototype.contentType = null
/**
* Body seq.
* @member {string|null|undefined} seq
@@ -978,6 +989,12 @@ export const Body = ($root.Body = (() => {
set: $util.oneOfSetter($oneOfFields)
})
// Virtual OneOf for proto3 optional field
Object.defineProperty(Body.prototype, '_contentType', {
get: $util.oneOfGetter(($oneOfFields = ['contentType'])),
set: $util.oneOfSetter($oneOfFields)
})
// Virtual OneOf for proto3 optional field
Object.defineProperty(Body.prototype, '_seq', {
get: $util.oneOfGetter(($oneOfFields = ['seq'])),
@@ -1027,10 +1044,12 @@ export const Body = ($root.Body = (() => {
writer.uint32(/* id 6, wireType 0 =*/ 48).int64(message.msgId)
if (message.content != null && Object.hasOwnProperty.call(message, 'content'))
writer.uint32(/* id 7, wireType 2 =*/ 58).string(message.content)
if (message.contentType != null && Object.hasOwnProperty.call(message, 'contentType'))
writer.uint32(/* id 8, wireType 0 =*/ 64).int32(message.contentType)
if (message.seq != null && Object.hasOwnProperty.call(message, 'seq'))
writer.uint32(/* id 8, wireType 2 =*/ 66).string(message.seq)
writer.uint32(/* id 9, wireType 2 =*/ 74).string(message.seq)
if (message.sessionId != null && Object.hasOwnProperty.call(message, 'sessionId'))
writer.uint32(/* id 9, wireType 2 =*/ 74).string(message.sessionId)
writer.uint32(/* id 10, wireType 2 =*/ 82).string(message.sessionId)
return writer
}
@@ -1094,10 +1113,14 @@ export const Body = ($root.Body = (() => {
break
}
case 8: {
message.seq = reader.string()
message.contentType = reader.int32()
break
}
case 9: {
message.seq = reader.string()
break
}
case 10: {
message.sessionId = reader.string()
break
}
@@ -1171,6 +1194,10 @@ export const Body = ($root.Body = (() => {
properties._content = 1
if (!$util.isString(message.content)) return 'content: string expected'
}
if (message.contentType != null && message.hasOwnProperty('contentType')) {
properties._contentType = 1
if (!$util.isInteger(message.contentType)) return 'contentType: integer expected'
}
if (message.seq != null && message.hasOwnProperty('seq')) {
properties._seq = 1
if (!$util.isString(message.seq)) return 'seq: string expected'
@@ -1208,6 +1235,7 @@ export const Body = ($root.Body = (() => {
object.msgId.high >>> 0
).toNumber()
if (object.content != null) message.content = String(object.content)
if (object.contentType != null) message.contentType = object.contentType | 0
if (object.seq != null) message.seq = String(object.seq)
if (object.sessionId != null) message.sessionId = String(object.sessionId)
return message
@@ -1261,6 +1289,10 @@ export const Body = ($root.Body = (() => {
object.content = message.content
if (options.oneofs) object._content = 'content'
}
if (message.contentType != null && message.hasOwnProperty('contentType')) {
object.contentType = message.contentType
if (options.oneofs) object._contentType = 'contentType'
}
if (message.seq != null && message.hasOwnProperty('seq')) {
object.seq = message.seq
if (options.oneofs) object._seq = 'seq'

View File

@@ -65,8 +65,9 @@ message Header {
| 5 | groupId | - | - | - | - | M | M | - | M | - | todo | todo |
| 6 | msgId | - | - | - | M | - | M | O | O | M | todo | todo |
| 7 | content | - | - | M | M | M | M | M | M | - | todo | todo |
| 8 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
| 9 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
| 8 | contentType | - | - | M | M | M | M | - | - | - | todo | todo |
| 9 | seq | - | - | M | M | M | M | O | O | M | todo | todo |
|10 | sessionId | - | - | M | M | M | M | M | M | M | todo | todo |
+---+--------------+------+-----------+---------|-----------+---------------+-----------------+----------+----------------+----------+-------------------+---------------------+
NO filed STATUS_REQ STATUS_RES STATUS_SYNC SYS_GROUP_XXX AT(up) AT(down) REVOKE DELETE
+---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
@@ -77,8 +78,9 @@ message Header {
| 5 | groupId | - | - | - | M | M | M | o | - |
| 6 | msgId | - | - | - | M | - | M | M | M |
| 7 | content | M | M | M | M | M | M | M | - |
| 8 | seq | - | - | - | - | M | M | - | - |
| 9 | sessionId | - | - | - | M | M | M | M | M |
| 8 | contentType | - | - | - | - | - | - | - | - |
| 9 | seq | - | - | - | - | M | M | - | - |
|10 | sessionId | - | - | - | M | M | M | M | M |
+---+--------------+------------+------------+-------------+------------+---------+---------+-----------+-----------+
*/
message Body {
@@ -89,8 +91,9 @@ message Body {
optional string groupId = 5;
optional int64 msgId = 6; //服务端生成的消息ID会话内单调递增可用于消息排序
optional string content = 7;
optional string seq = 8; //客户端生成的序列号ID会话内唯一可用于消息去重
optional string sessionId = 9; //MsgType=SENDER_SYNC需带上该字段因为此时fromId和toId都是发送端的账号无法识别是哪个session
optional int32 contentType = 8;
optional string seq = 9; //客户端生成的序列号ID会话内唯一可用于消息去重
optional string sessionId = 10; //MsgType=SENDER_SYNC需带上该字段因为此时fromId和toId都是发送端的账号无法识别是哪个session
}
message Extension {

View File

@@ -18,20 +18,18 @@ export const useAudioStore = defineStore('anylink-audio', () => {
audio.value[obj.objectId] = obj
}
const preloadAudio = async (msgRecords) => {
const preloadAudioFromMsgList = async (msgRecords) => {
const audioIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (
(contentJson && contentJson['type'] === msgContentType.RECORDING) ||
(contentJson && contentJson['type'] === msgContentType.AUDIO)
) {
const objectId = contentJson['value']
if (!audio.value[objectId]) {
audioIds.add(objectId)
const aar = jsonParseSafe(item.content)
aar.forEach((item) => {
if (item.type === msgContentType.AUDIO || item.type === msgContentType.RECORDING) {
const objectId = item.value
if (!audio.value[objectId]) {
audioIds.add(objectId)
}
}
}
})
})
if (audioIds.size > 0) {
@@ -55,7 +53,7 @@ export const useAudioStore = defineStore('anylink-audio', () => {
return {
audio,
setAudio,
preloadAudio,
preloadAudioFromMsgList,
clear
}
})

View File

@@ -18,17 +18,18 @@ export const useDocumentStore = defineStore('anylink-document', () => {
document.value[obj.objectId] = obj
}
const preloadDocument = async (msgRecords) => {
const preloadDocumentFromMsgList = async (msgRecords) => {
const documentIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (contentJson && contentJson['type'] === msgContentType.DOCUMENT) {
const objectId = contentJson['value']
if (!document.value[objectId]) {
documentIds.add(objectId)
const aar = jsonParseSafe(item.content)
aar.forEach((item) => {
if (item.type === msgContentType.DOCUMENT) {
const objectId = item.value
if (!document.value[objectId]) {
documentIds.add(objectId)
}
}
}
})
})
if (documentIds.size > 0) {
@@ -52,7 +53,7 @@ export const useDocumentStore = defineStore('anylink-document', () => {
return {
document,
setDocument,
preloadDocument,
preloadDocumentFromMsgList,
clear
}
})

View File

@@ -4,8 +4,6 @@ import { jsonParseSafe } from '@/js/utils/common'
import { defineStore } from 'pinia'
import { ref } from 'vue'
const pattern = /\{[a-f0-9]+\}/g
// image的缓存数据不持久化存储
export const useImageStore = defineStore('anylink-image', () => {
/**
@@ -43,19 +41,20 @@ export const useImageStore = defineStore('anylink-image', () => {
}
}
const loadImageInfoFromContent = async (content) => {
const preloadImageFromMsg = async (content) => {
if (!content) return
const imageIds = new Set()
const matches = content.match(pattern)
if (matches && matches.length > 0) {
matches.forEach((item) => {
let startIndex = item.indexOf('{')
let endIndex = item.indexOf('}')
const objectId = item.slice(startIndex + 1, endIndex)
const aar = jsonParseSafe(content)
aar.forEach((item) => {
if (item.type === msgContentType.SCREENSHOT || item.type === msgContentType.IMAGE) {
const objectId = item.value
if (!image.value[objectId]) {
imageIds.add(objectId)
}
})
}
}
})
if (imageIds.size > 0) {
const res = await mtsImageService({ objectIds: [...imageIds].join(',') })
res.data.data.forEach((item) => {
@@ -64,29 +63,18 @@ export const useImageStore = defineStore('anylink-image', () => {
}
}
const preloadImage = async (msgRecords) => {
const preloadImageFromMsgList = async (msgRecords) => {
const imageIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (contentJson && contentJson['type'] === msgContentType.IMAGE) {
const objectId = contentJson['value']
if (!image.value[objectId]) {
imageIds.add(objectId)
const aar = jsonParseSafe(item.content)
aar.forEach((item) => {
if (item.type === msgContentType.SCREENSHOT || item.type === msgContentType.IMAGE) {
const objectId = item.value
if (!image.value[objectId]) {
imageIds.add(objectId)
}
}
} else {
const matches = content.match(pattern)
if (matches && matches.length > 0) {
matches.forEach((item) => {
let startIndex = item.indexOf('{')
let endIndex = item.indexOf('}')
const objectId = item.slice(startIndex + 1, endIndex)
if (!image.value[objectId]) {
imageIds.add(objectId)
}
})
}
}
})
})
if (imageIds.size > 0) {
@@ -117,8 +105,8 @@ export const useImageStore = defineStore('anylink-image', () => {
setImage,
setImageInSession,
clearImageInSession,
loadImageInfoFromContent,
preloadImage,
preloadImageFromMsg,
preloadImageFromMsgList,
clear
}
})

View File

@@ -134,10 +134,10 @@ export const useMessageStore = defineStore('anylink-message', () => {
* @param {*} msgRecords
*/
const preloadResource = async (msgRecords) => {
await useImageStore().preloadImage(msgRecords)
await useAudioStore().preloadAudio(msgRecords)
await useVideoStore().preloadVideo(msgRecords)
await useDocumentStore().preloadDocument(msgRecords)
await useImageStore().preloadImageFromMsgList(msgRecords)
await useAudioStore().preloadAudioFromMsgList(msgRecords)
await useVideoStore().preloadVideoFromMsgList(msgRecords)
await useDocumentStore().preloadDocumentFromMsgList(msgRecords)
}
/**

View File

@@ -18,17 +18,18 @@ export const useVideoStore = defineStore('anylink-video', () => {
video.value[obj.objectId] = obj
}
const preloadVideo = async (msgRecords) => {
const preloadVideoFromMsgList = async (msgRecords) => {
const videoIds = new Set()
msgRecords.forEach((item) => {
const content = item.content
const contentJson = jsonParseSafe(content)
if (contentJson && contentJson['type'] === msgContentType.VIDEO) {
const objectId = contentJson['value']
if (!video.value[objectId]) {
videoIds.add(objectId)
const aar = jsonParseSafe(item.content)
aar.forEach((item) => {
if (item.type === msgContentType.VIDEO) {
const objectId = item.value
if (!video.value[objectId]) {
videoIds.add(objectId)
}
}
}
})
})
if (videoIds.size > 0) {
@@ -52,7 +53,7 @@ export const useVideoStore = defineStore('anylink-video', () => {
return {
video,
setVideo,
preloadVideo,
preloadVideoFromMsgList,
clear
}
})

View File

@@ -43,7 +43,7 @@ import { onReceiveChatMsg, onReceiveGroupChatMsg, onReceiveGroupSystemMsg } from
import { userQueryService } from '@/api/user'
import { ElLoading, ElMessage } from 'element-plus'
import { el_loading_options } from '@/const/commonConst'
import { combineId, sessionIdConvert } from '@/js/utils/common'
import { combineId, jsonParseSafe, sessionIdConvert } from '@/js/utils/common'
import MenuSession from '@/views/message/components/MenuSession.vue'
import router from '@/router'
import { BEGIN_MSG_ID, msgContentType, msgSendStatus } from '@/const/msgConst'
@@ -120,29 +120,45 @@ const unreadAtRecords = computed(() => {
})
const handleShowHighlight = (msgId) => {
const readElement = document.querySelector(
let targetKey = msgId
let element = document.querySelector(
`#message-item-${sessionIdConvert(selectedSessionId.value)}-${msgId}`
)
if (!readElement) {
ElMessage.success('请加载更多消息后查找')
} else {
const msgListRect = msgListDiv.value.getBoundingClientRect()
const rect = readElement.getBoundingClientRect()
// 判断 readElement 是否在 msgListDiv 的视口内
const isInViewport = rect.top >= msgListRect.top && rect.bottom <= msgListRect.bottom
if (!isInViewport) {
nextTick(() => {
msgListDiv.value.scrollTo({
top: msgListDiv.value.scrollTop - (msgListRect.top - rect.top),
behavior: 'smooth'
})
})
) // 先拿msgId定位到元素如果不行再用msgKey
if (!element) {
// 用msgId逆向找msgKey
for (const msgKey of msgKeysShow.value) {
const msg = messageData.getMsg(selectedSessionId.value, msgKey)
if (msg.msgId == msgId) {
targetKey = msgKey
element = document.querySelector(
`#message-item-${sessionIdConvert(selectedSessionId.value)}-${msgKey}`
)
break
}
}
if (!element) {
ElMessage.success('请加载更多消息后查找')
return
}
highlightedMsgIds.value.add(msgId + '')
setTimeout(() => {
highlightedMsgIds.value.delete(msgId + '')
}, 2000)
}
const msgListRect = msgListDiv.value.getBoundingClientRect()
const rect = element.getBoundingClientRect()
// 判断 element 是否在 msgListDiv 的视口内
const isInViewport = rect.top >= msgListRect.top && rect.bottom <= msgListRect.bottom
if (!isInViewport) {
nextTick(() => {
msgListDiv.value.scrollTo({
top: msgListDiv.value.scrollTop - (msgListRect.top - rect.top),
behavior: 'smooth'
})
})
}
highlightedMsgIds.value.add(targetKey + '')
setTimeout(() => {
highlightedMsgIds.value.delete(targetKey + '')
}, 2000)
}
const handleReadAt = () => {
@@ -563,7 +579,7 @@ const sendRead = () => {
selectedSession.value.sessionType === MsgType.CHAT
? MsgType.CHAT_READ
: MsgType.GROUP_CHAT_READ
wsConnect.sendMsg(selectedSessionId.value, showId.value, msgType, content + '', '', () => {})
wsConnect.sendMsg(selectedSessionId.value, showId.value, msgType, content + '', 0, '', () => {})
// 更新本地缓存的已读位置
messageData.updateSession({
sessionId: selectedSessionId.value,
@@ -577,7 +593,7 @@ const sendRead = () => {
/**
* 处理发送转发的消息
*/
const handleSendForwardMsg = async ({ session, content }) => {
const handleSendForwardMsg = async ({ session, content, contentType }) => {
if (session.sessionType === MsgType.GROUP_CHAT && session.leave) {
ElMessage.warning('您已离开该群或群已被解散')
return
@@ -609,12 +625,13 @@ const handleSendForwardMsg = async ({ session, content }) => {
const seq = uuidv4()
const msg = {
msgId: seq,
seq: seq,
seq,
sessionId: session.sessionId,
fromId: myAccount.value,
remoteId: session.remoteId,
msgType: session.sessionType,
content: content,
content,
contentType,
status: msgSendStatus.PENDING,
msgTime: new Date(),
sendTime: new Date()
@@ -657,13 +674,22 @@ const handleSendForwardMsg = async ({ session, content }) => {
const after = (msgId) => {
messageData.updateMsg(msg.sessionId, msg.msgId, { msgId, status: msgSendStatus.OK })
}
wsConnect.sendMsg(msg.sessionId, msg.remoteId, msg.msgType, msg.content, msg.seq, before, after)
wsConnect.sendMsg(
msg.sessionId,
msg.remoteId,
msg.msgType,
msg.content,
msg.contentType,
msg.seq,
before,
after
)
}
/**
* 发送时先添加本地消息,可以立即渲染
*/
const handleLocalMsg = ({ content, contentType, objectId, fn }) => {
const handleLocalMsg = ({ content, contentType, fn }) => {
const seq = uuidv4()
const msg = {
msgId: seq,
@@ -671,10 +697,8 @@ const handleLocalMsg = ({ content, contentType, objectId, fn }) => {
sessionId: selectedSessionId.value,
fromId: myAccount.value,
msgType: selectedSession.value.sessionType,
content:
contentType === msgContentType.MIX
? content
: JSON.stringify({ type: contentType, value: objectId }),
content,
contentType,
status: msgSendStatus.PENDING,
msgTime: new Date(),
sendTime: new Date()
@@ -693,7 +717,7 @@ const handleLocalMsg = ({ content, contentType, objectId, fn }) => {
fn(msg)
}
const handleSendMessage = ({ msg, at }) => {
const handleSendMessage = ({ msg, content, at }) => {
if (isNotInGroup.value) {
ElMessage.warning('您已离开该群或群已被解散')
return
@@ -754,7 +778,8 @@ const handleSendMessage = ({ msg, at }) => {
msg.sessionId,
showId.value,
selectedSession.value.sessionType,
msg.content,
content,
msg.contentType,
msg.seq,
before,
after
@@ -774,10 +799,9 @@ const handleResendMessage = (msg) => {
})
const toSendAtList = []
msg.content.split(/(<.*?>)/).forEach((item) => {
if (item && item.startsWith('<') && item.endsWith('>')) {
const index = item.indexOf('-')
const account = item.slice(1, index) // 第一个字符是<所以起点从1开始
jsonParseSafe(msg.content).forEach((item) => {
if (item.type === msgContentType.AT) {
const account = item.value.account
if (account == 0) {
toSendAtList.push(account)
} else {
@@ -787,6 +811,7 @@ const handleResendMessage = (msg) => {
}
}
})
handleSendMessage({ msg, at: toSendAtList })
}
@@ -800,7 +825,7 @@ const handleSendAt = (at, sessionId, referMsgId) => {
contentObj.isAtAll = true
} else {
contentObj.isAtAll = false
contentObj.atList = at.filter((item) => groupMembers.value[item]) // 过滤数据
contentObj.atList = at.filter((item) => groupMembers.value[item]) // 过滤不是当前合法群成员的数据
}
if (contentObj.isAtAll || (!contentObj.isAtAll && contentObj.atList.length > 0)) {
@@ -809,6 +834,7 @@ const handleSendAt = (at, sessionId, referMsgId) => {
showId.value,
MsgType.AT,
JSON.stringify(contentObj),
0,
null,
() => {},
() => {}
@@ -1386,7 +1412,8 @@ const handleConfirmForwardMsg = async (sessions) => {
const msg = messageData.getMsg(selectedSessionId.value, msgKey)
await handleSendForwardMsg({
session: item,
content: msg.content
content: msg.content,
contentType: msg.contentType
})
}
} else if (showForwardMsgDialogTitle.value === '合并转发') {
@@ -1411,13 +1438,16 @@ const handleConfirmForwardMsg = async (sessions) => {
})
await handleSendForwardMsg({
session: item,
content: JSON.stringify({
type: msgContentType.FORWARD_TOGETHER,
value: {
sessionId: selectedSessionId.value,
data: [...msgs]
content: JSON.stringify([
{
type: msgContentType.FORWARD_TOGETHER,
value: {
sessionId: selectedSessionId.value,
data: [...msgs]
}
}
})
]),
contentType: msgContentType.FORWARD_TOGETHER
})
}
}
@@ -1513,7 +1543,7 @@ const onConfirmSelect = async (selected) => {
const inputEditorRef = ref()
const onSendEmoji = (key) => {
inputEditorRef.value.addEmoji(key)
inputEditorRef.value?.addEmoji(key)
}
const inputRecorderRef = ref(null)

View File

@@ -24,8 +24,8 @@ import MsgBoxVideo from '@/views/message/components/MsgBoxVideo.vue'
import MsgBoxDocument from '@/views/message/components/MsgBoxDocument.vue'
import DialogForMsgList from '@/views/message/components/DialogForMsgList.vue'
import { emojis } from '@/js/utils/emojis'
import { MsgType } from '@/proto/msg'
import { msgChatQueryMessagesService } from '@/api/message'
import { showSimplifyMsgContent } from '@/js/utils/message'
const props = defineProps(['isShow', 'title', 'sessionId', 'msgs', 'tier'])
const emit = defineEmits(['update:isShow', 'showUserCard', 'close'])
@@ -38,13 +38,14 @@ const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
const msgsFromServer = ref({})
const forwardMsgs = ref({})
const quoteMsg = ref({})
onMounted(async () => {
const loadingInstance = ElLoading.service(el_loading_options)
try {
await messageData.preloadResource(props.msgs)
await loadForwardTogetherMsgs()
await loadRelatedMsg()
} finally {
loadingInstance.close()
}
@@ -60,60 +61,58 @@ watch(
}
)
const loadForwardTogetherMsgs = async () => {
const loadRelatedMsg = async () => {
for (const msg of props.msgs) {
const content = msg.content
const contentJson = jsonParseSafe(content)
if (!contentJson) {
const arr = jsonParseSafe(content)
if (!arr) {
continue
}
const type = contentJson['type']
const value = contentJson['value']
if (!type || !value) {
continue
} else {
if (type === msgContentType.FORWARD_TOGETHER) {
let res
try {
const msgIds = value.data
.map((item) => {
return item.msgId
})
.join(',')
res = await msgChatQueryMessagesService({
sessionId: value.sessionId,
msgIds
for (const item of arr) {
if (item.type === msgContentType.QUOTE) {
// 先从本地消息缓存中获取
const msgFromStore = messageData.getMsg(msg.sessionId, item.value.msgId)
if (!msgFromStore.msgId) {
// 如果本地消息缓存中没有,再去服务器查询
const res = await msgChatQueryMessagesService({
sessionId: msg.sessionId,
msgIds: item.value.msgId
})
} catch (error) {
console.error(error)
continue
}
const msgs = res.data.data
if (!res.data.data || res.data.data.length == 0) {
continue
}
// value.data(取里面的nickName) 和 msgs合一
const newMsgs = {}
msgs.forEach((item) => {
newMsgs[item.msgId] = item
})
value.data.forEach((item) => {
if (item.msgId in newMsgs) {
newMsgs[item.msgId] = {
...newMsgs[item.msgId],
...item
}
if (res.data.data && res.data.data.length > 0) {
quoteMsg.value[msg.msgId] = res.data.data[0]
}
})
} else {
quoteMsg.value[msg.msgId] = msgFromStore
}
} else if (item.type === msgContentType.FORWARD_TOGETHER) {
if (!forwardMsgs.value[msg.msgId]) {
forwardMsgs.value[msg.msgId] = []
}
msgsFromServer.value[msg.msgId] = Object.values(newMsgs).sort((a, b) => {
const timeA = new Date(a.sendTime || a.msgTime).getTime()
const timeB = new Date(b.sendTime || b.msgTime).getTime()
return timeA - timeB
})
const forwatdMsgIds = item.value.data.map((item) => item.msgId)
const toQueryMsgIds = []
for (const msgId of forwatdMsgIds) {
// 先从本地消息缓存中获取
const msgFromStore = messageData.getMsg(item.value.sessionId, msgId)
if (!msgFromStore.msgId) {
// 如果本地消息缓存中没有,再去服务器查询
toQueryMsgIds.push(msgId)
} else {
forwardMsgs.value[msg.msgId].push(msgFromStore)
}
}
if (toQueryMsgIds.length > 0) {
const res = await msgChatQueryMessagesService({
sessionId: item.value.sessionId,
msgIds: toQueryMsgIds.join(',')
})
res.data.data.forEach((item) => {
forwardMsgs.value[msg.msgId].push(item)
})
}
}
}
}
@@ -130,98 +129,62 @@ const isMyAccount = (account) => {
const renderContent = ({ msg }) => {
const content = msg.content
const msgId = msg.msgId
const msgType = msg.msgType
const contentJson = jsonParseSafe(content)
if (!contentJson) {
return renderMix(content, msgType)
const arr = jsonParseSafe(content)
// 不允许非结构化的content
if (!arr) {
return <span></span>
}
const type = contentJson['type']
const value = contentJson['value']
if (!type || !value) {
return renderMix(content, msgType)
}
switch (type) {
case msgContentType.MIX:
return renderMix(value, msgType)
case msgContentType.TEXT:
return renderText(value)
case msgContentType.RECORDING:
return renderRecording(value)
case msgContentType.AUDIO:
return renderAudio(value)
case msgContentType.IMAGE:
return renderImage(value)
case msgContentType.EMOJI:
return renderEmoji(value)
case msgContentType.VIDEO:
return renderVideo(value, msgId)
case msgContentType.DOCUMENT:
return renderDocument(value)
case msgContentType.FORWARD_TOGETHER:
return renderForwardTogether(msgId)
default:
return <span>{content}</span>
}
}
const renderMix = (content, msgType) => {
if (!content) return h('div', [])
let contentArray = []
// 1. 先匹配quote引用内容
content.split(/(「\{.*?\}」)/).forEach((item) => {
if (item.startsWith('「{') && item.endsWith('}」')) {
// quote引用内容直接添加如数组
contentArray.push(item)
} else {
//2. 匹配内容中的图片
item.split(/(\{\d+\})/).forEach((item) => {
//3. 匹配内容中的表情
item.split(/(\[.*?\])/).forEach((item) => {
item.split(/(<.*?>)/).forEach((item) => {
if (item) {
contentArray.push(item)
}
})
})
})
return arr.map((item) => {
if (!item.type || !item.value) {
return <span></span>
}
})
return contentArray.map((item) => {
if (item.startsWith('{') && item.endsWith('}')) {
return renderImage(item.slice(1, -1), true)
} else if (item.startsWith('[') && item.endsWith(']')) {
return renderEmoji(item.slice(1, -1))
} else if (item.startsWith('<') && item.endsWith('>')) {
return renderAt(item.slice(1, -1), msgType)
} else if (item.startsWith('「{') && item.endsWith('}」')) {
return renderQuote(item.slice(1, -1))
} else {
return <span>{item.trim()}</span>
switch (item.type) {
case msgContentType.TEXT:
return renderText(item.value)
case msgContentType.EMOJI:
return renderEmoji(item.value)
case msgContentType.SCREENSHOT:
return renderImage(item.value, true)
case msgContentType.AT:
return renderAt(item.value)
case msgContentType.QUOTE:
return renderQuote(item.value, msgId)
case msgContentType.IMAGE:
return renderImage(item.value)
case msgContentType.RECORDING:
return renderRecording(item.value)
case msgContentType.AUDIO:
return renderAudio(item.value)
case msgContentType.VIDEO:
return renderVideo(item.value, msgId)
case msgContentType.DOCUMENT:
return renderDocument(item.value)
case msgContentType.FORWARD_TOGETHER:
return renderForwardTogether(item.value, msgId)
default:
return <span></span>
}
})
}
const renderText = (content) => {
return <span>{content}</span>
const renderText = (text) => {
return <span>{text}</span>
}
const renderRecording = (content) => {
const audioId = content
const renderRecording = (audioId) => {
const url = audioData.audio[audioId]?.downloadUrl
const duration = audioData.audio[audioId]?.duration
if (url) {
return <MsgBoxRecording audioUrl={url} duration={duration}></MsgBoxRecording>
} else {
return <span>{content}</span>
return <span>{'[语音]'}</span>
}
}
const renderAudio = (content) => {
const audioId = content
const renderAudio = (audioId) => {
const url = audioData.audio[audioId]?.downloadUrl
if (url) {
return (
@@ -232,40 +195,37 @@ const renderAudio = (content) => {
></MsgBoxAudio>
)
} else {
return <span>{content}</span>
return <span>{`[${audioId}]`}</span>
}
}
const renderEmoji = (content) => {
const emojiId = `[${content}]`
const renderEmoji = (emojiId) => {
const url = emojis[emojiId]
if (url) {
return <img class={'emoji'} src={url} alt={emojiId} title={content}></img>
return <img class={'emoji'} src={url} alt={emojiId} title={emojiId.slice(1, -1)}></img>
} else {
return <span>{content}</span>
return <span>{emojiId}</span>
}
}
const renderImage = (content, isForMix = false) => {
const imgId = content
imageData.setImageInSession(props.sessionId, imageData.image[imgId])
const renderImage = (imgId, isScreenShot = false) => {
if (imageData.image[imgId]) {
imageData.setImageInSession(props.sessionId, imageData.image[imgId])
return (
<MsgBoxImage
sessionId={props.sessionId}
imgId={imgId}
isForMix={isForMix}
isScreenShot={isScreenShot}
thumbWidth={imageData.image[imgId].thumbWidth}
thumbHeight={imageData.image[imgId].thumbHeight}
></MsgBoxImage>
)
} else {
return <span>{isForMix ? content : `[${content}]`}</span>
return <span>{`[${imgId}]`}</span>
}
}
const renderVideo = (content, msgId) => {
const videoId = content
const renderVideo = (videoId, msgId) => {
const url = videoData.video[videoId]?.downloadUrl
if (url) {
return (
@@ -280,12 +240,11 @@ const renderVideo = (content, msgId) => {
></MsgBoxVideo>
)
} else {
return <span>{content}</span>
return <span>{`[${videoId}]`}</span>
}
}
const renderDocument = (content) => {
const documentId = content
const renderDocument = (documentId) => {
const url = documentData.document[documentId]?.downloadUrl
if (url) {
return (
@@ -297,18 +256,42 @@ const renderDocument = (content) => {
></MsgBoxDocument>
)
} else {
return <span>{content}</span>
return <span>{`[${documentId}]`}</span>
}
}
const renderForwardTogether = (msgId) => {
const title = '聊天记录'
const msgsSorted = msgsFromServer.value[msgId]
const renderForwardTogether = (forwardContent, msgId) => {
const msgs = forwardMsgs.value[msgId]
if (!msgs) {
return <div class={'forward-together'}></div>
}
// forwardContent(取里面的nickName) 和 msgs合一
const newMsgs = {}
msgs.forEach((item) => {
newMsgs[item.msgId] = item
})
forwardContent.data.forEach((item) => {
if (item.msgId in newMsgs) {
newMsgs[item.msgId] = {
...newMsgs[item.msgId],
...item
}
}
})
const msgsSorted = Object.values(newMsgs).sort((a, b) => {
const timeA = new Date(a.sendTime || a.msgTime).getTime()
const timeB = new Date(b.sendTime || b.msgTime).getTime()
return timeA - timeB
})
if (!msgsSorted) {
return <div class={'forward-together'}></div>
}
const title = '聊天记录'
return (
<div
class={'forward-together'}
@@ -343,9 +326,7 @@ const renderForwardTogether = (msgId) => {
<div key={index} class={'msg-item'}>
<span class={'msg-item-nickname'}>{msg.nickName || msg.fromId}</span>
<span>{''}</span>
<span class={'msg-item-content'}>
{showMsgContentInForwardTogether(msg.content)}
</span>
<span class={'msg-item-content'}>{showSimplifyMsgContent(msg.content)}</span>
</div>
)
})}
@@ -356,108 +337,29 @@ const renderForwardTogether = (msgId) => {
)
}
const renderAt = (content, msgType) => {
const index = content.indexOf('-')
if (index !== -1) {
const nickName = content.slice(index + 1)
if (msgType === MsgType.GROUP_CHAT && nickName) {
return <span>{`@${nickName} `}</span>
} else {
return <span>{`<${content}>`}</span>
}
} else {
return <span>{`<${content}>`}</span>
}
const renderAt = (atContent) => {
return <span>{`@${atContent.nickName} `}</span>
}
const renderQuote = (quoteContent) => {
const { nickName, content, msgTime } = jsonParseSafe(quoteContent)
let showContent = content || ''
if (content) {
const defaultContent = content.replace(/<(?:.*?)-(.*?)>/g, '@$1').replace(/\{\d+\}/g, '[图片]')
showContent = defaultContent
const contentJson = jsonParseSafe(defaultContent)
if (contentJson) {
const type = contentJson['type']
const objectId = contentJson['value']
switch (type) {
case msgContentType.RECORDING:
showContent = '[语音]'
break
case msgContentType.AUDIO:
showContent = `[音频] ${audioData.audio[objectId].fileName}`
break
case msgContentType.IMAGE:
showContent = `[图片] ${imageData.image[objectId].fileName}`
break
case msgContentType.VIDEO:
showContent = `[视频] ${videoData.video[objectId].fileName}`
break
case msgContentType.DOCUMENT:
showContent = `[文档] ${documentData.document[objectId].fileName}`
break
default:
break
}
}
}
const renderQuote = (quoteContent, msgId) => {
const { nickName } = quoteContent
const { content, msgTime } = quoteMsg.value[msgId]
? quoteMsg.value[msgId]
: { content: '', msgTime: '' }
// 和InputEditor.vue中的结构保持一致使用相同class可以复用样式
return (
<div class={'quote-block'}>
<div class={'quote-wrapper'}>
<div class={'quote-sender'}>
<span class="quote-nickName">{nickName + ' '}</span>
<span class={'quote-msgTime'}>{msgTime + ''}</span>
<span class="quote-nickName">{nickName}</span>
<span class={'quote-msgTime'}>{` ${showTimeFormat(msgTime)}`}</span>
</div>
<span class={'quote-content'}>{showContent}</span>
<span class={'quote-content'}>{showSimplifyMsgContent(content)}</span>
</div>
</div>
)
}
const showMsgContentInForwardTogether = (content) => {
const jsonContent = jsonParseSafe(content)
let template
if (jsonContent && jsonContent['type'] && jsonContent['value']) {
if (jsonContent['type'] == msgContentType.IMAGE) {
template = '[图片]'
} else if (jsonContent['type'] == msgContentType.AUDIO) {
template = '[音频]'
} else if (jsonContent['type'] == msgContentType.RECORDING) {
template = '[语音]'
} else if (jsonContent['type'] == msgContentType.VIDEO) {
template = '[视频]'
} else if (jsonContent['type'] == msgContentType.DOCUMENT) {
template = '[文件]'
} else if (jsonContent['type'] == msgContentType.FORWARD_TOGETHER) {
template = '[聊天记录]'
} else {
template = jsonContent['value']
}
return template
} else {
return content
.replace(/\{\d+\}/g, '[图片]')
.replace(/(「\{.*?\}」)/, '[引用]')
.split(/(<.*?>)/)
.map((item) => {
const sliceStr = item.slice(1, -1)
const index = sliceStr.indexOf('-')
if (index !== -1) {
const nickName = sliceStr.slice(index + 1)
if (nickName) {
return `@${nickName}`
} else {
return item
}
}
return item
})
.join('')
}
}
const onClose = () => {
emit('update:isShow', false)
emit('close')
@@ -494,6 +396,7 @@ const onShowUserCard = (account) => {
class="dialog-msg-list"
:model-value="props.isShow"
:modal="false"
draggable
:width="'600px'"
:top="`${30 + (props.tier || 0)}vh`"
:z-index="1000"

View File

@@ -20,14 +20,35 @@ import { prehandleImage } from '@/js/utils/image'
import { MsgType } from '@/proto/msg'
import AtList from '@/views/message/components/AtList.vue'
import AgreeBeforeSend from '@/views/message/components/AgreeBeforeSend.vue'
import { isMatchMsgStruct, showSimplifyMsgContent } from '@/js/utils/message'
import { msgChatQueryMessagesService } from '@/api/message'
/**
* 处理粘贴格式问题
* 处理复制/粘贴结构化数据
*/
const Clipboard = Quill.import('modules/clipboard')
class PlainClipboard extends Clipboard {
onPaste(range, { text }) {
handlePaste(range, text)
onPaste(range, data) {
if (!data.html) {
handlePaste(range, data.text)
return
}
const parser = new DOMParser()
const doc = parser.parseFromString(data.html, 'text/html')
const elements = doc.querySelectorAll('[data-quill-custom]') // 查找所有具有 data-quill-custom 属性的元素
if (elements.length > 0) {
// 取第一个匹配的元素
const encodedData = elements[0].getAttribute('data-quill-custom')
const decodedData = decodeURIComponent(encodedData)
handlePaste(range, decodedData)
} else {
handlePaste(range, data.text) // 降级方案
}
}
onCopy(range) {
return handleCopy(range)
}
}
Quill.register(
@@ -96,53 +117,21 @@ class QuoteBlock extends Embed {
super.remove()
}
static create({ account, nickName, msgId, content, msgTime }) {
static create({ account, nickName, msgKey, msgId, content, msgTime }) {
const node = super.create()
node.dataset.account = account
node.dataset.nickName = nickName
node.dataset.msgKey = msgKey
node.dataset.msgId = msgId
node.dataset.msgTime = showTimeFormat(msgTime)
node.dataset.content = content
.split(/(「\{.*?\}」)/)
.filter((item) => !(item.startsWith('「{') && item.endsWith('}」'))) // 引用的引用不予展示
.join('')
const defaultContent = node.dataset.content
.replace(/<(?:.*?)-(.*?)>/g, '@$1')
.replace(/\{\d+\}/g, '[图片]')
const contentJson = jsonParseSafe(defaultContent)
let showContent = defaultContent
if (contentJson) {
const type = contentJson['type']
const objectId = contentJson['value']
switch (type) {
case msgContentType.RECORDING:
showContent = '[语音]'
break
case msgContentType.AUDIO:
showContent = `[音频] ${audioData.audio[objectId].fileName}`
break
case msgContentType.IMAGE:
showContent = `[图片] ${imageData.image[objectId].fileName}`
break
case msgContentType.VIDEO:
showContent = `[视频] ${videoData.video[objectId].fileName}`
break
case msgContentType.DOCUMENT:
showContent = `[文档] ${documentData.document[objectId].fileName}`
break
default:
break
}
}
node.innerHTML = `
<div class="quote-wrapper">
<div class="quote-sender">
<span class="quote-nickName">${node.dataset.nickName} </span>
<span class="quote-nickName">${node.dataset.nickName}</span>
<span class="quote-msgTime">${node.dataset.msgTime}</span>
</div>
<span class="quote-content">${showContent}</span>
<span class="quote-content">${showSimplifyMsgContent(node.dataset.content)}</span>
<button type="button" class="quote-close-btn">
<span >&times;</span>
</button>
@@ -155,6 +144,7 @@ class QuoteBlock extends Embed {
return {
account: node.dataset.account,
nickName: node.dataset.nickName,
msgKey: node.dataset.msgKey,
msgId: node.dataset.msgId,
content: node.dataset.content,
msgTime: node.dataset.msgTime
@@ -207,8 +197,8 @@ onMounted(async () => {
toSendAtList.value = []
// 给组件增加滚动条样式
document.querySelector('.ql-editor').classList.add('my-scrollbar')
await imageData.loadImageInfoFromContent(props.draft)
renderContent(props.draft) // 渲染草稿
await imageData.preloadImageFromMsg(props.draft)
await renderContent(props.draft) // 渲染草稿
quill.value.on('composition-start', () => {
// 当用户使用拼音输入法开始输入汉字时,这个事件就会被触发
quill.value.root.dataset.placeholder = ''
@@ -333,10 +323,10 @@ onBeforeUnmount(async () => {
}
}
fn(contentObj.contentFromLocal.join('').trim())
fn(JSON.stringify(contentObj.contentFromLocal.filter((item) => item)))
callbacks.allUploadedSuccessFn = () => {
fn(contentObj.contentFromServer.join('').trim())
fn(JSON.stringify(contentObj.contentFromServer.filter((item) => item)))
}
})
@@ -383,8 +373,8 @@ const cursorProtectForQuote = () => {
*/
const parseContent = async (callbacks) => {
const delta = quill.value.getContents()
let contentFromLocal = new Array(delta.ops.length).fill('')
let contentFromServer = new Array(delta.ops.length).fill('')
let contentFromLocal = new Array(delta.ops.length).fill('') // 这里用new Array + index填充方式而不用push是为了保证内容的顺序
let contentFromServer = new Array(delta.ops.length).fill('') // contentFromServer更新了某些服务端返回的数据
let needUploadCount = 0 // 需要上传的图片个数
let uploadedTotalCount = 0 // 已发上传请求的图片个数,包括上传成功和失败
let uploadSuccessCount = 0 // 已经上传成功的图片个数
@@ -405,39 +395,64 @@ const parseContent = async (callbacks) => {
const insert = op.insert
if (insert && typeof insert === 'string') {
// 文本
contentFromLocal[index] = insert
contentFromServer[index] = insert
let contentText = {}
if (index === delta.ops.length - 1) {
const lastInsert = insert.endsWith('\n') ? insert.slice(0, -1) : insert // 去除最后一个换行符
if (lastInsert) {
contentText = {
type: msgContentType.TEXT,
value: lastInsert
}
} else {
break
}
} else {
contentText = {
type: msgContentType.TEXT,
value: insert
}
}
contentFromLocal[index] = contentText
contentFromServer[index] = contentText
} else if (insert && insert.atMention) {
// 处理用于@的自定义Blot
const { account, nickName } = insert.atMention
contentFromLocal[index] = `<${account}-${nickName}>`
contentFromServer[index] = `<${account}-${nickName}>`
const contentAt = { type: msgContentType.AT, value: { account, nickName } }
contentFromLocal[index] = contentAt
contentFromServer[index] = contentAt
} else if (insert && insert.quoteBlock) {
// 处理用于引用的自定义Blot
const quoteContent = JSON.stringify({
account: insert.quoteBlock.account,
nickName: insert.quoteBlock.nickName,
msgId: insert.quoteBlock.msgId,
content: insert.quoteBlock.content,
msgTime: insert.quoteBlock.msgTime
})
contentFromLocal[index] = `${quoteContent}`
contentFromServer[index] = `${quoteContent}`
contentFromLocal[index] = {
type: msgContentType.QUOTE,
value: {
nickName: insert.quoteBlock.nickName,
msgId: insert.quoteBlock.msgKey // 注意这里的区别
}
}
contentFromServer[index] = {
type: msgContentType.QUOTE,
value: {
nickName: insert.quoteBlock.nickName,
msgId: insert.quoteBlock.msgId // 注意这里的区别
}
}
} else if (insert && insert.image) {
const alt = op.attributes?.alt
if (alt && alt.startsWith('[') && alt.endsWith(']')) {
// 表情id
contentFromLocal[index] = alt
contentFromServer[index] = alt
const contentEmoji = { type: msgContentType.EMOJI, value: alt }
contentFromLocal[index] = contentEmoji
contentFromServer[index] = contentEmoji
} else if (alt && alt.startsWith('{') && alt.endsWith('}')) {
// 图片id
contentFromLocal[index] = alt
contentFromServer[index] = alt
// 已有objectId的截图说明是已上传过服务端的
const contentSceenShot = { type: msgContentType.SCREENSHOT, value: alt.slice(1, -1) }
contentFromLocal[index] = contentSceenShot
contentFromServer[index] = contentSceenShot
} else if (insert.image.startsWith('data:') && insert.image.includes('base64')) {
// base64编码的图片
// 截图的原始base64编码的图片
const file = base64ToFile(insert.image, uuidv4()) // base64转file
const tempObjectId = new Date().getTime()
contentFromLocal[index] = `{${tempObjectId}}`
contentFromLocal[index] = { type: msgContentType.SCREENSHOT, value: tempObjectId }
// 发送的时候设置本地缓存(非服务端数据),用于立即渲染
const md5 = await getMd5(file)
const prehandleImageObj = await prehandleImage(file)
@@ -473,7 +488,10 @@ const parseContent = async (callbacks) => {
.then((res) => {
imageData.setImage(res.data.data) // 缓存image数据
uploadSuccessCount++
contentFromServer[index] = `{${res.data.data.objectId}}`
contentFromServer[index] = {
type: msgContentType.SCREENSHOT,
value: res.data.data.objectId
}
callbacks.someOneUploadedSuccessFn()
if (uploadSuccessCount === needUploadCount) {
callbacks.allUploadedSuccessFn()
@@ -487,8 +505,9 @@ const parseContent = async (callbacks) => {
})
} else {
// 当文本处理
contentFromLocal[index] = insert
contentFromServer[index] = insert
const contentText = { type: msgContentType.TEXT, value: insert }
contentFromLocal[index] = contentText
contentFromServer[index] = contentText
}
}
}
@@ -525,21 +544,20 @@ watch(
}
callbacks.allUploadedSuccessFn = () => {
fn(contentObj.contentFromServer.join('').trim())
// JSON.stringify(contentObj.contentFromServer.filter((item) => item))在空值时返回'[]''
let inputContent = JSON.stringify(contentObj.contentFromServer.filter((item) => item))
fn(inputContent === '[]' ? '' : inputContent)
}
fn(contentObj.contentFromLocal.join('').trim())
// JSON.stringify(contentObj.contentFromLocal.filter((item) => item))在空值时返回'[]''
let inputContent = JSON.stringify(contentObj.contentFromLocal.filter((item) => item))
fn(inputContent === '[]' ? '' : inputContent)
renderContent(messageData.sessionList[newSessionId].draft || '') // 切换session时渲染新session的草稿
await renderContent(messageData.sessionList[newSessionId].draft || '') // 切换session时渲染新session的草稿
},
{ deep: true }
)
// 实现消息复制的效果,步骤如下
// 1. 拷贝原消息中的content内容
// 2. 粘贴时自动调用renderContent渲染内容
// 3. 渲染时保存复制内容
// 4. 发送时使用保存的复制内容
const pasteObj = {
content: null,
contentType: null,
@@ -556,161 +574,235 @@ const clearPasteObj = () => {
pasteObj.url = null
}
const handlePaste = (range, content) => {
if (!content) {
/**
* 处理复制
*/
const handleCopy = ({ index, length }) => {
const delta = quill.value.getContents(index, length)
const clipboardContent = []
let clipboardText = ''
for (let index = 0; index < delta.ops.length; index++) {
const op = delta.ops[index]
const insert = op.insert
if (insert && typeof insert === 'string') {
// 文本
clipboardContent.push({
type: msgContentType.TEXT,
value: insert
})
clipboardText += insert
} else if (insert && insert.image) {
const alt = op.attributes?.alt
if (alt && alt.startsWith('[') && alt.endsWith(']')) {
// 表情
clipboardContent.push({ type: msgContentType.EMOJI, value: alt })
} else if (alt && alt.startsWith('{') && alt.endsWith('}')) {
// 已有objectId的截图复制原消息粘贴的撤回重新编辑从草稿渲染
clipboardContent.push({ type: msgContentType.SCREENSHOT, value: alt.slice(1, -1) })
} else if (insert.image.startsWith('data:') && insert.image.includes('base64')) {
// 截图后原始的base64编码
}
}
}
return {
// 在html自定义属性data-quill-custom中传递clipboardContent结构化数据
html: `<div data-quill-custom=${encodeURIComponent(JSON.stringify(clipboardContent))}></div>`,
text: clipboardText // 纯文本
}
}
/**
* 处理粘贴
* @param range
*/
const handlePaste = (range, text) => {
if (!text) {
return
}
const jsonContent = jsonParseSafe(content)
if (jsonContent && jsonContent['type'] && jsonContent['value']) {
clearPasteObj()
pasteObj.content = content
pasteObj.contentType = jsonContent['type']
const fileId = jsonContent['value']
switch (pasteObj.contentType) {
case msgContentType.IMAGE:
pasteObj.fileName = imageData.image[fileId]?.fileName
pasteObj.fileSize = imageData.image[fileId]?.size
pasteObj.url = imageData.image[fileId]?.thumbUrl
break
case msgContentType.AUDIO:
pasteObj.fileName = audioData.audio[fileId]?.fileName
pasteObj.fileSize = audioData.audio[fileId]?.size
break
case msgContentType.VIDEO:
pasteObj.fileName = videoData.video[fileId]?.fileName
pasteObj.fileSize = videoData.video[fileId]?.size
break
case msgContentType.DOCUMENT:
pasteObj.fileName = documentData.document[fileId]?.fileName
pasteObj.fileSize = documentData.document[fileId]?.size
break
default:
break
}
// 文件确实存在才发送
if (pasteObj.fileName) {
showAgreeDialog.value = true
return
}
if (!isMatchMsgStruct(text)) {
const delta = new Delta().retain(range.index).delete(range.length).insert(text)
quill.value.updateContents(delta, Quill.sources.USER)
quill.value.setSelection(delta.length() - range.length, Quill.sources.USER)
return
}
const delta = new Delta().retain(range.index).delete(range.length).insert(content)
const delta = new Delta().retain(range.index).delete(range.length)
const arr = jsonParseSafe(text)
for (const item of arr) {
const { type, value } = item
if (
type === msgContentType.IMAGE ||
type === msgContentType.AUDIO ||
type === msgContentType.VIDEO ||
type === msgContentType.DOCUMENT
) {
clearPasteObj()
pasteObj.content = item
pasteObj.contentType = type
const fileId = value
switch (type) {
case msgContentType.IMAGE:
pasteObj.fileName = imageData.image[fileId]?.fileName
pasteObj.fileSize = imageData.image[fileId]?.size
pasteObj.url = imageData.image[fileId]?.thumbUrl
break
case msgContentType.AUDIO:
pasteObj.fileName = audioData.audio[fileId]?.fileName
pasteObj.fileSize = audioData.audio[fileId]?.size
break
case msgContentType.VIDEO:
pasteObj.fileName = videoData.video[fileId]?.fileName
pasteObj.fileSize = videoData.video[fileId]?.size
break
case msgContentType.DOCUMENT:
pasteObj.fileName = documentData.document[fileId]?.fileName
pasteObj.fileSize = documentData.document[fileId]?.size
break
default:
break
}
// 文件确实存在才发送
if (pasteObj.fileName) {
showAgreeDialog.value = true
return // 这四种类型的数组只能有1个元素所以直接return
}
} else {
switch (type) {
case msgContentType.TEXT:
delta.insert(value)
break
case msgContentType.EMOJI: {
const emojiUrl = emojis[value]
if (emojiUrl) {
delta.insert({ image: emojiUrl }, { alt: value })
} else {
delta.insert(value)
}
break
}
case msgContentType.SCREENSHOT: {
const imageUrl = imageData.image[value]?.originUrl
if (imageUrl) {
delta.insert({ image: imageUrl }, { alt: `{${value}}` }) // 添加区别于emoji表情alt的符号方便parse时识别
} else {
delta.insert(value)
}
break
}
case msgContentType.AT: {
const { account, nickName } = value
toSendAtList.value.push(account)
delta.insert({ atMention: { account, nickName } })
break
}
case msgContentType.QUOTE:
default:
break
}
}
}
quill.value.updateContents(delta, Quill.sources.USER)
quill.value.setSelection(delta.length() - range.length, Quill.sources.USER)
}
/**
* 输入框的字符串内容渲染成富媒体内容
* @param content 字符串内容
* 输入框从空状态渲染可视内容
* 1. 渲染草稿
* 2. 消息撤回后的重新编辑
* @param content json结构化内容的字符串
*/
const renderContent = (content) => {
const renderContent = async (content) => {
if (!content) {
quill.value.setText('')
return
}
let contentArray = []
// 先匹配quote引用内容
content.split(/(「\{.*?\}」)/).forEach((item) => {
if (item.startsWith('「{') && item.endsWith('}」')) {
// quote引用内容直接添加如数组
contentArray.push(item)
} else {
//匹配内容中的图片
item.split(/(\{\d+\})/).forEach((item) => {
//匹配内容中的表情
item.split(/(\[.*?\])/).forEach((item) => {
//匹配内容中的@
item.split(/(<.*?>)/).forEach((item) => {
if (item) {
contentArray.push(item)
}
})
})
})
}
})
const arr = jsonParseSafe(content)
// 不允许非结构化的content
if (!arr) {
quill.value.setText('')
return
}
// 创建一个新的 Delta 对象
const delta = new Delta()
contentArray.map((item) => {
if (item.startsWith('{') && item.endsWith('}')) {
const imageId = item.slice(1, -1)
const imageUrl = imageData.image[imageId]?.originUrl
if (imageUrl) {
delta.insert({ image: imageUrl }, { alt: item })
} else {
delta.insert(item)
}
} else if (item.startsWith('[') && item.endsWith(']')) {
const emojiUrl = emojis[item]
if (emojiUrl) {
delta.insert({ image: emojiUrl }, { alt: item })
} else {
delta.insert(item)
}
} else if (item.startsWith('<') && item.endsWith('>')) {
const content = item.slice(1, -1)
const index = content.indexOf('-')
if (index !== -1) {
const account = content.slice(0, index)
const nickName = content.slice(index + 1)
if (nickName) {
toSendAtList.value.push(account)
delta.insert({ atMention: { account, nickName } })
for (const item of arr) {
if (!item.type || !item.value) {
delta.insert('')
}
switch (item.type) {
case msgContentType.TEXT:
delta.insert(item.value)
break
case msgContentType.EMOJI: {
const emojiUrl = emojis[item.value]
if (emojiUrl) {
delta.insert({ image: emojiUrl }, { alt: item.value })
} else {
delta.insert(item)
delta.insert(item.value)
}
} else {
delta.insert(item)
break
}
} else if (item.startsWith('「{') && item.endsWith('}」')) {
const quoteContent = item.slice(1, -1)
const { account, nickName, msgId, content, msgTime } = jsonParseSafe(quoteContent)
let showContent = content || ''
if (content) {
const defaultContent = content
.replace(/<(?:.*?)-(.*?)>/g, '@$1')
.replace(/\{\d+\}/g, '[图片]')
showContent = defaultContent
const contentJson = jsonParseSafe(defaultContent)
if (contentJson) {
const type = contentJson['type']
const objectId = contentJson['value']
switch (type) {
case msgContentType.RECORDING:
showContent = '[语音]'
break
case msgContentType.AUDIO:
showContent = `[音频] ${audioData.audio[objectId].fileName}`
break
case msgContentType.IMAGE:
showContent = `[图片] ${imageData.image[objectId].fileName}`
break
case msgContentType.VIDEO:
showContent = `[视频] ${videoData.video[objectId].fileName}`
break
case msgContentType.DOCUMENT:
showContent = `[文档] ${documentData.document[objectId].fileName}`
break
default:
break
case msgContentType.SCREENSHOT: {
const imageUrl = imageData.image[item.value]?.originUrl
if (imageUrl) {
delta.insert({ image: imageUrl }, { alt: `{${item.value}}` }) // 添加区别于emoji表情alt的符号方便parse时识别
} else {
delta.insert(item.value)
}
break
}
case msgContentType.AT: {
const { account, nickName } = item.value
toSendAtList.value.push(account)
delta.insert({ atMention: { account, nickName } })
break
}
case msgContentType.QUOTE: {
// 先从本地消息缓存中获取
let msg = messageData.getMsg(props.sessionId, item.value.msgId)
if (!msg) {
// 如果本地消息缓存中没有,再去服务器查询
const res = await msgChatQueryMessagesService({
sessionId: props.sessionId,
msgIds: [item.value.msgId]
})
if (res.data.data && res.data.data.length > 0) {
msg = res.data.data[0]
}
}
delta.insert({
quoteBlock: {
account: msg.fromId,
nickName: item.value.nickName,
msgId: msg.msgId,
content: showSimplifyMsgContent(msg.content),
msgTime: msg.msgTime
}
})
break
}
delta.insert({ quoteBlock: { account, nickName, msgId, content: showContent, msgTime } })
} else {
delta.insert(item)
default:
delta.insert('')
}
})
}
quill.value.setText('') // 清空编辑器内容
quill.value.updateContents(delta) // 使用 Delta 对象更新编辑器内容
quill.value.setSelection(quill.value.getLength(), 0, Quill.sources.USER) // 设置光标位置
}
/**
* 处理Enter发送
*/
const handleEnter = async () => {
if (isShowAtList.value) {
return
@@ -726,12 +818,22 @@ const handleEnter = async () => {
? { contentFromLocal: [pasteObj.content], contentFromServer: [pasteObj.content] }
: await parseContent(callbacks)
const content = contentObj.contentFromLocal.join('').trim()
if (!content) {
let textLength = 0
let contentType = 0
contentObj.contentFromLocal.forEach((item) => {
if (item.type === msgContentType.TEXT) {
textLength = textLength + item.value.length
}
contentType = contentType | item.type
})
if (contentType === 0) {
ElMessage.warning('请勿发送空内容')
quill.value.setText('')
return
} else if (content.length > 3000) {
}
if (textLength > 3000) {
ElMessage.warning('发送内容请不要超过3000个字')
return
}
@@ -739,8 +841,8 @@ const handleEnter = async () => {
// 发送的时候设置本地缓存(非服务端数据),用于立即渲染
let msg = {}
emit('saveLocalMsg', {
contentType: msgContentType.MIX,
content: content,
contentType: contentType,
content: JSON.stringify(contentObj.contentFromLocal.filter((item) => item)),
fn: (result) => {
msg = result
}
@@ -752,7 +854,8 @@ const handleEnter = async () => {
uploadProgress: 0
})
} else {
emit('sendMessage', { msg, at: toSendAtList.value })
const content = JSON.stringify(contentObj.contentFromServer.filter((item) => item))
emit('sendMessage', { msg, content, at: toSendAtList.value }) // content 要更新后发给服务端和saveLocalMsg的本地消息由些许差异
}
// callback每成功上传一个图片更新一下进度
@@ -771,14 +874,14 @@ const handleEnter = async () => {
}
// callback所有图片均上传则发送消息
const atTargets = toSendAtList.value
const atTargets = toSendAtList.value // 异步函数里避免调用响应式数据
callbacks.allUploadedSuccessFn = () => {
messageData.updateMsg(msg.sessionId, msg.msgId, {
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
uploadProgress: 100
})
msg.content = contentObj.contentFromServer.join('').trim()
emit('sendMessage', { msg, atTargets })
const content = JSON.stringify(contentObj.contentFromServer.filter((item) => item))
emit('sendMessage', { msg, content, at: atTargets })
}
clearPasteObj()
@@ -849,13 +952,13 @@ const onSelectedAtTarget = ({ account, nickName }) => {
}
}
const reeditFromRevoke = (content) => {
const reeditFromRevoke = async (content) => {
quill.value.setText('') // 清空编辑器内容
quill.value.setSelection(0, 0, Quill.sources.SILENT) // 设置光标位置
renderContent(content)
await renderContent(content)
}
const insertQuote = ({ account, nickName, msgId, content, msgTime }) => {
const insertQuote = ({ account, nickName, msgKey, msgId, content, msgTime }) => {
// 1. 保存原始选择范围
quill.value.focus() // 先使 Quill 编辑器获取焦点否则无法获取Selection
const originalRange = quill.value.getSelection()
@@ -874,7 +977,7 @@ const insertQuote = ({ account, nickName, msgId, content, msgTime }) => {
quill.value.insertEmbed(
0,
'quoteBlock',
{ account, nickName, msgId, content, msgTime },
{ account, nickName, msgKey, msgId, content, msgTime },
Quill.sources.USER
)
quill.value.insertText(1, '\n', Quill.sources.SILENT)
@@ -993,6 +1096,7 @@ img {
padding-right: 40px;
display: flex;
color: gray;
gap: 5px;
}
.quote-content {

View File

@@ -161,8 +161,8 @@ const uploadRecord = async () => {
})
let msg = {}
emit('saveLocalMsg', {
content: JSON.stringify([{ type: msgContentType.RECORDING, value: tempObjectId }]),
contentType: msgContentType.RECORDING,
objectId: tempObjectId,
fn: (result) => {
msg = result
}
@@ -194,11 +194,13 @@ const uploadRecord = async () => {
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
uploadProgress: 100
})
msg.content = JSON.stringify({
type: msgContentType.RECORDING,
value: res.data.data.objectId
})
emit('sendMessage', { msg })
const content = JSON.stringify([
{
type: msgContentType.RECORDING,
value: res.data.data.objectId
}
])
emit('sendMessage', { msg, content })
}
})
.catch((error) => {

View File

@@ -92,8 +92,8 @@ const onConfirmSendFile = () => {
// 写本地消息
let msg = {}
emit('saveLocalMsg', {
content: JSON.stringify([{ type: contentType, value: selectedFile.uid }]),
contentType: contentType,
objectId: selectedFile.uid,
fn: (result) => {
msg = result
}
@@ -134,8 +134,8 @@ const onConfirmSendFile = () => {
uploadStatus: msgFileUploadStatus.UPLOAD_SUCCESS,
uploadProgress: 100
})
msg.content = JSON.stringify({ type: contentType, value: res.data.data.objectId })
emit('sendMessage', { msg }) // 上传完成后发网络消息
const content = JSON.stringify([{ type: contentType, value: res.data.data.objectId }])
emit('sendMessage', { msg, content }) // 上传完成后发网络消息
}
})
.catch((error) => {

View File

@@ -112,6 +112,7 @@ defineExpose({
background-color: #fff;
position: absolute;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;

View File

@@ -7,7 +7,6 @@ import CopyIcon from '@/assets/svg/copy.svg'
import MultiselectIcon from '@/assets/svg/multiselect.svg'
import RevokeIcon from '@/assets/svg/revoke.svg'
import { useUserStore, useMenuStore } from '@/stores'
import { jsonParseSafe } from '@/js/utils/common'
import { MSG_REVOKE_TIME_LIMIT, msgContentType, msgSendStatus } from '@/const/msgConst'
const props = defineProps(['msg'])
@@ -26,17 +25,7 @@ const myAccount = computed(() => {
})
const contentType = computed(() => {
const contentJson = jsonParseSafe(props.msg.content)
if (!contentJson) {
return msgContentType.MIX
}
const type = contentJson['type']
if (!type) {
return msgContentType.MIX
} else {
return type
}
return props.msg.contentType
})
const msgStatus = computed(() => {
@@ -53,7 +42,10 @@ const menu = computed(() => {
}
]
if (contentType.value !== msgContentType.RECORDING) {
if (
contentType.value !== msgContentType.RECORDING &&
contentType.value !== msgContentType.FORWARD_TOGETHER
) {
o.push({
label: 'copy',
desc: '复制',
@@ -199,6 +191,7 @@ const handleClick = (item) => {
background-color: #fff;
position: absolute;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;

View File

@@ -111,6 +111,7 @@ const handleClick = (item) => {
background-color: #fff;
position: absolute;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;

View File

@@ -147,6 +147,7 @@ const handleClick = (item) => {
background-color: #fff;
position: fixed;
box-shadow: 2px 2px 20px gray;
z-index: 1000;
.menu-item {
padding: 5px;

View File

@@ -29,6 +29,7 @@ import {
msgChatRevokeMsgService
} from '@/api/message'
import DialogForMsgList from '@/views/message/components/DialogForMsgList.vue'
import { showSimplifyMsgContent } from '@/js/utils/message'
const props = defineProps([
'sessionId',
@@ -65,18 +66,76 @@ const audioData = useAudioStore()
const videoData = useVideoStore()
const documentData = useDocumentStore()
const forwardMsgs = ref([])
const quoteMsg = ref({})
onMounted(async () => {
await rendering()
await loadRelatedMsg()
rendering()
})
/**
* 加载和本消息相关的消息:引用消息,合并转发消息
*/
const loadRelatedMsg = async () => {
const arr = jsonParseSafe(msg.value.content)
if (!arr) {
return
}
for (const item of arr) {
if (item.type === msgContentType.QUOTE) {
// 先从本地消息缓存中获取
const msg = messageData.getMsg(props.sessionId, item.value.msgId)
if (!msg.msgId) {
// 如果本地消息缓存中没有,再去服务器查询
const res = await msgChatQueryMessagesService({
sessionId: props.sessionId,
msgIds: item.value.msgId
})
if (res.data.data && res.data.data.length > 0) {
quoteMsg.value = res.data.data[0]
}
} else {
quoteMsg.value = msg
}
} else if (item.type === msgContentType.FORWARD_TOGETHER) {
const forwatdMsgIds = item.value.data.map((item) => item.msgId)
const toQueryMsgIds = []
for (const msgId of forwatdMsgIds) {
// 先从本地消息缓存中获取
const msg = messageData.getMsg(props.sessionId, msgId)
if (!msg.msgId) {
// 如果本地消息缓存中没有,再去服务器查询
toQueryMsgIds.push(msgId)
} else {
forwardMsgs.value.push(msg)
}
}
if (toQueryMsgIds.length > 0) {
const res = await msgChatQueryMessagesService({
sessionId: item.value.sessionId,
msgIds: toQueryMsgIds.join(',')
})
res.data.data.forEach((item) => {
forwardMsgs.value.push(item)
})
}
}
}
}
let app = null
const rendering = async () => {
const rendering = () => {
const msgContent = document.querySelector(`#div-content-${msg.value.msgId}`)
if (msgContent) {
if (app) {
app.unmount()
}
const vnode = await renderComponent(msg.value.content)
const vnode = renderComponent(msg.value.content)
app = createApp({
render: () => vnode
})
@@ -88,117 +147,55 @@ const rendering = async () => {
* 动态渲染消息内容
* @param content 消息内容
*/
const renderComponent = async (content) => {
const contentJson = jsonParseSafe(content)
if (!contentJson) {
return renderMix(content)
const renderComponent = (content) => {
const arr = jsonParseSafe(content)
// 不允许非结构化的content
if (!arr) {
return h('span', '')
}
const type = contentJson['type']
const value = contentJson['value']
if (!type || !value) {
return renderMix(content)
}
return arr.map((item) => {
if (!item.type || !item.value) {
return h('span', '')
}
switch (type) {
case msgContentType.MIX:
return renderMix(value)
case msgContentType.TEXT:
return renderText(value)
case msgContentType.RECORDING:
return renderRecording(value)
case msgContentType.AUDIO:
return renderAudio(value)
case msgContentType.IMAGE:
return renderImage(value)
case msgContentType.EMOJI:
return renderEmoji(value)
case msgContentType.VIDEO:
return renderVideo(value)
case msgContentType.DOCUMENT:
return renderDocument(value)
case msgContentType.FORWARD_TOGETHER:
return await renderForwardTogether(value)
default:
return h('span', content)
}
}
switch (item.type) {
case msgContentType.TEXT:
return renderText(item.value)
case msgContentType.EMOJI:
return renderEmoji(item.value)
case msgContentType.SCREENSHOT:
return renderImage(item.value, true)
case msgContentType.AT:
return renderAt(item.value)
case msgContentType.QUOTE:
return renderQuote(item.value)
const renderText = (content) => {
return h('span', content)
}
const renderMix = (content) => {
if (!content) return h('div', [])
let contentArray = []
// 1. 先匹配quote引用内容
content.split(/(「\{.*?\}」)/).forEach((item) => {
if (item.startsWith('「{') && item.endsWith('}」')) {
// quote引用内容直接添加如数组
contentArray.push(item)
} else {
//2. 匹配内容中的图片
item.split(/(\{\d+\})/).forEach((item) => {
//3. 匹配内容中的表情
item.split(/(\[.*?\])/).forEach((item) => {
item.split(/(<.*?>)/).forEach((item) => {
if (item) {
contentArray.push(item)
}
})
})
})
case msgContentType.IMAGE:
return renderImage(item.value)
case msgContentType.RECORDING:
return renderRecording(item.value)
case msgContentType.AUDIO:
return renderAudio(item.value)
case msgContentType.VIDEO:
return renderVideo(item.value)
case msgContentType.DOCUMENT:
return renderDocument(item.value)
case msgContentType.FORWARD_TOGETHER:
return renderForwardTogether(item.value)
default:
return h('span', content)
}
})
}
return contentArray.map((item) => {
if (item.startsWith('{') && item.endsWith('}')) {
return renderImage(item.slice(1, -1), true)
} else if (item.startsWith('[') && item.endsWith(']')) {
return renderEmoji(item.slice(1, -1))
} else if (item.startsWith('<') && item.endsWith('>')) {
return renderAt(item.slice(1, -1))
} else if (item.startsWith('「{') && item.endsWith('}」')) {
return renderQuote(item.slice(1, -1))
} else {
return h('span', item.trim())
}
})
const renderText = (text) => {
return h('span', text)
}
const renderQuote = (quoteContent) => {
const { msgId, nickName, content, msgTime } = jsonParseSafe(quoteContent)
let showContent = content || ''
if (content) {
const defaultContent = content.replace(/<(?:.*?)-(.*?)>/g, '@$1').replace(/\{\d+\}/g, '[图片]')
showContent = defaultContent
const contentJson = jsonParseSafe(defaultContent)
if (contentJson) {
const type = contentJson['type']
const objectId = contentJson['value']
switch (type) {
case msgContentType.RECORDING:
showContent = '[语音]'
break
case msgContentType.AUDIO:
showContent = `[音频] ${audioData.audio[objectId].fileName}`
break
case msgContentType.IMAGE:
showContent = `[图片] ${imageData.image[objectId].fileName}`
break
case msgContentType.VIDEO:
showContent = `[视频] ${videoData.video[objectId].fileName}`
break
case msgContentType.DOCUMENT:
showContent = `[文档] ${documentData.document[objectId].fileName}`
break
default:
break
}
}
}
const { nickName, msgId } = quoteContent
return h(
// 和InputEditor.vue中的结构保持一致使用相同class可以复用样式
'div',
@@ -217,84 +214,25 @@ const renderQuote = (quoteContent) => {
[
h('div', { class: 'quote-sender' }, [
h('span', { class: 'quote-nickName' }, nickName + ' '),
h('span', { class: 'quote-msgTime' }, msgTime + '')
h('span', { class: 'quote-msgTime' }, showTimeFormat(quoteMsg.value.msgTime) + '')
]),
h('span', { class: 'quote-content' }, showContent)
h('span', { class: 'quote-content' }, showSimplifyMsgContent(quoteMsg.value.content))
]
)
)
}
const showMsgContentInForwardTogether = (content) => {
const jsonContent = jsonParseSafe(content)
let template
if (jsonContent && jsonContent['type'] && jsonContent['value']) {
if (jsonContent['type'] == msgContentType.IMAGE) {
template = '[图片]'
} else if (jsonContent['type'] == msgContentType.AUDIO) {
template = '[音频]'
} else if (jsonContent['type'] == msgContentType.RECORDING) {
template = '[语音]'
} else if (jsonContent['type'] == msgContentType.VIDEO) {
template = '[视频]'
} else if (jsonContent['type'] == msgContentType.DOCUMENT) {
template = '[文件]'
} else if (jsonContent['type'] == msgContentType.FORWARD_TOGETHER) {
template = '[聊天记录]'
} else {
template = jsonContent['value']
}
return template
} else {
return content
.replace(/\{\d+\}/g, '[图片]')
.replace(/(「\{.*?\}」)/, '[引用]')
.split(/(<.*?>)/)
.map((item) => {
const sliceStr = item.slice(1, -1)
const index = sliceStr.indexOf('-')
if (index !== -1) {
const nickName = sliceStr.slice(index + 1)
if (nickName) {
return `@${nickName}`
} else {
return item
}
}
return item
})
.join('')
}
}
const renderForwardTogether = async (content) => {
let res
try {
const msgIds = content.data
.map((item) => {
return item.msgId
})
.join(',')
res = await msgChatQueryMessagesService({
sessionId: content.sessionId,
msgIds
})
} catch (error) {
console.error(error)
return h('span', content)
}
const msgs = res.data.data
if (!res.data.data || res.data.data.length == 0) {
return h('span', content)
const renderForwardTogether = (forwardContent) => {
if (!forwardMsgs.value || forwardMsgs.value.length == 0) {
return h('span', '')
}
// 把content.data(取里面的nickName) 和 msgs合一
const newMsgs = {}
msgs.forEach((item) => {
forwardMsgs.value.forEach((item) => {
newMsgs[item.msgId] = item
})
content.data.forEach((item) => {
forwardContent.data.forEach((item) => {
if (item.msgId in newMsgs) {
newMsgs[item.msgId] = {
...newMsgs[item.msgId],
@@ -303,15 +241,16 @@ const renderForwardTogether = async (content) => {
}
})
const title =
(msgs[0].msgType === MsgType.GROUP_CHAT ? '群聊' : nickNameFromMsg.value) + '的聊天记录'
const msgsSorted = Object.values(newMsgs).sort((a, b) => {
const timeA = new Date(a.sendTime || a.msgTime).getTime()
const timeB = new Date(b.sendTime || b.msgTime).getTime()
return timeA - timeB
})
const title =
(forwardMsgs.value[0].msgType === MsgType.GROUP_CHAT ? '群聊' : nickNameFromMsg.value) +
'的聊天记录'
return h(
'div',
{
@@ -351,7 +290,7 @@ const renderForwardTogether = async (content) => {
return h('div', { class: 'msg-item', key: index }, [
h('span', { class: 'msg-item-nickname' }, msg.nickName || msg.fromId),
h('span', ''),
h('span', { class: 'msg-item-content' }, showMsgContentInForwardTogether(msg.content))
h('span', { class: 'msg-item-content' }, showSimplifyMsgContent(msg.content))
])
})
)
@@ -361,45 +300,33 @@ const renderForwardTogether = async (content) => {
)
}
const renderAt = (content) => {
const index = content.indexOf('-')
if (index !== -1) {
const account = content.slice(0, index)
const nickName = content.slice(index + 1)
if (messageData.sessionList[props.sessionId].sessionType === MsgType.GROUP_CHAT && nickName) {
const style = {
color: '#337ECC',
fontWeight: account === myAccount.value || account === '0' ? 'bold' : 'normal'
}
return h('span', { style }, `@${nickName} `)
} else {
return h('span', `<${content}>`)
}
} else {
return h('span', `<${content}>`)
const renderAt = (atContent) => {
const style = {
color: '#337ECC',
fontWeight:
atContent.account === myAccount.value || atContent.account === '0' ? 'bold' : 'normal'
}
return h('span', { style }, `@${atContent.nickName} `)
}
const renderEmoji = (content) => {
const emojiId = `[${content}]`
const renderEmoji = (emojiId) => {
const url = emojis[emojiId]
if (url) {
return h('img', {
class: 'emoji',
src: url,
alt: emojiId,
title: content,
title: emojiId.slice(1, -1),
onLoad: () => {
emit('loadFinished')
}
})
} else {
return h('span', `[${content}]`)
return h('span', emojiId)
}
}
const renderVideo = (content) => {
const videoId = content
const renderVideo = (videoId) => {
const url = videoData.video[videoId]?.downloadUrl
if (url) {
return h(MsgBoxVideo, {
@@ -415,19 +342,18 @@ const renderVideo = (content) => {
}
})
} else {
return h('span', `[${content}]`)
return h('span', `[${videoId}]`)
}
}
const renderImage = (content, isForMix = false) => {
const imgId = content
const renderImage = (imgId, isScreenShot = false) => {
if (imageData.image[imgId]) {
// 只要这里渲染就收集该session下的所有image用于preview-src-list
imageData.setImageInSession(props.sessionId, imageData.image[imgId])
return h(MsgBoxImage, {
sessionId: props.sessionId,
imgId,
isForMix,
isScreenShot,
thumbWidth: imageData.image[imgId].thumbWidth,
thumbHeight: imageData.image[imgId].thumbHeight,
onLoad: () => {
@@ -435,12 +361,11 @@ const renderImage = (content, isForMix = false) => {
}
})
} else {
return h('span', isForMix ? `{${content}}` : `[${content}]`)
return h('span', `[${imgId}]`)
}
}
const renderRecording = (content) => {
const audioId = content
const renderRecording = (audioId) => {
const url = audioData.audio[audioId]?.downloadUrl
const duration = audioData.audio[audioId]?.duration
if (url) {
@@ -452,12 +377,11 @@ const renderRecording = (content) => {
}
})
} else {
return h('span', `[${content}]`)
return h('span', '[语音]')
}
}
const renderAudio = (content) => {
const audioId = content
const renderAudio = (audioId) => {
const url = audioData.audio[audioId]?.downloadUrl
if (url) {
return h(MsgBoxAudio, {
@@ -469,12 +393,11 @@ const renderAudio = (content) => {
}
})
} else {
return h('span', `[${content}]`)
return h('span', `[${audioId}]`)
}
}
const renderDocument = (content) => {
const documentId = content
const renderDocument = (documentId) => {
const url = documentData.document[documentId]?.downloadUrl
if (url) {
return h(MsgBoxDocument, {
@@ -487,25 +410,10 @@ const renderDocument = (content) => {
}
})
} else {
return h('span', `[${content}]`)
return h('span', `[${documentId}]`)
}
}
const contentType = computed(() => {
const contentJson = jsonParseSafe(msg.value.content)
if (!contentJson) {
return msgContentType.MIX
}
const type = contentJson['type']
const value = contentJson['value']
if (!type || !value) {
return msgContentType.MIX
} else {
return type
}
})
const msg = computed(() => {
return messageData.getMsg(props.sessionId, props.msgKey)
})
@@ -892,13 +800,11 @@ const isDelete = computed(() => {
return msg.value.delete
})
/**
* 是否支持撤回重新编辑
*/
const isReedit = computed(() => {
const contentJson = jsonParseSafe(msg.value.content)
if (!contentJson) {
return true
}
const type = contentJson['type']
const type = msg.value.contentType
if (
type === msgContentType.IMAGE ||
type === msgContentType.RECORDING ||
@@ -964,10 +870,34 @@ const onSelectMenuMsgItem = async (label) => {
switch (label) {
case 'copy':
try {
await navigator.clipboard.writeText(msg.value.content)
let text = ''
const arr = jsonParseSafe(msg.value.content)
if (arr && Array.isArray(arr)) {
for (const item of arr) {
if (item.type === msgContentType.TEXT) {
text += item.value
}
}
}
const clipboardItem = new ClipboardItem({
'text/html': new Blob(
[`<div data-quill-custom=${encodeURIComponent(msg.value.content)}></div>`],
{
type: 'text/html'
}
), // 在html自定义属性data-quill-custom中传递clipboardContent结构化数据
'text/plain': new Blob([text], { type: 'text/plain' }) // 纯文本
})
try {
await navigator.clipboard.write([clipboardItem])
} catch (error) {
await navigator.clipboard.writeText(text) // 降级方案:仅写入纯文本
}
ElMessage.success('已复制到剪贴板')
} catch (error) {
ElMessage.error('复制出错')
ElMessage.error('复制出错 ', error)
}
break
case 'revoke':
@@ -1023,6 +953,7 @@ const onSelectMenuMsgItem = async (label) => {
props.inputEditorRef?.insertQuote({
account: msg.value.fromId,
nickName: nickNameFromMsg.value,
msgKey: props.msgKey, // 引用本地缓存消息的时候用
msgId: msg.value.msgId, // 引用要用msg.value.msgId
content: msg.value.content,
msgTime: msg.value.msgTime
@@ -1058,9 +989,12 @@ watch(
}
)
/**
* 哪些情况在多选模式下是禁选的
*/
const multiSelectOptionDisabled = computed(() => {
return (
contentType.value === msgContentType.RECORDING ||
msg.value.contentType === msgContentType.RECORDING ||
isSystemMsg.value ||
isRevoke.value ||
isDelete.value ||
@@ -1468,6 +1402,8 @@ const handleItemClick = () => {
border-top-left-radius: 0;
user-select: text;
white-space: pre-wrap;
word-break: break-word; /* 长单词或URL强制换行 */
overflow-wrap: break-word; /* 兼容性更好的换行 */
}
}
}

View File

@@ -5,17 +5,17 @@ import { formatFileSize } from '@/js/utils/common'
import { useImageStore } from '@/stores'
import ImageloadfailedIcon from '@/assets/svg/imageloadfailed.svg'
const props = defineProps(['sessionId', 'imgId', 'isForMix', 'thumbWidth', 'thumbHeight'])
const props = defineProps(['sessionId', 'imgId', 'isScreenShot', 'thumbWidth', 'thumbHeight'])
const emits = defineEmits(['load'])
const imageData = useImageStore()
const maxWidth = computed(() => {
return props.isForMix ? Math.min(props.thumbWidth, 360) : 360
return props.isScreenShot ? Math.min(props.thumbWidth, 360) : 360
})
const maxHeight = computed(() => {
return props.isForMix ? Math.min(props.thumbHeight, 270) : 270
return props.isScreenShot ? Math.min(props.thumbHeight, 270) : 270
})
const renderWidth = computed(() => {
@@ -65,11 +65,11 @@ const initialIndex = computed(() => {
})
const fileName = computed(() => {
return props.isForMix ? '' : imageData.image[props.imgId]?.fileName
return props.isScreenShot ? '' : imageData.image[props.imgId]?.fileName
})
const size = computed(() => {
return props.isForMix ? '' : imageData.image[props.imgId]?.size
return props.isScreenShot ? '' : imageData.image[props.imgId]?.size
})
const formatSize = computed(() => {

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { ElIcon, ElMessage } from 'element-plus'
import PlayIcon from '@/assets/svg/play.svg'
import PauseIcon from '@/assets/svg/pause.svg'
import { AVWaveform } from 'vue-audio-visual'
import { showDurationFormat } from '@/js/utils/common'
const props = defineProps(['audioUrl', 'duration'])
const emits = defineEmits(['load'])
@@ -12,17 +13,6 @@ const waveformRef = ref(null)
const isPlaying = ref(false)
const audioDuration = ref(null)
// 格式化时间
const formatDuration = computed(() => {
if (!audioDuration.value) {
return '0:00'
}
const minutes = Math.floor(audioDuration.value / 60)
const seconds = Math.floor(audioDuration.value % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
const playAudio = async () => {
const audioPlayer = waveformRef.value.querySelector('audio')
if (audioPlayer) {
@@ -98,7 +88,7 @@ onMounted(() => {
:playtime-slider-color="`#409eff`"
></AVWaveform>
<span class="time">{{ formatDuration }}</span>
<span class="time">{{ showDurationFormat(audioDuration) }}</span>
</div>
</template>

View File

@@ -3,14 +3,15 @@ import { ref, computed, watch } from 'vue'
import UserAvatarIcon from '@/components/common/UserAvatarIcon.vue'
import GroupAvatarIcon from '@/components/common/GroupAvatarIcon.vue'
import SessionTag from './SessionTag.vue'
import { jsonParseSafe, sessionShowTime } from '@/js/utils/common'
import { sessionShowTime } from '@/js/utils/common'
import { Top, MuteNotification } from '@element-plus/icons-vue'
import { MsgType } from '@/proto/msg'
import { useUserStore, useMessageStore, useGroupStore } from '@/stores'
import { msgChatCloseSessionService } from '@/api/message'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { msgContentType, msgSendStatus } from '@/const/msgConst'
import { msgSendStatus } from '@/const/msgConst'
import { showSimplifyMsgContent } from '@/js/utils/message'
const props = defineProps([
'sessionId',
@@ -217,122 +218,55 @@ const getGroupChatMsgTips = (content) => {
const showDetailContent = computed(() => {
if (isShowDraft.value) {
let formatDraft = sessionInfo.value.draft
?.replace(/\{\d+\}/g, '[图片]') // 把内容中的`{xxxxxx}`格式的图片统一转成`[图片]`
.replace(/(「\{.*?\}」)/, '[引用]') // 把内容中的`「xxxxxx」`格式的图片统一转成`[图片]`
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
formatDraft = formatDraft
.split(/(<.*?>)/)
.map((item) => {
const sliceStr = item.slice(1, -1)
const index = sliceStr.indexOf('-')
if (index !== -1) {
const nickName = sliceStr.slice(index + 1)
if (nickName) {
return `@${nickName}`
} else {
return item
}
}
return item
})
.join('')
}
return formatDraft
return showSimplifyMsgContent(sessionInfo.value.draft)
} else {
if (!lastMsg.value.content) {
return '...'
}
const jsonContent = jsonParseSafe(lastMsg.value.content)
let template
if (jsonContent && jsonContent['type'] && jsonContent['value']) {
if (jsonContent['type'] == msgContentType.IMAGE) {
template = '[图片]'
} else if (jsonContent['type'] == msgContentType.AUDIO) {
template = '[音频]'
} else if (jsonContent['type'] == msgContentType.RECORDING) {
template = '[语音]'
} else if (jsonContent['type'] == msgContentType.VIDEO) {
template = '[视频]'
} else if (jsonContent['type'] == msgContentType.DOCUMENT) {
template = '[文件]'
} else if (jsonContent['type'] == msgContentType.FORWARD_TOGETHER) {
template = '[聊天记录]'
} else {
template = jsonContent['value']
}
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
return getGroupChatMsgTips(template)
} else {
return template
}
}
if (sessionInfo.value.sessionType === MsgType.GROUP_CHAT) {
let content = jsonParseSafe(lastMsg.value.content)
switch (lastMsg.value.msgType) {
case MsgType.SYS_GROUP_CREATE:
return getSysGroupCreateMsgTips(content)
return getSysGroupCreateMsgTips(lastMsg.value.content)
case MsgType.SYS_GROUP_ADD_MEMBER:
return getSysGroupAddMemberMsgTips(content)
return getSysGroupAddMemberMsgTips(lastMsg.value.content)
case MsgType.SYS_GROUP_DEL_MEMBER:
return getSysGroupDelMemberMsgTips(content)
return getSysGroupDelMemberMsgTips(lastMsg.value.content)
case MsgType.SYS_GROUP_UPDATE_ANNOUNCEMENT:
return getSysGroupUpdateAnnouncement(content)
return getSysGroupUpdateAnnouncement(lastMsg.value.content)
case MsgType.SYS_GROUP_UPDATE_NAME:
return getSysGroupUpdateName(content)
return getSysGroupUpdateName(lastMsg.value.content)
case MsgType.SYS_GROUP_UPDATE_AVATAR:
return getSysGroupUpdateAvatar(content)
return getSysGroupUpdateAvatar(lastMsg.value.content)
case MsgType.SYS_GROUP_SET_ADMIN:
case MsgType.SYS_GROUP_CANCEL_ADMIN:
return getSysGroupChangeRoleMsgTips(lastMsg.value.msgType, content)
return getSysGroupChangeRoleMsgTips(lastMsg.value.msgType, lastMsg.value.content)
case MsgType.SYS_GROUP_SET_ALL_MUTED:
case MsgType.SYS_GROUP_CANCEL_ALL_MUTED:
return getSysGroupUpdateAllMuted(lastMsg.value.msgType, content)
return getSysGroupUpdateAllMuted(lastMsg.value.msgType, lastMsg.value.content)
case MsgType.SYS_GROUP_SET_JOIN_APPROVAL:
case MsgType.SYS_GROUP_CANCEL_JOIN_APPROVAL:
return getSysGroupUpdateJoinApproval(lastMsg.value.msgType, content)
return getSysGroupUpdateJoinApproval(lastMsg.value.msgType, lastMsg.value.content)
case MsgType.SYS_GROUP_SET_HISTORY_BROWSE:
case MsgType.SYS_GROUP_CANCEL_HISTORY_BROWSE:
return getSysGroupUpdateHistoryBrowse(lastMsg.value.msgType, content)
return getSysGroupUpdateHistoryBrowse(lastMsg.value.msgType, lastMsg.value.content)
case MsgType.SYS_GROUP_OWNER_TRANSFER:
return getSysGroupOwnerTransfer(content)
return getSysGroupOwnerTransfer(lastMsg.value.content)
case MsgType.SYS_GROUP_UPDATE_MEMBER_MUTED:
return getSysGroupUpdateMemberMuted(content)
return getSysGroupUpdateMemberMuted(lastMsg.value.content)
case MsgType.SYS_GROUP_LEAVE:
return getSysGroupLeave(content)
return getSysGroupLeave(lastMsg.value.content)
case MsgType.SYS_GROUP_DROP:
return getSysGroupDrop(content)
return getSysGroupDrop(lastMsg.value.content)
case MsgType.GROUP_CHAT:
//格式化图片内容
content = lastMsg.value.content
.replace(/\{\d+\}/g, '[图片]')
.replace(/(「\{.*?\}」)/, '[引用]')
//格式化@内容
content = content
.split(/(<.*?>)/)
.map((item) => {
const sliceStr = item.slice(1, -1)
const index = sliceStr.indexOf('-')
if (index !== -1) {
const nickName = sliceStr.slice(index + 1)
if (nickName) {
return `@${nickName}`
} else {
return item
}
}
return item
})
.join('')
return getGroupChatMsgTips(content)
return getGroupChatMsgTips(showSimplifyMsgContent(lastMsg.value.content))
default:
return ''
return '...'
}
} else if (sessionInfo.value.sessionType === MsgType.CHAT) {
return showSimplifyMsgContent(lastMsg.value.content)
} else {
return lastMsg.value.content.replace(/\{\d+\}/g, '[图片]').replace(/(「\{.*?\}」)/, '[引用]')
return '...'
}
}
})