Compare commits
12 Commits
a95e53d8a0
...
959978a2c7
Author | SHA1 | Date | |
---|---|---|---|
|
959978a2c7 | ||
|
4863bc2220 | ||
|
b18a1b2604 | ||
|
3f89777bf8 | ||
|
a05d637bd2 | ||
|
3363f23ad3 | ||
|
c3abd733ad | ||
|
0b8de6f5c2 | ||
|
cc5cf41ad1 | ||
|
af2c80f902 | ||
5f94c7d83a | |||
1956bb05ef |
@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@floating-ui/dom": "^1.7.2",
|
||||||
"@highlightjs/vue-plugin": "^2.1.0",
|
"@highlightjs/vue-plugin": "^2.1.0",
|
||||||
"@iconify-json/ion": "^1.2.3",
|
"@iconify-json/ion": "^1.2.3",
|
||||||
"@kangc/v-md-editor": "^2.3.18",
|
"@kangc/v-md-editor": "^2.3.18",
|
||||||
|
@ -11,6 +11,9 @@ importers:
|
|||||||
'@ant-design/icons-vue':
|
'@ant-design/icons-vue':
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1(vue@3.5.17(typescript@5.2.2))
|
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':
|
'@highlightjs/vue-plugin':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0(highlight.js@11.11.1)(vue@3.5.17(typescript@5.2.2))
|
version: 2.1.0(highlight.js@11.11.1)(vue@3.5.17(typescript@5.2.2))
|
||||||
@ -568,6 +571,15 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@hapi/hoek@9.3.0':
|
||||||
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
|
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
|
||||||
|
|
||||||
@ -4280,6 +4292,17 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.5':
|
'@esbuild/win32-x64@0.25.5':
|
||||||
optional: true
|
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/hoek@9.3.0': {}
|
||||||
|
|
||||||
'@hapi/topo@5.1.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>
|
<script setup>
|
||||||
// 引入Tiptap编辑器相关依赖
|
|
||||||
import { Editor, EditorContent, useEditor } from '@tiptap/vue-3'
|
import { Editor, EditorContent, useEditor } from '@tiptap/vue-3'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Image from '@tiptap/extension-image'
|
import Image from '@tiptap/extension-image'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import Mention from '@tiptap/extension-mention'
|
import Mention from '@tiptap/extension-mention'
|
||||||
|
import { computePosition, flip, shift } from '@floating-ui/dom'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import { Extension, Node } from '@tiptap/core'
|
import { Extension, Node } from '@tiptap/core'
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
|
||||||
// 引入Vue核心功能
|
|
||||||
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||||
// 引入Naive UI的弹出框组件
|
|
||||||
import { NPopover, NIcon } from 'naive-ui'
|
import { NPopover, NIcon } from 'naive-ui'
|
||||||
// 引入图标组件
|
|
||||||
import {
|
import {
|
||||||
Voice as IconVoice, // 语音图标
|
Voice as IconVoice,
|
||||||
SourceCode, // 代码图标
|
SourceCode,
|
||||||
Local, // 地理位置图标
|
Local,
|
||||||
SmilingFace, // 表情图标
|
SmilingFace,
|
||||||
Pic, // 图片图标
|
Pic,
|
||||||
FolderUpload, // 文件上传图标
|
FolderUpload,
|
||||||
Ranking, // 排名图标(用于投票)
|
Ranking,
|
||||||
History, // 历史记录图标
|
History,
|
||||||
Close // 关闭图标
|
Close
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
// 引入状态管理
|
|
||||||
import { useDialogueStore, useEditorDraftStore } from '@/store'
|
import { useDialogueStore, useEditorDraftStore } from '@/store'
|
||||||
// 引入获取图片信息的工具函数
|
|
||||||
import { getImageInfo } from '@/utils/functions'
|
import { getImageInfo } from '@/utils/functions'
|
||||||
// 引入编辑器常量定义
|
|
||||||
import { EditorConst } from '@/constant/event-bus'
|
import { EditorConst } from '@/constant/event-bus'
|
||||||
// 引入事件调用工具
|
|
||||||
import { emitCall } from '@/utils/common'
|
import { emitCall } from '@/utils/common'
|
||||||
// 引入默认头像常量
|
|
||||||
import { defAvatar } from '@/constant/default'
|
import { defAvatar } from '@/constant/default'
|
||||||
// 引入编辑器各子组件
|
|
||||||
import MeEditorVote from './MeEditorVote.vue' // 投票组件
|
import suggestion from './suggestion.js'
|
||||||
import MeEditorEmoticon from './MeEditorEmoticon.vue' // 表情组件
|
|
||||||
import MeEditorCode from './MeEditorCode.vue' // 代码编辑组件
|
import MeEditorVote from './MeEditorVote.vue'
|
||||||
import MeEditorRecorder from './MeEditorRecorder.vue' // 录音组件
|
import MeEditorEmoticon from './MeEditorEmoticon.vue'
|
||||||
// 引入上传API
|
import MeEditorCode from './MeEditorCode.vue'
|
||||||
|
import MeEditorRecorder from './MeEditorRecorder.vue'
|
||||||
|
|
||||||
import { uploadImg } from '@/api/upload'
|
import { uploadImg } from '@/api/upload'
|
||||||
// 引入事件总线钩子
|
|
||||||
import { useEventBus } from '@/hooks'
|
import { useEventBus } from '@/hooks'
|
||||||
|
|
||||||
// 定义组件的事件
|
|
||||||
const emit = defineEmits(['editor-event'])
|
const emit = defineEmits(['editor-event'])
|
||||||
// 获取对话状态管理
|
|
||||||
const dialogueStore = useDialogueStore()
|
const dialogueStore = useDialogueStore()
|
||||||
// 获取编辑器草稿状态管理
|
|
||||||
const editorDraftStore = useEditorDraftStore()
|
const editorDraftStore = useEditorDraftStore()
|
||||||
// 定义组件props
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
vote: {
|
vote: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false // 是否显示投票功能
|
default: false
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
default: () => [] // 聊天成员列表,用于@功能
|
default: () => []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算当前对话索引名称(标识当前聊天)
|
|
||||||
const indexName = computed(() => dialogueStore.index_name)
|
const indexName = computed(() => dialogueStore.index_name)
|
||||||
// 控制是否显示编辑器的投票界面
|
|
||||||
const isShowEditorVote = ref(false)
|
const isShowEditorVote = ref(false)
|
||||||
// 控制是否显示编辑器的代码界面
|
|
||||||
const isShowEditorCode = ref(false)
|
const isShowEditorCode = ref(false)
|
||||||
// 控制是否显示录音界面
|
|
||||||
const isShowEditorRecorder = ref(false)
|
const isShowEditorRecorder = ref(false)
|
||||||
const uploadingImages = ref(new Map())
|
const uploadingImages = ref(new Map())
|
||||||
// 图片文件上传DOM引用
|
|
||||||
const fileImageRef = ref()
|
const fileImageRef = ref()
|
||||||
// 文件上传DOM引用
|
|
||||||
const uploadFileRef = ref()
|
const uploadFileRef = ref()
|
||||||
// 表情面板引用
|
|
||||||
const emoticonRef = ref()
|
const emoticonRef = ref()
|
||||||
// 表情面板显示状态
|
|
||||||
const showEmoticon = ref(false)
|
const showEmoticon = ref(false)
|
||||||
// 引用消息数据
|
|
||||||
const quoteData = ref(null)
|
const quoteData = ref(null)
|
||||||
|
|
||||||
// 自定义Emoji扩展
|
|
||||||
const Emoji = Node.create({
|
const Emoji = Node.create({
|
||||||
name: 'emoji',
|
name: 'emoji',
|
||||||
group: 'inline',
|
group: 'inline',
|
||||||
@ -126,12 +129,12 @@ const Emoji = Node.create({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 创建自定义键盘处理插件,处理Enter键发送消息
|
|
||||||
const EnterKeyPlugin = new Plugin({
|
const EnterKeyPlugin = new Plugin({
|
||||||
key: new PluginKey('enterKey'),
|
key: new PluginKey('enterKey'),
|
||||||
props: {
|
props: {
|
||||||
handleKeyDown: (view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
// 如果按下Enter键且没有按下Shift键,则发送消息
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onSendMessage()
|
onSendMessage()
|
||||||
@ -142,7 +145,7 @@ const EnterKeyPlugin = new Plugin({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 自定义键盘扩展
|
|
||||||
const CustomKeyboard = Extension.create({
|
const CustomKeyboard = Extension.create({
|
||||||
name: 'customKeyboard',
|
name: 'customKeyboard',
|
||||||
|
|
||||||
@ -153,7 +156,7 @@ const CustomKeyboard = Extension.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建编辑器实例
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
@ -205,118 +208,18 @@ const editor = useEditor({
|
|||||||
class: 'mention',
|
class: 'mention',
|
||||||
},
|
},
|
||||||
suggestion: {
|
suggestion: {
|
||||||
allowedPrefixes: null,
|
...suggestion,
|
||||||
hideOnClickOutside: true,
|
char: '@',
|
||||||
hideOnKeyDown: true,
|
allowSpaces: false,
|
||||||
emptyQueryClass: 'is-empty-query',
|
allowedPrefixes: null,
|
||||||
items: ({ query }) => {
|
items: ({ query }) => {
|
||||||
if (!props.members.length) {
|
return suggestion.items({
|
||||||
return []
|
query,
|
||||||
}
|
props: {
|
||||||
|
members: props.members,
|
||||||
let list = [...props.members]
|
isGroupManager: (dialogueStore.groupInfo).is_manager
|
||||||
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -384,11 +287,6 @@ const editor = useEditor({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传图片函数
|
|
||||||
* @param file 文件对象
|
|
||||||
* @returns Promise,成功时返回图片URL
|
|
||||||
*/
|
|
||||||
function findImagePos(url) {
|
function findImagePos(url) {
|
||||||
if (!editor.value) return -1
|
if (!editor.value) return -1
|
||||||
let pos = -1
|
let pos = -1
|
||||||
@ -418,51 +316,43 @@ function onUploadImage(file) {
|
|||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
form.append("source", "fonchain-chat"); // 图片来源标识
|
form.append("source", "fonchain-chat");
|
||||||
// 添加图片尺寸信息作为URL参数
|
|
||||||
form.append("urlParam", `width=${image.width}&height=${image.height}`);
|
form.append("urlParam", `width=${image.width}&height=${image.height}`);
|
||||||
|
|
||||||
// 调用上传API
|
|
||||||
uploadImg(form).then(({ code, data, message }) => {
|
uploadImg(form).then(({ code, data, message }) => {
|
||||||
if (code == 0) {
|
if (code == 0) {
|
||||||
resolve(data.ori_url) // 返回原始图片URL
|
resolve(data.ori_url)
|
||||||
} else {
|
} else {
|
||||||
resolve('')
|
resolve('')
|
||||||
window['$message'].error(message) // 显示错误信息
|
window['$message'].error(message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 投票事件处理
|
|
||||||
* @param data 投票数据
|
|
||||||
*/
|
|
||||||
function onVoteEvent(data) {
|
function onVoteEvent(data) {
|
||||||
const msg = emitCall('vote_event', data, (ok) => {
|
const msg = emitCall('vote_event', data, (ok) => {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
isShowEditorVote.value = false // 成功后关闭投票界面
|
isShowEditorVote.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('editor-event', msg)
|
emit('editor-event', msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 表情事件处理
|
|
||||||
* @param data 表情数据
|
|
||||||
*/
|
|
||||||
function onEmoticonEvent(data) {
|
function onEmoticonEvent(data) {
|
||||||
// 关闭表情面板
|
|
||||||
showEmoticon.value = false
|
showEmoticon.value = false
|
||||||
|
|
||||||
if (data.type == 1) {
|
if (data.type == 1) {
|
||||||
// 插入文本表情
|
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
if (data.img) {
|
if (data.img) {
|
||||||
// 插入图片表情
|
|
||||||
editor.value.chain().focus().insertContent({
|
editor.value.chain().focus().insertContent({
|
||||||
type: 'emoji',
|
type: 'emoji',
|
||||||
attrs: {
|
attrs: {
|
||||||
@ -473,39 +363,31 @@ function onEmoticonEvent(data) {
|
|||||||
},
|
},
|
||||||
}).run()
|
}).run()
|
||||||
} else {
|
} else {
|
||||||
// 插入文本表情
|
|
||||||
editor.value.chain().focus().insertContent(data.value).run()
|
editor.value.chain().focus().insertContent(data.value).run()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 发送整个表情包
|
|
||||||
let fn = emitCall('emoticon_event', data.value, () => {})
|
let fn = emitCall('emoticon_event', data.value, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 代码事件处理
|
|
||||||
* @param data 代码数据
|
|
||||||
*/
|
|
||||||
function onCodeEvent(data) {
|
function onCodeEvent(data) {
|
||||||
const msg = emitCall('code_event', data, (ok) => {
|
const msg = emitCall('code_event', data, (ok) => {
|
||||||
isShowEditorCode.value = false // 成功后关闭代码界面
|
isShowEditorCode.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('editor-event', msg)
|
emit('editor-event', msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件上传处理
|
|
||||||
* @param e 上传事件对象
|
|
||||||
*/
|
|
||||||
async function onUploadFile(e) {
|
async function onUploadFile(e) {
|
||||||
let file = e.target.files[0]
|
let file = e.target.files[0]
|
||||||
|
|
||||||
e.target.value = null // 清空input,允许再次选择相同文件
|
e.target.value = null
|
||||||
|
|
||||||
if (file.type.indexOf('image/') === 0) {
|
if (file.type.indexOf('image/') === 0) {
|
||||||
// 处理图片文件 - 立即显示临时消息,然后上传
|
|
||||||
let fn = emitCall('image_event', file, () => {})
|
let fn = emitCall('image_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
|
|
||||||
@ -513,26 +395,22 @@ async function onUploadFile(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file.type.indexOf('video/') === 0) {
|
if (file.type.indexOf('video/') === 0) {
|
||||||
// 处理视频文件
|
|
||||||
let fn = emitCall('video_event', file, () => {})
|
let fn = emitCall('video_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
} else {
|
} else {
|
||||||
// 处理其他类型文件
|
|
||||||
let fn = emitCall('file_event', file, () => {})
|
let fn = emitCall('file_event', file, () => {})
|
||||||
emit('editor-event', fn)
|
emit('editor-event', fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 录音事件处理
|
|
||||||
* @param file 录音文件
|
|
||||||
*/
|
|
||||||
function onRecorderEvent(file) {
|
function onRecorderEvent(file) {
|
||||||
emit('editor-event', emitCall('file_event', file))
|
emit('editor-event', emitCall('file_event', file))
|
||||||
isShowEditorRecorder.value = false // 关闭录音界面
|
isShowEditorRecorder.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将Tiptap内容转换为消息格式
|
|
||||||
function tiptapToMessage() {
|
function tiptapToMessage() {
|
||||||
if (!editor.value) return []
|
if (!editor.value) return []
|
||||||
|
|
||||||
@ -573,7 +451,7 @@ function tiptapToMessage() {
|
|||||||
} else if (node.type === 'hardBreak') {
|
} else if (node.type === 'hardBreak') {
|
||||||
currentTextBuffer += '\n'
|
currentTextBuffer += '\n'
|
||||||
} else if (node.type === 'image') {
|
} else if (node.type === 'image') {
|
||||||
// 处理段落内的图片
|
|
||||||
flushTextBuffer()
|
flushTextBuffer()
|
||||||
const data = {
|
const data = {
|
||||||
...getImageInfo(node.attrs.src),
|
...getImageInfo(node.attrs.src),
|
||||||
@ -590,7 +468,7 @@ function tiptapToMessage() {
|
|||||||
if (node.content) {
|
if (node.content) {
|
||||||
processInlines(node.content)
|
processInlines(node.content)
|
||||||
}
|
}
|
||||||
currentTextBuffer += '\n' // Add newline after each paragraph
|
currentTextBuffer += '\n'
|
||||||
} else if (node.type === 'image') {
|
} else if (node.type === 'image') {
|
||||||
flushTextBuffer()
|
flushTextBuffer()
|
||||||
const data = {
|
const data = {
|
||||||
@ -617,20 +495,49 @@ function tiptapToMessage() {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将Tiptap内容转换为纯文本
|
|
||||||
function tiptapToString() {
|
function tiptapToString() {
|
||||||
if (!editor.value) return ''
|
if (!editor.value) return ''
|
||||||
|
|
||||||
return editor.value.getText()
|
const json = editor.value.getJSON()
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
const processInlines = nodes => {
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
result += node.text
|
||||||
|
} else if (node.type === 'mention') {
|
||||||
|
result += `@${node.attrs.label} `
|
||||||
|
} else if (node.type === 'emoji') {
|
||||||
|
// 关键修改:使用表情的alt文本而不是忽略
|
||||||
|
result += node.attrs.alt || ''
|
||||||
|
} else if (node.type === 'hardBreak') {
|
||||||
|
result += '\n'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.content) {
|
||||||
|
json.content.forEach(node => {
|
||||||
|
if (node.type === 'paragraph') {
|
||||||
|
if (node.content) {
|
||||||
|
processInlines(node.content)
|
||||||
|
}
|
||||||
|
result += '\n'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查编辑器是否为空
|
|
||||||
function isEditorEmpty() {
|
function isEditorEmpty() {
|
||||||
if (!editor.value) return true
|
if (!editor.value) return true
|
||||||
|
|
||||||
const json = editor.value.getJSON()
|
const json = editor.value.getJSON()
|
||||||
|
|
||||||
// 检查是否只有一个空段落
|
|
||||||
return !json.content || (
|
return !json.content || (
|
||||||
json.content.length === 1 &&
|
json.content.length === 1 &&
|
||||||
json.content[0].type === 'paragraph' &&
|
json.content[0].type === 'paragraph' &&
|
||||||
@ -638,10 +545,6 @@ function isEditorEmpty() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送消息处理
|
|
||||||
* 根据编辑器内容类型发送不同类型的消息
|
|
||||||
*/
|
|
||||||
function onSendMessage() {
|
function onSendMessage() {
|
||||||
if (uploadingImages.value.size > 0) {
|
if (uploadingImages.value.size > 0) {
|
||||||
return window['$message'].info('正在上传图片,请稍后再发')
|
return window['$message'].info('正在上传图片,请稍后再发')
|
||||||
@ -663,7 +566,7 @@ function onSendMessage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加引用消息参数
|
|
||||||
if (quoteData.value) {
|
if (quoteData.value) {
|
||||||
msg.data.quoteId = quoteData.value.id
|
msg.data.quoteId = quoteData.value.id
|
||||||
msg.data.quote = { ...quoteData.value }
|
msg.data.quote = { ...quoteData.value }
|
||||||
@ -678,7 +581,7 @@ function onSendMessage() {
|
|||||||
url: msg.data.url,
|
url: msg.data.url,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加引用消息参数
|
|
||||||
if (quoteData.value) {
|
if (quoteData.value) {
|
||||||
data.quoteId = quoteData.value.id
|
data.quoteId = quoteData.value.id
|
||||||
data.quote = { ...quoteData.value }
|
data.quote = { ...quoteData.value }
|
||||||
@ -688,7 +591,7 @@ function onSendMessage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果只有引用消息但没有内容,也发送一条空文本消息带引用
|
|
||||||
if (messages.length === 0 && quoteData.value) {
|
if (messages.length === 0 && quoteData.value) {
|
||||||
const emptyData = {
|
const emptyData = {
|
||||||
items: [{ type: 1, content: '' }],
|
items: [{ type: 1, content: '' }],
|
||||||
@ -702,49 +605,41 @@ function onSendMessage() {
|
|||||||
|
|
||||||
if (canClear) {
|
if (canClear) {
|
||||||
editor.value?.commands.clearContent(true)
|
editor.value?.commands.clearContent(true)
|
||||||
// 清空引用数据
|
|
||||||
quoteData.value = null
|
quoteData.value = null
|
||||||
// 更新草稿
|
|
||||||
onEditorChange()
|
onEditorChange()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑器内容改变时的处理
|
|
||||||
* 保存草稿并触发输入事件
|
|
||||||
*/
|
|
||||||
function onEditorChange() {
|
function onEditorChange() {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
const text = tiptapToString()
|
const text = tiptapToString()
|
||||||
|
|
||||||
if (!isEditorEmpty() || quoteData.value) {
|
if (!isEditorEmpty() || quoteData.value) {
|
||||||
// 保存草稿到store
|
|
||||||
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
|
||||||
text: text,
|
text: text,
|
||||||
content: editor.value.getJSON(),
|
content: editor.value.getJSON(),
|
||||||
quoteData: quoteData.value
|
quoteData: quoteData.value
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 编辑器为空时删除对应草稿
|
|
||||||
delete editorDraftStore.items[indexName.value || '']
|
delete editorDraftStore.items[indexName.value || '']
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发输入事件
|
|
||||||
emit('editor-event', emitCall('input_event', text))
|
emit('editor-event', emitCall('input_event', text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载编辑器草稿内容
|
|
||||||
* 当切换聊天对象时,加载对应的草稿
|
|
||||||
*/
|
|
||||||
function loadEditorDraftText() {
|
function loadEditorDraftText() {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
// 切换会话时清空引用数据,不保存当前引用数据
|
|
||||||
quoteData.value = null
|
quoteData.value = null
|
||||||
|
|
||||||
// 从缓存中加载编辑器草稿
|
|
||||||
let draft = editorDraftStore.items[indexName.value || '']
|
let draft = editorDraftStore.items[indexName.value || '']
|
||||||
if (draft) {
|
if (draft) {
|
||||||
const parsed = JSON.parse(draft)
|
const parsed = JSON.parse(draft)
|
||||||
@ -754,26 +649,22 @@ function loadEditorDraftText() {
|
|||||||
editor.value.commands.setContent(parsed.text)
|
editor.value.commands.setContent(parsed.text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果草稿中有引用数据,恢复它
|
|
||||||
if (parsed.quoteData) {
|
if (parsed.quoteData) {
|
||||||
quoteData.value = parsed.quoteData
|
quoteData.value = parsed.quoteData
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
editor.value.commands.clearContent(true) // 没有草稿则清空编辑器
|
editor.value.commands.clearContent(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置光标位置到末尾
|
|
||||||
editor.value.commands.focus('end')
|
editor.value.commands.focus('end')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理@成员事件
|
|
||||||
* @param data @成员数据
|
|
||||||
*/
|
|
||||||
function onSubscribeMention(data) {
|
function onSubscribeMention(data) {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
// 插入@项
|
|
||||||
editor.value.chain().focus().insertContent({
|
editor.value.chain().focus().insertContent({
|
||||||
type: 'mention',
|
type: 'mention',
|
||||||
attrs: {
|
attrs: {
|
||||||
@ -783,53 +674,42 @@ function onSubscribeMention(data) {
|
|||||||
}).run()
|
}).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理引用事件
|
|
||||||
* @param data 引用数据
|
|
||||||
*/
|
|
||||||
function onSubscribeQuote(data) {
|
function onSubscribeQuote(data) {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
// 保存引用数据
|
|
||||||
quoteData.value = data
|
quoteData.value = data
|
||||||
// 更新草稿
|
|
||||||
onEditorChange()
|
onEditorChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空引用数据并更新草稿
|
|
||||||
*/
|
|
||||||
function clearQuoteData() {
|
function clearQuoteData() {
|
||||||
quoteData.value = null
|
quoteData.value = null
|
||||||
// 更新草稿
|
|
||||||
onEditorChange()
|
onEditorChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理编辑消息事件
|
|
||||||
* @param data 消息数据
|
|
||||||
*/
|
|
||||||
function onSubscribeEdit(data) {
|
function onSubscribeEdit(data) {
|
||||||
if (!editor.value) return
|
if (!editor.value) return
|
||||||
|
|
||||||
// 清空当前编辑器内容
|
|
||||||
editor.value.commands.clearContent(true)
|
editor.value.commands.clearContent(true)
|
||||||
|
|
||||||
// 插入要编辑的文本内容
|
|
||||||
editor.value.commands.insertContent(data.content)
|
editor.value.commands.insertContent(data.content)
|
||||||
|
|
||||||
// 设置光标位置到末尾
|
|
||||||
editor.value.commands.focus('end')
|
editor.value.commands.focus('end')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底部工具栏配置
|
|
||||||
const navs = reactive([
|
const navs = reactive([
|
||||||
{
|
{
|
||||||
title: '图片',
|
title: '图片',
|
||||||
icon: markRaw(Pic),
|
icon: markRaw(Pic),
|
||||||
show: true,
|
show: true,
|
||||||
click: () => {
|
click: () => {
|
||||||
fileImageRef.value.click() // 触发图片上传
|
fileImageRef.value.click()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -837,38 +717,34 @@ const navs = reactive([
|
|||||||
icon: markRaw(FolderUpload),
|
icon: markRaw(FolderUpload),
|
||||||
show: true,
|
show: true,
|
||||||
click: () => {
|
click: () => {
|
||||||
uploadFileRef.value.click() // 触发文件上传
|
uploadFileRef.value.click()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// 监听聊天索引变化,切换聊天时加载对应草稿
|
|
||||||
watch(indexName, loadEditorDraftText, { immediate: true })
|
watch(indexName, loadEditorDraftText, { immediate: true })
|
||||||
|
|
||||||
// 组件挂载时初始化
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadEditorDraftText()
|
loadEditorDraftText()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 订阅编辑器相关事件总线事件
|
|
||||||
useEventBus([
|
useEventBus([
|
||||||
{ name: EditorConst.Mention, event: onSubscribeMention }, // @成员事件
|
{ name: EditorConst.Mention, event: onSubscribeMention },
|
||||||
{ name: EditorConst.Quote, event: onSubscribeQuote }, // 引用事件
|
{ name: EditorConst.Quote, event: onSubscribeQuote },
|
||||||
{ name: EditorConst.Edit, event: onSubscribeEdit } // 编辑消息事件
|
{ name: EditorConst.Edit, event: onSubscribeEdit }
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 编辑器容器 -->
|
|
||||||
<section class="el-container editor">
|
<section class="el-container editor">
|
||||||
<section class="el-container is-vertical">
|
<section class="el-container is-vertical">
|
||||||
|
|
||||||
|
|
||||||
<!-- 工具栏区域 -->
|
|
||||||
<header class="el-header toolbar bdr-t">
|
<header class="el-header toolbar bdr-t">
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<!-- 表情选择器弹出框 -->
|
|
||||||
<n-popover
|
<n-popover
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
trigger="click"
|
trigger="click"
|
||||||
@ -888,8 +764,6 @@ useEventBus([
|
|||||||
|
|
||||||
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
<MeEditorEmoticon @on-select="onEmoticonEvent" />
|
||||||
</n-popover>
|
</n-popover>
|
||||||
|
|
||||||
<!-- 工具栏其他功能按钮 -->
|
|
||||||
<div
|
<div
|
||||||
class="item pointer"
|
class="item pointer"
|
||||||
v-for="nav in navs"
|
v-for="nav in navs"
|
||||||
@ -902,7 +776,7 @@ useEventBus([
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<!-- 引用消息块 -->
|
|
||||||
<div v-if="quoteData" class="quote-card-wrapper">
|
<div v-if="quoteData" class="quote-card-wrapper">
|
||||||
<div class="quote-card-content">
|
<div class="quote-card-content">
|
||||||
<div class="quote-card-title">
|
<div class="quote-card-title">
|
||||||
@ -917,20 +791,20 @@ useEventBus([
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 编辑器主体区域 -->
|
|
||||||
<main class="el-main height100">
|
<main class="el-main height100">
|
||||||
<editor-content :editor="editor" class="tiptap-editor" />
|
<editor-content :editor="editor" class="tiptap-editor" />
|
||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 隐藏的文件上传表单 -->
|
|
||||||
<form enctype="multipart/form-data" style="display: none">
|
<form enctype="multipart/form-data" style="display: none">
|
||||||
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
|
<input type="file" ref="fileImageRef" accept="image/*" @change="onUploadFile" />
|
||||||
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
|
<input type="file" ref="uploadFileRef" @change="onUploadFile" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- 条件渲染的功能组件 -->
|
|
||||||
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
|
<MeEditorVote v-if="isShowEditorVote" @close="isShowEditorVote = false" @submit="onVoteEvent" />
|
||||||
|
|
||||||
<MeEditorCode
|
<MeEditorCode
|
||||||
@ -947,12 +821,12 @@ useEventBus([
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
/* 编辑器容器样式 */
|
|
||||||
.editor {
|
.editor {
|
||||||
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
|
--tip-bg-color: rgb(241 241 241 / 90%);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
/* 引用消息块样式 */
|
|
||||||
.quote-card-wrapper {
|
.quote-card-wrapper {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
@ -1025,7 +899,7 @@ useEventBus([
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
.tip-title {
|
.tip-title {
|
||||||
display: none; /* 默认隐藏提示文字 */
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 40px;
|
top: 40px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
@ -1043,7 +917,7 @@ useEventBus([
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.tip-title {
|
.tip-title {
|
||||||
display: block; /* 悬停时显示提示文字 */
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1051,7 +925,6 @@ useEventBus([
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色模式样式调整 */
|
|
||||||
html[theme-mode='dark'] {
|
html[theme-mode='dark'] {
|
||||||
.editor {
|
.editor {
|
||||||
--tip-bg-color: #48484d;
|
--tip-bg-color: #48484d;
|
||||||
@ -1082,7 +955,6 @@ html[theme-mode='dark'] {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
/* 全局编辑器样式 */
|
|
||||||
.tiptap-editor {
|
.tiptap-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -1102,7 +974,7 @@ html[theme-mode='dark'] {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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-size: 30px 30px;
|
||||||
background-position: center center;
|
background-position: center center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
@ -1110,7 +982,6 @@ html[theme-mode='dark'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条样式 */
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
@ -1158,10 +1029,10 @@ html[theme-mode='dark'] {
|
|||||||
|
|
||||||
/* 提及样式 */
|
/* 提及样式 */
|
||||||
.mention {
|
.mention {
|
||||||
color: #0366d6;
|
color: #fff;
|
||||||
background-color: rgba(3, 102, 214, 0.1);
|
background-color: var(--im-primary-color);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0 2px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 引用卡片样式 */
|
/* 引用卡片样式 */
|
||||||
@ -1171,39 +1042,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'] {
|
html[theme-mode='dark'] {
|
||||||
.tiptap-editor {
|
.tiptap-editor {
|
||||||
@ -1215,20 +1053,5 @@ html[theme-mode='dark'] {
|
|||||||
background-color: var(--im-message-bg-color);
|
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>
|
</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)
|
textContent = textReplaceLink(textContent)
|
||||||
|
|
||||||
if (props.data.talk_type == 2) {
|
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)
|
textContent = textReplaceEmoji(textContent)
|
||||||
|
@ -124,48 +124,72 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
|
|
||||||
// 加载数据列表
|
// 加载数据列表
|
||||||
const load = async (params: Params) => {
|
const load = async (params: Params) => {
|
||||||
|
// 使用性能标记测量加载时间
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
talk_type: params.talk_type,
|
talk_type: params.talk_type,
|
||||||
receiver_id: params.receiver_id,
|
receiver_id: params.receiver_id,
|
||||||
cursor: loadConfig.cursor,
|
cursor: loadConfig.cursor,
|
||||||
limit: 30
|
limit: 30
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不是从本地数据库加载的,则设置加载状态为0(加载中)
|
// 如果不是从本地数据库加载的,则设置加载状态为0(加载中)
|
||||||
if (loadConfig.status !== 2 && loadConfig.status !== 3) {
|
if (loadConfig.status !== 2 && loadConfig.status !== 3) {
|
||||||
loadConfig.status = 0
|
loadConfig.status = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录当前滚动高度,用于后续保持滚动位置
|
||||||
let scrollHeight = 0
|
let scrollHeight = 0
|
||||||
const el = document.getElementById('imChatPanel')
|
const el = document.getElementById('imChatPanel')
|
||||||
if (el) {
|
if (el) {
|
||||||
scrollHeight = el.scrollHeight
|
scrollHeight = el.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发起网络请求获取服务器数据
|
||||||
const { data, code } = await ServeTalkRecords(request)
|
const { data, code } = await ServeTalkRecords(request)
|
||||||
|
|
||||||
|
// 处理请求失败的情况
|
||||||
if (code != 200) {
|
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 (
|
if (request.talk_type != loadConfig.talk_type || request.receiver_id != loadConfig.receiver_id) {
|
||||||
request.talk_type != loadConfig.talk_type ||
|
location.msgid = ''
|
||||||
request.receiver_id != loadConfig.receiver_id
|
return
|
||||||
) {
|
|
||||||
return (location.msgid = '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = (data.items || []).map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
// 优化:使用批量处理而不是map,减少内存分配
|
||||||
|
const serverItems = data.items || []
|
||||||
// 同步到本地数据库
|
const items = new Array(serverItems.length)
|
||||||
try {
|
for (let i = 0; i < serverItems.length; i++) {
|
||||||
const { batchAddOrUpdateMessages } = await import('@/utils/db')
|
items[i] = formatTalkRecord(uid, serverItems[i])
|
||||||
await batchAddOrUpdateMessages(data.items || [], params.talk_type, params.receiver_id, true, 'sequence')
|
|
||||||
console.log('聊天记录已同步到本地数据库')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('同步聊天记录到本地数据库失败:', error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到本地数据库(异步操作,不阻塞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
|
// 如果是从本地数据库加载的数据,且服务器返回的数据与本地数据相同,则不需要更新UI
|
||||||
if ((loadConfig.status === 2 || loadConfig.status === 3) && request.cursor === 0) {
|
if ((loadConfig.status === 2 || loadConfig.status === 3) && request.cursor === 0) {
|
||||||
try {
|
try {
|
||||||
|
const compareStartTime = performance.now()
|
||||||
|
|
||||||
// 获取最新的本地数据库消息进行比较
|
// 获取最新的本地数据库消息进行比较
|
||||||
const { getMessages } = await import('@/utils/db')
|
const { getMessages } = await import('@/utils/db')
|
||||||
const localMessages = await getMessages(
|
const localMessages = await getMessages(
|
||||||
@ -173,80 +197,121 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
uid,
|
uid,
|
||||||
params.receiver_id,
|
params.receiver_id,
|
||||||
items.length || 30, // 获取与服务器返回数量相同的消息
|
items.length || 30, // 获取与服务器返回数量相同的消息
|
||||||
0 // 从第一页开始
|
0, // 从第一页开始
|
||||||
|
'sequence' // 明确指定排序字段
|
||||||
)
|
)
|
||||||
|
|
||||||
// 格式化本地消息,确保与服务器消息结构一致
|
// 快速路径:如果本地消息数量与服务器不同,直接更新UI
|
||||||
const formattedLocalMessages = localMessages.map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
if (localMessages.length !== items.length) {
|
||||||
|
console.log('本地数据与服务器数据数量不一致,更新UI')
|
||||||
|
} else if (items.length > 0) {
|
||||||
// 改进比较逻辑:检查消息数量和所有消息的ID是否匹配
|
// 优化:使用位图标记需要更新的消息,减少内存使用
|
||||||
if (formattedLocalMessages.length === items.length && formattedLocalMessages.length > 0) {
|
const needsUpdate = new Uint8Array(items.length)
|
||||||
// 创建消息ID映射,用于快速查找
|
let updateCount = 0
|
||||||
|
|
||||||
|
// 优化:使用哈希表存储消息ID到索引的映射,加速查找
|
||||||
const serverMsgMap = new Map()
|
const serverMsgMap = new Map()
|
||||||
items.forEach(item => serverMsgMap.set(item.msg_id, item))
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
serverMsgMap.set(items[i].msg_id, i)
|
||||||
// 检查每条本地消息是否与服务器消息匹配
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优化:首先检查首尾消息,如果它们匹配,再使用抽样检查中间消息
|
||||||
|
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')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数据不一致,需要更新UI
|
|
||||||
console.log('本地数据与服务器数据不一致,更新UI')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('比较本地数据和服务器数据时出错:', error)
|
console.error('比较本地数据和服务器数据时出错:', error)
|
||||||
// 出错时默认更新UI
|
// 出错时默认更新UI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新UI
|
||||||
|
const updateUIStartTime = performance.now()
|
||||||
|
|
||||||
if (request.cursor == 0) {
|
if (request.cursor == 0) {
|
||||||
// 判断是否是初次加载
|
// 判断是否是初次加载
|
||||||
dialogueStore.clearDialogueRecord()
|
dialogueStore.clearDialogueRecord()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 反转消息顺序并添加到对话记录
|
||||||
dialogueStore.unshiftDialogueRecord(items.reverse())
|
dialogueStore.unshiftDialogueRecord(items.reverse())
|
||||||
|
|
||||||
|
// 更新加载状态
|
||||||
loadConfig.status = items.length >= request.limit ? 1 : 2
|
loadConfig.status = items.length >= request.limit ? 1 : 2
|
||||||
|
|
||||||
loadConfig.cursor = data.cursor
|
loadConfig.cursor = data.cursor
|
||||||
|
|
||||||
nextTick(() => {
|
// 使用requestAnimationFrame代替nextTick,提高滚动性能
|
||||||
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById('imChatPanel')
|
const el = document.getElementById('imChatPanel')
|
||||||
if (el) {
|
if (el) {
|
||||||
if (request.cursor == 0) {
|
if (request.cursor == 0) {
|
||||||
// el.scrollTop = el.scrollHeight
|
|
||||||
|
|
||||||
// setTimeout(() => {
|
|
||||||
// el.scrollTop = el.scrollHeight + 1000
|
|
||||||
// }, 500)
|
|
||||||
console.log('滚动到底部')
|
console.log('滚动到底部')
|
||||||
|
|
||||||
// 在初次加载完成后恢复上传任务
|
// 在初次加载完成后恢复上传任务
|
||||||
// 确保在所有聊天记录加载完成后再恢复上传任务
|
|
||||||
dialogueStore.restoreUploadTasks()
|
dialogueStore.restoreUploadTasks()
|
||||||
|
|
||||||
|
// 使用优化的滚动函数
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
} else {
|
} else {
|
||||||
|
// 保持滚动位置
|
||||||
el.scrollTop = el.scrollHeight - scrollHeight
|
el.scrollTop = el.scrollHeight - scrollHeight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果有需要定位的消息ID,执行定位
|
||||||
if (location.msgid) {
|
if (location.msgid) {
|
||||||
onJumpMessage(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))
|
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) => {
|
const loadFromLocalDB = async (params: Params) => {
|
||||||
try {
|
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 函数
|
// 导入 getMessages 函数
|
||||||
const { getMessages } = await import('@/utils/db')
|
const { getMessages } = await import('@/utils/db')
|
||||||
// 从本地数据库获取聊天记录
|
|
||||||
|
// 从本地数据库获取聊天记录,使用sequence作为排序字段以提高性能
|
||||||
const localMessages = await getMessages(
|
const localMessages = await getMessages(
|
||||||
params.talk_type,
|
params.talk_type,
|
||||||
uid,
|
uid,
|
||||||
params.receiver_id,
|
params.receiver_id,
|
||||||
params.limit || 30,
|
params.limit || 30,
|
||||||
0 // 从第一页开始
|
0, // 从第一页开始
|
||||||
// 不传入 maxSequence 参数,获取最新的消息
|
'sequence' // 明确指定排序字段
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果有本地数据
|
// 如果有本地数据
|
||||||
if (localMessages && localMessages.length > 0) {
|
if (localMessages && localMessages.length > 0) {
|
||||||
// 清空现有记录
|
// 清空现有记录
|
||||||
dialogueStore.clearDialogueRecord()
|
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)
|
dialogueStore.unshiftDialogueRecord(formattedMessages)
|
||||||
|
|
||||||
// 设置加载状态为完成(3表示从本地数据库加载完成)
|
// 设置加载状态为完成(3表示从本地数据库加载完成)
|
||||||
@ -290,17 +413,27 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
// 恢复上传任务
|
// 恢复上传任务
|
||||||
dialogueStore.restoreUploadTasks()
|
dialogueStore.restoreUploadTasks()
|
||||||
|
|
||||||
// 滚动到底部
|
// 使用requestAnimationFrame优化滚动性能
|
||||||
nextTick(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
console.log(`从本地数据库加载聊天记录耗时: ${(endTime - startTime).toFixed(2)}ms,加载了${localMessages.length}条记录`)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 无数据时清除缓存
|
||||||
|
localDBCache.key = ''
|
||||||
|
localDBCache.data = null
|
||||||
|
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('从本地数据库加载聊天记录失败:', error)
|
console.error('从本地数据库加载聊天记录失败:', error)
|
||||||
|
// 出错时清除缓存
|
||||||
|
localDBCache.key = ''
|
||||||
|
localDBCache.data = null
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -311,6 +444,10 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
* @param options 可选,{ specifiedMsg } 指定消息对象
|
* @param options 可选,{ specifiedMsg } 指定消息对象
|
||||||
*/
|
*/
|
||||||
const onLoad = async (params: Params, options?: LoadOptions) => {
|
const onLoad = async (params: Params, options?: LoadOptions) => {
|
||||||
|
// 使用性能标记测量加载时间
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
// 检查会话是否变更,如果变更则重置配置
|
||||||
if (
|
if (
|
||||||
params.talk_type !== loadConfig.talk_type ||
|
params.talk_type !== loadConfig.talk_type ||
|
||||||
params.receiver_id !== loadConfig.receiver_id
|
params.receiver_id !== loadConfig.receiver_id
|
||||||
@ -324,8 +461,10 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
|
|
||||||
// 新增:支持指定消息定位模式,参数以传入为准合并
|
// 新增:支持指定消息定位模式,参数以传入为准合并
|
||||||
if (options?.specifiedMsg?.cursor !== undefined) {
|
if (options?.specifiedMsg?.cursor !== undefined) {
|
||||||
|
// 特殊消息定位模式
|
||||||
loadConfig.specialParams = { ...options.specifiedMsg } // 记录特殊参数,供分页加载用
|
loadConfig.specialParams = { ...options.specifiedMsg } // 记录特殊参数,供分页加载用
|
||||||
loadConfig.status = 0 // 复用主流程 loading 状态
|
loadConfig.status = 0 // 复用主流程 loading 状态
|
||||||
|
|
||||||
// 以 params 为基础,合并 specifiedMsg 的所有字段(只要有就覆盖)
|
// 以 params 为基础,合并 specifiedMsg 的所有字段(只要有就覆盖)
|
||||||
const contextParams = {
|
const contextParams = {
|
||||||
...params,
|
...params,
|
||||||
@ -333,20 +472,36 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
}
|
}
|
||||||
//msg_id是用来做定位的,不做参数,所以这里清空
|
//msg_id是用来做定位的,不做参数,所以这里清空
|
||||||
contextParams.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) {
|
if (code !== 200) {
|
||||||
loadConfig.status = 2
|
loadConfig.status = 2
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 记录当前滚动高度
|
|
||||||
const el = document.getElementById('imChatPanel')
|
console.log('data', data)
|
||||||
const scrollHeight = el?.scrollHeight || 0
|
|
||||||
|
// 优化:使用批量处理而不是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) {
|
if (contextParams.direction === 'down' && !contextParams.type) {
|
||||||
dialogueStore.clearDialogueRecord()
|
dialogueStore.clearDialogueRecord()
|
||||||
}
|
}
|
||||||
const items = (data.items || []).map((item: ITalkRecord) => formatTalkRecord(uid, item))
|
|
||||||
if (contextParams.type && contextParams.type === 'loadMore') {
|
if (contextParams.type && contextParams.type === 'loadMore') {
|
||||||
dialogueStore.addDialogueRecordForLoadMore(items)
|
dialogueStore.addDialogueRecordForLoadMore(items)
|
||||||
} else {
|
} else {
|
||||||
@ -354,12 +509,14 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
contextParams.direction === 'down' ? items : items.reverse()
|
contextParams.direction === 'down' ? items : items.reverse()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
contextParams.direction === 'up' ||
|
contextParams.direction === 'up' ||
|
||||||
(contextParams.direction === 'down' && !contextParams.type)
|
(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
|
loadConfig.cursor = data.cursor
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 来确保在下一帧渲染前设置滚动位置
|
// 使用 requestAnimationFrame 来确保在下一帧渲染前设置滚动位置
|
||||||
@ -375,7 +532,7 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
} else if (contextParams.type && contextParams.type === 'loadMore') {
|
} else if (contextParams.type && contextParams.type === 'loadMore') {
|
||||||
// 如果是向下加载更多,保持目标消息在可视区域底部
|
// 如果是向下加载更多,保持目标消息在可视区域底部
|
||||||
// 使用可视区域高度来调整,而不是新内容的总高度
|
// 使用可视区域高度来调整,而不是新内容的总高度
|
||||||
nextTick(() => {
|
requestAnimationFrame(() => { // 使用requestAnimationFrame替代nextTick
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollTop = scrollHeight - el.clientHeight
|
el.scrollTop = scrollHeight - el.clientHeight
|
||||||
}
|
}
|
||||||
@ -383,8 +540,8 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
} else if (target && msgId) {
|
} else if (target && msgId) {
|
||||||
// 只有在有目标元素且有 msg_id 时才执行定位逻辑
|
// 只有在有目标元素且有 msg_id 时才执行定位逻辑
|
||||||
// 如果是定位到特定消息,计算并滚动到目标位置
|
// 如果是定位到特定消息,计算并滚动到目标位置
|
||||||
// 使用 nextTick 确保 DOM 完全渲染后再计算位置
|
// 使用 requestAnimationFrame 确保 DOM 完全渲染后再计算位置
|
||||||
nextTick(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById('imChatPanel')
|
const el = document.getElementById('imChatPanel')
|
||||||
const target = document.getElementById(msgId)
|
const target = document.getElementById(msgId)
|
||||||
|
|
||||||
@ -431,23 +588,39 @@ export const useTalkRecord = (uid: number) => {
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
console.log(`特殊消息定位模式加载耗时: ${(endTime - startTime).toFixed(2)}ms`)
|
||||||
})
|
})
|
||||||
})
|
} catch (error) {
|
||||||
|
console.error('特殊消息定位模式加载失败:', error)
|
||||||
|
loadConfig.status = 2
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 普通模式
|
||||||
loadConfig.specialParams = undefined // 普通模式清空
|
loadConfig.specialParams = undefined // 普通模式清空
|
||||||
|
|
||||||
// 设置初始加载状态为0(加载中)
|
// 设置初始加载状态为0(加载中)
|
||||||
loadConfig.status = 0
|
loadConfig.status = 0
|
||||||
|
|
||||||
// 先从本地数据库加载数据
|
// 使用Promise.all并行处理本地数据库加载和网络请求准备
|
||||||
const hasLocalData = await loadFromLocalDB(params)
|
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() {
|
export function getAccessToken() {
|
||||||
// return storage.get(AccessToken) || ''
|
// return storage.get(AccessToken) || ''
|
||||||
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d22eec7a138bb20774ef183e109945229d43e1f63fb01cdee46f5f663037f4ed946a0c04441b1f642c945d218180e84e91d272dc621be157602785ef226dd21b9b6c92c292bc73be90fad0320bad0812e11'
|
return JSON.parse(localStorage.getItem('token'))||'46d71a72d8d845ad7ed23eba9bdde260e635407190c2ce1bf7fd22088e41682ea07773ec65cae8946d2003f264d55961f96e0fc5da10eb96d3a348c1664e9644ce2108c311309f398ae8ea1b8200bfd490e5cb6e8c52c9e5d493cbabb163368f8351420451a631dbfa749829ee4cda49b77b5ed2d3dced5d0f2b7dd9ee76ba5465c84a17c23af040cd92b6b2a4ea48befbb5c729dcdad0a9c9668befe84074cc24f78899c1d947f8e7f94c7eda5325b8ed698df729e76febb98549ef3482ae942fb4f4a1c92d21836fa784728f0c5483aab2760a991b6b36e6b10c84f840a6433a6ecc31dee36e8f1c6158818bc89d220365eb2ca93ef31880576e2aa3ca8c45a705b447d40e300a54644829e2da528ea463bd2581a396336ed74880960d35716f5f7594e5b8cbb597027c6133b97b12df23427ca728fd2625977a0658ab470d'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
131
src/utils/db.js
131
src/utils/db.js
@ -114,31 +114,71 @@ export async function addMessage(message) {
|
|||||||
/**
|
/**
|
||||||
* 批量添加或更新聊天记录
|
* 批量添加或更新聊天记录
|
||||||
* @param {Array<object>} messages - 消息对象数组
|
* @param {Array<object>} messages - 消息对象数组
|
||||||
|
* @param {number} talkType - 会话类型
|
||||||
|
* @param {number} receiverId - 接收者ID
|
||||||
|
* @param {boolean} [updateConversation=true] - 是否更新会话信息
|
||||||
|
* @param {string} [sortField='created_at'] - 排序字段
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function batchAddOrUpdateMessages(messages) {
|
export async function batchAddOrUpdateMessages(messages, talkType, receiverId, updateConversation = true, sortField = 'created_at') {
|
||||||
try {
|
try {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) {
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesToStore = messages.map(message => {
|
// 使用批处理优化性能
|
||||||
if (!message.msg_id) {
|
return await db.transaction('rw', db.messages, db.conversations, async () => {
|
||||||
message.msg_id = generateUUID();
|
// 预处理消息数据,避免在循环中多次创建对象
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('批量添加或更新消息失败:', error);
|
console.error('批量添加或更新消息失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -152,35 +192,78 @@ export async function batchAddOrUpdateMessages(messages) {
|
|||||||
* @param {number} receiverId - 接收者ID (私聊为对方用户ID,群聊为群ID)
|
* @param {number} receiverId - 接收者ID (私聊为对方用户ID,群聊为群ID)
|
||||||
* @param {number} [limit=30] - 限制返回的记录数量
|
* @param {number} [limit=30] - 限制返回的记录数量
|
||||||
* @param {number|null} [maxSequence=null] - 最大sequence值,用于分页加载更早的消息
|
* @param {number|null} [maxSequence=null] - 最大sequence值,用于分页加载更早的消息
|
||||||
|
* @param {string} [sortField='sequence'] - 排序字段,默认按sequence排序
|
||||||
* @returns {Promise<Array<object>>} 消息列表 (按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 {
|
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;
|
let collection;
|
||||||
|
|
||||||
|
// 优化查询策略
|
||||||
if (maxSequence !== null) {
|
if (maxSequence !== null) {
|
||||||
// 加载更多:查询 sequence 小于 maxSequence 的消息
|
// 加载更多:查询 sequence 小于 maxSequence 的消息
|
||||||
|
// 使用复合索引优化查询
|
||||||
collection = db.messages
|
collection = db.messages
|
||||||
.where('[talk_type+receiver_id+sequence]')
|
.where('[talk_type+receiver_id+sequence]')
|
||||||
.between([talkType, receiverId, 0], [talkType, receiverId, maxSequence], true, false);
|
.between([talkType, receiverId, 0], [talkType, receiverId, maxSequence], true, false);
|
||||||
} else {
|
} 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() - 限制数量,实现分页
|
let messages;
|
||||||
// 3. toArray() - 执行查询
|
if (sortField === 'sequence') {
|
||||||
const messages = await collection.reverse().limit(limit).toArray();
|
// 使用sequence字段排序(默认)
|
||||||
|
// 1. reverse() - 利用索引倒序排列,获取最新的消息
|
||||||
// 再次 reverse() - 将获取到的分页消息按时间正序排列,以便于在界面上显示
|
// 2. limit() - 限制数量,实现分页
|
||||||
return messages.reverse();
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存查询结果
|
||||||
|
messageCache.set(cacheKey, {
|
||||||
|
data: messages,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取消息失败:', error);
|
console.error('获取消息失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 简单的内存缓存实现
|
||||||
|
const messageCache = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记指定会话的所有消息为已读
|
* 标记指定会话的所有消息为已读
|
||||||
* @param {number} talkType - 会话类型
|
* @param {number} talkType - 会话类型
|
||||||
|
@ -42,9 +42,9 @@ export function textReplaceLink(text, color = '#409eff') {
|
|||||||
* @param {String} text 文本
|
* @param {String} text 文本
|
||||||
* @param {String} color 超链接颜色
|
* @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 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