Compare commits

...

9 Commits

Author SHA1 Message Date
Phoenix
e5a5b36dcc Merge branch 'dev' of http://172.16.100.91:3000/scout666/chat-pc into dev 2025-07-07 10:26:52 +08:00
Phoenix
b18a1b2604 refactor(TiptapEditor): 清理代码注释和格式化代码 2025-07-07 10:26:09 +08:00
Phoenix
3f89777bf8 fix(TextMessage): 修复提及文本颜色在右侧浮动时的显示问题 2025-07-07 09:44:39 +08:00
Phoenix
a05d637bd2 style(编辑器): 调整提及样式颜色和背景以适配深色主题
统一编辑器与消息组件中的提及样式,使用主题变量并增加内边距
2025-07-07 09:43:43 +08:00
Phoenix
3363f23ad3 feat(editor): 添加提及功能组件并使用floating-ui优化定位
添加提及功能的Vue组件和实现逻辑,使用@floating-ui/dom库优化弹窗定位
重构Tiptap编辑器的提及功能实现,将逻辑抽离为独立组件
更新package.json添加@floating-ui/dom依赖
2025-07-07 09:25:49 +08:00
Phoenix
c3abd733ad refactor(useTalkRecord): 简化消息比较逻辑,改为全量检查
根据用户建议,只比较msg_id和is_revoke字段,并改为全量检查所有消息。因为消息ID是唯一的,且一次只有30条消息,全量检查不会带来太大性能负担。
2025-07-03 14:13:09 +08:00
Phoenix
0b8de6f5c2 计算优化 2025-07-03 13:22:01 +08:00
Phoenix
cc5cf41ad1 Merge branch 'main' into xingyy 2025-07-03 11:33:25 +08:00
Phoenix
cd8f1ce311 Merge branch 'xingyy' 2025-07-03 11:29:45 +08:00
10 changed files with 778 additions and 435 deletions

View File

@ -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",

View File

@ -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':

View 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>

View File

@ -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) => {
// EnterShift
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>

View 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()
},
}
},
}

View File

@ -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)

View File

@ -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))
// 改进比较逻辑检查消息数量和所有消息的ID是否匹配
if (formattedLocalMessages.length === items.length && formattedLocalMessages.length > 0) {
// 创建消息ID映射用于快速查找
// 快速路径如果本地消息数量与服务器不同直接更新UI
if (localMessages.length !== items.length) {
console.log('本地数据与服务器数据数量不一致更新UI')
} else if (items.length > 0) {
// 优化:使用位图标记需要更新的消息,减少内存使用
const needsUpdate = new Uint8Array(items.length)
let updateCount = 0
// 优化使用哈希表存储消息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)
}
// 优化:首先检查首尾消息,如果它们匹配,再使用抽样检查中间消息
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) {
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)
// 无论是否有本地数据,都从服务器获取最新数据
// 原有逻辑
console.log('onLoad()执行load')
load(params)
// 使用Promise.all并行处理本地数据库加载和网络请求准备
try {
// 先从本地数据库加载数据
const hasLocalData = await loadFromLocalDB(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
}
}
// 向上加载更多(兼容特殊参数模式)

View File

@ -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'
}
/**

View File

@ -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();
// 再次 reverse() - 将获取到的分页消息按时间正序排列,以便于在界面上显示
return messages.reverse();
// 优化:根据排序字段选择最优索引
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();
}
// 缓存查询结果
messageCache.set(cacheKey, {
data: messages,
timestamp: Date.now()
});
return messages;
} catch (error) {
console.error('获取消息失败:', error);
throw error;
}
}
// 简单的内存缓存实现
const messageCache = new Map();
/**
* 标记指定会话的所有消息为已读
* @param {number} talkType - 会话类型

View File

@ -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>`
})
}