chat-pc/src/components/editor/Editor.vue
Phoenix ef0eb903a7 feat(消息组件): 优化图片消息上传体验并修复文件扩展名获取
1. 在图片消息组件中添加上传进度显示和加载状态
2. 重构图片上传逻辑,先显示本地预览再上传
3. 修复文件消息组件中从文件名获取扩展名改为从文件路径获取
4. 根据消息浮动方向调整提及文本颜色

重构了图片上传流程,现在会先显示本地预览图片,然后在上传过程中显示进度条。同时修复了文件扩展名获取逻辑,现在从文件路径而非文件名获取扩展名。优化了提及文本的颜色显示,使其根据消息浮动方向(左/右)显示不同颜色。
2025-06-05 14:13:50 +08:00

856 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
// 引入Quill编辑器的样式文件
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// 引入图片上传插件的样式
import 'quill-image-uploader/dist/quill.imageUploader.min.css'
// 引入自定义的提及功能样式
import '@/assets/css/editor-mention.less'
// 引入Vue核心功能
import { reactive, watch, ref, markRaw, computed, onMounted, onUnmounted } from 'vue'
// 引入Naive UI的弹出框组件
import { NPopover } from 'naive-ui'
// 引入图标组件
import {
Voice as IconVoice, // 语音图标
SourceCode, // 代码图标
Local, // 地理位置图标
SmilingFace, // 表情图标
Pic, // 图片图标
FolderUpload, // 文件上传图标
Ranking, // 排名图标(用于投票)
History // 历史记录图标
} from '@icon-park/vue-next'
// 引入Quill编辑器及其核心实例
import { QuillEditor, Quill } from '@vueup/vue-quill'
// 引入图片上传插件
import ImageUploader from 'quill-image-uploader'
// 引入自定义表情符号格式
import EmojiBlot from './formats/emoji'
// 引入自定义引用格式
import QuoteBlot from './formats/quote'
// 引入提及功能
import 'quill-mention'
// 引入状态管理
import { useDialogueStore, useEditorDraftStore } from '@/store'
// 引入编辑器工具函数
import { deltaToMessage, deltaToString, isEmptyDelta } from './util'
// 引入获取图片信息的工具函数
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 { ServeUploadImage } from '@/api/upload'
import { uploadImg } from '@/api/upload'
// 引入事件总线钩子
import { useEventBus } from '@/hooks'
// 注册Quill编辑器的自定义格式
Quill.register('formats/emoji', EmojiBlot) // 注册表情格式
Quill.register('formats/quote', QuoteBlot) // 注册引用格式
Quill.register('modules/imageUploader', ImageUploader) // 注册图片上传模块
// 定义组件的事件
const emit = defineEmits(['editor-event'])
// 获取对话状态管理
const dialogueStore = useDialogueStore()
// 获取编辑器草稿状态管理
const editorDraftStore = useEditorDraftStore()
// 定义组件props
const props = defineProps({
vote: {
type: Boolean,
default: false // 是否显示投票功能
},
members: {
default: () => [] // 聊天成员列表,用于@功能
}
})
// 编辑器引用
const editor = ref()
// 获取Quill编辑器实例
const getQuill = () => {
return editor.value?.getQuill()
}
// 获取当前编辑器光标位置
const getQuillSelectionIndex = () => {
let quill = getQuill()
return (quill.getSelection() || {}).index || quill.getLength()
}
// 计算当前对话索引名称(标识当前聊天)
const indexName = computed(() => dialogueStore.index_name)
// 控制是否显示编辑器的投票界面
const isShowEditorVote = ref(false)
// 控制是否显示编辑器的代码界面
const isShowEditorCode = ref(false)
// 控制是否显示录音界面
const isShowEditorRecorder = ref(false)
// 图片文件上传DOM引用
const fileImageRef = ref()
// 文件上传DOM引用
const uploadFileRef = ref()
// 表情面板引用
const emoticonRef = ref()
// 编辑器配置选项
const editorOption = {
debug: false,
modules: {
toolbar: false, // 禁用默认工具栏
clipboard: {
// 粘贴处理,去除粘贴时的自带样式
matchers: [[Node.ELEMENT_NODE, onClipboardMatcher]]
},
keyboard: {
bindings: {
enter: {
key: 13,
handler: onSendMessage // 按Enter键发送消息
}
}
},
// 图片上传配置
imageUploader: {
upload: onEditorUpload
},
// @功能配置
mention: {
allowedChars: /^[\u4e00-\u9fa5]*$/, // 允许中文字符
mentionDenotationChars: ['@'], // @符号触发
positioningStrategy: 'fixed', // 定位策略
// 渲染@项目的函数
renderItem: (data: any) => {
const el = document.createElement('div')
el.className = 'ed-member-item'
el.innerHTML = `<img src="${data.avatar}" class="avator"/>`
el.innerHTML += `<span class="nickname">${data.nickname}</span>`
return el
},
// 数据源函数,过滤匹配的用户
source: function (searchTerm: string, renderList: any) {
console.log("source")
if (!props.members.length) {
return renderList([])
}
let list = [
...props.members
] as any
if((dialogueStore.groupInfo as any).is_manager){
list.unshift({ id: 0, nickname: '所有人', avatar: defAvatar, value: '所有人' })
}
const items = list.filter(
(item: any) => item.nickname.toLowerCase().indexOf(searchTerm) !== -1
)
renderList(items)
},
mentionContainerClass: 'ql-mention-list-container me-scrollbar me-scrollbar-thumb'
}
},
placeholder: '按Enter发送 / Shift+Enter 换行',
theme: 'snow' // 使用snow主题
}
// 底部工具栏配置
const navs = reactive([
{
title: '图片',
icon: markRaw(Pic),
show: true,
click: () => {
fileImageRef.value.click() // 触发图片上传
}
},
{
title: '文件',
icon: markRaw(FolderUpload),
show: true,
click: () => {
uploadFileRef.value.click() // 触发文件上传
}
},
// 以下功能已被注释掉,但保留代码
// {
// title: '代码',
// icon: markRaw(SourceCode),
// show: true,
// click: () => {
// isShowEditorCode.value = true
// }
// },
// {
// title: '语音消息',
// icon: markRaw(IconVoice),
// show: true,
// click: () => {
// isShowEditorRecorder.value = true
// }
// },
// {
// title: '地理位置',
// icon: markRaw(Local),
// show: true,
// click: () => {}
// },
// {
// title: '群投票',
// icon: markRaw(Ranking),
// show: computed(() => props.vote),
// click: () => {
// isShowEditorVote.value = true
// }
// },
// {
// title: '历史记录',
// icon: markRaw(History),
// show: true,
// click: () => {
// emit('editor-event', emitCall('history_event'))
// }
// }
])
/**
* 上传图片函数
* @param file 文件对象
* @returns Promise成功时返回图片URL
*/
function onUploadImage(file: File) {
return new Promise((resolve) => {
let image = new Image()
image.src = URL.createObjectURL(file)
image.onload = () => {
const form = new FormData()
form.append('file', file)
form.append("source", "fonchain-chat"); // 图片来源标识
// 添加图片尺寸信息作为URL参数
form.append("urlParam", `width=${image.width}&height=${image.height}`);
// 调用上传API
uploadImg(form).then(({ code, data, message }) => {
if (code == 0) {
resolve(data.ori_url) // 返回原始图片URL
} else {
resolve('')
window['$message'].error(message) // 显示错误信息
}
})
}
})
}
/**
* 编辑器上传处理函数
* @param file 要上传的文件
* @returns Promise
*/
function onEditorUpload(file: File) {
async function fn(file: File, resolve: Function, reject: Function) {
if (file.type.indexOf('image/') === 0) {
// 如果是图片,使用图片上传处理
return resolve(await onUploadImage(file))
}
reject()
// 非图片文件的处理
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)
}
}
return new Promise((resolve, reject) => {
fn(file, resolve, reject)
})
}
/**
* 投票事件处理
* @param data 投票数据
*/
function onVoteEvent(data: any) {
const msg = emitCall('vote_event', data, (ok: boolean) => {
if (ok) {
isShowEditorVote.value = false // 成功后关闭投票界面
}
})
emit('editor-event', msg)
}
/**
* 表情事件处理
* @param data 表情数据
*/
function onEmoticonEvent(data: any) {
emoticonRef.value.setShow(false) // 关闭表情面板
if (data.type == 1) {
// 插入文本表情
const quill = getQuill()
let index = getQuillSelectionIndex()
// 删除编辑器中多余的换行符
if (index == 1 && quill.getLength() == 1 && quill.getText(0, 1) == '\n') {
quill.deleteText(0, 1)
index = 0
}
if (data.img) {
// 插入图片表情
quill.insertEmbed(index, 'emoji', {
alt: data.value,
src: data.img,
width: '24px',
height: '24px'
})
} else {
// 插入文本表情
quill.insertText(index, data.value)
}
// 设置光标位置
quill.setSelection(index + 1, 0, 'user')
} else {
// 发送整个表情包
let fn = emitCall('emoticon_event', data.value, () => {})
emit('editor-event', fn)
}
}
/**
* 代码事件处理
* @param data 代码数据
*/
function onCodeEvent(data: any) {
const msg = emitCall('code_event', data, (ok: boolean) => {
isShowEditorCode.value = false // 成功后关闭代码界面
})
emit('editor-event', msg)
}
/**
* 文件上传处理
* @param e 上传事件对象
*/
async function onUploadFile(e: any) {
let file = e.target.files[0]
e.target.value = null // 清空input允许再次选择相同文件
console.log("文件类型"+file.type)
if (file.type.indexOf('image/') === 0) {
console.log("进入图片")
// 处理图片文件 - 立即显示临时消息,然后上传
let fn = emitCall('image_event', file, () => {})
emit('editor-event', fn)
return
}
if (file.type.indexOf('video/') === 0) {
console.log("进入视频")
// 处理视频文件
let fn = emitCall('video_event', file, () => {})
emit('editor-event', fn)
} else {
console.log("进入其他")
// 处理其他类型文件
let fn = emitCall('file_event', file, () => {})
emit('editor-event', fn)
}
}
/**
* 录音事件处理
* @param file 录音文件
*/
function onRecorderEvent(file: any) {
emit('editor-event', emitCall('file_event', file))
isShowEditorRecorder.value = false // 关闭录音界面
}
/**
* 粘贴内容处理,移除粘贴内容中的样式
* @param node DOM节点
* @param Delta Quill Delta对象
* @returns 处理后的Delta
*/
function onClipboardMatcher(node: any, Delta) {
const ops: any[] = []
Delta.ops.forEach((op) => {
// 处理粘贴内容
if (op.insert && typeof op.insert === 'string') {
ops.push({
insert: op.insert, // 文字内容
attributes: {} // 移除所有样式
})
} else {
ops.push(op)
}
})
Delta.ops = ops
return Delta
}
/**
* 发送消息处理
* 根据编辑器内容类型发送不同类型的消息
*/
function onSendMessage() {
var delta = getQuill().getContents()
let data = deltaToMessage(delta) // 转换Delta为消息格式
if (data.items.length === 0||!data.items[0].content.trim()) {
return // 没有内容不发送
}
switch (data.msgType) {
case 1: // 文字消息
if (data.items[0].content.length > 1024) {
return window['$message'].info('发送内容超长,请分条发送')
}
// 发送文本消息
emit(
'editor-event',
emitCall('text_event', data, (ok: any) => {
ok && getQuill().setContents([], Quill.sources.USER) // 成功发送后清空编辑器
})
)
break
case 3: // 图片消息
// 发送图片消息
emit(
'editor-event',
emitCall(
'image_event',
{ ...getImageInfo(data.items[0].content), url: data.items[0].content, size: 10000 },
(ok: any) => {
ok && getQuill().setContents([]) // 成功发送后清空编辑器
}
)
)
break
case 12: // 图文混合消息
// 发送混合消息
emit(
'editor-event',
emitCall('mixed_event', data, (ok: any) => {
ok && getQuill().setContents([]) // 成功发送后清空编辑器
})
)
break
}
}
/**
* 编辑器内容改变时的处理
* 保存草稿并触发输入事件
*/
function onEditorChange() {
let delta = getQuill().getContents()
let text = deltaToString(delta) // 将Delta转为纯文本
if (!isEmptyDelta(delta)) {
// 保存草稿到store
editorDraftStore.items[indexName.value || ''] = JSON.stringify({
text: text,
ops: delta.ops
})
} else {
// 编辑器为空时删除对应草稿
delete editorDraftStore.items[indexName.value || '']
}
// 触发输入事件
emit('editor-event', emitCall('input_event', text))
}
/**
* 加载编辑器草稿内容
* 当切换聊天对象时,加载对应的草稿
*/
function loadEditorDraftText() {
if (!editor.value) return
// 延迟处理确保DOM已渲染
setTimeout(() => {
hideMentionDom() // 隐藏@菜单
const quill = getQuill()
if (!quill) return
// 从缓存中加载编辑器草稿
let draft = editorDraftStore.items[indexName.value || '']
if (draft) {
quill.setContents(JSON.parse(draft)?.ops || [])
} else {
quill.setContents([]) // 没有草稿则清空编辑器
}
// 设置光标位置到末尾
const index = getQuillSelectionIndex()
quill.setSelection(index, 0, 'user')
}, 0)
}
/**
* 处理@成员事件
* @param data @成员数据
*/
function onSubscribeMention(data: any) {
const mention = getQuill().getModule('mention')
// 插入@项
mention.insertItem({ id: data?.id, denotationChar: '@', value: data.value }, true)
}
/**
* 处理引用事件
* @param data 引用数据
*/
function onSubscribeQuote(data: any) {
// 检查是否已有引用内容
const delta = getQuill().getContents()
if (delta.ops?.some((item: any) => item.insert.quote)) {
return // 已有引用则不再添加
}
const quill = getQuill()
const index = getQuillSelectionIndex()
// 在编辑器开头插入引用
quill.insertEmbed(0, 'quote', data)
quill.setSelection(index + 1, 0, 'user') // 设置光标到引用后
}
/**
* 隐藏@成员DOM元素
*/
function hideMentionDom() {
let el = document.querySelector('.ql-mention-list-container')
if (el) {
document.querySelector('body')?.removeChild(el)
}
}
/**
* 处理编辑消息事件
* @param data 消息数据
*/
function onSubscribeEdit(data: any) {
const quill = getQuill()
if (!quill) return
// 清空当前编辑器内容
quill.setContents([])
// 插入要编辑的文本内容
quill.setText(data.content)
// 设置光标位置到末尾
const index = quill.getLength() - 1
quill.setSelection(index > 0 ? index : 0, 0, 'user')
}
// 监听聊天索引变化,切换聊天时加载对应草稿
watch(indexName, loadEditorDraftText, { immediate: true })
// 组件挂载时初始化
onMounted(() => {
loadEditorDraftText()
})
// 组件卸载时清理
onUnmounted(() => {
hideMentionDom()
})
// 订阅编辑器相关事件总线事件
useEventBus([
{ 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"
raw
:show-arrow="false"
:width="300"
ref="emoticonRef"
style="width: 500px; height: 250px; border-radius: 10px; overflow: hidden"
>
<template #trigger>
<div class="item pointer">
<n-icon size="18" class="icon" :component="SmilingFace" />
<p class="tip-title">表情符号</p>
</div>
</template>
<MeEditorEmoticon @on-select="onEmoticonEvent" />
</n-popover>
<!-- 工具栏其他功能按钮 -->
<div
class="item pointer"
v-for="nav in navs"
:key="nav.title"
v-show="nav.show"
@click="nav.click"
>
<n-icon size="18" class="icon" :component="nav.icon" />
<p class="tip-title">{{ nav.title }}</p>
</div>
</div>
</header>
<!-- 编辑器主体区域 -->
<main class="el-main height100">
<QuillEditor
ref="editor"
id="editor"
:options="editorOption"
@editorChange="onEditorChange"
style="height: 100%; border: none"
/>
</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
v-if="isShowEditorCode"
@on-submit="onCodeEvent"
@close="isShowEditorCode = false"
/>
<MeEditorRecorder
v-if="isShowEditorRecorder"
@on-submit="onRecorderEvent"
@close="isShowEditorRecorder = false"
/>
</template>
<style lang="less" scoped>
.editor {
--tip-bg-color: rgb(241 241 241 / 90%); /* 提示背景颜色 */
height: 100%;
.toolbar {
height: 38px;
display: flex;
.tools {
height: 100%;
flex: auto;
display: flex;
.item {
display: flex;
align-items: center;
justify-content: center;
width: 35px;
margin: 0 2px;
position: relative;
user-select: none;
.tip-title {
display: none; /* 默认隐藏提示文字 */
position: absolute;
top: 40px;
left: 0px;
line-height: 26px;
background-color: var(--tip-bg-color);
color: var(--im-text-color);
min-width: 20px;
font-size: 12px;
padding: 0 5px;
border-radius: 2px;
white-space: pre;
user-select: none;
z-index: 999999999999;
}
&:hover {
.tip-title {
display: block; /* 悬停时显示提示文字 */
}
}
}
}
}
}
/* 暗色模式样式调整 */
html[theme-mode='dark'] {
.editor {
--tip-bg-color: #48484d;
}
}
</style>
<style lang="less">
/* 全局编辑器样式 */
#editor {
overflow: hidden;
}
/* 编辑器主体区域样式 */
.ql-editor {
padding: 8px;
/* 滚动条样式 */
&::-webkit-scrollbar {
width: 3px;
height: 3px;
background-color: unset;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: transparent;
}
/* 悬停时显示滚动条 */
&:hover {
&::-webkit-scrollbar-thumb {
background-color: var(--im-scrollbar-thumb);
}
}
}
/* 编辑器占位符样式 */
.ql-editor.ql-blank::before {
font-family:
PingFang SC,
Microsoft YaHei,
'Alibaba PuHuiTi 2.0 45' !important;
left: 8px;
}
/* 编辑器中图片样式 */
.ql-snow .ql-editor img {
max-width: 100px;
border-radius: 3px;
background-color: #48484d;
margin: 0px 2px;
}
/* 图片上传中样式 */
.image-uploading {
display: flex;
width: 100px;
height: 100px;
background: #f5f5f5;
border-radius: 5px;
img {
filter: unset;
display: none;
}
}
/* 表情符号样式 */
.ed-emoji {
background-color: unset !important;
}
/* 编辑器占位符样式 */
.ql-editor.ql-blank::before {
font-style: unset;
color: #b8b3b3;
}
/* 引用卡片样式 */
.quote-card-content {
display: flex;
background-color: #f6f6f6;
flex-direction: column;
padding: 8px;
margin-bottom: 5px;
cursor: pointer;
user-select: none;
.quote-card-title {
height: 22px;
line-height: 22px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
justify-content: space-between;
.quote-card-remove {
margin-right: 15px;
font-size: 18px;
}
}
.quote-card-meta {
margin-top: 4px;
font-size: 12px;
line-height: 20px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* 暗色模式下的样式调整 */
html[theme-mode='dark'] {
.ql-editor.ql-blank::before {
color: #57575a;
}
.quote-card-content {
background-color: var(--im-message-bg-color);
}
}
</style>