diff --git a/src/const/msgConst.js b/src/const/msgConst.js index ec53b47..b13978b 100644 --- a/src/const/msgConst.js +++ b/src/const/msgConst.js @@ -29,3 +29,11 @@ export const msgSendStatus = { OK: 'ok', // 发送成功 FAILED: 'failed' // 发送失败 } + +// 消息中文件的上传状态 +export const msgFileUploadStatus = { + UPLOAD_DEFAULT: 0, // 默认状态,不上传 + UPLOADING: 1, // 上传中 + UPLOAD_SUCCESS: 2, // 上传成功 + UPLOAD_FAILED: 3 // 上传失败 +} diff --git a/src/models/message.js b/src/models/message.js new file mode 100644 index 0000000..3a15c71 --- /dev/null +++ b/src/models/message.js @@ -0,0 +1,89 @@ +import { msgSendStatus } from '@/const/msgConst' + +/** + * 消息渲染及缓存时用到实体类,收录所有可能用到的属性,目前作为参考用,并未实际调用 + */ +class Message { + /** + * 会话内唯一消息Id + */ + msgId + + /** + * 消息序列号 + */ + seq + + /** + * 消息所属的会话ID + */ + sessionId + + /** + * 消息发送ID + */ + fromId + + /** + * 消息类型 + */ + msgType + + /** + * 消息内容 + */ + content + + /** + * 消息状态:发送中,发送成功,发送失败 + */ + status + + /** + * 接收消息的时间 + */ + msgTime + + /** + * 消息发送的时间,发送消息时才需要填 + */ + sendTime + + /** + * 消息中文件的上传状态 + */ + uploadStatus + + /** + * 消息中文件的上传进度 + */ + uploadProgress + + constructor( + sessionId, + fromId, + msgType, + content, + msgTime, + sendTime = undefined, + msgId = undefined, + seq = undefined, + status = msgSendStatus.PENDING, + uploadStatus = undefined, + uploadProgress = undefined + ) { + this.msgId = msgId + this.seq = seq + this.sessionId = sessionId + this.fromId = fromId + this.msgType = msgType + this.content = content + this.status = status + this.msgTime = msgTime + this.sendTime = sendTime + this.uploadStatus = uploadStatus + this.uploadProgress = uploadProgress + } +} + +export { Message } diff --git a/src/stores/image.js b/src/stores/image.js index e3afde0..087f3b3 100644 --- a/src/stores/image.js +++ b/src/stores/image.js @@ -21,7 +21,15 @@ export const useImageStore = defineStore('anylink-image', () => { */ const imageInSession = ref({}) - const setImage = (sessionId, obj) => { + /** + * 本地图片只是临时的,不用放进imageInSession + * @param {*} obj + */ + const setLocalImage = (obj) => { + image.value[obj.objectId] = obj + } + + const setServerImage = (sessionId, obj) => { image.value[obj.objectId] = obj if (!imageInSession.value[sessionId]) { imageInSession.value[sessionId] = [] @@ -29,7 +37,7 @@ export const useImageStore = defineStore('anylink-image', () => { imageInSession.value[sessionId].push(obj.objectId) } - const imageTrans = (content, maxWidth = 400, maxHeight = 300) => { + const imageTrans = (content, maxWidth = 360, maxHeight = 180) => { const matches = content.match(pattern) if (!matches || matches.length === 0) { return content @@ -68,7 +76,7 @@ export const useImageStore = defineStore('anylink-image', () => { if (imageIds.size > 0) { const res = await mtsImageService({ objectIds: [...imageIds].join(',') }) res.data.data.forEach((item) => { - setImage(sessionId, item) // 缓存image数据 + setServerImage(sessionId, item) // 缓存image数据 }) } } @@ -101,7 +109,7 @@ export const useImageStore = defineStore('anylink-image', () => { if (imageIds.size > 0) { const res = await mtsImageService({ objectIds: [...imageIds].join(',') }) res.data.data.forEach((item) => { - setImage(sessionId, item) + setServerImage(sessionId, item) }) } } @@ -109,7 +117,8 @@ export const useImageStore = defineStore('anylink-image', () => { return { image, imageInSession, - setImage, + setLocalImage, + setServerImage, imageTrans, loadImageInfoFromContent, preloadImage diff --git a/src/stores/message.js b/src/stores/message.js index cd4f07e..6d21c28 100644 --- a/src/stores/message.js +++ b/src/stores/message.js @@ -117,7 +117,7 @@ export const useMessageStore = defineStore('anylink-message', () => { } /** - * 对话列表中加入新的消息数组 + * 对话列表中加入新的消息数组(预加载资源) * @param {*} sessionId 会话id * @param {*} msgRecords 新的消息数组 */ @@ -128,6 +128,15 @@ export const useMessageStore = defineStore('anylink-message', () => { await useVideoStore().preloadVideo(sessionId, msgRecords) await useDocumentStore().preloadDocument(sessionId, msgRecords) + addMsgRecordsWithOutPreLoad(sessionId, msgRecords) + } + + /** + * 对话列表中加入新的消息数组 + * @param {*} sessionId 会话id + * @param {*} msgRecords 新的消息数组 + */ + const addMsgRecordsWithOutPreLoad = (sessionId, msgRecords) => { if (!msgRecords?.length) return msgRecords.forEach((item) => { if (!msgRecordsList.value[sessionId]) { @@ -272,6 +281,7 @@ export const useMessageStore = defineStore('anylink-message', () => { msgRecordsList, msgIdSortArray, addMsgRecords, + addMsgRecordsWithOutPreLoad, removeMsgRecord, getMsg, diff --git a/src/views/message/MessageLayout.vue b/src/views/message/MessageLayout.vue index fe2dd9b..1ea20e3 100644 --- a/src/views/message/MessageLayout.vue +++ b/src/views/message/MessageLayout.vue @@ -9,6 +9,7 @@ import { ArrowDownBold, ArrowUp } from '@element-plus/icons-vue' +import { v4 as uuidv4 } from 'uuid' import DragLine from '@/components/common/DragLine.vue' import SearchBox from '@/components/search/SearchBox.vue' import AddButton from '@/components/common/AddButton.vue' @@ -478,7 +479,7 @@ const handleRead = () => { } } -const handleSendMessage = (content, resendSeq = '') => { +const handleSendMessage = (msg) => { if (isNotInGroup.value) { ElMessage.warning('您已离开该群或群已被解散') return @@ -490,14 +491,16 @@ const handleSendMessage = (content, resendSeq = '') => { if (inputToolBarRef.value) inputToolBarRef.value.closeWindow() - const msg = { - sessionId: selectedSessionId.value, - fromId: myAccount.value, - msgType: selectedSession.value.sessionType, - content: content, - status: msgSendStatus.PENDING, - msgTime: new Date(), - sendTime: new Date() + if (typeof msg === 'string') { + msg = { + sessionId: selectedSessionId.value, + fromId: myAccount.value, + msgType: selectedSession.value.sessionType, + content: msg, + status: msgSendStatus.PENDING, + msgTime: new Date(), + sendTime: new Date() + } } const resendInterval = 2000 //2秒 @@ -556,8 +559,8 @@ const handleSendMessage = (content, resendSeq = '') => { msg.sessionId, showId.value, selectedSession.value.sessionType, - content, - resendSeq, + msg.content, + msg.seq, before, after ) @@ -567,8 +570,12 @@ const handleSendMessage = (content, resendSeq = '') => { locateSession(msg.sessionId) } -const handleResendMessage = ({ content, seq }) => { - handleSendMessage(content, seq) +const handleResendMessage = (msg) => { + // 重发消息时更新这三个属性,其他不变 + msg.status = msgSendStatus.PENDING + msg.msgTime = new Date() + msg.sendTime = new Date() + handleSendMessage(msg) } const onLoadMore = async () => { @@ -982,24 +989,27 @@ const onSendEmoji = (key) => { inputEditorRef.value.addEmoji(key) } -const onSendImage = ({ objectId }) => { - handleSendMessage(JSON.stringify({ type: msgContentType.IMAGE, value: objectId })) -} - -const onSendAudio = ({ objectId }) => { - handleSendMessage(JSON.stringify({ type: msgContentType.AUDIO, value: objectId })) -} - -const onSendRecording = ({ objectId }) => { - handleSendMessage(JSON.stringify({ type: msgContentType.RECORDING, value: objectId })) -} - -const onSendVideo = ({ objectId }) => { - handleSendMessage(JSON.stringify({ type: msgContentType.VIDEO, value: objectId })) -} - -const onSendDocument = ({ objectId }) => { - handleSendMessage(JSON.stringify({ type: msgContentType.DOCUMENT, value: objectId })) +/** + * 发送时先添加本地消息,可以立即渲染 + */ +const handleLocalMsg = ({ content, contentType, objectId, fn }) => { + const seq = uuidv4() + const msg = { + msgId: seq, + seq: seq, + sessionId: selectedSessionId.value, + fromId: myAccount.value, + msgType: selectedSession.value.sessionType, + content: + contentType === msgContentType.MIX + ? content + : JSON.stringify({ type: contentType, value: objectId }), + status: msgSendStatus.PENDING, + msgTime: new Date(), + sendTime: new Date() + } + messageData.addMsgRecordsWithOutPreLoad(msg.sessionId, [msg]) + fn(msg) } const inputRecorderRef = ref(null) @@ -1193,7 +1203,8 @@ const onShowRecorder = () => { ref="inputRecorderRef" :sessionId="selectedSessionId" @exit="isShowRecorder = false" - @sendRecording="onSendRecording" + @saveLocalMsg="handleLocalMsg" + @sendMessage="handleSendMessage" > @@ -1203,11 +1214,9 @@ const onShowRecorder = () => { :sessionId="selectedSessionId" :isShowToolSet="!isNotInGroup" @sendEmoji="onSendEmoji" - @sendImage="onSendImage" - @sendAudio="onSendAudio" - @sendVideo="onSendVideo" - @sendDocument="onSendDocument" @showRecorder="onShowRecorder" + @saveLocalMsg="handleLocalMsg" + @sendMessage="handleSendMessage" > @@ -1230,6 +1239,7 @@ const onShowRecorder = () => { ref="inputEditorRef" :sessionId="selectedSessionId" :draft="selectedSession.draft || ''" + @saveLocalMsg="handleLocalMsg" @sendMessage="handleSendMessage" > diff --git a/src/views/message/components/ImageMsgBox.vue b/src/views/message/components/ImageMsgBox.vue index 8fb5fb5..bbccfef 100644 --- a/src/views/message/components/ImageMsgBox.vue +++ b/src/views/message/components/ImageMsgBox.vue @@ -8,20 +8,16 @@ const emits = defineEmits(['load']) const onLoad = (e) => { const img = e.target - const ratio = img.naturalWidth / img.naturalHeight - const maxRatio = 300 / 200 // 最大宽高比 + const maxWidth = 360 + const maxHeight = 180 - // 如果图片尺寸在限制范围内,保持原始尺寸 - if (img.naturalWidth <= 300 && img.naturalHeight <= 200) { - img.style.width = img.naturalWidth + 'px' - img.style.height = img.naturalHeight + 'px' - } else if (ratio > maxRatio) { + if (img.naturalWidth / img.naturalHeight > maxWidth / maxHeight) { // 如果图片更宽,以宽度为基准 - img.style.width = '300px' + img.style.width = maxWidth + 'px' img.style.height = 'auto' } else { // 如果图片更高,以高度为基准 - img.style.height = '200px' + img.style.height = maxHeight + 'px' img.style.width = 'auto' } @@ -63,8 +59,6 @@ const formatSize = computed(() => { position: relative; .el-image { - max-width: 300px; - max-height: 200px; width: auto; height: auto; diff --git a/src/views/message/components/InputEditor.vue b/src/views/message/components/InputEditor.vue index 9ee0467..bc144d8 100644 --- a/src/views/message/components/InputEditor.vue +++ b/src/views/message/components/InputEditor.vue @@ -4,14 +4,14 @@ import '@vueup/vue-quill/dist/vue-quill.snow.css' import { onMounted, onUnmounted, onBeforeUnmount, ref, watch } from 'vue' import { v4 as uuidv4 } from 'uuid' import { useMessageStore, useImageStore } from '@/stores' -import { ElMessage, ElLoading } from 'element-plus' +import { ElMessage } from 'element-plus' import { emojiTrans, getEmojiHtml } from '@/js/utils/emojis' import { base64ToFile } from '@/js/utils/common' import { mtsUploadService } from '@/api/mts' -import { el_loading_options } from '@/const/commonConst' +import { msgContentType, msgFileUploadStatus } from '@/const/msgConst' const props = defineProps(['sessionId', 'draft']) -const emit = defineEmits(['sendMessage']) +const emit = defineEmits(['saveLocalMsg', 'sendMessage']) const messageData = useMessageStore() const imageData = useImageStore() @@ -37,15 +37,36 @@ onMounted(async () => { }) onBeforeUnmount(async () => { - let content = await getContent() - // 草稿若没发生变动,则不触发存储 + const contentObj = parseContent() const draft = messageData.sessionList[props.sessionId]?.draft + const content = contentObj.contentFromLocal.join('').trim() + // 草稿若发生变动,则触发存储 if (content && draft && content !== draft) { messageData.updateSession({ sessionId: props.sessionId, draft: content }) } + + // 有图片需要上传,再保存一次draft + if (contentObj.needUploadCount.value > 0) { + const stopWatch = watch( + () => contentObj.uploadedTotalCount.value, + () => { + if (contentObj.needUploadCount.value === contentObj.uploadedTotalCount.value) { + // 满足第一个相等条件就停止监视 + stopWatch() + if (contentObj.uploadSuccessCount.value === contentObj.needUploadCount.value) { + // 满足第二个相等条件才保存草稿 + messageData.updateSession({ + sessionId: props.sessionId, + draft: contentObj.contentFromServer.join('').trim() + }) + } + } + } + ) + } }) onUnmounted(() => { @@ -58,54 +79,114 @@ onUnmounted(() => { } }) -const getContent = async () => { +const parseContent = (sessionId = props.sessionId) => { const delta = getQuill().getContents() - let content = '' + let contentFromLocal = new Array(delta.ops.length).fill('') + let contentFromServer = new Array(delta.ops.length).fill('') + let needUploadCount = ref(0) // 需要上传的图片个数 + let uploadedTotalCount = ref(0) // 已发上传请求的图片个数,包括上传成功和失败 + let uploadSuccessCount = ref(0) // 已经上传成功的图片个数 for (let index = 0; index < delta.ops.length; index++) { const op = delta.ops[index] const insert = op.insert if (insert && typeof insert === 'string') { // 文本 - content = content + insert + contentFromLocal[index] = insert + contentFromServer[index] = insert } else if (insert && insert.image) { const alt = op.attributes?.alt if (alt && alt.startsWith('[') && alt.endsWith(']')) { - // 表情 - content = content + alt + // 表情id + contentFromLocal[index] = alt + contentFromServer[index] = alt } else if (alt && alt.startsWith('{') && alt.endsWith('}')) { - // 图片 - content = content + alt + // 图片id + contentFromLocal[index] = alt + contentFromServer[index] = alt } else if (insert.image.startsWith('data:') && insert.image.includes('base64')) { // base64编码的图片 + needUploadCount.value++ const file = base64ToFile(insert.image, uuidv4()) // base64转file - el_loading_options.text = '图片上传中...' //上传中加一个loading效果 - const loadingInstance = ElLoading.service(el_loading_options) - try { - const res = await mtsUploadService({ file: file, storeType: 1 }) //上传图片至服务端 - imageData.setImage(props.sessionId, res.data.data) // 缓存image数据 - content = content + `{${res.data.data.objectId}}` - } finally { - loadingInstance.close() - } + const tempObjectId = new Date().getTime() + // 发送的时候设置本地缓存(非服务端数据),用于立即渲染 + const localSrc = URL.createObjectURL(file) + imageData.setLocalImage({ + objectId: tempObjectId, + originUrl: localSrc, + thumbUrl: localSrc, + fileName: file.name, + size: file.size + }) + contentFromLocal[index] = `{${tempObjectId}}` + + //上传图片至服务端 + mtsUploadService({ file: file, storeType: 1 }) + .then((res) => { + imageData.setServerImage(sessionId, res.data.data) // 缓存image数据 + uploadSuccessCount.value++ + contentFromServer[index] = `{${res.data.data.objectId}}` + // TODO 这里要判断是最后一个上传的图片 + // 这里用异步有个问题,后面请求上传的图片传的块,在content中就会跑到前面去,图片的顺序会错乱 + // 可以把content设成一个数组,按照index下标给每个数组元素设置,防止乱序 + // 这样也可以watch每个元素如果都填满,就sendMessage + }) + .finally(() => { + uploadedTotalCount.value++ + }) + } else { + // 当文本处理 + contentFromLocal[index] = insert + contentFromServer[index] = insert } } } - return content.trim() + + return { + needUploadCount: needUploadCount, + uploadedTotalCount: uploadedTotalCount, + uploadSuccessCount: uploadSuccessCount, + contentFromLocal: contentFromLocal, + contentFromServer: contentFromServer + } } // 监控session发生了切换 watch( () => props.sessionId, - async (newValue, oldValue) => { - let content = await getContent() - // 草稿若没发生变动,则不触发存储 - if (oldValue && content !== messageData.sessionList[oldValue].draft) { + async (newSessionId, oldSessionId) => { + const contentObj = parseContent(oldSessionId) + const content = contentObj.contentFromLocal.join('').trim() + // 草稿若发生变动,则触发存储 + if (oldSessionId && content !== messageData.sessionList[oldSessionId].draft) { messageData.updateSession({ - sessionId: oldValue, + sessionId: oldSessionId, draft: content }) } - formatContent(messageData.sessionList[newValue].draft || '') + + // 有图片需要上传,再保存一次draft + if (contentObj.needUploadCount.value > 0) { + const stopWatch = watch( + () => contentObj.uploadedTotalCount.value, + () => { + if (contentObj.needUploadCount.value === contentObj.uploadedTotalCount.value) { + // 满足第一个相等条件就停止监视 + stopWatch() + if (contentObj.uploadSuccessCount.value === contentObj.needUploadCount.value) { + // 满足第二个相等条件才保存草稿 + if (oldSessionId) { + messageData.updateSession({ + sessionId: oldSessionId, + draft: contentObj.contentFromServer.join('').trim() + }) + } + } + } + } + ) + } + + formatContent(messageData.sessionList[newSessionId].draft || '') }, { deep: true } ) @@ -120,16 +201,56 @@ const formatContent = (content) => { } const handleEnter = async () => { - const content = await getContent() + const contentObj = parseContent() + const content = contentObj.contentFromLocal.join('').trim() if (!content) { ElMessage.warning('请勿发送空内容') getQuill().setText('') + return } else if (content.length > 3000) { ElMessage.warning('发送内容请不要超过3000个字') - } else { - emit('sendMessage', content) - getQuill().setText('') + return } + + if (contentObj.needUploadCount.value === 0) { + emit('sendMessage', content) + } else { + // 发送的时候设置本地缓存(非服务端数据),用于立即渲染 + let msg = {} + emit('saveLocalMsg', { + contentType: msgContentType.MIX, + content: content, + fn: (result) => { + msg = result + } + }) + + // 有图片需要上传 + if (contentObj.needUploadCount.value > 0) { + msg.uploadStatus = msgFileUploadStatus.UPLOADING + msg.uploadProgress = 0 + } + // 监视图片上传结果,图片上传完后向服务器发送消息 + const stopWatch = watch( + () => contentObj.uploadedTotalCount.value, + () => { + msg.uploadProgress = Math.floor( + (contentObj.uploadSuccessCount.value / contentObj.needUploadCount.value) * 100 + ) + if (contentObj.uploadedTotalCount.value === contentObj.needUploadCount.value) { + stopWatch() + if (contentObj.uploadSuccessCount.value === contentObj.needUploadCount.value) { + msg.uploadStatus = msgFileUploadStatus.UPLOAD_SUCCESS + msg.content = contentObj.contentFromServer.join('').trim() + emit('sendMessage', msg) + } + } else { + msg.uploadStatus = msgFileUploadStatus.UPLOAD_FAILED + } + } + ) + } + getQuill().setText('') // 编辑窗口置空 } /** diff --git a/src/views/message/components/InputRecorder.vue b/src/views/message/components/InputRecorder.vue index a18770e..96c16fd 100644 --- a/src/views/message/components/InputRecorder.vue +++ b/src/views/message/components/InputRecorder.vue @@ -1,14 +1,14 @@