diff --git a/src/layout/components/SystemSettingDialog.vue b/src/layout/components/SystemSettingDialog.vue index 2ccdf65..fcff424 100644 --- a/src/layout/components/SystemSettingDialog.vue +++ b/src/layout/components/SystemSettingDialog.vue @@ -29,8 +29,8 @@ --> - Ctrl+Enter发送 - Enter发送 + Ctrl+Enter发送 + Enter发送 @@ -173,7 +173,7 @@ export default { cacheAuthorized: false, basicForm: { notification: true, - sendKey: "enter", + sendKey: "Ctrl+Enter", videoPath: "", cachePath: "", }, @@ -215,6 +215,7 @@ export default { const defaultPath = this.getDefaultPath(); if (savedSettings) { const settings = JSON.parse(savedSettings); + let needSave = false; // 合并保存的设置到表单 if (settings.basicForm) { this.basicForm = { ...this.basicForm, ...settings.basicForm }; @@ -225,6 +226,11 @@ export default { if (!this.basicForm.cachePath) { this.basicForm.cachePath = defaultPath; } + // 如果没有保存发送键设置,使用默认值 Ctrl+Enter + if (!this.basicForm.sendKey) { + this.basicForm.sendKey = "Ctrl+Enter"; + needSave = true; + } } if (settings.audioForm) { this.audioForm = { ...this.audioForm, ...settings.audioForm }; @@ -232,10 +238,20 @@ export default { if (settings.otherForm) { this.otherForm = { ...this.otherForm, ...settings.otherForm }; } + // 迁移逻辑:如果设置中没有版本标记,说明是旧版本,更新发送键为新默认值 + if (!settings.version) { + this.basicForm.sendKey = "Ctrl+Enter"; + needSave = true; + } + if (needSave) { + this.saveSettings(); + } } else { - // 如果没有保存的设置,使用默认路径 + // 如果没有保存的设置,使用默认值并保存 this.basicForm.videoPath = defaultPath; this.basicForm.cachePath = defaultPath; + this.basicForm.sendKey = "Ctrl+Enter"; + this.saveSettings(); } } catch (e) { console.error("加载系统设置失败:", e); @@ -245,11 +261,16 @@ export default { saveSettings() { try { const settings = { + version: "1.0", basicForm: { ...this.basicForm }, audioForm: { ...this.audioForm }, otherForm: { ...this.otherForm }, }; localStorage.setItem(SYSTEM_SETTINGS_KEY, JSON.stringify(settings)); + const event = new CustomEvent('systemSettingsChanged', { + detail: { settings } + }); + window.dispatchEvent(event); } catch (e) { console.error("保存系统设置失败:", e); } @@ -400,7 +421,7 @@ export default { this.cacheAuthorized = false; this.basicForm = { notification: true, - sendKey: "enter", + sendKey: "Ctrl+Enter", videoPath: defaultPath, cachePath: defaultPath, }; diff --git a/src/views/message/components/MessageEditor.vue b/src/views/message/components/MessageEditor.vue index 55d131d..23a1705 100644 --- a/src/views/message/components/MessageEditor.vue +++ b/src/views/message/components/MessageEditor.vue @@ -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)" /> @@ -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..."); - setTimeout(() => { - this.initQuill(); - }, 100); + 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; } - // 防止重复初始化 - 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) => { - if (this.sendMode === "enter") { - // Enter发送模式:Enter发送,Ctrl+Enter换行 - if (e.key === "Enter" && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - this.handleSendText(); - } - } else { - // Ctrl+Enter发送模式:Ctrl+Enter发送,Enter换行 - if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.handleSendText(); - } - } - }); + // ========= 删除原来这里的 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) { - // 条件: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,24 +472,32 @@ export default { try { await takeScreenshot( async (blob) => { - 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; - this.insertImageToEditor(uploadedPath); - this.$message.success("截图已插入到编辑器"); + 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("已取消截图"); @@ -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); + } + }, + // 直接发送文件消息 + 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.$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 = `
+
+ +
+
+
${file.name}
+
${fileSize}
+
+
+ +
+
`; + 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; + } +} \ No newline at end of file