|
|
|
|
@ -125,7 +125,8 @@ |
|
|
|
|
ref="fileInput" |
|
|
|
|
type="file" |
|
|
|
|
style="display: none" |
|
|
|
|
@change="(e) => handleFileChange(e, 'file')" |
|
|
|
|
accept="image/*,video/*,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,.zip,.rar,.txt" |
|
|
|
|
@change="(e) => handleFileChange(e)" |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
</template> |
|
|
|
|
@ -159,7 +160,7 @@ export default { |
|
|
|
|
currentRange: null, |
|
|
|
|
sending: false, |
|
|
|
|
maxFileSize: 100 * 1024 * 1024, |
|
|
|
|
sendMode: "enter", |
|
|
|
|
sendMode: this.getSystemSendMode(), |
|
|
|
|
fontSizeMap: { |
|
|
|
|
10: "small", |
|
|
|
|
11: "small", |
|
|
|
|
@ -170,6 +171,11 @@ export default { |
|
|
|
|
// 记录被@的用户 |
|
|
|
|
selectedAtUsers: [], |
|
|
|
|
meetingModes: meetingModes(), |
|
|
|
|
// Quill初始化重试次数 |
|
|
|
|
quillInitRetryCount: 0, |
|
|
|
|
// 存储编辑器中的文件信息 |
|
|
|
|
editorFiles: [], |
|
|
|
|
maxQuillInitRetries: 20, |
|
|
|
|
}; |
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
@ -190,40 +196,104 @@ export default { |
|
|
|
|
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) { |
|
|
|
|
console.warn("Quill container not found, retrying..."); |
|
|
|
|
this.quillInitRetryCount++; |
|
|
|
|
if (this.quillInitRetryCount <= this.maxQuillInitRetries) { |
|
|
|
|
console.warn( |
|
|
|
|
`Quill container not found, retrying... (${this.quillInitRetryCount}/${this.maxQuillInitRetries})` |
|
|
|
|
); |
|
|
|
|
setTimeout(() => { |
|
|
|
|
this.initQuill(); |
|
|
|
|
}, 100); |
|
|
|
|
return; |
|
|
|
|
} else { |
|
|
|
|
console.error("Quill container not found after maximum retries"); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// 防止重复初始化 |
|
|
|
|
if (this.quill) { |
|
|
|
|
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, |
|
|
|
|
}); |
|
|
|
|
@ -233,49 +303,103 @@ export default { |
|
|
|
|
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); |
|
|
|
|
|
|
|
|
|
// 回车发送逻辑 |
|
|
|
|
this.quill.root.addEventListener("keydown", (e) => { |
|
|
|
|
// ========= 删除原来这里的 keydown 监听,改用 bindings 控制 ========= |
|
|
|
|
}, |
|
|
|
|
// 根据当前 sendMode 返回 Quill 按键规则 |
|
|
|
|
getQuillKeyBindings() { |
|
|
|
|
const self = this; |
|
|
|
|
if (this.sendMode === "enter") { |
|
|
|
|
// Enter发送模式:Enter发送,Ctrl+Enter换行 |
|
|
|
|
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey) { |
|
|
|
|
e.preventDefault(); |
|
|
|
|
this.handleSendText(); |
|
|
|
|
} |
|
|
|
|
// 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发送模式:Ctrl+Enter发送,Enter换行 |
|
|
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { |
|
|
|
|
e.preventDefault(); |
|
|
|
|
this.handleSendText(); |
|
|
|
|
} |
|
|
|
|
// 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) { |
|
|
|
|
// 条件:Ctrl+Enter / Command+Enter(Mac)发送 |
|
|
|
|
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(); |
|
|
|
|
} |
|
|
|
|
@ -323,6 +447,7 @@ export default { |
|
|
|
|
if (this.quill) { |
|
|
|
|
this.quill.setText(""); |
|
|
|
|
} |
|
|
|
|
this.editorFiles = []; |
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
// 字号切换 - 调整消息显示区域的文字大小 |
|
|
|
|
@ -333,6 +458,8 @@ export default { |
|
|
|
|
// 发送方式切换 |
|
|
|
|
handleSendModeChange(mode) { |
|
|
|
|
this.sendMode = mode; |
|
|
|
|
// 切换后刷新键盘规则立即生效 |
|
|
|
|
this.refreshQuillBindings(); |
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
// 文件按钮 |
|
|
|
|
@ -345,6 +472,7 @@ export default { |
|
|
|
|
try { |
|
|
|
|
await takeScreenshot( |
|
|
|
|
async (blob) => { |
|
|
|
|
try { |
|
|
|
|
const fileName = `screenshot_${Date.now()}.png`; |
|
|
|
|
const objectName = this.getObjectPath(fileName); |
|
|
|
|
|
|
|
|
|
@ -361,8 +489,15 @@ export default { |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
const uploadedPath = bucket + "/" + result.objectName; |
|
|
|
|
this.insertImageToEditor(uploadedPath); |
|
|
|
|
// 创建文件对象用于 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("已取消截图"); |
|
|
|
|
@ -375,8 +510,6 @@ export default { |
|
|
|
|
|
|
|
|
|
// ========== 核心:选中@成员 插入富文本 ========== |
|
|
|
|
handleAtSelect(user) { |
|
|
|
|
console.log(user); |
|
|
|
|
|
|
|
|
|
if (!this.quill || !user) return; |
|
|
|
|
const index = this.currentRange |
|
|
|
|
? this.currentRange.index |
|
|
|
|
@ -433,7 +566,14 @@ export default { |
|
|
|
|
const html = this.quill.root.innerHTML; |
|
|
|
|
const textContent = this.quill.getText().trim(); |
|
|
|
|
|
|
|
|
|
if (!textContent) return; |
|
|
|
|
// 检查是否有内容可以发送(文本或文件) |
|
|
|
|
const hasText = textContent.replace(/\s+/g, "").length > 0; |
|
|
|
|
const files = this.extractFilesFromEditor(); |
|
|
|
|
|
|
|
|
|
if (!hasText && files.length === 0) { |
|
|
|
|
this.$message.warning("请输入内容或上传文件"); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
this.sending = true; |
|
|
|
|
|
|
|
|
|
@ -476,13 +616,13 @@ export default { |
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
// 解析编辑器内容,分离文本和图片 |
|
|
|
|
// 解析编辑器内容,分离文本和文件 |
|
|
|
|
parseEditorContent(html, textContent) { |
|
|
|
|
const messages = []; |
|
|
|
|
const atUserIds = this.extractAtUsers(html); |
|
|
|
|
|
|
|
|
|
const textOnly = textContent.replace(/\s+/g, "").length > 0; |
|
|
|
|
const images = this.extractImagesFromEditor(); |
|
|
|
|
const files = this.extractFilesFromEditor(); |
|
|
|
|
|
|
|
|
|
if (textOnly) { |
|
|
|
|
messages.push({ |
|
|
|
|
@ -505,67 +645,86 @@ export default { |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
images.forEach((image) => { |
|
|
|
|
files.forEach((file) => { |
|
|
|
|
const msgType = this.getMessageType(file.type); |
|
|
|
|
|
|
|
|
|
messages.push({ |
|
|
|
|
at_users: [], |
|
|
|
|
message_id: 0, |
|
|
|
|
payload: { |
|
|
|
|
content: "", |
|
|
|
|
file_duration: 0, |
|
|
|
|
file_ico: "", |
|
|
|
|
file_name: image.fileName, |
|
|
|
|
file_path: image.filePath, |
|
|
|
|
file_size: image.fileSize || 0, |
|
|
|
|
file_type: "image", |
|
|
|
|
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: "image", |
|
|
|
|
type: msgType, |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
return messages; |
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
// 从编辑器中提取图片信息 |
|
|
|
|
extractImagesFromEditor() { |
|
|
|
|
const images = []; |
|
|
|
|
if (!this.quill) return images; |
|
|
|
|
// 从编辑器中提取所有文件信息 |
|
|
|
|
extractFilesFromEditor() { |
|
|
|
|
const files = []; |
|
|
|
|
if (!this.quill) return files; |
|
|
|
|
|
|
|
|
|
const config = this.$store.getters.config; |
|
|
|
|
const endpoint = config.MINIO_ENDPOINT_HTTPS || ""; |
|
|
|
|
const bucket = config.MINIO_BUCKET_FILE || "remote-file-test"; |
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
images.push({ |
|
|
|
|
src: src, |
|
|
|
|
filePath: filePath, |
|
|
|
|
fileName: fileName, |
|
|
|
|
files.push({ |
|
|
|
|
name: fileName, |
|
|
|
|
path: filePath, |
|
|
|
|
type: "image", |
|
|
|
|
size: 0, |
|
|
|
|
icon: "", |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
return images; |
|
|
|
|
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, type) { |
|
|
|
|
async handleFileChange(e) { |
|
|
|
|
const files = e.target.files; |
|
|
|
|
if (!files || !files.length) return; |
|
|
|
|
const file = files[0]; |
|
|
|
|
e.target.value = ""; |
|
|
|
|
|
|
|
|
|
if (!file.type.startsWith("image/")) { |
|
|
|
|
this.$message.warning("只能上传图片文件"); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (file.size > this.maxFileSize) { |
|
|
|
|
this.$message.warning("文件大小不能超过100MB"); |
|
|
|
|
return; |
|
|
|
|
@ -593,12 +752,104 @@ export default { |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
const uploadedPath = bucket + "/" + result.objectName; |
|
|
|
|
this.insertImageToEditor(uploadedPath); |
|
|
|
|
this.$message.success("图片上传成功"); |
|
|
|
|
|
|
|
|
|
// 所有文件都插入到编辑器 |
|
|
|
|
await this.insertFileToEditor(file, uploadedPath); |
|
|
|
|
this.$message.success("文件上传成功"); |
|
|
|
|
} catch (e) { |
|
|
|
|
this.$message.error("图片上传失败: " + e.message); |
|
|
|
|
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; |
|
|
|
|
@ -728,6 +979,103 @@ export default { |
|
|
|
|
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) { |
|
|
|
|
@ -852,6 +1200,14 @@ export default { |
|
|
|
|
::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 { |
|
|
|
|
@ -878,4 +1234,89 @@ export default { |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.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> |