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

888 lines
22 KiB

1 month ago
<template>
<div class="message-editor">
<!-- 工具栏 -->
<div class="editor-toolbar">
<el-tooltip content="发送图片" placement="top">
<el-button
type="text"
icon="el-icon-picture-outline"
@click="handleSelectImage"
/>
</el-tooltip>
<el-tooltip content="发送视频" placement="top">
<el-button
type="text"
icon="el-icon-video-camera"
@click="handleSelectVideo"
/>
</el-tooltip>
<el-tooltip content="发送文件" placement="top">
<el-button
type="text"
icon="el-icon-folder-opened"
@click="handleSelectFile"
/>
</el-tooltip>
<el-tooltip content="截图" placement="top">
<el-button
type="text"
icon="el-icon-camera"
@click="handleScreenshot"
/>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip content="视频通话" placement="top">
<el-button
type="text"
icon="el-icon-phone-outline"
@click="handleVideoCall"
/>
</el-tooltip>
<el-tooltip content="@成员" placement="top">
<el-button type="text" @click="handleShowAtPanel"> @ </el-button>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip :content="`当前字号: ${currentFontSize}px`" placement="top">
<el-button type="text" @click="showFontSizeMenu = !showFontSizeMenu">
<span class="font-size-icon">A</span>
</el-button>
</el-tooltip>
<!-- 字号选择菜单 -->
<div v-if="showFontSizeMenu" class="font-size-menu" @click.stop>
<div
v-for="size in fontSizeOptions"
:key="size"
:class="['font-size-item', { active: currentFontSize === size }]"
@click="setFontSize(size)"
>
{{ size }}px
</div>
</div>
<span class="shortcut-hint">Enter 发送 / Ctrl+Enter 换行</span>
</div>
<!-- 输入区 -->
<div class="editor-input-area">
<el-input
ref="inputRef"
v-model="inputText"
type="textarea"
:rows="4"
:style="{ fontSize: currentFontSize + 'px' }"
placeholder="请输入消息..."
resize="none"
@keydown.enter.native="handleKeydown"
@paste.native="handlePaste"
@keydown.at-sign.native="handleAtKeydown"
/>
</div>
<!-- 发送按钮 -->
<div class="editor-footer">
<el-button
type="primary"
size="small"
:disabled="!canSend"
:loading="sending"
@click="handleSendText"
>
发送
</el-button>
</div>
<!-- @成员面板 -->
<div v-if="atPanelVisible" class="at-panel" :style="atPanelStyle">
<div class="at-panel-header">选择要@的成员</div>
<el-input
v-model="atSearchKey"
placeholder="搜索成员"
size="small"
prefix-icon="el-icon-search"
/>
<div class="at-panel-list">
<div
v-for="user in filteredAtUsers"
:key="user.id"
class="at-panel-item"
@click="handleSelectAtUser(user)"
>
<el-avatar :size="28" :src="user.avatar" icon="el-icon-user-solid" />
<span class="at-user-name">{{ user.name }}</span>
</div>
<div v-if="!filteredAtUsers.length" class="at-panel-empty">
无匹配成员
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="imageInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileChange($event, 'image')"
/>
<input
ref="fileInput"
type="file"
style="display: none"
@change="handleFileChange($event, 'file')"
/>
<input
ref="videoInput"
type="file"
accept="video/*"
style="display: none"
@change="handleFileChange($event, 'video')"
/>
<!-- 视频通话弹窗 -->
<el-dialog
v-if="videoCallVisible"
title="视频通话"
:visible.sync="videoCallVisible"
width="400px"
:close-on-click-modal="false"
:show-close="false"
>
<div class="video-call-container">
<div class="video-call-avatar">
<el-avatar :size="80" icon="el-icon-user-solid" />
</div>
<div class="video-call-info">
<div class="video-call-name">{{ currentChat.name || '通话中' }}</div>
<div class="video-call-status">{{ callStatus }}</div>
</div>
<div class="video-call-timer" v-if="callStarted">
{{ formatCallTime(callDuration) }}
</div>
<div class="video-call-buttons">
<el-button
type="text"
icon="el-icon-video-off"
@click="toggleVideo"
:class="{ active: !videoEnabled }"
>
{{ videoEnabled ? '关闭摄像头' : '开启摄像头' }}
</el-button>
<el-button
type="text"
icon="el-icon-microphone"
@click="toggleMute"
:class="{ active: !micEnabled }"
>
{{ micEnabled ? '静音' : '取消静音' }}
</el-button>
<el-button
type="danger"
icon="el-icon-phone-off"
@click="endCall"
>
结束通话
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import { sendPrivateMessage, sendMultiChatMessage } from "@/api/message";
import {
MessageType,
ContactsScene,
MinioPaths,
MqttTopics,
} from "@/utils/constants";
import { takeScreenshot } from "@/utils/screenshot";
export default {
name: "MessageEditor",
data() {
return {
inputText: "",
sending: false,
atPanelVisible: false,
atSearchKey: "",
atPanelStyle: {},
atUsers: [],
selectedAtUsers: [],
maxFileSize: 100 * 1024 * 1024, // 100MB
currentFontSize: 14,
fontSizeOptions: [12, 14, 16, 18, 20, 24],
showFontSizeMenu: false,
videoCallVisible: false,
callStatus: "正在呼叫...",
callStarted: false,
callDuration: 0,
callTimer: null,
videoEnabled: true,
micEnabled: true,
};
},
computed: {
...mapGetters(["currentChat", "userInfo"]),
canSend() {
return (
this.inputText.trim().length > 0 && this.currentChat && !this.sending
);
},
filteredAtUsers() {
const key = this.atSearchKey.trim().toLowerCase();
if (!key) return this.atUsers;
return this.atUsers.filter((u) => u.name.toLowerCase().includes(key));
},
},
watch: {
currentChat: {
immediate: true,
handler(chat) {
if (chat && chat.scene === ContactsScene.GROUP) {
this.loadGroupMembers();
} else {
this.atUsers = [];
}
this.inputText = "";
this.selectedAtUsers = [];
},
},
showFontSizeMenu(val) {
if (val) {
document.addEventListener("click", this.handleClickOutside);
} else {
document.removeEventListener("click", this.handleClickOutside);
}
},
},
beforeDestroy() {
document.removeEventListener("click", this.handleClickOutside);
this.stopCallTimer();
},
methods: {
handleClickOutside(e) {
const toolbar = this.$el.querySelector(".editor-toolbar");
if (toolbar && !toolbar.contains(e.target)) {
this.showFontSizeMenu = false;
}
},
handleKeydown(e) {
if (e.ctrlKey || e.shiftKey) {
return;
}
e.preventDefault();
this.handleSendText();
},
handleAtKeydown() {
this.handleShowAtPanel();
},
async handleSendText() {
const text = this.inputText.trim();
if (!text || !this.currentChat) return;
this.sending = true;
const messageId = this.generateMessageId();
const timestamp = Date.now();
try {
const atUserIds = this.extractAtUsers(text);
const content = {
type: MessageType.TEXT,
scene: this.currentChat.scene,
target_id: this.currentChat.id,
source_id: this.userInfo.id,
payload: text,
at_users: atUserIds,
timestamp,
message_id: messageId,
};
const body = {
topic: this.getTopic(),
client_id: this.getClientId(),
target_id: this.currentChat.id,
content: JSON.stringify(content),
};
this.$emit("send-message", {
...content,
is_self: true,
sending: true,
message_id: messageId,
});
this.inputText = "";
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;
}
},
handleSelectImage() {
this.$refs.imageInput.click();
},
handleSelectFile() {
this.$refs.fileInput.click();
},
handleSelectVideo() {
this.$refs.videoInput.click();
},
handleScreenshot() {
takeScreenshot(
async (blob) => {
// ==============================
// 生成位图数据(核心)
// ==============================
const file = new File([blob], `screenshot_${Date.now()}.png`, { type: "image/png" });
const dataUrl = URL.createObjectURL(blob);
// ==============================
// 预览 + 编辑弹窗(Element UI)
// ==============================
this.$msgbox({
title: "截图预览",
dangerouslyUseHTMLString: true,
message: `<img src="${dataUrl}" style="width:100%;max-height:60vh;object-fit:contain;border-radius:6px">`,
showCancelButton: true,
cancelButtonText: "取消",
confirmButtonText: "发送",
showClose: true,
customClass: "screenshot-preview-dialog",
showConfirmButton: true,
showCancelButton: true,
closeOnClickModal: false,
buttonSize: "small",
callback: (action) => {
if (action === "confirm") {
// ==============================
// 分享:直接发送
// ==============================
this.sendFileMessage(file, "image");
} else {
URL.revokeObjectURL(dataUrl);
}
}
}).then(() => {});
// ==============================
// 复制到剪贴板(自动)
// ==============================
try {
const item = new ClipboardItem({ "image/png": blob });
navigator.clipboard.write([item]);
this.$message.success("已复制到剪贴板");
} catch (e) {}
// ==============================
// 保存到本地(自动)
// ==============================
const a = document.createElement("a");
a.href = dataUrl;
a.download = `截图_${new Date().getTime()}.png`;
a.click();
URL.revokeObjectURL(dataUrl);
},
() => {
this.$message.info("已取消截图");
}
).catch((err) => {
this.$message.error(err.message);
});
},
handleVideoCall() {
if (!this.currentChat) {
this.$message.warning("请先选择聊天对象");
return;
}
this.videoCallVisible = true;
this.callStatus = "正在呼叫...";
this.callStarted = false;
this.callDuration = 0;
this.videoEnabled = true;
this.micEnabled = true;
setTimeout(() => {
this.callStatus = "通话中";
this.callStarted = true;
this.startCallTimer();
}, 2000);
},
startCallTimer() {
this.callTimer = setInterval(() => {
this.callDuration++;
}, 1000);
},
stopCallTimer() {
if (this.callTimer) {
clearInterval(this.callTimer);
this.callTimer = null;
}
},
formatCallTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
},
toggleVideo() {
this.videoEnabled = !this.videoEnabled;
},
toggleMute() {
this.micEnabled = !this.micEnabled;
},
endCall() {
this.stopCallTimer();
this.videoCallVisible = false;
this.$message.info("通话已结束");
},
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 timestamp = Date.now();
const fileName = this.generateFileName(file.name);
const objectName = this.getObjectPath(fileName);
const localUrl = URL.createObjectURL(file);
const payload = {
file_name: file.name,
file_size: file.size,
object_name: objectName,
mime_type: file.type,
url: localUrl,
};
const content = {
type: this.getMessageTypeByFileType(type, file),
scene: this.currentChat.scene,
target_id: this.currentChat.id,
source_id: this.userInfo.id,
payload: type === "image" ? localUrl : payload,
at_users: [],
timestamp,
message_id: messageId,
};
this.$emit("send-message", {
...content,
is_self: true,
sending: true,
isUploading: true,
message_id: messageId,
});
try {
await this.uploadToMinIO(file, objectName);
const serverPayload = {
...payload,
url: objectName,
};
const body = {
topic: this.getTopic(),
client_id: this.getClientId(),
target_id: this.currentChat.id,
content: JSON.stringify({
...content,
payload: type === "image" ? objectName : serverPayload,
}),
};
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,
});
} 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;
},
extractAtUsers(text) {
const ids = [];
this.selectedAtUsers.forEach((user) => {
if (text.includes("@" + user.name)) {
ids.push(user.id);
}
});
return ids;
},
handleShowAtPanel() {
if (!this.currentChat || this.currentChat.scene !== ContactsScene.GROUP)
return;
const inputEl = this.$refs.inputRef.$refs.textarea;
if (inputEl) {
const rect = inputEl.getBoundingClientRect();
this.atPanelStyle = {
bottom: "80px",
left: "20px",
};
}
this.atPanelVisible = true;
this.atSearchKey = "";
},
handleSelectAtUser(user) {
this.inputText += "@" + user.name + " ";
this.selectedAtUsers.push(user);
this.atPanelVisible = false;
this.$refs.inputRef.focus();
},
async loadGroupMembers() {
this.atUsers = [];
},
setAtUsers(users) {
this.atUsers = users || [];
},
setFontSize(size) {
this.currentFontSize = size;
this.showFontSizeMenu = false;
this.$message.info(`字号已设置为 ${size}px`);
},
},
};
</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;
}
&.active {
color: #f56c6c;
}
}
.font-size-icon {
font-size: 16px;
font-weight: bold;
color: #606266;
}
.shortcut-hint {
margin-left: auto;
font-size: 12px;
color: #c0c4cc;
}
}
.font-size-menu {
position: absolute;
top: 40px;
right: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
z-index: 100;
.font-size-item {
padding: 8px 16px;
font-size: 13px;
color: #303133;
cursor: pointer;
white-space: nowrap;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
color: #409eff;
font-weight: 500;
}
}
}
.editor-input-area {
.el-textarea {
::v-deep .el-textarea__inner {
background: #f5f7fa;
border: none;
border-radius: 8px;
padding: 10px 12px;
line-height: 1.5;
&:focus {
background: #fff;
box-shadow: 0 0 0 1px #409eff;
}
}
}
}
.editor-footer {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.at-panel {
position: absolute;
bottom: 100px;
left: 20px;
width: 240px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
padding: 8px 0;
.at-panel-header {
padding: 0 12px 8px;
font-size: 13px;
color: #909399;
border-bottom: 1px solid #ebeef5;
}
.el-input {
padding: 8px 12px;
}
.at-panel-list {
max-height: 200px;
overflow-y: auto;
.at-panel-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
.at-user-name {
font-size: 13px;
color: #303133;
}
}
.at-panel-empty {
padding: 16px;
text-align: center;
font-size: 13px;
color: #c0c4cc;
}
}
}
.video-call-container {
text-align: center;
padding: 30px 0;
.video-call-avatar {
margin-bottom: 16px;
}
.video-call-info {
margin-bottom: 16px;
.video-call-name {
font-size: 18px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.video-call-status {
font-size: 14px;
color: #67c23a;
}
}
.video-call-timer {
font-size: 24px;
font-weight: 600;
color: #409eff;
margin-bottom: 24px;
letter-spacing: 2px;
}
.video-call-buttons {
display: flex;
justify-content: center;
gap: 24px;
.el-button {
font-size: 14px;
padding: 10px 20px;
&.active {
color: #f56c6c;
}
}
}
}
</style>