海信医疗-远程超声管理平台-信创国产化
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.

689 lines
18 KiB

1 month ago
<template>
<div class="message-editor">
<!-- 工具栏 -->
<div class="editor-toolbar">
<el-tooltip content="文件" placement="top">
1 month ago
<el-button
type="text"
icon="el-icon-folder-opened"
@click="handleFileClick"
1 month ago
/>
</el-tooltip>
<el-tooltip content="截屏" placement="top">
1 month ago
<el-button
type="text"
icon="el-icon-scissors"
@click="handleScreenshotClick"
1 month ago
/>
</el-tooltip>
<!-- 字号下拉 -->
<el-dropdown @command="handleFontSizeChange">
<el-button type="text"> A </el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="10">10px</el-dropdown-item>
<el-dropdown-item command="11">11px</el-dropdown-item>
<el-dropdown-item command="12">12px</el-dropdown-item>
<el-dropdown-item command="13">13px</el-dropdown-item>
<el-dropdown-item command="14">14px</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- @成员下拉 仅群聊显示 -->
<el-dropdown @command="handleAtSelect" v-if="currentChat.scene == 4">
<el-button type="text"> @ </el-button>
<el-dropdown-menu
slot="dropdown"
style="height: 200px; overflow-y: auto"
>
<el-dropdown-item
command="{ id: '0', name: '所有人' }"
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>
<el-tooltip content="发起教学" placement="top">
1 month ago
<el-button
type="text"
icon="el-icon-video-camera"
@click="handleTeachingClick"
1 month ago
/>
</el-tooltip>
<el-tooltip content="发起会诊" placement="top">
1 month ago
<el-button
type="text"
icon="el-icon-video-camera-solid"
@click="handleConsultClick"
1 month ago
/>
</el-tooltip>
</div>
<!-- Quill 编辑区 + 右下角发送按钮 容器 -->
<div class="quill-wrap">
<div ref="quillEditor" class="quill-container"></div>
<!-- 右下角发送区域 -->
<div class="send-area">
<el-button
type="primary"
size="small"
:disabled="!canSend"
:loading="sending"
@click="handleSendText"
1 month ago
>
发送
</el-button>
<el-dropdown @command="handleSendModeChange">
<el-button type="text" size="small">
<span class="send-mode-text">{{ sendModeLabel }}</span>
<i class="el-icon-arrow-down el-icon--right"></i>
</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>
1 month ago
</div>
</div>
<!-- 隐藏文件上传 -->
1 month ago
<input
ref="fileInput"
type="file"
style="display: none"
@change="(e) => handleFileChange(e, 'file')"
1 month ago
/>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Quill from "quill";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";
1 month ago
import { sendPrivateMessage, sendMultiChatMessage } from "@/api/message";
import {
MessageType,
ContactsScene,
MinioPaths,
MqttTopics,
} from "@/utils/constants";
export default {
name: "MessageEditor",
props: {
groupInfo: Object,
},
1 month ago
data() {
return {
quill: null,
currentRange: null,
1 month ago
sending: false,
maxFileSize: 100 * 1024 * 1024,
sendMode: "enter",
fontSizeMap: {
10: "small",
11: "small",
12: false,
13: "large",
14: "large",
},
// 记录被@的用户
1 month ago
selectedAtUsers: [],
};
},
computed: {
...mapGetters(["currentChat", "userInfo"]),
canSend() {
const text = this.quill ? this.quill.getText().trim() : "";
return text.length > 0 && this.currentChat && !this.sending;
1 month ago
},
sendModeLabel() {
return this.sendMode === "enter" ? "Enter发送" : "Ctrl+Enter发送";
1 month ago
},
},
mounted() {
this.initQuill();
1 month ago
},
beforeDestroy() {
this.quill = null;
1 month ago
},
methods: {
// 初始化Quill
initQuill() {
const editorDom = this.$refs.quillEditor;
this.quill = new Quill(editorDom, {
theme: "snow",
placeholder: "请输入消息...",
modules: {
toolbar: false,
},
readOnly: false,
});
const toolbar = this.quill.getModule("toolbar");
if (toolbar.container) {
toolbar.container.style.display = "none";
1 month ago
}
// 监听光标位置
this.quill.on("selection-change", (range) => {
this.currentRange = range;
});
// 粘贴图片
this.quill.root.addEventListener("paste", this.handlePaste, true);
// 回车发送逻辑
this.quill.root.addEventListener("keydown", (e) => {
if (this.sendMode === "enter") {
if (e.key === "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
this.handleSendText();
}
} else {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleSendText();
}
}
});
1 month ago
},
clearEditor() {
if (this.quill) {
this.quill.setText("");
}
},
// 字号切换
handleFontSizeChange(size) {
const quillSize = this.fontSizeMap[size];
if (this.quill) {
this.quill.format("size", quillSize);
this.$refs.quillEditor.style.fontSize = size + "px";
}
},
// 发送方式切换
handleSendModeChange(mode) {
this.sendMode = mode;
},
// 文件按钮
handleFileClick() {
this.$refs.fileInput.click();
},
// 截屏
handleScreenshotClick() {
this.$emit("open-screenshot");
},
// ========== 核心:选中@成员 插入富文本 ==========
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.setSelection(index + insertText.length);
// 记录被@用户(去重)
const hasUser = this.selectedAtUsers.find((u) => u.id === user.id);
if (!hasUser) {
this.selectedAtUsers.push(user);
1 month ago
}
},
handleTeachingClick() {
this.$emit("open-teaching");
1 month ago
},
handleConsultClick() {
this.$emit("open-consult");
},
// 提取@用户ID(发送接口使用)
extractAtUsers(html) {
const ids = [];
this.selectedAtUsers.forEach((user) => {
if (html.includes(`@${user.name}`)) {
ids.push(user.id);
}
});
return ids;
},
// 发送消息
1 month ago
async handleSendText() {
if (!this.quill || !this.currentChat) return;
const html = this.quill.root.innerHTML;
const text = this.quill.getText().trim();
if (!text) return;
1 month ago
this.sending = true;
const messageId = this.generateMessageId();
const timestamp = Date.now();
try {
const atUserIds = this.extractAtUsers(html);
1 month ago
const message = {
at_users: atUserIds,
message_id: 0,
payload: {
content: text,
file_duration: 0,
file_ico: "",
file_name: "",
file_path: "",
file_size: 0,
file_type: "",
},
1 month ago
scene: this.currentChat.scene,
source_id: this.userInfo.id,
target_id: this.currentChat.id,
timestamp: 0,
type: "text",
1 month ago
};
const body = {
client_id: this.getClientId(),
message,
1 month ago
target_id: this.currentChat.id,
topic: this.getTopic(),
1 month ago
};
this.$emit("send-message", {
...message,
1 month ago
is_self: true,
sending: true,
message_id: messageId,
});
this.clearEditor();
1 month ago
this.selectedAtUsers = [];
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,
});
} finally {
this.sending = false;
}
},
// 文件相关逻辑
1 month ago
async handleFileChange(e, type) {
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;
}
await this.sendFileMessage(file, type);
},
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();
const fileName = this.generateFileName(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: "",
1 month ago
file_name: file.name,
file_path: objectName,
1 month ago
file_size: file.size,
file_type: fileType,
1 month ago
};
const message = {
at_users: [],
message_id: 0,
payload: localPayload,
1 month ago
scene: this.currentChat.scene,
source_id: this.userInfo.id,
target_id: this.currentChat.id,
timestamp: 0,
type: type === "image" ? "image" : "file",
1 month ago
};
this.$emit("send-message", {
...message,
1 month ago
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",
},
1 month ago
target_id: this.currentChat.id,
topic: this.getTopic(),
1 month ago
};
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);
}
1 month ago
} catch (e) {
this.$message.error("发送文件失败: " + e.message);
this.$emit("update-message-status", messageId, {
sending: false,
sendFailed: true,
isUploading: false,
});
}
},
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);
1 month ago
const { default: request } = await import("@/utils/request");
1 month ago
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)
);
},
generateFileName(originalName) {
const ext = originalName.split(".").pop();
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 6);
return `${timestamp}_${random}.${ext}`;
},
getObjectPath(fileName) {
const prefix =
this.currentChat.scene === ContactsScene.GROUP
? MinioPaths.FILE_MULTICHAT
: MinioPaths.FILE_PRIVATE;
return prefix + 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] || "";
1 month ago
},
},
};
</script>
<style lang="scss" scoped>
.message-editor {
background: #fff;
border-top: 1px solid #ebeef5;
padding: 10px 16px;
position: relative;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 8px;
.el-button {
font-size: 18px;
padding: 4px 8px;
color: #606266;
&:hover {
color: #409eff;
}
}
}
.quill-wrap {
position: relative;
}
1 month ago
.quill-container {
height: 100px;
padding-right: 100px;
box-sizing: border-box;
1 month ago
::v-deep .ql-container {
height: 100%;
font-size: 14px;
1 month ago
}
::v-deep .ql-editor {
min-height: 100%;
1 month ago
}
}
.send-area {
position: absolute;
right: 8px;
bottom: 8px;
1 month ago
display: flex;
align-items: center;
gap: 8px;
.send-mode-text {
font-size: 12px;
color: #909399;
}
1 month ago
}
.at-panel {
position: absolute;
bottom: 80px;
1 month ago
left: 20px;
width: 240px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 999;
1 month ago
padding: 8px 0;
.at-panel-header {
padding: 0 12px 8px;
font-size: 13px;
color: #909399;
border-bottom: 1px solid #ebeef5;
}
.at-panel-list {
max-height: 200px;
overflow-y: auto;
margin-top: 8px;
1 month ago
}
.at-panel-item {
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
1 month ago
&:hover {
background: #f5f7fa;
1 month ago
}
}
.at-panel-empty {
padding: 12px;
text-align: center;
font-size: 13px;
color: #c0c4cc;
1 month ago
}
}
</style>