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