Compare commits
9 Commits
2ede30426a
...
e5a5b36dcc
Author | SHA1 | Date | |
---|---|---|---|
|
e5a5b36dcc | ||
|
b18a1b2604 | ||
|
3f89777bf8 | ||
|
a05d637bd2 | ||
|
3363f23ad3 | ||
|
c3abd733ad | ||
|
0b8de6f5c2 | ||
|
cc5cf41ad1 | ||
|
cd8f1ce311 |
@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@floating-ui/dom": "^1.7.2",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@iconify-json/ion": "^1.2.3",
|
||||
"@kangc/v-md-editor": "^2.3.18",
|
||||
|
@ -11,6 +11,9 @@ importers:
|
||||
'@ant-design/icons-vue':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(vue@3.5.17(typescript@5.2.2))
|
||||
'@floating-ui/dom':
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2
|
||||
'@highlightjs/vue-plugin':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(highlight.js@11.11.1)(vue@3.5.17(typescript@5.2.2))
|
||||
@ -568,6 +571,15 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@floating-ui/core@1.7.2':
|
||||
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
|
||||
|
||||
'@floating-ui/dom@1.7.2':
|
||||
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@hapi/hoek@9.3.0':
|
||||
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
|
||||
|
||||
@ -4280,6 +4292,17 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.5':
|
||||
optional: true
|
||||
|
||||
'@floating-ui/core@1.7.2':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.2':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.2
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@hapi/hoek@9.3.0': {}
|
||||
|
||||
'@hapi/topo@5.1.0':
|
||||
|
161
src/components/editor/MentionList.vue
Normal file
161
src/components/editor/MentionList.vue
Normal file
@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="dropdown-menu">
|
||||
<n-virtual-list
|
||||
ref="virtualListRef"
|
||||
style="max-height: 240px"
|
||||
:item-size="50"
|
||||
:items="props.items"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<button
|
||||
:class="{ 'is-selected': props.items[selectedIndex] === item }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<img :src="item.avatar" class="avatar" />
|
||||
<span class="nickname">{{ item.nickname }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, defineProps, defineExpose } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
command: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const selectedIndex = ref(0)
|
||||
const virtualListRef = ref(null)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
)
|
||||
|
||||
const onKeyDown = ({ event }) => {
|
||||
console.log('event',event)
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value + props.items.length - 1) % props.items.length
|
||||
virtualListRef.value?.scrollTo({ index: selectedIndex.value })
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
|
||||
virtualListRef.value?.scrollTo({ index: selectedIndex.value })
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(props.items[selectedIndex.value])
|
||||
}
|
||||
|
||||
const selectItem = item => {
|
||||
if (item) {
|
||||
props.command({ id: item.id, label: item.nickname })
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
onKeyDown
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.dropdown-menu {
|
||||
background: var(--white, #fff);
|
||||
border: 1px solid var(--gray-1, #e0e0e0);
|
||||
border-radius: 0.7rem;
|
||||
box-shadow: var(--shadow, 0 2px 12px 0 rgba(0, 0, 0, 0.1));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
overflow: auto;
|
||||
padding: 0.4rem;
|
||||
position: relative;
|
||||
max-height: 200px;
|
||||
width: 200px;
|
||||
button {
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:hover.is-selected {
|
||||
background-color: var(--gray-3, #f5f7fa);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: var(--gray-2, #f0f0f0);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式下的样式调整 */
|
||||
html[theme-mode='dark'] {
|
||||
.dropdown-menu {
|
||||
background-color: #1e1e1e;
|
||||
border-color: #333;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
button {
|
||||
&:hover,
|
||||
&:hover.is-selected {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,89 +1,92 @@
|
||||
<script setup>
|
||||
// 引入Tiptap编辑器相关依赖
|
||||
|
||||
import { Editor, EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import { computePosition, flip, shift } from '@floating-ui/dom'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Extension, Node } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
// 引入Vue核心功能
|
||||
|
||||
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||
// 引入Naive UI的弹出框组件
|
||||
|
||||
import { NPopover, NIcon } from 'naive-ui'
|
||||
// 引入图标组件
|
||||
|
||||
import {
|
||||
Voice as IconVoice, // 语音图标
|
||||
SourceCode, // 代码图标
|
||||
Local, // 地理位置图标
|
||||
SmilingFace, // 表情图标
|
||||
Pic, // 图片图标
|
||||
FolderUpload, // 文件上传图标
|
||||
Ranking, // 排名图标(用于投票)
|
||||
History, // 历史记录图标
|
||||
Close // 关闭图标
|
||||
Voice as IconVoice,
|
||||
SourceCode,
|
||||
Local,
|
||||
SmilingFace,
|
||||
Pic,
|
||||
FolderUpload,
|
||||
Ranking,
|
||||
History,
|
||||
Close
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
// 引入状态管理
|
||||
|
||||
import { useDialogueStore, useEditorDraftStore } from '@/store'
|
||||
// 引入获取图片信息的工具函数
|
||||
|
||||
import { getImageInfo } from '@/utils/functions'
|
||||
// 引入编辑器常量定义
|
||||
|
||||
import { EditorConst } from '@/constant/event-bus'
|
||||
// 引入事件调用工具
|
||||
|
||||
import { emitCall } from '@/utils/common'
|
||||
// 引入默认头像常量
|
||||
|
||||
import { defAvatar } from '@/constant/default'
|
||||
// 引入编辑器各子组件
|
||||
import MeEditorVote from './MeEditorVote.vue' // 投票组件
|
||||
import MeEditorEmoticon from './MeEditorEmoticon.vue' // 表情组件
|
||||
import MeEditorCode from './MeEditorCode.vue' // 代码编辑组件
|
||||
import MeEditorRecorder from './MeEditorRecorder.vue' // 录音组件
|
||||
// 引入上传API
|
||||
|
||||
import suggestion from './suggestion.js'
|
||||
|
||||
import MeEditorVote from './MeEditorVote.vue'
|
||||
import MeEditorEmoticon from './MeEditorEmoticon.vue'
|
||||
import MeEditorCode from './MeEditorCode.vue'
|
||||
import MeEditorRecorder from './MeEditorRecorder.vue'
|
||||
|
||||
import { uploadImg } from '@/api/upload'
|
||||
// 引入事件总线钩子
|
||||
|
||||
import { useEventBus } from '@/hooks'
|
||||
|
||||
// 定义组件的事件
|
||||
|
||||
const emit = defineEmits(['editor-event'])
|
||||
// 获取对话状态管理
|
||||
|
||||
const dialogueStore = useDialogueStore()
|
||||
// 获取编辑器草稿状态管理
|
||||
|
||||
const editorDraftStore = useEditorDraftStore()
|
||||
// 定义组件props
|
||||
|
||||
const props = defineProps({
|
||||
vote: {
|
||||
type: Boolean,
|
||||
default: false // 是否显示投票功能
|
||||
default: false
|
||||
},
|
||||
members: {
|
||||
default: () => [] // 聊天成员列表,用于@功能
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// 计算当前对话索引名称(标识当前聊天)
|
||||
|
||||
const indexName = computed(() => dialogueStore.index_name)
|
||||
// 控制是否显示编辑器的投票界面
|
||||
|
||||
const isShowEditorVote = ref(false)
|
||||
// 控制是否显示编辑器的代码界面
|
||||
|
||||
const isShowEditorCode = ref(false)
|
||||
// 控制是否显示录音界面
|
||||
|
||||
const isShowEditorRecorder = ref(false)
|
||||
const uploadingImages = ref(new Map())
|
||||
// 图片文件上传DOM引用
|
||||
|
||||
const fileImageRef = ref()
|
||||
// 文件上传DOM引用
|
||||
|
||||
const uploadFileRef = ref()
|
||||
// 表情面板引用
|
||||
|
||||
const emoticonRef = ref()
|
||||
// 表情面板显示状态
|
||||
|
||||
const showEmoticon = ref(false)
|
||||
// 引用消息数据
|
||||
|
||||
const quoteData = ref(null)
|
||||
|
||||
// 自定义Emoji扩展
|
||||
|
||||
const Emoji = Node.create({
|
||||
name: 'emoji',
|
||||
group: 'inline',
|
||||
@ -126,12 +129,12 @@ const Emoji = Node.create({
|
||||
|
||||
|
||||
|
||||
// 创建自定义键盘处理插件,处理Enter键发送消息
|
||||
|
||||
const EnterKeyPlugin = new Plugin({
|
||||
key: new PluginKey('enterKey'),
|
||||
props: {
|
||||
handleKeyDown: (view, event) => {
|
||||
// 如果按下Enter键且没有按下Shift键,则发送消息
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
onSendMessage()
|
||||
@ -142,7 +145,7 @@ const EnterKeyPlugin = new Plugin({
|
||||
},
|
||||
})
|
||||
|
||||
// 自定义键盘扩展
|
||||
|
||||
const CustomKeyboard = Extension.create({
|
||||
name: 'customKeyboard',
|
||||
|
||||
@ -153,7 +156,7 @@ const CustomKeyboard = Extension.create({
|
||||
},
|
||||
})
|
||||
|
||||
// 创建编辑器实例
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@ -205,118 +208,15 @@ const editor = useEditor({
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: {
|
||||
allowedPrefixes: null,
|
||||
hideOnClickOutside: true,
|
||||
hideOnKeyDown: true,
|
||||
emptyQueryClass: 'is-empty-query',
|
||||
...suggestion,
|
||||
items: ({ query }) => {
|
||||
if (!props.members.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
let list = [...props.members]
|
||||
|
||||
if ((dialogueStore.groupInfo).is_manager) {
|
||||
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
|
||||
}
|
||||
|
||||
const filteredItems = list.filter(
|
||||
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
// 如果没有匹配项,返回空数组以关闭弹窗
|
||||
if (filteredItems.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return filteredItems
|
||||
},
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
let handleClickOutside
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
// 创建提及列表容器
|
||||
popup = document.createElement('div')
|
||||
popup.classList.add('ql-mention-list-container', 'me-scrollbar', 'me-scrollbar-thumb')
|
||||
document.body.appendChild(popup)
|
||||
|
||||
// 添加全局点击事件监听器,点击弹窗外部时关闭弹窗
|
||||
handleClickOutside = (event) => {
|
||||
if (popup && !popup.contains(event.target)) {
|
||||
popup.remove()
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
// 使用setTimeout确保事件不会立即触发
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 100)
|
||||
|
||||
// 渲染提及列表
|
||||
props.items.forEach((item, index) => {
|
||||
const mentionItem = document.createElement('div')
|
||||
mentionItem.classList.add('ed-member-item')
|
||||
mentionItem.innerHTML = `<img src="${item.avatar}" class="avator"/><span class="nickname">${item.nickname}</span>`
|
||||
mentionItem.addEventListener('click', () => {
|
||||
props.command({ id: item.id, label: item.nickname })
|
||||
})
|
||||
|
||||
if (index === props.selectedIndex) {
|
||||
mentionItem.classList.add('selected')
|
||||
}
|
||||
|
||||
popup.appendChild(mentionItem)
|
||||
})
|
||||
|
||||
// 定位提及列表
|
||||
const coords = props.clientRect()
|
||||
popup.style.position = 'fixed'
|
||||
popup.style.top = `${coords.top + window.scrollY}px`
|
||||
popup.style.left = `${coords.left + window.scrollX}px`
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
// 更新选中项
|
||||
const items = popup.querySelectorAll('.ed-member-item')
|
||||
items.forEach((item, index) => {
|
||||
if (index === props.selectedIndex) {
|
||||
item.classList.add('selected')
|
||||
} else {
|
||||
item.classList.remove('selected')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
// 处理键盘事件
|
||||
// Escape键关闭弹窗
|
||||
if (props.event.key === 'Escape') {
|
||||
popup.remove()
|
||||
return true
|
||||
}
|
||||
|
||||
// 空格键、回车键或其他非导航键也关闭弹窗
|
||||
const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab']
|
||||
if (!navigationKeys.includes(props.event.key) && props.items.length === 0) {
|
||||
popup.remove()
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
// 清理弹窗和事件监听器
|
||||
if (popup) {
|
||||
popup.remove()
|
||||
// 移除所有可能的点击事件监听器
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
},
|
||||
}
|
||||
return suggestion.items({
|
||||
query,
|
||||
props: {
|
||||
members: props.members,
|
||||
isGroupManager: (dialogueStore.groupInfo).is_manager
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
@ -384,11 +284,6 @@ const editor = useEditor({
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传图片函数
|
||||
* @param file 文件对象
|
||||
* @returns Promise,成功时返回图片URL
|
||||
*/
|
||||
function findImagePos(url) {
|
||||
if (!editor.value) return -1
|
||||
let pos = -1
|
||||
@ -418,51 +313,43 @@ function onUploadImage(file) {
|
||||
image.onload = () => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append("source", "fonchain-chat"); // 图片来源标识
|
||||
// 添加图片尺寸信息作为URL参数
|
||||
form.append("source", "fonchain-chat");
|
||||
|
||||
form.append("urlParam", `width=${image.width}&height=${image.height}`);
|
||||
|
||||
// 调用上传API
|
||||
|
||||
uploadImg(form).then(({ code, data, message }) => {
|
||||
if (code == 0) {
|
||||
resolve(data.ori_url) // 返回原始图片URL
|
||||
resolve(data.ori_url)
|
||||
} else {
|
||||
resolve('')
|
||||
window['$message'].error(message) // 显示错误信息
|
||||
window['$message'].error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 投票事件处理
|
||||
* @param data 投票数据
|
||||
*/
|
||||
function onVoteEvent(data) {
|
||||
const msg = emitCall('vote_event', data, (ok) => {
|
||||
if (ok) {
|
||||
isShowEditorVote.value = false // 成功后关闭投票界面
|
||||
isShowEditorVote.value = false
|
||||
}
|
||||
})
|
||||
|
||||
emit('editor-event', msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 表情事件处理
|
||||
* @param data 表情数据
|
||||
*/
|
||||
function onEmoticonEvent(data) {
|
||||
// 关闭表情面板
|
||||
|
||||
showEmoticon.value = false
|
||||
|
||||
if (data.type == 1) {
|
||||
// 插入文本表情
|
||||
|
||||
if (!editor.value) return
|
||||
|
||||
if (data.img) {
|
||||
// 插入图片表情
|
||||
|
||||
editor.value.chain().focus().insertContent({
|
||||
type: 'emoji',
|
||||
attrs: {
|
||||
@ -473,39 +360,31 @@ function onEmoticonEvent(data) {
|
||||
},
|
||||
}).run()
|
||||
} else {
|
||||
// 插入文本表情
|
||||
|
||||
editor.value.chain().focus().insertContent(data.value).run()
|
||||
}
|
||||
} else {
|
||||
// 发送整个表情包
|
||||
|
||||
let fn = emitCall('emoticon_event', data.value, () => {})
|
||||
emit('editor-event', fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码事件处理
|
||||
* @param data 代码数据
|
||||
*/
|
||||
function onCodeEvent(data) {
|
||||
const msg = emitCall('code_event', data, (ok) => {
|
||||
isShowEditorCode.value = false // 成功后关闭代码界面
|
||||
isShowEditorCode.value = false
|
||||
})
|
||||
|
||||
emit('editor-event', msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传处理
|
||||
* @param e 上传事件对象
|
||||
*/
|
||||
async function onUploadFile(e) {
|
||||
let file = e.target.files[0]
|
||||
|
||||
e.target.value = null // 清空input,允许再次选择相同文件
|
||||
e.target.value = null
|
||||
|
||||
if (file.type.indexOf('image/') === 0) {
|
||||
// 处理图片文件 - 立即显示临时消息,然后上传
|
||||
|
||||
let fn = emitCall('image_event', file, () => {})
|
||||
emit('editor-event', fn)
|
||||
|
||||
@ -513,26 +392,22 @@ async function onUploadFile(e) {
|
||||
}
|
||||
|
||||
if (file.type.indexOf('video/') === 0) {
|
||||
// 处理视频文件
|
||||
|
||||
let fn = emitCall('video_event', file, () => {})
|
||||
emit('editor-event', fn)
|
||||
} else {
|
||||
// 处理其他类型文件
|
||||
|
||||
let fn = emitCall('file_event', file, () => {})
|
||||
emit('editor-event', fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音事件处理
|
||||
* @param file 录音文件
|
||||
*/
|
||||
function onRecorderEvent(file) {
|
||||
emit('editor-event', emitCall('file_event', file))
|
||||
isShowEditorRecorder.value = false // 关闭录音界面
|
||||
isShowEditorRecorder.value = false
|
||||
}
|
||||
|
||||
// 将Tiptap内容转换为消息格式
|
||||
|
||||
function tiptapToMessage() {
|
||||
if (!editor.value) return []
|
||||
|
||||
@ -573,7 +448,7 @@ function tiptapToMessage() {
|
||||
} else if (node.type === 'hardBreak') {
|
||||
currentTextBuffer += '\n'
|
||||
} else if (node.type === 'image') {
|
||||
// 处理段落内的图片
|
||||
|
||||
flushTextBuffer()
|
||||
const data = {
|
||||
...getImageInfo(node.attrs.src),
|
||||
@ -590,7 +465,7 @@ function tiptapToMessage() {
|
||||
if (node.content) {
|
||||
processInlines(node.content)
|
||||
}
|
||||
currentTextBuffer += '\n' // Add newline after each paragraph
|
||||
currentTextBuffer += '\n'
|
||||
} else if (node.type === 'image') {
|
||||
flushTextBuffer()
|
||||
const data = {
|
||||
@ -617,20 +492,20 @@ function tiptapToMessage() {
|
||||
return messages
|
||||
}
|
||||
|
||||
// 将Tiptap内容转换为纯文本
|
||||
|
||||
function tiptapToString() {
|
||||
if (!editor.value) return ''
|
||||
|
||||
return editor.value.getText()
|
||||
}
|
||||
|
||||
// 检查编辑器是否为空
|
||||
|
||||
function isEditorEmpty() {
|
||||
if (!editor.value) return true
|
||||
|
||||
const json = editor.value.getJSON()
|
||||
|
||||
// 检查是否只有一个空段落
|
||||
|
||||
return !json.content || (
|
||||
json.content.length === 1 &&
|
||||
json.content[0].type === 'paragraph' &&
|
||||
@ -638,10 +513,6 @@ function isEditorEmpty() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息处理
|
||||
* 根据编辑器内容类型发送不同类型的消息
|
||||
*/
|
||||
function onSendMessage() {
|
||||
if (uploadingImages.value.size > 0) {
|
||||
return window['$message'].info('正在上传图片,请稍后再发')
|
||||
@ -663,7 +534,7 @@ function onSendMessage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 添加引用消息参数
|
||||
|
||||
if (quoteData.value) {
|
||||
msg.data.quoteId = quoteData.value.id
|
||||
msg.data.quote = { ...quoteData.value }
|
||||
@ -678,7 +549,7 @@ function onSendMessage() {
|
||||
url: msg.data.url,
|
||||
}
|
||||
|
||||
// 添加引用消息参数
|
||||
|
||||
if (quoteData.value) {
|
||||
data.quoteId = quoteData.value.id
|
||||
data.quote = { ...quoteData.value }
|
||||
@ -688,7 +559,7 @@ function onSendMessage() {
|
||||
}
|
||||
})
|
||||
|
||||
// 如果只有引用消息但没有内容,也发送一条空文本消息带引用
|
||||
|
||||
if (messages.length === 0 && quoteData.value) {
|
||||
const emptyData = {
|
||||
items: [{ type: 1, content: '' }],
|
||||
@ -702,49 +573,41 @@ function onSendMessage() {
|
||||
|
||||
if (canClear) {
|
||||
editor.value?.commands.clearContent(true)
|
||||
// 清空引用数据
|
||||
|
||||
quoteData.value = null
|
||||
// 更新草稿
|
||||
|
||||
onEditorChange()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器内容改变时的处理
|
||||
* 保存草稿并触发输入事件
|
||||
*/
|
||||
function onEditorChange() {
|
||||
if (!editor.value) return
|
||||
|
||||
const text = tiptapToString()
|
||||
|
||||
if (!isEditorEmpty() || quoteData.value) {
|
||||
// 保存草稿到store
|
||||
|
||||
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
||||
text: text,
|
||||
content: editor.value.getJSON(),
|
||||
quoteData: quoteData.value
|
||||
})
|
||||
} else {
|
||||
// 编辑器为空时删除对应草稿
|
||||
|
||||
delete editorDraftStore.items[indexName.value || '']
|
||||
}
|
||||
|
||||
// 触发输入事件
|
||||
|
||||
emit('editor-event', emitCall('input_event', text))
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载编辑器草稿内容
|
||||
* 当切换聊天对象时,加载对应的草稿
|
||||
*/
|
||||
function loadEditorDraftText() {
|
||||
if (!editor.value) return
|
||||
|
||||
// 切换会话时清空引用数据,不保存当前引用数据
|
||||
|
||||
quoteData.value = null
|
||||
|
||||
// 从缓存中加载编辑器草稿
|
||||
|
||||
let draft = editorDraftStore.items[indexName.value || '']
|
||||
if (draft) {
|
||||
const parsed = JSON.parse(draft)
|
||||
@ -754,26 +617,22 @@ function loadEditorDraftText() {
|
||||
editor.value.commands.setContent(parsed.text)
|
||||
}
|
||||
|
||||
// 如果草稿中有引用数据,恢复它
|
||||
|
||||
if (parsed.quoteData) {
|
||||
quoteData.value = parsed.quoteData
|
||||
}
|
||||
} else {
|
||||
editor.value.commands.clearContent(true) // 没有草稿则清空编辑器
|
||||
editor.value.commands.clearContent(true)
|
||||
}
|
||||
|
||||
// 设置光标位置到末尾
|
||||
|
||||
editor.value.commands.focus('end')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理@成员事件
|
||||
* @param data @成员数据
|
||||
*/
|
||||
function onSubscribeMention(data) {
|
||||
if (!editor.value) return
|
||||
|
||||
// 插入@项
|
||||
|
||||
editor.value.chain().focus().insertContent({
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
@ -783,53 +642,42 @@ function onSubscribeMention(data) {
|
||||
}).run()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理引用事件
|
||||
* @param data 引用数据
|
||||
*/
|
||||
function onSubscribeQuote(data) {
|
||||
if (!editor.value) return
|
||||
|
||||
// 保存引用数据
|
||||
|
||||
quoteData.value = data
|
||||
// 更新草稿
|
||||
|
||||
onEditorChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空引用数据并更新草稿
|
||||
*/
|
||||
function clearQuoteData() {
|
||||
quoteData.value = null
|
||||
// 更新草稿
|
||||
|
||||
onEditorChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理编辑消息事件
|
||||
* @param data 消息数据
|
||||
*/
|
||||
function onSubscribeEdit(data) {
|
||||
if (!editor.value) return
|
||||
|
||||
// 清空当前编辑器内容
|
||||
|
||||
editor.value.commands.clearContent(true)
|
||||
|
||||
// 插入要编辑的文本内容
|
||||
|
||||
editor.value.commands.insertContent(data.content)
|
||||
|
||||
// 设置光标位置到末尾
|
||||
|
||||
editor.value.commands.focus('end')
|
||||
}
|
||||
|
||||
// 底部工具栏配置
|
||||
|
||||
const navs = reactive([
|
||||
{
|
||||
title: '图片',
|
||||
icon: markRaw(Pic),
|
||||
show: true,
|
||||
click: () => {
|
||||
fileImageRef.value.click() // 触发图片上传
|
||||
fileImageRef.value.click()
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -837,38 +685,34 @@ const navs = reactive([
|
||||
icon: markRaw(FolderUpload),
|
||||
show: true,
|
||||
click: () => {
|
||||
uploadFileRef.value.click() // 触发文件上传
|
||||
uploadFileRef.value.click()
|
||||
}
|
||||
},
|
||||
|
||||
])
|
||||
|
||||
// 监听聊天索引变化,切换聊天时加载对应草稿
|
||||
|
||||
watch(indexName, loadEditorDraftText, { immediate: true })
|
||||
|
||||
// 组件挂载时初始化
|
||||
|
||||
onMounted(() => {
|
||||
loadEditorDraftText()
|
||||
})
|
||||
|
||||
// 订阅编辑器相关事件总线事件
|
||||
|
||||
useEventBus([
|
||||
{ name: EditorConst.Mention, event: onSubscribeMention }, // @成员事件
|
||||
{ name: EditorConst.Quote, event: onSubscribeQuote }, // 引用事件
|
||||
{ name: EditorConst.Edit, event: onSubscribeEdit } // 编辑消息事件
|
||||
{ name: EditorConst.Mention, event: onSubscribeMention },
|
||||
{ name: EditorConst.Quote, event: onSubscribeQuote },
|
||||
{ name: EditorConst.Edit, event: onSubscribeEdit }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 编辑器容器 -->
|
||||
<section class="el-container editor">
|
||||
<section class="el-container is-vertical">
|
||||
|
||||
|
||||
<!-- 工具栏区域 -->
|
||||
<header class="el-header toolbar bdr-t">
|
||||
<div class="tools">
|
||||
<!-- 表情选择器弹出框 -->
|
||||
<n-popover
|
||||
placement="top-start"
|
||||
trigger="click"
|
||||
@ -888,8 +732,6 @@ useEventBus([
|
||||
|
||||
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
||||
</n-popover>
|
||||
|
||||
<!-- 工具栏其他功能按钮 -->
|
||||
<div
|
||||
class="item pointer"
|
||||
v-for="nav in navs"
|
||||
@ -902,7 +744,7 @@ useEventBus([
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 引用消息块 -->
|
||||
|
||||
<div v-if="quoteData" class="quote-card-wrapper">
|
||||
<div class="quote-card-content">
|
||||
<div class="quote-card-title">
|
||||
@ -917,20 +759,20 @@ useEventBus([
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 编辑器主体区域 -->
|
||||
|
||||
<main class="el-main height100">
|
||||
<editor-content :editor="editor" class="tiptap-editor" />
|
||||
</main>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 隐藏的文件上传表单 -->
|
||||
|
||||
<form enctype="multipart/form-data" style="display: none">
|
||||
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
|
||||
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
|
||||
</form>
|
||||
|
||||
<!-- 条件渲染的功能组件 -->
|
||||
|
||||
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
|
||||
|
||||
<MeEditorCode
|
||||
@ -947,12 +789,12 @@ useEventBus([
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 编辑器容器样式 */
|
||||
|
||||
.editor {
|
||||
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
|
||||
--tip-bg-color: rgb(241 241 241 / 90%);
|
||||
height: 100%;
|
||||
|
||||
/* 引用消息块样式 */
|
||||
|
||||
.quote-card-wrapper {
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
@ -1025,7 +867,7 @@ useEventBus([
|
||||
user-select: none;
|
||||
|
||||
.tip-title {
|
||||
display: none; /* 默认隐藏提示文字 */
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0px;
|
||||
@ -1043,7 +885,7 @@ useEventBus([
|
||||
|
||||
&:hover {
|
||||
.tip-title {
|
||||
display: block; /* 悬停时显示提示文字 */
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1051,7 +893,6 @@ useEventBus([
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式样式调整 */
|
||||
html[theme-mode='dark'] {
|
||||
.editor {
|
||||
--tip-bg-color: #48484d;
|
||||
@ -1082,7 +923,6 @@ html[theme-mode='dark'] {
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
/* 全局编辑器样式 */
|
||||
.tiptap-editor {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
@ -1102,7 +942,7 @@ html[theme-mode='dark'] {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5) url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="background:0 0"><circle cx="50" cy="50" r="32" stroke-width="8" stroke="%23fff" stroke-dasharray="50.26548245743669 50.26548245743669" fill="none" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="0 50 50;360 50 50"/></svg>');
|
||||
background: rgba(0, 0, 0, 0.5) url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="background:0 0"><circle cx="50" cy="50" r="32" stroke-width="8" stroke="%23fff" stroke-dasharray="50.26548245743669 50.26548245743669" fill="none" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" keyTimes="0;1" values="0 50 50;360 50 50"/></svg>');
|
||||
background-size: 30px 30px;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
@ -1110,7 +950,6 @@ html[theme-mode='dark'] {
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
@ -1158,10 +997,10 @@ html[theme-mode='dark'] {
|
||||
|
||||
/* 提及样式 */
|
||||
.mention {
|
||||
color: #0366d6;
|
||||
background-color: rgba(3, 102, 214, 0.1);
|
||||
color: #fff;
|
||||
background-color: var(--im-primary-color);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
/* 引用卡片样式 */
|
||||
@ -1171,39 +1010,6 @@ html[theme-mode='dark'] {
|
||||
|
||||
}
|
||||
|
||||
/* 提及列表样式 */
|
||||
.ql-mention-list-container {
|
||||
width: 270px;
|
||||
max-height: 200px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
z-index: 10000;
|
||||
|
||||
.ed-member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover, &.selected {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.avator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式下的样式调整 */
|
||||
html[theme-mode='dark'] {
|
||||
.tiptap-editor {
|
||||
@ -1215,20 +1021,5 @@ html[theme-mode='dark'] {
|
||||
background-color: var(--im-message-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ql-mention-list-container {
|
||||
background-color: #1e1e1e;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
.ed-member-item {
|
||||
&:hover, &.selected {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
|
||||
.nickname {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
111
src/components/editor/suggestion.js
Normal file
111
src/components/editor/suggestion.js
Normal file
@ -0,0 +1,111 @@
|
||||
import { computePosition, flip, shift } from '@floating-ui/dom'
|
||||
import { posToDOMRect, VueRenderer } from '@tiptap/vue-3'
|
||||
|
||||
import MentionList from './MentionList.vue'
|
||||
import { defAvatar } from '@/constant/default'
|
||||
|
||||
const updatePosition = (editor, element) => {
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to),
|
||||
}
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'absolute',
|
||||
middleware: [shift(), flip()],
|
||||
}).then(({ x, y, strategy }) => {
|
||||
element.style.position = strategy
|
||||
if (window.__POWERED_BY_WUJIE__) {
|
||||
element.style.left = `${x + 200}px`
|
||||
element.style.top = `${y + 100}px`
|
||||
} else {
|
||||
element.style.left = `${x}px`
|
||||
element.style.top = `${y}px`
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
items: ({ query, editor, props }) => {
|
||||
if (!props.members || !props.members.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
let list = [...props.members]
|
||||
|
||||
// 如果是群组管理员,添加"所有人"选项
|
||||
if (props.isGroupManager) {
|
||||
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar })
|
||||
}
|
||||
|
||||
const filteredItems = list.filter(
|
||||
(item) => item.nickname.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
// 如果没有匹配项,返回空数组以关闭弹窗
|
||||
if (filteredItems.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return filteredItems
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
// 如果没有匹配项,不创建弹窗
|
||||
if (!props.items || props.items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
component = new VueRenderer(MentionList, {
|
||||
// Vue 3 props格式
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
component.element.style.position = 'absolute'
|
||||
|
||||
document.body.appendChild(component.element)
|
||||
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (props.items.length === 0) {
|
||||
this.onExit()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
updatePosition(props.editor, component.element)
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
this.onExit()
|
||||
return true
|
||||
}
|
||||
return component.ref.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
component.element.remove()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
@ -17,7 +17,7 @@ let textContent = props.extra?.content || ''
|
||||
textContent = textReplaceLink(textContent)
|
||||
|
||||
if (props.data.talk_type == 2) {
|
||||
textContent = textReplaceMention(textContent, float==='right'?'#fff':'#462AA0')
|
||||
textContent = textReplaceMention(textContent, float==='right'?'#462AA0':'#fff',float==='right'?'#EEE9F9':'#462AA0')
|
||||
}
|
||||
|
||||
textContent = textReplaceEmoji(textContent)
|
||||
|
@ -124,48 +124,72 @@ export const useTalkRecord = (uid: number) => {
|
||||
|
||||
// 加载数据列表
|
||||
const load = async (params: Params) => {
|
||||
// 使用性能标记测量加载时间
|
||||
const startTime = performance.now()
|
||||
|
||||
const request = {
|
||||
talk_type: params.talk_type,
|
||||
receiver_id: params.receiver_id,
|
||||
cursor: loadConfig.cursor,
|
||||
limit: 30
|
||||
}
|
||||
|
||||
// 如果不是从本地数据库加载的,则设置加载状态为0(加载中)
|
||||
if (loadConfig.status !== 2 && loadConfig.status !== 3) {
|
||||
loadConfig.status = 0
|
||||
}
|
||||
|
||||
// 记录当前滚动高度,用于后续保持滚动位置
|
||||
let scrollHeight = 0
|
||||
const el = document.getElementById('imChatPanel')
|
||||
if (el) {
|
||||
scrollHeight = el.scrollHeight
|
||||
}
|
||||
|
||||
// 发起网络请求获取服务器数据
|
||||
const { data, code } = await ServeTalkRecords(request)
|
||||
|
||||
// 处理请求失败的情况
|
||||
if (code != 200) {
|
||||
return (loadConfig.status = (loadConfig.status === 2 || loadConfig.status === 3) ? loadConfig.status : 1) // 如果已经从本地加载了数据,保持原状态
|
||||
// 如果已经从本地加载了数据,保持原状态
|
||||
loadConfig.status = (loadConfig.status === 2 || loadConfig.status === 3) ? loadConfig.status : 1
|
||||
return
|
||||
}
|
||||
|
||||
// 防止对话切换过快,数据渲染错误
|
||||
if (
|
||||
request.talk_type != loadConfig.talk_type ||
|
||||
request.receiver_id != loadConfig.receiver_id
|
||||
) {
|
||||
return (location.msgid = '')
|
||||
if (request.talk_type != loadConfig.talk_type || request.receiver_id != loadConfig.receiver_id) {
|
||||
location.msgid = ''
|
||||
return
|
||||
}
|
||||
|
||||
const items = (data.items || []).map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
||||
|
||||
// 同步到本地数据库
|
||||
try {
|
||||
const { batchAddOrUpdateMessages } = await import('@/utils/db')
|
||||
await batchAddOrUpdateMessages(data.items || [], params.talk_type, params.receiver_id, true, 'sequence')
|
||||
console.log('聊天记录已同步到本地数据库')
|
||||
} catch (error) {
|
||||
console.error('同步聊天记录到本地数据库失败:', error)
|
||||
// 优化:使用批量处理而不是map,减少内存分配
|
||||
const serverItems = data.items || []
|
||||
const items = new Array(serverItems.length)
|
||||
for (let i = 0; i < serverItems.length; i++) {
|
||||
items[i] = formatTalkRecord(uid, serverItems[i])
|
||||
}
|
||||
|
||||
// 同步到本地数据库(异步操作,不阻塞UI更新)
|
||||
const syncToLocalDB = async () => {
|
||||
try {
|
||||
const syncStartTime = performance.now()
|
||||
const { batchAddOrUpdateMessages } = await import('@/utils/db')
|
||||
await batchAddOrUpdateMessages(serverItems, params.talk_type, params.receiver_id, true, 'sequence')
|
||||
const syncEndTime = performance.now()
|
||||
console.log(`聊天记录已同步到本地数据库,耗时: ${(syncEndTime - syncStartTime).toFixed(2)}ms`)
|
||||
} catch (error) {
|
||||
console.error('同步聊天记录到本地数据库失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动异步同步过程
|
||||
syncToLocalDB()
|
||||
|
||||
// 如果是从本地数据库加载的数据,且服务器返回的数据与本地数据相同,则不需要更新UI
|
||||
if ((loadConfig.status === 2 || loadConfig.status === 3) && request.cursor === 0) {
|
||||
try {
|
||||
const compareStartTime = performance.now()
|
||||
|
||||
// 获取最新的本地数据库消息进行比较
|
||||
const { getMessages } = await import('@/utils/db')
|
||||
const localMessages = await getMessages(
|
||||
@ -173,80 +197,121 @@ export const useTalkRecord = (uid: number) => {
|
||||
uid,
|
||||
params.receiver_id,
|
||||
items.length || 30, // 获取与服务器返回数量相同的消息
|
||||
0 // 从第一页开始
|
||||
0, // 从第一页开始
|
||||
'sequence' // 明确指定排序字段
|
||||
)
|
||||
|
||||
// 格式化本地消息,确保与服务器消息结构一致
|
||||
const formattedLocalMessages = localMessages.map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
||||
// 快速路径:如果本地消息数量与服务器不同,直接更新UI
|
||||
if (localMessages.length !== items.length) {
|
||||
console.log('本地数据与服务器数据数量不一致,更新UI')
|
||||
} else if (items.length > 0) {
|
||||
// 优化:使用位图标记需要更新的消息,减少内存使用
|
||||
const needsUpdate = new Uint8Array(items.length)
|
||||
let updateCount = 0
|
||||
|
||||
|
||||
// 改进比较逻辑:检查消息数量和所有消息的ID是否匹配
|
||||
if (formattedLocalMessages.length === items.length && formattedLocalMessages.length > 0) {
|
||||
// 创建消息ID映射,用于快速查找
|
||||
// 优化:使用哈希表存储消息ID到索引的映射,加速查找
|
||||
const serverMsgMap = new Map()
|
||||
items.forEach(item => serverMsgMap.set(item.msg_id, item))
|
||||
|
||||
// 检查每条本地消息是否与服务器消息匹配
|
||||
const allMatch = formattedLocalMessages.every(localMsg => {
|
||||
const serverMsg = serverMsgMap.get(localMsg.msg_id)
|
||||
// 检查消息是否存在且关键状态是否一致(考虑撤回、已读等状态变化)
|
||||
return serverMsg &&
|
||||
serverMsg.is_revoke === localMsg.is_revoke &&
|
||||
serverMsg.is_read === localMsg.is_read &&
|
||||
(serverMsg.send_status === localMsg.send_status ||
|
||||
(!serverMsg.send_status && !localMsg.send_status)) &&
|
||||
serverMsg.content === localMsg.content
|
||||
})
|
||||
|
||||
if (allMatch) {
|
||||
console.log('本地数据与服务器数据一致,无需更新UI')
|
||||
return
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
serverMsgMap.set(items[i].msg_id, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据不一致,需要更新UI
|
||||
console.log('本地数据与服务器数据不一致,更新UI')
|
||||
// 优化:首先检查首尾消息,如果它们匹配,再使用抽样检查中间消息
|
||||
const firstLocalMsg = localMessages[0]
|
||||
const lastLocalMsg = localMessages[localMessages.length - 1]
|
||||
|
||||
const firstServerIdx = serverMsgMap.get(firstLocalMsg.msg_id)
|
||||
const lastServerIdx = serverMsgMap.get(lastLocalMsg.msg_id)
|
||||
|
||||
// 如果首尾消息ID存在于服务器数据中,进行详细比较
|
||||
if (firstServerIdx !== undefined && lastServerIdx !== undefined) {
|
||||
// 根据用户建议,只比较msg_id和is_revoke字段
|
||||
// 因为消息ID是唯一的,内容变化主要是由撤回操作引起的
|
||||
const compareMessage = (localMsg, serverMsg) => {
|
||||
// 消息ID已在外部比较过,这里只需检查is_revoke状态
|
||||
return localMsg.is_revoke === serverMsg.is_revoke
|
||||
}
|
||||
|
||||
const firstMatch = compareMessage(firstLocalMsg, items[firstServerIdx])
|
||||
const lastMatch = compareMessage(lastLocalMsg, items[lastServerIdx])
|
||||
|
||||
// 如果首尾消息匹配,进行全量检查所有消息
|
||||
if (firstMatch && lastMatch) {
|
||||
// 全量检查策略:检查所有消息
|
||||
// 由于一次只有30条消息,全量检查不会带来太大的性能负担
|
||||
let allMatch = true
|
||||
|
||||
// 遍历所有本地消息,与服务器消息进行比较
|
||||
for (let i = 0; i < localMessages.length; i++) {
|
||||
const localMsg = localMessages[i]
|
||||
const serverIdx = serverMsgMap.get(localMsg.msg_id)
|
||||
|
||||
// 如果消息ID不存在于服务器数据中,或者消息内容不匹配
|
||||
if (serverIdx === undefined || !compareMessage(localMsg, items[serverIdx])) {
|
||||
allMatch = false
|
||||
console.log(`消息不匹配,索引: ${i}, 消息ID: ${localMsg.msg_id}`)
|
||||
break // 一旦发现不匹配,立即退出循环
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatch) {
|
||||
const compareEndTime = performance.now()
|
||||
console.log(`本地数据与服务器数据一致(全量检查),无需更新UI,比较耗时: ${(compareEndTime - compareStartTime).toFixed(2)}ms`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('本地数据与服务器数据不一致,更新UI')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('比较本地数据和服务器数据时出错:', error)
|
||||
// 出错时默认更新UI
|
||||
}
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
const updateUIStartTime = performance.now()
|
||||
|
||||
if (request.cursor == 0) {
|
||||
// 判断是否是初次加载
|
||||
dialogueStore.clearDialogueRecord()
|
||||
}
|
||||
|
||||
// 反转消息顺序并添加到对话记录
|
||||
dialogueStore.unshiftDialogueRecord(items.reverse())
|
||||
|
||||
// 更新加载状态
|
||||
loadConfig.status = items.length >= request.limit ? 1 : 2
|
||||
|
||||
loadConfig.cursor = data.cursor
|
||||
|
||||
nextTick(() => {
|
||||
// 使用requestAnimationFrame代替nextTick,提高滚动性能
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById('imChatPanel')
|
||||
if (el) {
|
||||
if (request.cursor == 0) {
|
||||
// el.scrollTop = el.scrollHeight
|
||||
|
||||
// setTimeout(() => {
|
||||
// el.scrollTop = el.scrollHeight + 1000
|
||||
// }, 500)
|
||||
console.log('滚动到底部')
|
||||
|
||||
// 在初次加载完成后恢复上传任务
|
||||
// 确保在所有聊天记录加载完成后再恢复上传任务
|
||||
dialogueStore.restoreUploadTasks()
|
||||
|
||||
// 使用优化的滚动函数
|
||||
scrollToBottom()
|
||||
} else {
|
||||
// 保持滚动位置
|
||||
el.scrollTop = el.scrollHeight - scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有需要定位的消息ID,执行定位
|
||||
if (location.msgid) {
|
||||
onJumpMessage(location.msgid)
|
||||
}
|
||||
|
||||
const updateUIEndTime = performance.now()
|
||||
const totalEndTime = performance.now()
|
||||
|
||||
console.log(`UI更新耗时: ${(updateUIEndTime - updateUIStartTime).toFixed(2)}ms`)
|
||||
console.log(`load函数总耗时: ${(totalEndTime - startTime).toFixed(2)}ms`)
|
||||
})
|
||||
}
|
||||
|
||||
@ -261,27 +326,85 @@ export const useTalkRecord = (uid: number) => {
|
||||
return Math.max(...records.value.map((item) => item.sequence))
|
||||
}
|
||||
|
||||
// 本地数据库加载缓存,用于优化短时间内的重复加载
|
||||
const localDBCache = {
|
||||
key: '', // 缓存键:talk_type-receiver_id
|
||||
data: null, // 缓存的消息数据
|
||||
timestamp: 0, // 缓存时间戳
|
||||
ttl: 2000 // 缓存有效期(毫秒)
|
||||
}
|
||||
|
||||
// 从本地数据库加载聊天记录
|
||||
const loadFromLocalDB = async (params: Params) => {
|
||||
try {
|
||||
// 使用性能标记测量加载时间
|
||||
const startTime = performance.now()
|
||||
|
||||
// 生成缓存键
|
||||
const cacheKey = `${params.talk_type}-${params.receiver_id}`
|
||||
|
||||
// 检查缓存是否有效
|
||||
const now = Date.now()
|
||||
if (localDBCache.key === cacheKey &&
|
||||
localDBCache.data &&
|
||||
now - localDBCache.timestamp < localDBCache.ttl) {
|
||||
console.log('使用缓存的本地数据库消息')
|
||||
|
||||
// 清空现有记录
|
||||
dialogueStore.clearDialogueRecord()
|
||||
|
||||
// 直接使用缓存数据
|
||||
dialogueStore.unshiftDialogueRecord([...localDBCache.data]) // 创建副本避免引用问题
|
||||
|
||||
// 设置加载状态为完成(3表示从本地数据库加载完成)
|
||||
loadConfig.status = 3
|
||||
|
||||
// 恢复上传任务
|
||||
dialogueStore.restoreUploadTasks()
|
||||
|
||||
// 使用requestAnimationFrame优化滚动性能
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`从缓存加载聊天记录耗时: ${(endTime - startTime).toFixed(2)}ms,加载了${localDBCache.data.length}条记录`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 导入 getMessages 函数
|
||||
const { getMessages } = await import('@/utils/db')
|
||||
// 从本地数据库获取聊天记录
|
||||
|
||||
// 从本地数据库获取聊天记录,使用sequence作为排序字段以提高性能
|
||||
const localMessages = await getMessages(
|
||||
params.talk_type,
|
||||
uid,
|
||||
params.receiver_id,
|
||||
params.limit || 30,
|
||||
0 // 从第一页开始
|
||||
// 不传入 maxSequence 参数,获取最新的消息
|
||||
0, // 从第一页开始
|
||||
'sequence' // 明确指定排序字段
|
||||
)
|
||||
|
||||
// 如果有本地数据
|
||||
if (localMessages && localMessages.length > 0) {
|
||||
// 清空现有记录
|
||||
dialogueStore.clearDialogueRecord()
|
||||
|
||||
// 格式化并添加记录
|
||||
const formattedMessages = localMessages.map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
||||
// 优化:预分配数组大小,减少内存重分配
|
||||
const formattedMessages = new Array(localMessages.length)
|
||||
|
||||
// 优化:使用批量处理而不是map,减少内存分配和GC压力
|
||||
for (let i = 0; i < localMessages.length; i++) {
|
||||
formattedMessages[i] = formatTalkRecord(uid, localMessages[i])
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
localDBCache.key = cacheKey
|
||||
localDBCache.data = formattedMessages
|
||||
localDBCache.timestamp = now
|
||||
|
||||
// 批量添加记录
|
||||
dialogueStore.unshiftDialogueRecord(formattedMessages)
|
||||
|
||||
// 设置加载状态为完成(3表示从本地数据库加载完成)
|
||||
@ -290,17 +413,27 @@ export const useTalkRecord = (uid: number) => {
|
||||
// 恢复上传任务
|
||||
dialogueStore.restoreUploadTasks()
|
||||
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
// 使用requestAnimationFrame优化滚动性能
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`从本地数据库加载聊天记录耗时: ${(endTime - startTime).toFixed(2)}ms,加载了${localMessages.length}条记录`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 无数据时清除缓存
|
||||
localDBCache.key = ''
|
||||
localDBCache.data = null
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('从本地数据库加载聊天记录失败:', error)
|
||||
// 出错时清除缓存
|
||||
localDBCache.key = ''
|
||||
localDBCache.data = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -311,6 +444,10 @@ export const useTalkRecord = (uid: number) => {
|
||||
* @param options 可选,{ specifiedMsg } 指定消息对象
|
||||
*/
|
||||
const onLoad = async (params: Params, options?: LoadOptions) => {
|
||||
// 使用性能标记测量加载时间
|
||||
const startTime = performance.now()
|
||||
|
||||
// 检查会话是否变更,如果变更则重置配置
|
||||
if (
|
||||
params.talk_type !== loadConfig.talk_type ||
|
||||
params.receiver_id !== loadConfig.receiver_id
|
||||
@ -324,8 +461,10 @@ export const useTalkRecord = (uid: number) => {
|
||||
|
||||
// 新增:支持指定消息定位模式,参数以传入为准合并
|
||||
if (options?.specifiedMsg?.cursor !== undefined) {
|
||||
// 特殊消息定位模式
|
||||
loadConfig.specialParams = { ...options.specifiedMsg } // 记录特殊参数,供分页加载用
|
||||
loadConfig.status = 0 // 复用主流程 loading 状态
|
||||
|
||||
// 以 params 为基础,合并 specifiedMsg 的所有字段(只要有就覆盖)
|
||||
const contextParams = {
|
||||
...params,
|
||||
@ -333,20 +472,36 @@ export const useTalkRecord = (uid: number) => {
|
||||
}
|
||||
//msg_id是用来做定位的,不做参数,所以这里清空
|
||||
contextParams.msg_id = ''
|
||||
ServeTalkRecords(contextParams).then(({ data, code }) => {
|
||||
console.log('data',data)
|
||||
|
||||
// 使用Promise.all并行处理数据库操作和网络请求
|
||||
const serverDataPromise = ServeTalkRecords(contextParams)
|
||||
|
||||
// 记录当前滚动高度
|
||||
const el = document.getElementById('imChatPanel')
|
||||
const scrollHeight = el?.scrollHeight || 0
|
||||
|
||||
try {
|
||||
// 等待服务器响应
|
||||
const { data, code } = await serverDataPromise
|
||||
|
||||
if (code !== 200) {
|
||||
loadConfig.status = 2
|
||||
return
|
||||
}
|
||||
// 记录当前滚动高度
|
||||
const el = document.getElementById('imChatPanel')
|
||||
const scrollHeight = el?.scrollHeight || 0
|
||||
|
||||
console.log('data', data)
|
||||
|
||||
// 优化:使用批量处理而不是map,减少内存分配
|
||||
const items = new Array(data.items?.length || 0)
|
||||
for (let i = 0; i < (data.items?.length || 0); i++) {
|
||||
items[i] = formatTalkRecord(uid, data.items[i])
|
||||
}
|
||||
|
||||
// 根据方向和类型处理数据
|
||||
if (contextParams.direction === 'down' && !contextParams.type) {
|
||||
dialogueStore.clearDialogueRecord()
|
||||
}
|
||||
const items = (data.items || []).map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
||||
|
||||
if (contextParams.type && contextParams.type === 'loadMore') {
|
||||
dialogueStore.addDialogueRecordForLoadMore(items)
|
||||
} else {
|
||||
@ -354,12 +509,14 @@ export const useTalkRecord = (uid: number) => {
|
||||
contextParams.direction === 'down' ? items : items.reverse()
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
contextParams.direction === 'up' ||
|
||||
(contextParams.direction === 'down' && !contextParams.type)
|
||||
) {
|
||||
loadConfig.status = items[0].sequence == 1 || data.length === 0 ? 2 : 1
|
||||
loadConfig.status = items[0]?.sequence == 1 || data.length === 0 ? 2 : 1
|
||||
}
|
||||
|
||||
loadConfig.cursor = data.cursor
|
||||
|
||||
// 使用 requestAnimationFrame 来确保在下一帧渲染前设置滚动位置
|
||||
@ -375,7 +532,7 @@ export const useTalkRecord = (uid: number) => {
|
||||
} else if (contextParams.type && contextParams.type === 'loadMore') {
|
||||
// 如果是向下加载更多,保持目标消息在可视区域底部
|
||||
// 使用可视区域高度来调整,而不是新内容的总高度
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => { // 使用requestAnimationFrame替代nextTick
|
||||
if (el) {
|
||||
el.scrollTop = scrollHeight - el.clientHeight
|
||||
}
|
||||
@ -383,8 +540,8 @@ export const useTalkRecord = (uid: number) => {
|
||||
} else if (target && msgId) {
|
||||
// 只有在有目标元素且有 msg_id 时才执行定位逻辑
|
||||
// 如果是定位到特定消息,计算并滚动到目标位置
|
||||
// 使用 nextTick 确保 DOM 完全渲染后再计算位置
|
||||
nextTick(() => {
|
||||
// 使用 requestAnimationFrame 确保 DOM 完全渲染后再计算位置
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById('imChatPanel')
|
||||
const target = document.getElementById(msgId)
|
||||
|
||||
@ -431,23 +588,39 @@ export const useTalkRecord = (uid: number) => {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`特殊消息定位模式加载耗时: ${(endTime - startTime).toFixed(2)}ms`)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('特殊消息定位模式加载失败:', error)
|
||||
loadConfig.status = 2
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 普通模式
|
||||
loadConfig.specialParams = undefined // 普通模式清空
|
||||
|
||||
// 设置初始加载状态为0(加载中)
|
||||
loadConfig.status = 0
|
||||
|
||||
// 先从本地数据库加载数据
|
||||
const hasLocalData = await loadFromLocalDB(params)
|
||||
// 使用Promise.all并行处理本地数据库加载和网络请求准备
|
||||
try {
|
||||
// 先从本地数据库加载数据
|
||||
const hasLocalData = await loadFromLocalDB(params)
|
||||
|
||||
// 无论是否有本地数据,都从服务器获取最新数据
|
||||
// 原有逻辑
|
||||
console.log('onLoad()执行load')
|
||||
load(params)
|
||||
// 无论是否有本地数据,都从服务器获取最新数据
|
||||
console.log('onLoad()执行load')
|
||||
await load(params)
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log(`普通模式加载总耗时: ${(endTime - startTime).toFixed(2)}ms`)
|
||||
} catch (error) {
|
||||
console.error('加载聊天记录失败:', error)
|
||||
loadConfig.status = 2
|
||||
}
|
||||
}
|
||||
|
||||
// 向上加载更多(兼容特殊参数模式)
|
||||
|
@ -18,7 +18,7 @@ export function isLoggedIn() {
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
// return storage.get(AccessToken) || ''
|
||||
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d22eec7a138bb20774ef183e109945229d43e1f63fb01cdee46f5f663037f4ed946a0c04441b1f642c945d218180e84e91d272dc621be157602785ef226dd21b9b6c92c292bc73be90fad0320bad0812e11'
|
||||
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d220365eb2ca93ef31880576e2aa3ca8c45a705b447d40e300a54644829e2da528ea463bd2581a396336ed74880960d35716f5f7594e5b8cbb597027c6133b97b12df23427ca728fd2625977a0658ab470d'
|
||||
}
|
||||
|
||||
/**
|
||||
|
129
src/utils/db.js
129
src/utils/db.js
@ -114,31 +114,71 @@ export async function addMessage(message) {
|
||||
/**
|
||||
* 批量添加或更新聊天记录
|
||||
* @param {Array<object>} messages - 消息对象数组
|
||||
* @param {number} talkType - 会话类型
|
||||
* @param {number} receiverId - 接收者ID
|
||||
* @param {boolean} [updateConversation=true] - 是否更新会话信息
|
||||
* @param {string} [sortField='created_at'] - 排序字段
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function batchAddOrUpdateMessages(messages) {
|
||||
export async function batchAddOrUpdateMessages(messages, talkType, receiverId, updateConversation = true, sortField = 'created_at') {
|
||||
try {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesToStore = messages.map(message => {
|
||||
if (!message.msg_id) {
|
||||
message.msg_id = generateUUID();
|
||||
// 使用批处理优化性能
|
||||
return await db.transaction('rw', db.messages, db.conversations, async () => {
|
||||
// 预处理消息数据,避免在循环中多次创建对象
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
// 使用for循环替代map,减少内存分配
|
||||
const messagesToStore = new Array(messages.length);
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
// 确保必要字段存在
|
||||
if (!message.msg_id) {
|
||||
message.msg_id = generateUUID();
|
||||
}
|
||||
if (!message.created_at) {
|
||||
message.created_at = now;
|
||||
}
|
||||
// 确保talk_type和receiver_id字段存在
|
||||
if (talkType && !message.talk_type) {
|
||||
message.talk_type = talkType;
|
||||
}
|
||||
if (receiverId && !message.receiver_id) {
|
||||
message.receiver_id = receiverId;
|
||||
}
|
||||
messagesToStore[i] = message;
|
||||
}
|
||||
if (!message.created_at) {
|
||||
message.created_at = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
// 使用bulkPut批量插入/更新,提高性能
|
||||
await db.messages.bulkPut(messagesToStore);
|
||||
|
||||
// 只有在需要时才更新会话信息
|
||||
if (updateConversation && messagesToStore.length > 0) {
|
||||
// 根据排序字段找出最新消息
|
||||
let latestMessage;
|
||||
if (sortField === 'sequence') {
|
||||
// 按sequence排序找出最大的
|
||||
latestMessage = messagesToStore.reduce((max, current) => {
|
||||
return (current.sequence > (max.sequence || 0)) ? current : max;
|
||||
}, messagesToStore[0]);
|
||||
} else {
|
||||
// 默认按created_at排序
|
||||
latestMessage = messagesToStore.reduce((latest, current) => {
|
||||
if (!latest.created_at) return current;
|
||||
if (!current.created_at) return latest;
|
||||
return new Date(current.created_at) > new Date(latest.created_at) ? current : latest;
|
||||
}, messagesToStore[0]);
|
||||
}
|
||||
|
||||
// 异步更新会话最后消息,不阻塞主流程
|
||||
updateConversationLastMessage(latestMessage).catch(err => {
|
||||
console.error('更新会话最后消息失败:', err);
|
||||
});
|
||||
}
|
||||
return message;
|
||||
});
|
||||
|
||||
await db.messages.bulkPut(messagesToStore);
|
||||
|
||||
// 更新最后一条消息到会话
|
||||
const latestMessage = messagesToStore[messagesToStore.length - 1];
|
||||
if (latestMessage) {
|
||||
await updateConversationLastMessage(latestMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量添加或更新消息失败:', error);
|
||||
throw error;
|
||||
@ -152,35 +192,78 @@ export async function batchAddOrUpdateMessages(messages) {
|
||||
* @param {number} receiverId - 接收者ID (私聊为对方用户ID,群聊为群ID)
|
||||
* @param {number} [limit=30] - 限制返回的记录数量
|
||||
* @param {number|null} [maxSequence=null] - 最大sequence值,用于分页加载更早的消息
|
||||
* @param {string} [sortField='sequence'] - 排序字段,默认按sequence排序
|
||||
* @returns {Promise<Array<object>>} 消息列表 (按sequence升序排列)
|
||||
*/
|
||||
export async function getMessages(talkType, userId, receiverId, limit = 30, maxSequence = null) {
|
||||
export async function getMessages(talkType, userId, receiverId, limit = 30, maxSequence = null, sortField = 'sequence') {
|
||||
try {
|
||||
// 使用缓存优化重复查询
|
||||
const cacheKey = `${talkType}_${receiverId}_${limit}_${maxSequence}_${sortField}`;
|
||||
const cachedResult = messageCache.get(cacheKey);
|
||||
|
||||
// 如果缓存存在且未过期,直接返回缓存结果
|
||||
if (cachedResult && (Date.now() - cachedResult.timestamp < 2000)) { // 2秒缓存
|
||||
return cachedResult.data;
|
||||
}
|
||||
|
||||
let collection;
|
||||
|
||||
// 优化查询策略
|
||||
if (maxSequence !== null) {
|
||||
// 加载更多:查询 sequence 小于 maxSequence 的消息
|
||||
// 使用复合索引优化查询
|
||||
collection = db.messages
|
||||
.where('[talk_type+receiver_id+sequence]')
|
||||
.between([talkType, receiverId, 0], [talkType, receiverId, maxSequence], true, false);
|
||||
} else {
|
||||
// 首次加载:查询指定会话的所有消息
|
||||
collection = db.messages.where({ '[talk_type+receiver_id]': [talkType, receiverId] });
|
||||
// 使用复合索引优化查询
|
||||
collection = db.messages.where('[talk_type+receiver_id]').equals([talkType, receiverId]);
|
||||
}
|
||||
|
||||
// 1. reverse() - 利用索引倒序排列,获取最新的消息
|
||||
// 2. limit() - 限制数量,实现分页
|
||||
// 3. toArray() - 执行查询
|
||||
const messages = await collection.reverse().limit(limit).toArray();
|
||||
// 优化:根据排序字段选择最优索引
|
||||
let messages;
|
||||
if (sortField === 'sequence') {
|
||||
// 使用sequence字段排序(默认)
|
||||
// 1. reverse() - 利用索引倒序排列,获取最新的消息
|
||||
// 2. limit() - 限制数量,实现分页
|
||||
// 3. toArray() - 执行查询,一次性获取所有数据减少IO操作
|
||||
messages = await collection.reverse().limit(limit).toArray();
|
||||
// 再次 reverse() - 将获取到的分页消息按时间正序排列,以便于在界面上显示
|
||||
messages = messages.reverse();
|
||||
} else if (sortField === 'created_at') {
|
||||
// 使用created_at字段排序
|
||||
messages = await collection.toArray();
|
||||
// 在内存中排序,避免数据库排序开销
|
||||
messages.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at || 0);
|
||||
const dateB = new Date(b.created_at || 0);
|
||||
return dateA - dateB; // 升序排列
|
||||
});
|
||||
// 限制返回数量
|
||||
messages = messages.slice(-limit);
|
||||
} else {
|
||||
// 默认排序逻辑
|
||||
messages = await collection.reverse().limit(limit).toArray();
|
||||
messages = messages.reverse();
|
||||
}
|
||||
|
||||
// 再次 reverse() - 将获取到的分页消息按时间正序排列,以便于在界面上显示
|
||||
return messages.reverse();
|
||||
// 缓存查询结果
|
||||
messageCache.set(cacheKey, {
|
||||
data: messages,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error('获取消息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的内存缓存实现
|
||||
const messageCache = new Map();
|
||||
|
||||
/**
|
||||
* 标记指定会话的所有消息为已读
|
||||
* @param {number} talkType - 会话类型
|
||||
|
@ -42,9 +42,9 @@ export function textReplaceLink(text, color = '#409eff') {
|
||||
* @param {String} text 文本
|
||||
* @param {String} color 超链接颜色
|
||||
*/
|
||||
export function textReplaceMention(text, color = '#2196F3') {
|
||||
export function textReplaceMention(text, color = '#2196F3',bg) {
|
||||
return text.replace(new RegExp(/@\S+/, 'g'), ($0, $1) => {
|
||||
return `<span style="color:${color};">${$0}</span>`
|
||||
return `<span style="color:${color};background:${bg};border-radius:2px;padding:0 5px">${$0}</span>`
|
||||
})
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user