|
|
|
|
<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);
|
|
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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>
|