diff --git a/src/const/msgConst.js b/src/const/msgConst.js index ecaf536..4b5f173 100644 --- a/src/const/msgConst.js +++ b/src/const/msgConst.js @@ -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 // 合并转发消息 } // 消息发送状态 diff --git a/src/js/utils/common.js b/src/js/utils/common.js index bb2f35f..bab28ca 100644 --- a/src/js/utils/common.js +++ b/src/js/utils/common.js @@ -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 diff --git a/src/js/utils/message.js b/src/js/utils/message.js new file mode 100644 index 0000000..61a96cb --- /dev/null +++ b/src/js/utils/message.js @@ -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 + ] +} diff --git a/src/js/websocket/constructor.js b/src/js/websocket/constructor.js index 0f8d650..3650345 100644 --- a/src/js/websocket/constructor.js +++ b/src/js/websocket/constructor.js @@ -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 }) diff --git a/src/js/websocket/wsConnect.js b/src/js/websocket/wsConnect.js index 7d09f7b..6e87458 100644 --- a/src/js/websocket/wsConnect.js +++ b/src/js/websocket/wsConnect.js @@ -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) diff --git a/src/models/message.js b/src/models/message.js index 3a15c71..352e60d 100644 --- a/src/models/message.js +++ b/src/models/message.js @@ -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 diff --git a/src/proto/msg.js b/src/proto/msg.js index a1261ff..a23b82e 100644 --- a/src/proto/msg.js +++ b/src/proto/msg.js @@ -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' diff --git a/src/proto/msg.proto b/src/proto/msg.proto index 151d8a7..e3e058b 100644 --- a/src/proto/msg.proto +++ b/src/proto/msg.proto @@ -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 { diff --git a/src/stores/audio.js b/src/stores/audio.js index bfb0b43..3ff0f14 100644 --- a/src/stores/audio.js +++ b/src/stores/audio.js @@ -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 } }) diff --git a/src/stores/document.js b/src/stores/document.js index d0e7e76..e4a6c83 100644 --- a/src/stores/document.js +++ b/src/stores/document.js @@ -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 } }) diff --git a/src/stores/image.js b/src/stores/image.js index a0a111e..ea325d7 100644 --- a/src/stores/image.js +++ b/src/stores/image.js @@ -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 } }) diff --git a/src/stores/message.js b/src/stores/message.js index 6349c51..ab0f53b 100644 --- a/src/stores/message.js +++ b/src/stores/message.js @@ -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) } /** diff --git a/src/stores/video.js b/src/stores/video.js index d932262..9d745ef 100644 --- a/src/stores/video.js +++ b/src/stores/video.js @@ -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 } }) diff --git a/src/views/message/MessageLayout.vue b/src/views/message/MessageLayout.vue index 4c9f400..b56f3dc 100644 --- a/src/views/message/MessageLayout.vue +++ b/src/views/message/MessageLayout.vue @@ -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) diff --git a/src/views/message/components/DialogForMsgList.vue b/src/views/message/components/DialogForMsgList.vue index 12d4b2e..2e52037 100644 --- a/src/views/message/components/DialogForMsgList.vue +++ b/src/views/message/components/DialogForMsgList.vue @@ -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 } - 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 {content} - } -} - -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 } - }) - 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 {item.trim()} + 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 } }) } -const renderText = (content) => { - return {content} +const renderText = (text) => { + return {text} } -const renderRecording = (content) => { - const audioId = content +const renderRecording = (audioId) => { const url = audioData.audio[audioId]?.downloadUrl const duration = audioData.audio[audioId]?.duration if (url) { return } else { - return {content} + return {'[语音]'} } } -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) => { > ) } else { - return {content} + return {`[${audioId}]`} } } -const renderEmoji = (content) => { - const emojiId = `[${content}]` +const renderEmoji = (emojiId) => { const url = emojis[emojiId] if (url) { - return {emojiId} + return {emojiId} } else { - return {content} + return {emojiId} } } -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 ( ) } else { - return {isForMix ? content : `[${content}]`} + return {`[${imgId}]`} } } -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) => { > ) } else { - return {content} + return {`[${videoId}]`} } } -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) => { > ) } else { - return {content} + return {`[${documentId}]`} } } -const renderForwardTogether = (msgId) => { - const title = '聊天记录' - const msgsSorted = msgsFromServer.value[msgId] +const renderForwardTogether = (forwardContent, msgId) => { + const msgs = forwardMsgs.value[msgId] + if (!msgs) { + return
+ } + + // 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
} + const title = '聊天记录' + return (
{
{msg.nickName || msg.fromId} {':'} - - {showMsgContentInForwardTogether(msg.content)} - + {showSimplifyMsgContent(msg.content)}
) })} @@ -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 {`@${nickName} `} - } else { - return {`<${content}>`} - } - } else { - return {`<${content}>`} - } +const renderAt = (atContent) => { + return {`@${atContent.nickName} `} } -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 (
- {nickName + ' '} - {msgTime + ':'} + {nickName} + {` ${showTimeFormat(msgTime)}:`}
- {showContent} + {showSimplifyMsgContent(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 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" diff --git a/src/views/message/components/InputEditor.vue b/src/views/message/components/InputEditor.vue index 3160048..a95528c 100644 --- a/src/views/message/components/InputEditor.vue +++ b/src/views/message/components/InputEditor.vue @@ -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 = `
- ${node.dataset.nickName} + ${node.dataset.nickName} ${node.dataset.msgTime}:
- ${showContent} + ${showSimplifyMsgContent(node.dataset.content)} @@ -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: `
`, + 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 { diff --git a/src/views/message/components/InputRecorder.vue b/src/views/message/components/InputRecorder.vue index c194cd2..5806836 100644 --- a/src/views/message/components/InputRecorder.vue +++ b/src/views/message/components/InputRecorder.vue @@ -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) => { diff --git a/src/views/message/components/InputToolBar.vue b/src/views/message/components/InputToolBar.vue index 4392ca0..5adf606 100644 --- a/src/views/message/components/InputToolBar.vue +++ b/src/views/message/components/InputToolBar.vue @@ -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) => { diff --git a/src/views/message/components/MenuAddOpr.vue b/src/views/message/components/MenuAddOpr.vue index 7dbc65c..2a499db 100644 --- a/src/views/message/components/MenuAddOpr.vue +++ b/src/views/message/components/MenuAddOpr.vue @@ -112,6 +112,7 @@ defineExpose({ background-color: #fff; position: absolute; box-shadow: 2px 2px 20px gray; + z-index: 1000; .menu-item { padding: 5px; diff --git a/src/views/message/components/MenuMsgItem.vue b/src/views/message/components/MenuMsgItem.vue index 0420bdf..4497fe0 100644 --- a/src/views/message/components/MenuMsgItem.vue +++ b/src/views/message/components/MenuMsgItem.vue @@ -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; diff --git a/src/views/message/components/MenuMsgMain.vue b/src/views/message/components/MenuMsgMain.vue index 41be8ef..955b153 100644 --- a/src/views/message/components/MenuMsgMain.vue +++ b/src/views/message/components/MenuMsgMain.vue @@ -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; diff --git a/src/views/message/components/MenuSession.vue b/src/views/message/components/MenuSession.vue index c58f165..70c0c18 100644 --- a/src/views/message/components/MenuSession.vue +++ b/src/views/message/components/MenuSession.vue @@ -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; diff --git a/src/views/message/components/MessageItem.vue b/src/views/message/components/MessageItem.vue index 2945dc9..711f2fe 100644 --- a/src/views/message/components/MessageItem.vue +++ b/src/views/message/components/MessageItem.vue @@ -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( + [`
`], + { + 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; /* 兼容性更好的换行 */ } } } diff --git a/src/views/message/components/MsgBoxImage.vue b/src/views/message/components/MsgBoxImage.vue index 95e0428..d202d81 100644 --- a/src/views/message/components/MsgBoxImage.vue +++ b/src/views/message/components/MsgBoxImage.vue @@ -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(() => { diff --git a/src/views/message/components/MsgBoxRecording.vue b/src/views/message/components/MsgBoxRecording.vue index 705d800..8c1d8af 100644 --- a/src/views/message/components/MsgBoxRecording.vue +++ b/src/views/message/components/MsgBoxRecording.vue @@ -1,9 +1,10 @@