You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1322 lines
37 KiB
1322 lines
37 KiB
<template> |
|
<div class="message-editor"> |
|
<!-- Quill 编辑区 + 右下角发送按钮 容器 --> |
|
<div class="quill-wrap"> |
|
<div ref="quillEditor" class="quill-container"></div> |
|
<!-- 右下角发送区域 --> |
|
<!-- 工具栏 --> |
|
<div class="editor-toolbar"> |
|
<div class="editor-toolbar-left"> |
|
<el-button |
|
type="text" |
|
icon="el-icon-folder-opened" |
|
@click="handleFileClick" |
|
title="文件" |
|
/> |
|
<el-button |
|
type="text" |
|
icon="el-icon-scissors" |
|
@click="handleScreenshotClick" |
|
title="截屏" |
|
/> |
|
<el-dropdown @command="handleFontSizeChange"> |
|
<el-button type="text" style="font-size: 18px">A</el-button> |
|
<el-dropdown-menu slot="dropdown"> |
|
<el-dropdown-item command="10">10</el-dropdown-item> |
|
<el-dropdown-item command="11">11</el-dropdown-item> |
|
<el-dropdown-item command="12">12</el-dropdown-item> |
|
<el-dropdown-item command="13">13</el-dropdown-item> |
|
<el-dropdown-item command="14">14</el-dropdown-item> |
|
</el-dropdown-menu> |
|
</el-dropdown> |
|
<el-dropdown |
|
ref="atDropdown" |
|
@command="handleAtSelect" |
|
v-if="currentChat.scene == 4 && groupInfo" |
|
> |
|
<el-button type="text" style="font-size: 16px">@</el-button> |
|
<el-dropdown-menu |
|
slot="dropdown" |
|
style="height: 200px; overflow-y: auto" |
|
> |
|
<el-dropdown-item |
|
:command="{ id: 0, name: 'all' }" |
|
style=" |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
padding-top: 8px; |
|
padding-bottom: 8px; |
|
" |
|
> |
|
<div style="display: flex; align-items: center; gap: 8px"> |
|
<el-avatar :size="38" icon="el-icon-user-solid" /> |
|
所有人 |
|
</div> |
|
</el-dropdown-item> |
|
<el-dropdown-item |
|
:command="item" |
|
v-for="item in groupInfo.user_list" |
|
:key="item.id" |
|
style=" |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
padding-top: 8px; |
|
padding-bottom: 8px; |
|
" |
|
> |
|
<div style="display: flex; align-items: center; gap: 8px"> |
|
<el-avatar |
|
:size="38" |
|
:src=" |
|
$store.state.user.netConfig.MINIO_ENDPOINT_HTTPS + |
|
item.avatar |
|
" |
|
/> |
|
{{ item.name }} |
|
</div> |
|
<div style="color: #8492a6"> |
|
{{ item.role_name }} |
|
</div> |
|
</el-dropdown-item> |
|
</el-dropdown-menu> |
|
</el-dropdown> |
|
</div> |
|
<div class="editor-toolbar-right"> |
|
<el-button |
|
type="text" |
|
icon="el-icon-video-camera" |
|
@click="handleTeachingClick(1)" |
|
title="发起教学" |
|
v-if="currentChat.scene == ContactsScene.GROUP" |
|
/> |
|
<el-button |
|
type="text" |
|
icon="el-icon-video-camera-solid" |
|
@click="handleTeachingClick(9)" |
|
title="发起会诊" |
|
v-if="currentChat.scene == ContactsScene.PRIVATE" |
|
/> |
|
<el-button |
|
type="primary" |
|
size="mini" |
|
:loading="sending" |
|
@click="handleSendText" |
|
> |
|
发送 |
|
</el-button> |
|
<el-dropdown @command="handleSendModeChange"> |
|
<el-button type="text"> |
|
{{ sendModeLabel }}<i class="el-icon-arrow-down el-icon--right" /> |
|
</el-button> |
|
<el-dropdown-menu slot="dropdown"> |
|
<el-dropdown-item command="enter">Enter发送</el-dropdown-item> |
|
<el-dropdown-item command="ctrlEnter"> |
|
Ctrl+Enter发送 |
|
</el-dropdown-item> |
|
</el-dropdown-menu> |
|
</el-dropdown> |
|
</div> |
|
</div> |
|
</div> |
|
<!-- 隐藏文件上传 --> |
|
<input |
|
ref="fileInput" |
|
type="file" |
|
style="display: none" |
|
accept="image/*,video/*,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,.zip,.rar,.txt" |
|
@change="(e) => handleFileChange(e)" |
|
/> |
|
</div> |
|
</template> |
|
|
|
<script> |
|
import { mapGetters } from "vuex"; |
|
import Quill from "quill"; |
|
import "quill/dist/quill.core.css"; |
|
import "quill/dist/quill.snow.css"; |
|
import { sendPrivateMessage, sendMultiChatMessage } from "@/api/message"; |
|
import { uploadFile } from "@/utils/requestMinio"; |
|
import { takeScreenshot } from "@/utils/screenshot"; |
|
import { |
|
MessageType, |
|
ContactsScene, |
|
MinioPaths, |
|
MqttTopics, |
|
} from "@/utils/constants"; |
|
import { meetingModes } from "@/api/videoCommunication"; |
|
export default { |
|
name: "MessageEditor", |
|
props: { |
|
groupInfo: Object, |
|
}, |
|
|
|
data() { |
|
return { |
|
MessageType, |
|
ContactsScene, |
|
quill: null, |
|
currentRange: null, |
|
sending: false, |
|
maxFileSize: 100 * 1024 * 1024, |
|
sendMode: this.getSystemSendMode(), |
|
fontSizeMap: { |
|
10: "small", |
|
11: "small", |
|
12: false, |
|
13: "large", |
|
14: "large", |
|
}, |
|
// 记录被@的用户 |
|
selectedAtUsers: [], |
|
meetingModes: meetingModes(), |
|
// Quill初始化重试次数 |
|
quillInitRetryCount: 0, |
|
// 存储编辑器中的文件信息 |
|
editorFiles: [], |
|
maxQuillInitRetries: 20, |
|
}; |
|
}, |
|
|
|
computed: { |
|
...mapGetters(["currentChat", "userInfo", "config"]), |
|
|
|
canSend() { |
|
const text = this.quill ? this.quill.getText().trim() : ""; |
|
return text.length > 0 && this.currentChat && !this.sending; |
|
}, |
|
|
|
sendModeLabel() { |
|
return this.sendMode === "enter" ? "Enter发送" : "Ctrl+Enter发送"; |
|
}, |
|
}, |
|
|
|
mounted() { |
|
this.$nextTick(() => { |
|
this.initQuill(); |
|
}); |
|
document.addEventListener("keydown", this.handleGlobalKeydown); |
|
window.addEventListener("systemSettingsChanged", this.handleSettingsChanged); |
|
this.loadSendModeFromSettings(); |
|
}, |
|
|
|
beforeDestroy() { |
|
if (this.quill) { |
|
this.quill = null; |
|
} |
|
document.removeEventListener("keydown", this.handleGlobalKeydown); |
|
window.removeEventListener("systemSettingsChanged", this.handleSettingsChanged); |
|
}, |
|
|
|
watch: { |
|
sendMode(newMode) { |
|
this.saveSendModeToSettings(newMode); |
|
}, |
|
}, |
|
|
|
methods: { |
|
getSystemSendMode() { |
|
try { |
|
const settings = localStorage.getItem("systemSettings"); |
|
if (settings) { |
|
const parsed = JSON.parse(settings); |
|
if (parsed.basicForm && parsed.basicForm.sendKey) { |
|
const savedKey = parsed.basicForm.sendKey; |
|
if (savedKey === "Ctrl+Enter") { |
|
return "ctrlEnter"; |
|
} else if (savedKey === "Enter") { |
|
return "enter"; |
|
} |
|
} |
|
} |
|
} catch (e) { |
|
console.error("读取系统设置失败:", e); |
|
} |
|
return "ctrlEnter"; |
|
}, |
|
|
|
loadSendModeFromSettings() { |
|
const mode = this.getSystemSendMode(); |
|
if (mode !== this.sendMode) { |
|
this.sendMode = mode; |
|
this.refreshQuillBindings(); |
|
} |
|
}, |
|
|
|
saveSendModeToSettings(mode) { |
|
try { |
|
const settings = localStorage.getItem("systemSettings"); |
|
const parsed = settings ? JSON.parse(settings) : {}; |
|
if (!parsed.basicForm) { |
|
parsed.basicForm = {}; |
|
} |
|
parsed.basicForm.sendKey = mode === "ctrlEnter" ? "ctrl" : "enter"; |
|
localStorage.setItem("systemSettings", JSON.stringify(parsed)); |
|
} catch (e) { |
|
console.error("保存发送模式失败:", e); |
|
} |
|
}, |
|
|
|
handleSettingsChanged(e) { |
|
if (e.detail && e.detail.settings) { |
|
this.loadSendModeFromSettings(); |
|
} |
|
}, |
|
|
|
// 初始化Quill |
|
initQuill() { |
|
const editorDom = this.$refs.quillEditor; |
|
if (!editorDom) { |
|
this.quillInitRetryCount++; |
|
if (this.quillInitRetryCount <= this.maxQuillInitRetries) { |
|
console.warn( |
|
`Quill container not found, retrying... (${this.quillInitRetryCount}/${this.maxQuillInitRetries})` |
|
); |
|
setTimeout(() => { |
|
this.initQuill(); |
|
}, 100); |
|
} else { |
|
console.error("Quill container not found after maximum retries"); |
|
} |
|
return; |
|
} |
|
|
|
this.quillInitRetryCount = 0; |
|
if (this.quill) return; |
|
|
|
// 初始化时注入键盘绑定 |
|
this.quill = new Quill(editorDom, { |
|
theme: "snow", |
|
placeholder: "请输入消息...", |
|
modules: { |
|
toolbar: false, |
|
keyboard: { |
|
bindings: this.getQuillKeyBindings(), // 抽离绑定配置,支持动态更新 |
|
}, |
|
}, |
|
readOnly: false, |
|
}); |
|
|
|
const toolbar = this.quill.getModule("toolbar"); |
|
if (toolbar.container) { |
|
toolbar.container.style.display = "none"; |
|
} |
|
|
|
this.quill.on("selection-change", (range) => { |
|
this.currentRange = range; |
|
}); |
|
|
|
this.quill.on("text-change", (delta, oldDelta, source) => { |
|
if (source === "user") { |
|
this.handleTextChange(delta); |
|
} |
|
}); |
|
|
|
this.quill.root.addEventListener("paste", this.handlePaste, true); |
|
|
|
// ========= 删除原来这里的 keydown 监听,改用 bindings 控制 ========= |
|
}, |
|
// 根据当前 sendMode 返回 Quill 按键规则 |
|
getQuillKeyBindings() { |
|
const self = this; |
|
if (this.sendMode === "enter") { |
|
// Enter发送模式:Enter=发送,Ctrl/Cmd+Enter=换行,Shift+Enter=换行 |
|
return { |
|
enter: { |
|
key: "Enter", |
|
handler() { |
|
self.handleSendText(); |
|
}, |
|
}, |
|
ctrlEnter: { |
|
key: "Enter", |
|
ctrlKey: true, |
|
metaKey: true, |
|
handler(range) { |
|
const quill = self.quill; |
|
quill.insertText(range.index, "\n"); |
|
quill.setSelection(range.index + 1); |
|
}, |
|
}, |
|
shiftEnter: { |
|
key: "Enter", |
|
shiftKey: true, |
|
handler(range) { |
|
const quill = self.quill; |
|
quill.insertText(range.index, "\n"); |
|
quill.setSelection(range.index + 1); |
|
}, |
|
}, |
|
}; |
|
} else { |
|
// Ctrl+Enter发送模式:Enter=换行,Shift+Enter=换行,Ctrl/Cmd+Enter=发送 |
|
return { |
|
enter: { |
|
key: "Enter", |
|
shiftKey: null, |
|
handler(range) { |
|
const quill = self.quill; |
|
quill.insertText(range.index, "\n"); |
|
quill.setSelection(range.index + 1); |
|
}, |
|
}, |
|
shiftEnter: { |
|
key: "Enter", |
|
shiftKey: true, |
|
handler(range) { |
|
const quill = self.quill; |
|
quill.insertText(range.index, "\n"); |
|
quill.setSelection(range.index + 1); |
|
}, |
|
}, |
|
ctrlEnter: { |
|
key: "Enter", |
|
ctrlKey: true, |
|
metaKey: true, |
|
handler() { |
|
self.handleSendText(); |
|
}, |
|
}, |
|
}; |
|
} |
|
}, |
|
|
|
// 切换发送模式后,刷新Quill键盘绑定 |
|
refreshQuillBindings() { |
|
if (!this.quill) return; |
|
const keyboard = this.quill.getModule("keyboard"); |
|
// 清空旧绑定,替换新规则 |
|
keyboard.bindings = this.getQuillKeyBindings(); |
|
}, |
|
handleGlobalKeydown(e) { |
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { |
|
const tag = document.activeElement.tagName; |
|
if (["INPUT", "TEXTAREA", "SELECT"].includes(tag)) return; |
|
|
|
const text = this.quill ? this.quill.getText().trim() : ""; |
|
if (!text || this.sending) return; |
|
|
|
// 仅在 ctrlEnter 模式下全局快捷键生效 |
|
if (this.sendMode !== "ctrlEnter") return; |
|
|
|
e.preventDefault(); |
|
this.handleSendText(); |
|
} |
|
}, |
|
|
|
// 处理文本变化,检测@符号输入 |
|
handleTextChange(delta) { |
|
try { |
|
if ( |
|
!this.currentChat || |
|
this.currentChat.scene !== ContactsScene.GROUP |
|
) { |
|
return; |
|
} |
|
if (!this.groupInfo) return; |
|
|
|
if (delta.ops) { |
|
delta.ops.forEach((op) => { |
|
if (op.insert && typeof op.insert === "string") { |
|
const atIndex = op.insert.lastIndexOf("@"); |
|
if (atIndex !== -1 && atIndex === op.insert.length - 1) { |
|
this.showAtDropdown(); |
|
} |
|
} |
|
}); |
|
} |
|
} catch (e) { |
|
console.warn("handleTextChange error:", e); |
|
} |
|
}, |
|
|
|
// 显示@成员下拉框 |
|
showAtDropdown() { |
|
try { |
|
const dropdownEl = this.$refs.atDropdown; |
|
if (dropdownEl && dropdownEl.$el) { |
|
dropdownEl.$el.click(); |
|
} |
|
} catch (e) { |
|
console.warn("showAtDropdown error:", e); |
|
} |
|
}, |
|
|
|
clearEditor() { |
|
if (this.quill) { |
|
this.quill.setText(""); |
|
} |
|
this.editorFiles = []; |
|
}, |
|
|
|
// 字号切换 - 调整消息显示区域的文字大小 |
|
handleFontSizeChange(size) { |
|
this.$emit("font-size-change", parseInt(size)); |
|
}, |
|
|
|
// 发送方式切换 |
|
handleSendModeChange(mode) { |
|
this.sendMode = mode; |
|
// 切换后刷新键盘规则立即生效 |
|
this.refreshQuillBindings(); |
|
}, |
|
|
|
// 文件按钮 |
|
handleFileClick() { |
|
this.$refs.fileInput.click(); |
|
}, |
|
|
|
// 截屏 |
|
async handleScreenshotClick() { |
|
try { |
|
await takeScreenshot( |
|
async (blob) => { |
|
try { |
|
const fileName = `screenshot_${Date.now()}.png`; |
|
const objectName = this.getObjectPath(fileName); |
|
|
|
await this.ensureMinioInitialized(); |
|
const config = this.$store.getters.config; |
|
const bucket = config.MINIO_BUCKET_FILE || "remote-file-test"; |
|
|
|
const result = await uploadFile( |
|
bucket, |
|
objectName, |
|
blob, |
|
{}, |
|
(percent) => {} |
|
); |
|
|
|
const uploadedPath = bucket + "/" + result.objectName; |
|
// 创建文件对象用于 insertFileToEditor |
|
const screenshotFile = new File([blob], fileName, { |
|
type: "image/png", |
|
}); |
|
await this.insertFileToEditor(screenshotFile, uploadedPath); |
|
this.$message.success("截图已插入到编辑器"); |
|
} catch (e) { |
|
this.$message.error("截图上传失败: " + e.message); |
|
} |
|
}, |
|
() => { |
|
this.$message.info("已取消截图"); |
|
} |
|
); |
|
} catch (e) { |
|
this.$message.error("截图失败: " + e.message); |
|
} |
|
}, |
|
|
|
// ========== 核心:选中@成员 插入富文本 ========== |
|
handleAtSelect(user) { |
|
if (!this.quill || !user) return; |
|
const index = this.currentRange |
|
? this.currentRange.index |
|
: this.quill.getLength(); |
|
const insertText = `@${user.name}`; |
|
this.quill.insertText(index, insertText); |
|
this.quill.formatText(index, insertText.length, { |
|
color: "#409eff", |
|
fontWeight: "500", |
|
}); |
|
this.quill.setSelection(index + insertText.length); |
|
const hasUser = this.selectedAtUsers.find((u) => u.id === user.id); |
|
if (!hasUser) { |
|
this.selectedAtUsers.push(user); |
|
} |
|
}, |
|
|
|
handleTeachingClick(meetingType) { |
|
const targetMode = this.meetingModes.find( |
|
(item) => item.type === meetingType |
|
); |
|
console.log(targetMode); |
|
// 正常跳转 |
|
this.$router.push({ |
|
name: targetMode.routeName, |
|
query: { |
|
name: this.userInfo.group, |
|
roomId_id: this.generateRoomId(targetMode.type, this.userInfo.id), |
|
}, |
|
}); |
|
}, |
|
generateRoomId(meetingType, id) { |
|
// 1. id固定3位,不足左侧补0 |
|
const idStr = String(id).padStart(3, "0"); |
|
// 拼接 = 会议类型 + 3位id + 前缀 |
|
const roomId = meetingType + idStr + this.config.MEETING_PREFIX; |
|
return roomId; |
|
}, |
|
|
|
// 提取@用户ID(发送接口使用) |
|
extractAtUsers(html) { |
|
const ids = []; |
|
this.selectedAtUsers.forEach((user) => { |
|
if (html.includes(`@${user.name}`)) { |
|
ids.push(user.id); |
|
} |
|
}); |
|
return ids; |
|
}, |
|
|
|
// 发送消息 |
|
async handleSendText() { |
|
if (!this.quill || !this.currentChat) return; |
|
const html = this.quill.root.innerHTML; |
|
const textContent = this.quill.getText().trim(); |
|
|
|
// 检查是否有内容可以发送(文本或文件) |
|
const hasText = textContent.replace(/\s+/g, "").length > 0; |
|
const files = this.extractFilesFromEditor(); |
|
|
|
if (!hasText && files.length === 0) { |
|
this.$message.warning("请输入内容或上传文件"); |
|
return; |
|
} |
|
|
|
this.sending = true; |
|
|
|
try { |
|
const messages = this.parseEditorContent(html, textContent); |
|
|
|
for (let i = 0; i < messages.length; i++) { |
|
const messageData = messages[i]; |
|
const messageId = this.generateMessageId(); |
|
|
|
this.$emit("send-message", { |
|
...messageData, |
|
is_self: true, |
|
sending: true, |
|
message_id: messageId, |
|
}); |
|
|
|
const body = { |
|
client_id: this.getClientId(), |
|
message: messageData, |
|
target_id: this.currentChat.id, |
|
topic: this.getTopic(), |
|
}; |
|
|
|
if (this.currentChat.scene === ContactsScene.PRIVATE) { |
|
await sendPrivateMessage(body); |
|
} else if (this.currentChat.scene === ContactsScene.GROUP) { |
|
await sendMultiChatMessage(body); |
|
} |
|
|
|
this.$emit("update-message-status", messageId, { sending: false }); |
|
} |
|
|
|
this.clearEditor(); |
|
this.selectedAtUsers = []; |
|
} catch (e) { |
|
this.$message.error("发送失败: " + (e.message || "未知错误")); |
|
} finally { |
|
this.sending = false; |
|
} |
|
}, |
|
|
|
// 解析编辑器内容,分离文本和文件 |
|
parseEditorContent(html, textContent) { |
|
const messages = []; |
|
const atUserIds = this.extractAtUsers(html); |
|
|
|
const textOnly = textContent.replace(/\s+/g, "").length > 0; |
|
const files = this.extractFilesFromEditor(); |
|
|
|
if (textOnly) { |
|
messages.push({ |
|
at_users: atUserIds, |
|
message_id: 0, |
|
payload: { |
|
content: textContent, |
|
file_duration: 0, |
|
file_ico: "", |
|
file_name: "", |
|
file_path: "", |
|
file_size: 0, |
|
file_type: "", |
|
}, |
|
scene: this.currentChat.scene, |
|
source_id: this.userInfo.id, |
|
target_id: this.currentChat.id, |
|
timestamp: 0, |
|
type: "text", |
|
}); |
|
} |
|
|
|
files.forEach((file) => { |
|
const msgType = this.getMessageType(file.type); |
|
|
|
messages.push({ |
|
at_users: [], |
|
message_id: 0, |
|
payload: { |
|
content: "", |
|
file_duration: 0, |
|
file_ico: file.icon, |
|
file_name: file.name, |
|
file_path: file.path, |
|
file_size: file.size || 0, |
|
file_type: file.type, |
|
}, |
|
scene: this.currentChat.scene, |
|
source_id: this.userInfo.id, |
|
target_id: this.currentChat.id, |
|
timestamp: 0, |
|
type: msgType, |
|
}); |
|
}); |
|
|
|
return messages; |
|
}, |
|
|
|
// 从编辑器中提取所有文件信息 |
|
extractFilesFromEditor() { |
|
const files = []; |
|
if (!this.quill) return files; |
|
|
|
const config = this.$store.getters.config; |
|
const endpoint = config.MINIO_ENDPOINT_HTTPS || ""; |
|
|
|
this.quill.root.querySelectorAll("img").forEach((img) => { |
|
const src = img.src; |
|
if (src.startsWith(endpoint)) { |
|
const filePath = src.replace(endpoint, ""); |
|
const fileName = filePath.split("/").pop(); |
|
files.push({ |
|
name: fileName, |
|
path: filePath, |
|
type: "image", |
|
size: 0, |
|
icon: "", |
|
}); |
|
} |
|
}); |
|
|
|
this.quill.root.querySelectorAll(".file-card").forEach((card) => { |
|
const fileIndex = card.getAttribute("data-file-index"); |
|
if (fileIndex !== null && this.editorFiles[fileIndex]) { |
|
files.push(this.editorFiles[fileIndex]); |
|
} |
|
}); |
|
|
|
return files; |
|
}, |
|
|
|
// 根据文件类型获取消息类型 |
|
getMessageType(fileType) { |
|
switch (fileType) { |
|
case "image": |
|
return "image"; |
|
case "video": |
|
return "video"; |
|
case "audio": |
|
return "audio"; |
|
default: |
|
return "file"; |
|
} |
|
}, |
|
|
|
// 文件相关逻辑 |
|
async handleFileChange(e) { |
|
const files = e.target.files; |
|
if (!files || !files.length) return; |
|
const file = files[0]; |
|
e.target.value = ""; |
|
|
|
if (file.size > this.maxFileSize) { |
|
this.$message.warning("文件大小不能超过100MB"); |
|
return; |
|
} |
|
|
|
if (!this.currentChat) { |
|
this.$message.warning("请先选择聊天对象"); |
|
return; |
|
} |
|
|
|
try { |
|
await this.ensureMinioInitialized(); |
|
const fileName = file.name; |
|
const objectName = this.getObjectPath(fileName); |
|
|
|
const config = this.$store.getters.config; |
|
const bucket = config.MINIO_BUCKET_FILE || "remote-file-test"; |
|
|
|
const result = await uploadFile( |
|
bucket, |
|
objectName, |
|
file, |
|
{}, |
|
(percent) => {} |
|
); |
|
|
|
const uploadedPath = bucket + "/" + result.objectName; |
|
|
|
// 所有文件都插入到编辑器 |
|
await this.insertFileToEditor(file, uploadedPath); |
|
this.$message.success("文件上传成功"); |
|
} catch (e) { |
|
this.$message.error("文件上传失败: " + e.message); |
|
} |
|
}, |
|
// 直接发送文件消息 |
|
async sendFileMessageDirect(file, filePath) { |
|
if (!this.currentChat) return; |
|
const messageId = this.generateMessageId(); |
|
|
|
const fileType = this.getFileType(file); |
|
const messageType = file.type.startsWith("video/") ? "video" : "file"; |
|
|
|
const message = { |
|
at_users: [], |
|
message_id: messageId, |
|
payload: { |
|
content: "", |
|
file_duration: 0, |
|
file_ico: this.getFileIcon(fileType), |
|
file_name: file.name, |
|
file_path: filePath, |
|
file_size: file.size, |
|
file_type: fileType, |
|
}, |
|
scene: this.currentChat.scene, |
|
source_id: this.userInfo.id, |
|
target_id: this.currentChat.id, |
|
timestamp: Date.now(), |
|
type: messageType, |
|
is_self: true, |
|
sending: true, |
|
}; |
|
|
|
this.$emit("send-message", message); |
|
|
|
try { |
|
const body = { |
|
client_id: this.getClientId(), |
|
message: { |
|
at_users: [], |
|
message_id: 0, |
|
payload: { |
|
content: "", |
|
file_duration: 0, |
|
file_ico: this.getFileIcon(fileType), |
|
file_name: file.name, |
|
file_path: filePath, |
|
file_size: file.size, |
|
file_type: fileType, |
|
}, |
|
scene: this.currentChat.scene, |
|
source_id: this.userInfo.id, |
|
target_id: this.currentChat.id, |
|
timestamp: 0, |
|
type: messageType, |
|
}, |
|
target_id: this.currentChat.id, |
|
topic: this.getTopic(), |
|
}; |
|
|
|
if (this.currentChat.scene === ContactsScene.PRIVATE) { |
|
await sendPrivateMessage(body); |
|
} else if (this.currentChat.scene === ContactsScene.GROUP) { |
|
await sendMultiChatMessage(body); |
|
} |
|
|
|
this.$emit("update-message-status", messageId, { sending: false }); |
|
} catch (e) { |
|
this.$message.error("发送文件失败: " + e.message); |
|
this.$emit("update-message-status", messageId, { |
|
sending: false, |
|
sendFailed: true, |
|
}); |
|
} |
|
}, |
|
// 获取文件类型 |
|
getFileType(file) { |
|
const ext = file.name.split(".").pop().toLowerCase(); |
|
const imageExts = ["jpg", "jpeg", "png", "gif", "bmp", "webp"]; |
|
const videoExts = ["mp4", "webm", "ogg", "mov", "avi"]; |
|
const docExts = ["doc", "docx"]; |
|
const excelExts = ["xls", "xlsx"]; |
|
const pptExts = ["ppt", "pptx"]; |
|
|
|
if (imageExts.includes(ext)) return "image"; |
|
if (videoExts.includes(ext)) return "video"; |
|
if (docExts.includes(ext)) return ext; |
|
if (excelExts.includes(ext)) return ext; |
|
if (pptExts.includes(ext)) return ext; |
|
if (ext === "pdf") return "pdf"; |
|
if (ext === "zip" || ext === "rar") return ext; |
|
if (ext === "txt") return "txt"; |
|
return "file"; |
|
}, |
|
|
|
async handlePaste(e) { |
|
const items = e.clipboardData.items; |
|
if (!items) return; |
|
for (let i = 0; i < items.length; i++) { |
|
const item = items[i]; |
|
if (item.type.indexOf("image") !== -1) { |
|
const file = item.getAsFile(); |
|
if (file) { |
|
e.preventDefault(); |
|
await this.sendFileMessage(file, "image"); |
|
return; |
|
} |
|
} |
|
} |
|
}, |
|
|
|
async sendFileMessage(file, type) { |
|
if (!this.currentChat) return; |
|
const messageId = this.generateMessageId(); |
|
console.log("sendFileMessage", file); |
|
const fileName = file.name; |
|
const objectName = this.getObjectPath(fileName); |
|
|
|
const fileType = |
|
type === "image" |
|
? "image" |
|
: type === "video" |
|
? "video" |
|
: type === "audio" |
|
? "audio" |
|
: file.name.split(".").pop().toLowerCase() || "file"; |
|
|
|
const localPayload = { |
|
content: "", |
|
file_duration: 0, |
|
file_ico: "", |
|
file_name: file.name, |
|
file_path: objectName, |
|
file_size: file.size, |
|
file_type: fileType, |
|
}; |
|
|
|
const message = { |
|
at_users: [], |
|
message_id: 0, |
|
payload: localPayload, |
|
scene: this.currentChat.scene, |
|
source_id: this.userInfo.id, |
|
target_id: this.currentChat.id, |
|
timestamp: 0, |
|
type: type === "image" ? "image" : "file", |
|
}; |
|
|
|
this.$emit("send-message", { |
|
...message, |
|
is_self: true, |
|
sending: true, |
|
isUploading: true, |
|
message_id: messageId, |
|
}); |
|
|
|
try { |
|
await this.uploadToMinIO(file, objectName); |
|
const body = { |
|
client_id: this.getClientId(), |
|
message: { |
|
at_users: [], |
|
message_id: 0, |
|
payload: { |
|
content: "", |
|
file_duration: 0, |
|
file_ico: type === "image" ? "" : this.getFileIcon(fileType), |
|
file_name: file.name, |
|
file_path: objectName, |
|
file_size: file.size, |
|
file_type: fileType, |
|
}, |
|
scene: this.currentChat.scene, |
|
source_id: this.userInfo.id, |
|
target_id: this.currentChat.id, |
|
timestamp: 0, |
|
type: type === "image" ? "image" : "file", |
|
}, |
|
target_id: this.currentChat.id, |
|
topic: this.getTopic(), |
|
}; |
|
|
|
if (this.currentChat.scene === ContactsScene.PRIVATE) { |
|
await sendPrivateMessage(body); |
|
} else if (this.currentChat.scene === ContactsScene.GROUP) { |
|
await sendMultiChatMessage(body); |
|
} |
|
|
|
this.$emit("update-message-status", messageId, { |
|
sending: false, |
|
isUploading: false, |
|
payload: type === "image" ? objectName : serverPayload, |
|
}); |
|
|
|
if (type === "image" && this.quill) { |
|
const index = this.currentRange |
|
? this.currentRange.index |
|
: this.quill.getLength(); |
|
this.quill.insertEmbed(index, "image", objectName); |
|
this.quill.setSelection(index + 1); |
|
} |
|
} catch (e) { |
|
this.$message.error("发送文件失败: " + e.message); |
|
this.$emit("update-message-status", messageId, { |
|
sending: false, |
|
sendFailed: true, |
|
isUploading: false, |
|
}); |
|
} |
|
}, |
|
|
|
insertImageToEditor(imagePath) { |
|
if (!this.quill) return; |
|
const index = this.currentRange |
|
? this.currentRange.index |
|
: this.quill.getLength(); |
|
const imageUrl = |
|
this.$store.state.user.netConfig.MINIO_ENDPOINT_HTTPS + imagePath; |
|
console.log(imageUrl); |
|
this.quill.insertEmbed(index, "image", imageUrl); |
|
this.quill.setSelection(index + 1); |
|
}, |
|
|
|
insertFileToEditor(file, filePath) { |
|
if (!this.quill) return; |
|
|
|
const index = this.currentRange |
|
? this.currentRange.index |
|
: this.quill.getLength(); |
|
|
|
const fileType = this.getFileType(file); |
|
const fileInfo = { |
|
name: file.name, |
|
path: filePath, |
|
type: fileType, |
|
size: file.size, |
|
icon: this.getFileIcon(fileType), |
|
}; |
|
|
|
if (!this.editorFiles) { |
|
this.editorFiles = []; |
|
} |
|
this.editorFiles.push(fileInfo); |
|
|
|
if (fileType === "image") { |
|
const imageUrl = |
|
this.$store.state.user.netConfig.MINIO_ENDPOINT_HTTPS + filePath; |
|
this.quill.insertEmbed(index, "image", imageUrl); |
|
} else { |
|
const fileSize = this.formatFileSize(file.size); |
|
const fileHtml = `<div class="file-card" data-file-index="${ |
|
this.editorFiles.length - 1 |
|
}"> |
|
<div class="file-icon-wrapper"> |
|
<img src="${fileInfo.icon}" class="file-icon" /> |
|
</div> |
|
<div class="file-info"> |
|
<div class="file-name">${file.name}</div> |
|
<div class="file-size">${fileSize}</div> |
|
</div> |
|
<div class="file-remove" onclick="this.parentElement.remove()"> |
|
<i class="el-icon-close"></i> |
|
</div> |
|
</div>`; |
|
this.quill.clipboard.dangerouslyPasteHTML(index, fileHtml); |
|
} |
|
this.quill.setSelection(index + 1); |
|
}, |
|
|
|
formatFileSize(bytes) { |
|
if (bytes === 0) return "0 B"; |
|
const k = 1024; |
|
const sizes = ["B", "KB", "MB", "GB"]; |
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; |
|
}, |
|
|
|
getFileType(file) { |
|
const fileName = file.name.toLowerCase(); |
|
const ext = fileName.split(".").pop(); |
|
|
|
if ( |
|
file.type.startsWith("image/") || |
|
["jpg", "jpeg", "png", "gif", "bmp", "svg"].includes(ext) |
|
) { |
|
return "image"; |
|
} |
|
if ( |
|
file.type.startsWith("video/") || |
|
["mp4", "webm", "ogg", "mov", "avi"].includes(ext) |
|
) { |
|
return "video"; |
|
} |
|
if ( |
|
file.type.startsWith("audio/") || |
|
["mp3", "wav", "ogg"].includes(ext) |
|
) { |
|
return "audio"; |
|
} |
|
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) { |
|
return "zip"; |
|
} |
|
if (["doc", "docx"].includes(ext)) { |
|
return "doc"; |
|
} |
|
if (["xls", "xlsx"].includes(ext)) { |
|
return "xls"; |
|
} |
|
if (["ppt", "pptx"].includes(ext)) { |
|
return "ppt"; |
|
} |
|
if (ext === "pdf") { |
|
return "pdf"; |
|
} |
|
if (ext === "txt") { |
|
return "txt"; |
|
} |
|
return "other"; |
|
}, |
|
|
|
ensureMinioInitialized() { |
|
const config = this.$store.getters.config; |
|
if (!config || !config.MINIO_ENDPOINT) { |
|
return this.$store.dispatch("GetNetConfig"); |
|
} |
|
return Promise.resolve(); |
|
}, |
|
|
|
async uploadToMinIO(file, objectName) { |
|
return new Promise((resolve, reject) => { |
|
const reader = new FileReader(); |
|
reader.onload = async () => { |
|
try { |
|
const formData = new FormData(); |
|
formData.append("file", file); |
|
formData.append("object_name", objectName); |
|
const { default: request } = await import("@/utils/request"); |
|
await request({ |
|
url: "/api/v1/common/upload", |
|
method: "post", |
|
data: formData, |
|
headers: { "Content-Type": "multipart/form-data" }, |
|
}); |
|
resolve(); |
|
} catch (err) { |
|
reject(err); |
|
} |
|
}; |
|
reader.onerror = reject; |
|
reader.readAsArrayBuffer(file); |
|
}); |
|
}, |
|
|
|
getMessageTypeByFileType(type, file) { |
|
if (type === "image") return MessageType.IMAGE; |
|
if (type === "video") return MessageType.VIDEO; |
|
if (file.type.startsWith("audio/")) return MessageType.AUDIO; |
|
return MessageType.FILE; |
|
}, |
|
|
|
generateMessageId() { |
|
return ( |
|
"msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9) |
|
); |
|
}, |
|
|
|
getObjectPath(fileName) { |
|
let sceneDir; |
|
switch (this.currentChat.scene) { |
|
case ContactsScene.GROUP: |
|
sceneDir = MinioPaths.FILE_MULTICHAT; |
|
break; |
|
case ContactsScene.NOTIFY: |
|
sceneDir = MinioPaths.FILE_SYSTEM_NOTIFY; |
|
break; |
|
case ContactsScene.PRIVATE: |
|
sceneDir = MinioPaths.FILE_PRIVATE; |
|
break; |
|
default: |
|
sceneDir = MinioPaths.FILE_PRIVATE; |
|
break; |
|
} |
|
const sourceId = this.userInfo.id; |
|
const targetId = this.currentChat.id; |
|
return `${sceneDir}${sourceId}-${targetId}/${fileName}`; |
|
}, |
|
|
|
getTopic() { |
|
if (this.currentChat.scene === ContactsScene.PRIVATE) { |
|
return MqttTopics.PRIVATE_CHAT + this.userInfo.id; |
|
} |
|
return MqttTopics.MULTI_CHAT + this.currentChat.id; |
|
}, |
|
|
|
getClientId() { |
|
return MqttTopics.CLIENT_ID_PREFIX + this.userInfo?.id; |
|
}, |
|
|
|
getFileIcon(fileType) { |
|
const iconMap = { |
|
doc: ":/message/image/Message/file_icon/doc.svg", |
|
docx: ":/message/image/Message/file_icon/docx.svg", |
|
xls: ":/message/image/Message/file_icon/xls.svg", |
|
xlsx: ":/message/image/Message/file_icon/xlsx.svg", |
|
ppt: ":/message/image/Message/file_icon/ppt.svg", |
|
pptx: ":/message/image/Message/file_icon/pptx.svg", |
|
pdf: ":/message/image/Message/file_icon/pdf.svg", |
|
zip: ":/message/image/Message/file_icon/zip.svg", |
|
rar: ":/message/image/Message/file_icon/rar.svg", |
|
txt: ":/message/image/Message/file_icon/txt.svg", |
|
video: ":/message/image/Message/file_icon/video.svg", |
|
audio: ":/message/image/Message/file_icon/audio.svg", |
|
}; |
|
return iconMap[fileType] || ""; |
|
}, |
|
}, |
|
}; |
|
</script> |
|
|
|
<style lang="scss" scoped> |
|
.message-editor { |
|
background: #fff; |
|
padding: 10px 16px; |
|
.quill-wrap { |
|
display: flex; |
|
flex-direction: column; |
|
border: 1px solid #e4e7ed; |
|
border-radius: 4px; |
|
gap: 8px; |
|
|
|
.quill-container { |
|
height: 100px; |
|
min-height: 100px; |
|
overflow-y: auto; |
|
box-sizing: border-box; |
|
|
|
::v-deep .ql-container { |
|
height: 100%; |
|
font-size: 14px; |
|
} |
|
|
|
::v-deep .ql-editor { |
|
min-height: 100%; |
|
padding: 8px; |
|
|
|
img { |
|
max-width: 100%; |
|
height: auto; |
|
display: block; |
|
margin: 4px 0; |
|
border-radius: 4px; |
|
} |
|
} |
|
|
|
::v-deep .ql-toolbar { |
|
display: none; |
|
} |
|
} |
|
.editor-toolbar { |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
width: 100%; |
|
} |
|
|
|
.editor-toolbar-left, |
|
.editor-toolbar-right { |
|
display: flex; |
|
align-items: center; |
|
gap: 12px; |
|
|
|
.el-button { |
|
margin: 0; |
|
padding: 4px 8px; |
|
} |
|
} |
|
} |
|
} |
|
|
|
.file-card { |
|
display: inline-flex; |
|
align-items: center; |
|
background: #f5f7fa; |
|
border: 1px solid #e4e7ed; |
|
border-radius: 8px; |
|
padding: 8px 12px; |
|
margin: 4px 4px 4px 0; |
|
cursor: pointer; |
|
transition: all 0.2s ease; |
|
|
|
&:hover { |
|
background: #eef2f7; |
|
border-color: #c0c4cc; |
|
} |
|
|
|
.file-icon-wrapper { |
|
width: 32px; |
|
height: 32px; |
|
border-radius: 6px; |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
margin-right: 10px; |
|
flex-shrink: 0; |
|
|
|
.file-icon { |
|
width: 18px; |
|
height: 18px; |
|
object-fit: contain; |
|
filter: brightness(0) invert(1); |
|
} |
|
} |
|
|
|
.file-info { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 2px; |
|
min-width: 0; |
|
|
|
.file-name { |
|
font-size: 13px; |
|
color: #303133; |
|
font-weight: 500; |
|
white-space: nowrap; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
max-width: 180px; |
|
} |
|
|
|
.file-size { |
|
font-size: 11px; |
|
color: #909399; |
|
} |
|
} |
|
|
|
.file-remove { |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 50%; |
|
background: #ccc; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
margin-left: 10px; |
|
cursor: pointer; |
|
opacity: 0; |
|
transition: all 0.2s ease; |
|
|
|
i { |
|
font-size: 12px; |
|
color: #fff; |
|
} |
|
|
|
&:hover { |
|
background: #f56c6c; |
|
} |
|
} |
|
|
|
&:hover .file-remove { |
|
opacity: 1; |
|
} |
|
} |
|
</style> |