消息消息模块-功能联调

main
ysn 2 weeks ago
parent 30af97e1ee
commit ed66c64bce
  1. 307
      src/utils/screenshot.js
  2. 27
      src/views/message/components/MessageDisplay.vue
  3. 538
      src/views/message/components/MessageEditor.vue
  4. 21
      src/views/message/index.vue

@ -23,6 +23,9 @@ class ScreenshotTool {
this.annotations = [];
this.textInput = null;
this.dpr = window.devicePixelRatio || 1;
this.lineWidth = 2;
this.lineColor = '#ff4d4f';
}
async start(onComplete, onCancel) {
@ -30,23 +33,23 @@ class ScreenshotTool {
this.onCancel = onCancel;
if (!this.isSupported()) {
throw new Error("当前浏览器不支持截图");
throw new Error('当前浏览器不支持截图');
}
try {
this.stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "monitor", width: 3840, height: 2160 },
video: { displaySurface: 'monitor', width: 3840, height: 2160 },
audio: false,
});
this.video = document.createElement("video");
this.video = document.createElement('video');
this.video.srcObject = this.stream;
this.video.muted = true;
await this.video.play();
this.createOverlay();
} catch (err) {
if (err.name === "NotAllowedError") throw new Error("已拒绝屏幕权限");
throw new Error("截图启动失败");
if (err.name === 'NotAllowedError') throw new Error('已拒绝屏幕权限');
throw new Error('截图启动失败');
}
}
@ -57,14 +60,14 @@ class ScreenshotTool {
createOverlay() {
this.scrollX = window.scrollX;
this.scrollY = window.scrollY;
document.body.style.overflow = "hidden";
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
this.overlay = document.createElement("div");
this.overlay.id = "screenshot-overlay";
this.overlay = document.createElement('div');
this.overlay.id = 'screenshot-overlay';
this.overlay.style.cssText = `
position:fixed;top:0;left:0;width:100vw;height:100vh;
background:rgba(0,0,0,0.6);z-index:999999;cursor:crosshair;user-select:none;
background:rgba(0,0,0,0.5);z-index:999999;cursor:crosshair;user-select:none;
`;
document.body.appendChild(this.overlay);
@ -77,78 +80,118 @@ class ScreenshotTool {
const w = this.video.videoWidth;
const h = this.video.videoHeight;
this.canvas = document.createElement("canvas");
this.canvas = document.createElement('canvas');
this.canvas.width = w;
this.canvas.height = h;
this.canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%";
this.canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%';
this.overlay.appendChild(this.canvas);
this.canvas.getContext("2d").drawImage(this.video, 0, 0, w, h);
this.canvas.getContext('2d').drawImage(this.video, 0, 0, w, h);
this.selectionCanvas = document.createElement("canvas");
this.selectionCanvas = document.createElement('canvas');
this.selectionCanvas.width = w;
this.selectionCanvas.height = h;
this.selectionCanvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
this.selectionCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none';
this.overlay.appendChild(this.selectionCanvas);
this.drawCanvas = document.createElement("canvas");
this.drawCanvas = document.createElement('canvas');
this.drawCanvas.width = w;
this.drawCanvas.height = h;
this.drawCanvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;display:none";
this.drawCanvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;display:none';
this.overlay.appendChild(this.drawCanvas);
}
createToolbar() {
this.toolbar = document.createElement("div");
this.toolbar = document.createElement('div');
this.toolbar.style.cssText = `
position:fixed;bottom:60px;left:50%;transform:translateX(-50%);
background:#1f1f1f;border-radius:10px;padding:10px 20px;
display:flex;gap:28px;box-shadow:0 4px 20px rgba(0,0,0,0.4);z-index:1000000;
position:fixed;top:40px;left:50%;transform:translateX(-50%);
background:#fff;border-radius:8px;padding:6px;
display:flex;gap:4px;box-shadow:0 2px 12px rgba(0,0,0,0.15);z-index:1000000;
`;
const btns = [
{ icon: "✓", color: "#67c23a", action: "confirm" },
{ icon: "✕", color: "#f56c6c", action: "cancel" },
{ icon: "🖵", color: "#409eff", action: "fullscreen" },
const tools = [
{ icon: '▢', tool: 'rect', title: '矩形' },
{ icon: '○', tool: 'circle', title: '圆形' },
{ icon: '✏', tool: 'pen', title: '画笔' },
{ icon: '➤', tool: 'arrow', title: '箭头' },
{ icon: 'A', tool: 'text', title: '文字' },
{ icon: '▦', tool: 'mosaic', title: '马赛克' },
{ icon: '↩', tool: 'undo', title: '撤销' },
{ icon: '🖵', tool: 'fullscreen', title: '全屏' },
];
btns.forEach((b) => {
const el = document.createElement("button");
el.innerHTML = `<span style="font-size:20px">${b.icon}</span>`;
el.style.cssText = `background:none;border:none;color:${b.color};cursor:pointer;padding:6px;border-radius:6px`;
el.onmouseover = () => el.style.background = "rgba(255,255,255,0.1)";
el.onmouseout = () => el.style.background = "transparent";
el.onclick = (e) => { e.stopPropagation(); this.handleAction(b.action); };
tools.forEach((t) => {
const el = document.createElement('button');
el.innerHTML = `<span style="font-size:18px">${t.icon}</span>`;
el.style.cssText = `
background:none;border:none;color:#333;cursor:pointer;
padding:8px 12px;border-radius:6px;
display:flex;align-items:center;justify-content:center;
min-width:36px;height:36px;
transition:background-color 0.2s;
`;
el.title = t.title;
el.onmouseover = () => { el.style.background = '#f0f0f0'; };
el.onmouseout = () => { el.style.background = 'transparent'; };
el.onclick = (e) => {
e.stopPropagation();
this.handleToolbarTool(t.tool);
};
this.toolbar.appendChild(el);
});
this.overlay.appendChild(this.toolbar);
this.actionToolbar = document.createElement("div");
this.actionToolbar = document.createElement('div');
this.actionToolbar.style.cssText = `
position:fixed;background:#fff;border-radius:8px;padding:6px 10px;
display:none;gap:12px;box-shadow:0 2px 12px rgba(0,0,0,0.15);z-index:1000001;
position:fixed;bottom:40px;left:50%;transform:translateX(-50%);
background:#fff;border-radius:8px;padding:6px;
display:flex;gap:4px;box-shadow:0 2px 12px rgba(0,0,0,0.15);z-index:1000001;
`;
const tools = [
{ icon: "▢", tool: "rect" }, { icon: "○", tool: "circle" },
{ icon: "✏", tool: "pen" }, { icon: "➤", tool: "arrow" },
{ icon: "A", tool: "text" }, { icon: "▦", tool: "mosaic" },
{ icon: "↩", tool: "undo" },
const actions = [
{ icon: '✕', action: 'cancel', color: '#666', title: '取消' },
{ icon: '✓', action: 'confirm', color: '#52c41a', title: '确认' },
];
tools.forEach((t) => {
const el = document.createElement("button");
el.innerHTML = `<span style="font-size:16px">${t.icon}</span>`;
el.style.cssText = "background:none;border:none;color:#333;cursor:pointer;padding:4px 6px;border-radius:4px";
el.onmouseover = () => el.style.background = "#f0f0f0";
el.onmouseout = () => el.style.background = "transparent";
el.onclick = (e) => { e.stopPropagation(); this.handleAnnotateTool(t.tool); };
actions.forEach((a) => {
const el = document.createElement('button');
el.innerHTML = `<span style="font-size:20px">${a.icon}</span>`;
el.style.cssText = `
background:none;border:none;color:${a.color};cursor:pointer;
padding:10px 20px;border-radius:6px;
font-weight:500;
transition:background-color 0.2s;
min-width:80px;height:36px;
display:flex;align-items:center;justify-content:center;
`;
el.title = a.title;
el.onmouseover = () => { el.style.background = '#f0f0f0'; };
el.onmouseout = () => { el.style.background = 'transparent'; };
el.onclick = (e) => {
e.stopPropagation();
this.handleAction(a.action);
};
this.actionToolbar.appendChild(el);
});
this.overlay.appendChild(this.actionToolbar);
}
handleToolbarTool(tool) {
if (tool === 'undo') {
this.undoAnnotation();
return;
}
if (tool === 'fullscreen') {
this.captureFullscreen();
return;
}
this.currentTool = tool;
this.isAnnotating = true;
this.drawCanvas.style.display = 'block';
this.overlay.style.cursor = tool === 'text' ? 'text' : 'crosshair';
}
addEventListeners() {
this.overlay.addEventListener("mousedown", (e) => {
this.overlay.addEventListener('mousedown', (e) => {
if (this.toolbar.contains(e.target) || this.actionToolbar.contains(e.target)) return;
if (this.isAnnotating && this.currentTool) {
this.startAnnotation(e);
@ -161,7 +204,7 @@ class ScreenshotTool {
}
});
this.overlay.addEventListener("mousemove", (e) => {
this.overlay.addEventListener('mousemove', (e) => {
if (this.isDrawing) {
this.currentX = e.clientX;
this.currentY = e.clientY;
@ -171,65 +214,77 @@ class ScreenshotTool {
}
});
this.overlay.addEventListener("mouseup", () => {
this.overlay.addEventListener('mouseup', () => {
if (this.isDrawing) {
this.isDrawing = false;
this.showActionToolbar();
const w = Math.abs(this.currentX - this.startX);
const h = Math.abs(this.currentY - this.startY);
if (w >= 15 && h >= 15) {
this.isAnnotating = true;
this.drawCanvas.style.display = 'block';
}
} else if (this.isDrawingAnnotation) {
this.finishAnnotation();
}
});
this.keydownHandler = (e) => e.key === "Escape" && this.cancel();
document.addEventListener("keydown", this.keydownHandler);
this.keydownHandler = (e) => e.key === 'Escape' && this.cancel();
document.addEventListener('keydown', this.keydownHandler);
}
updateSelection() {
const ctx = this.selectionCanvas.getContext("2d");
const ctx = this.selectionCanvas.getContext('2d');
ctx.clearRect(0, 0, this.selectionCanvas.width, this.selectionCanvas.height);
const x = Math.min(this.startX, this.currentX);
const y = Math.min(this.startY, this.currentY);
const w = Math.abs(this.currentX - this.startX);
const h = Math.abs(this.currentY - this.startY);
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, this.selectionCanvas.width, this.selectionCanvas.height);
ctx.clearRect(x, y, w, h);
ctx.strokeStyle = "#409eff";
ctx.lineWidth = 2;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
}
showActionToolbar() {
const x = Math.min(this.startX, this.currentX);
const y = Math.min(this.startY, this.currentY);
const w = Math.abs(this.currentX - this.startX);
const h = Math.abs(this.currentY - this.startY);
if (w < 15 || h < 15) return;
ctx.strokeStyle = '#52c41a';
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, w - 2, h - 2);
this.isAnnotating = true;
this.drawCanvas.style.display = "block";
this.actionToolbar.style.display = "flex";
this.actionToolbar.style.left = `${x + w - 220}px`;
this.actionToolbar.style.top = `${y + h + 10}px`;
this.drawCorner(ctx, x, y, 12);
this.drawCorner(ctx, x + w, y, 12);
this.drawCorner(ctx, x, y + h, 12);
this.drawCorner(ctx, x + w, y + h, 12);
ctx.fillStyle = '#52c41a';
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const sizeText = `${Math.round(w)} × ${Math.round(h)}`;
ctx.fillText(sizeText, x + 8, y - 20);
}
handleAnnotateTool(tool) {
if (tool === "undo") return this.undoAnnotation();
this.currentTool = tool;
this.overlay.style.cursor = tool === "text" ? "text" : "crosshair";
drawCorner(ctx, x, y, size) {
ctx.strokeStyle = '#52c41a';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - size, y);
ctx.lineTo(x, y);
ctx.lineTo(x, y - size);
ctx.stroke();
}
startAnnotation(e) {
this.isDrawingAnnotation = true;
this.annotStartX = e.clientX;
this.annotStartY = e.clientY;
if (this.currentTool === "text") this.createTextInput(e.clientX, e.clientY);
if (this.currentTool === 'text') this.createTextInput(e.clientX, e.clientY);
}
continueAnnotation(e) {
if (!this.isDrawingAnnotation || !this.currentTool || this.currentTool === "text") return;
const ctx = this.drawCanvas.getContext("2d");
if (!this.isDrawingAnnotation || !this.currentTool || this.currentTool === 'text') return;
const ctx = this.drawCanvas.getContext('2d');
ctx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height);
this.redrawAnnotations(ctx);
@ -237,61 +292,88 @@ class ScreenshotTool {
const y = this.annotStartY;
const ex = e.clientX;
const ey = e.clientY;
ctx.strokeStyle = "#ff4d4f";
ctx.fillStyle = "#ff4d4f";
ctx.lineWidth = 2;
ctx.strokeStyle = this.lineColor;
ctx.fillStyle = this.lineColor;
ctx.lineWidth = this.lineWidth;
switch (this.currentTool) {
case "rect": ctx.strokeRect(x, y, ex - x, ey - y); break;
case "circle": ctx.beginPath(); ctx.arc(x, y, Math.hypot(ex - x, ey - y), 0, Math.PI * 2); ctx.stroke(); break;
case "pen": ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(ex, ey); ctx.stroke(); this.annotStartX = ex; this.annotStartY = ey; break;
case "arrow": this.drawArrow(ctx, x, y, ex, ey); break;
case "mosaic": this.drawMosaic(ctx, ex, ey); break;
case 'rect':
ctx.strokeRect(x, y, ex - x, ey - y);
break;
case 'circle':
ctx.beginPath();
ctx.arc(x, y, Math.hypot(ex - x, ey - y), 0, Math.PI * 2);
ctx.stroke();
break;
case 'pen':
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex, ey);
ctx.stroke();
this.annotStartX = ex;
this.annotStartY = ey;
break;
case 'arrow':
this.drawArrow(ctx, x, y, ex, ey);
break;
case 'mosaic':
this.drawMosaic(ctx, ex, ey);
break;
}
}
finishAnnotation() {
if (!this.isDrawingAnnotation || !this.currentTool || this.currentTool === "text") return;
if (!this.isDrawingAnnotation || !this.currentTool || this.currentTool === 'text') return;
this.isDrawingAnnotation = false;
const ctx = this.drawCanvas.getContext("2d");
const ctx = this.drawCanvas.getContext('2d');
this.annotations.push(ctx.getImageData(0, 0, this.drawCanvas.width, this.drawCanvas.height));
}
drawArrow(ctx, x1, y1, x2, y2) {
const head = 15;
const head = 12;
const angle = Math.atan2(y2 - y1, x2 - x1);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - head * Math.cos(angle - Math.PI / 6), y2 - head * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - head * Math.cos(angle + Math.PI / 6), y2 - head * Math.sin(angle + Math.PI / 6));
ctx.closePath(); ctx.fill();
ctx.closePath();
ctx.fill();
}
drawMosaic(ctx, x, y) {
const s = 8;
const data = ctx.getImageData(x - s / 2, y - s / 2, s, s);
const r = data.data[0], g = data.data[1], b = data.data[2];
const rectX = Math.floor(x / s) * s;
const rectY = Math.floor(y / s) * s;
const data = ctx.getImageData(rectX, rectY, s, s);
const r = data.data[0];
const g = data.data[1];
const b = data.data[2];
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(x - s / 2, y - s / 2, s, s);
ctx.fillRect(rectX, rectY, s, s);
}
createTextInput(x, y) {
if (this.textInput) this.textInput.remove();
this.textInput = document.createElement("input");
this.textInput = document.createElement('input');
this.textInput.style.cssText = `
position:fixed;left:${x}px;top:${y}px;border:none;outline:none;
font-size:16px;color:#ff4d4f;background:transparent;z-index:1000002;min-width:100px;
`;
this.textInput.onkeydown = (e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
this.addText(x, y, this.textInput.value);
this.textInput.remove(); this.textInput = null;
this.textInput.remove();
this.textInput = null;
}
};
this.textInput.onblur = () => {
if (this.textInput.value) this.addText(x, y, this.textInput.value);
this.textInput?.remove(); this.textInput = null;
this.textInput?.remove();
this.textInput = null;
};
this.overlay.appendChild(this.textInput);
this.textInput.focus();
@ -299,30 +381,33 @@ class ScreenshotTool {
addText(x, y, text) {
if (!text) return;
const ctx = this.drawCanvas.getContext("2d");
ctx.font = "16px Arial";
ctx.fillStyle = "#ff4d4f";
const ctx = this.drawCanvas.getContext('2d');
ctx.font = '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = '#ff4d4f';
ctx.fillText(text, x, y + 16);
this.annotations.push(ctx.getImageData(0, 0, this.drawCanvas.width, this.drawCanvas.height));
}
redrawAnnotations(ctx) {
this.annotations.forEach(d => ctx.putImageData(d, 0, 0));
this.annotations.forEach((d) => ctx.putImageData(d, 0, 0));
}
undoAnnotation() {
if (this.annotations.length === 0) return;
this.annotations.pop();
const ctx = this.drawCanvas.getContext("2d");
const ctx = this.drawCanvas.getContext('2d');
ctx.clearRect(0, 0, this.drawCanvas.width, this.drawCanvas.height);
this.redrawAnnotations(ctx);
}
handleAction(a) {
switch (a) {
case "confirm": this.confirm(); break;
case "cancel": this.cancel(); break;
case "fullscreen": this.captureFullscreen(); break;
case 'confirm':
this.confirm();
break;
case 'cancel':
this.cancel();
break;
}
}
@ -339,10 +424,10 @@ class ScreenshotTool {
}
capture(cx, cy, cw, ch) {
const out = document.createElement("canvas");
const out = document.createElement('canvas');
out.width = cw * this.dpr;
out.height = ch * this.dpr;
const ctx = out.getContext("2d");
const ctx = out.getContext('2d');
ctx.scale(this.dpr, this.dpr);
const vw = this.video.videoWidth;
@ -354,7 +439,7 @@ class ScreenshotTool {
ctx.drawImage(this.drawCanvas, cx * sx, cy * sy, cw * sx, ch * sy, 0, 0, cw, ch);
this.cleanup();
out.toBlob(b => this.onComplete?.(b), "image/png", 1);
out.toBlob((b) => this.onComplete?.(b), 'image/png', 1);
}
cancel() {
@ -363,13 +448,13 @@ class ScreenshotTool {
}
cleanup() {
document.body.style.overflow = "";
document.documentElement.style.overflow = "";
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
window.scrollTo(this.scrollX, this.scrollY);
this.overlay?.remove();
this.textInput?.remove();
this.stream?.getTracks().forEach(t => t.stop());
document.removeEventListener("keydown", this.keydownHandler);
this.stream?.getTracks().forEach((t) => t.stop());
document.removeEventListener('keydown', this.keydownHandler);
}
}

@ -406,7 +406,8 @@ export default {
newChat &&
(!oldChat ||
newChat.id !== oldChat.id ||
newChat.scene !== oldChat.scene)
newChat.scene !== oldChat.scene ||
newChat.last_message_id !== oldChat.last_message_id)
) {
this.switchToChat(newChat);
}
@ -535,15 +536,19 @@ export default {
let merged;
// ID
const existingIds = new Set(existing.map(m => m.message_id || m.id));
const existingIds = new Set(existing.map((m) => m.message_id || m.id));
if (up === 0) {
//
const newMessages = processed.filter(m => !existingIds.has(m.message_id || m.id));
const newMessages = processed.filter(
(m) => !existingIds.has(m.message_id || m.id)
);
merged = [...existing, ...newMessages];
} else {
//
const newMessages = processed.filter(m => !existingIds.has(m.message_id || m.id));
const newMessages = processed.filter(
(m) => !existingIds.has(m.message_id || m.id)
);
merged = [...newMessages, ...existing];
}
@ -658,7 +663,18 @@ export default {
formatTextContent(content) {
if (!content) return "";
// content content
let text = content;
if (typeof content === "object") {
text = content.content || content.payload || JSON.stringify(content);
}
// text
if (typeof text !== "string") {
text = String(text);
}
// HTML
text = text
.replace(/&/g, "&amp;")
@ -887,6 +903,7 @@ export default {
this.fontSize = fontSize;
const container = this.$refs.messageContainer;
if (container) {
container.style.setProperty("--message-font-size", fontSize + "px");
container.style.fontSize = fontSize + "px";
}
},
@ -999,7 +1016,7 @@ export default {
padding: 10px 14px;
border-radius: 12px;
word-break: break-word;
font-size: 14px;
font-size: var(--message-font-size, 14px);
line-height: 1.5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);

@ -1,26 +1,26 @@
<template>
<div class="message-editor">
<!-- Quill 编辑区 + 右下角发送按钮 容器 -->
<div class="quill-wrap">
<div ref="quillEditor" class="quill-container"></div>
<!-- 右下角发送区域 -->
<!-- 工具栏 -->
<div class="editor-toolbar">
<el-tooltip content="文件" placement="top">
<div class="editor-toolbar-left">
<el-button
type="text"
icon="el-icon-folder-opened"
@click="handleFileClick"
title="文件"
/>
</el-tooltip>
<el-tooltip content="截屏" placement="top">
<el-button
type="text"
icon="el-icon-scissors"
@click="handleScreenshotClick"
title="截屏"
/>
</el-tooltip>
<!-- 字号下拉 -->
<el-dropdown @command="handleFontSizeChange">
<el-button type="text"> A </el-button>
<el-button type="text" style="font-size: 18px">A</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="10">10</el-dropdown-item>
<el-dropdown-item command="11">11</el-dropdown-item>
@ -29,15 +29,18 @@
<el-dropdown-item command="14">14</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<!-- @成员下拉 仅群聊显示 -->
<el-dropdown @command="handleAtSelect" v-if="currentChat.scene == 4&&groupInfo">
<el-button type="text"> @ </el-button>
<el-dropdown
ref="atDropdown"
@command="handleAtSelect"
v-if="currentChat.scene == 4 && groupInfo"
>
<el-button type="text" style="font-size: 16px">@</el-button>
<el-dropdown-menu
slot="dropdown"
style="height: 200px; overflow-y: auto"
>
<el-dropdown-item
command="{ id: '0', name: '所有人' }"
:command="{ id: 0, name: 'all' }"
style="
display: flex;
align-items: center;
@ -67,7 +70,8 @@
<el-avatar
:size="38"
:src="
$store.state.user.netConfig.MINIO_ENDPOINT_HTTPS + item.avatar
$store.state.user.netConfig.MINIO_ENDPOINT_HTTPS +
item.avatar
"
/>
{{ item.name }}
@ -78,53 +82,44 @@
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<el-tooltip content="发起教学" placement="top">
</div>
<div class="editor-toolbar-right">
<el-button
type="text"
icon="el-icon-video-camera"
@click="handleTeachingClick"
@click="handleTeachingClick(1)"
title="发起教学"
v-if="currentChat.scene == ContactsScene.GROUP"
/>
</el-tooltip>
<el-tooltip content="发起会诊" placement="top">
<el-button
type="text"
icon="el-icon-video-camera-solid"
@click="handleConsultClick"
@click="handleTeachingClick(9)"
title="发起会诊"
v-if="currentChat.scene == ContactsScene.PRIVATE"
/>
</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"
size="mini"
:loading="sending"
@click="handleSendText"
>
发送
</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 type="text">
{{ sendModeLabel }}<i class="el-icon-arrow-down el-icon--right" />
</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-item command="ctrlEnter">
Ctrl+Enter发送
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</div>
<!-- 隐藏文件上传 -->
<input
ref="fileInput"
@ -141,13 +136,15 @@ import Quill from "quill";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";
import { sendPrivateMessage, sendMultiChatMessage } from "@/api/message";
import { uploadFile } from "@/utils/requestMinio";
import { takeScreenshot } from "@/utils/screenshot";
import {
MessageType,
ContactsScene,
MinioPaths,
MqttTopics,
} from "@/utils/constants";
import { meetingModes } from "@/api/videoCommunication";
export default {
name: "MessageEditor",
props: {
@ -156,6 +153,8 @@ export default {
data() {
return {
MessageType,
ContactsScene,
quill: null,
currentRange: null,
sending: false,
@ -170,11 +169,12 @@ export default {
},
// @
selectedAtUsers: [],
meetingModes: meetingModes(),
};
},
computed: {
...mapGetters(["currentChat", "userInfo"]),
...mapGetters(["currentChat", "userInfo", "config"]),
canSend() {
const text = this.quill ? this.quill.getText().trim() : "";
@ -187,17 +187,38 @@ export default {
},
mounted() {
this.$nextTick(() => {
this.initQuill();
});
//
document.addEventListener("keydown", this.handleGlobalKeydown);
},
beforeDestroy() {
if (this.quill) {
this.quill = null;
}
//
document.removeEventListener("keydown", this.handleGlobalKeydown);
},
methods: {
// Quill
initQuill() {
const editorDom = this.$refs.quillEditor;
if (!editorDom) {
console.warn("Quill container not found, retrying...");
setTimeout(() => {
this.initQuill();
}, 100);
return;
}
//
if (this.quill) {
return;
}
this.quill = new Quill(editorDom, {
theme: "snow",
placeholder: "请输入消息...",
@ -217,17 +238,26 @@ export default {
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") {
if (e.key === "Enter" && !e.ctrlKey && !e.shiftKey) {
// EnterEnterCtrl+Enter
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.handleSendText();
}
} else {
// Ctrl+EnterCtrl+EnterEnter
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleSendText();
@ -235,6 +265,59 @@ export default {
}
});
},
handleGlobalKeydown(e) {
// Ctrl+Enter / Command+EnterMac
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;
e.preventDefault();
this.handleSendText();
}
},
// @
handleTextChange(delta) {
try {
if (
!this.currentChat ||
this.currentChat.scene !== ContactsScene.GROUP
) {
return;
}
if (!this.groupInfo) return;
if (delta.ops) {
delta.ops.forEach((op) => {
if (op.insert && typeof op.insert === "string") {
const atIndex = op.insert.lastIndexOf("@");
if (atIndex !== -1 && atIndex === op.insert.length - 1) {
this.showAtDropdown();
}
}
});
}
} catch (e) {
console.warn("handleTextChange error:", e);
}
},
// @
showAtDropdown() {
try {
const dropdownEl = this.$refs.atDropdown;
if (dropdownEl && dropdownEl.$el) {
dropdownEl.$el.click();
}
} catch (e) {
console.warn("showAtDropdown error:", e);
}
},
clearEditor() {
if (this.quill) {
@ -258,36 +341,79 @@ export default {
},
//
handleScreenshotClick() {
this.$emit("open-screenshot");
async handleScreenshotClick() {
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("截图已插入到编辑器");
},
() => {
this.$message.info("已取消截图");
}
);
} catch (e) {
this.$message.error("截图失败: " + e.message);
}
},
// ========== @ ==========
handleAtSelect(user) {
console.log(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.formatText(index, insertText.length, {
color: "#409eff",
fontWeight: "500",
});
this.quill.setSelection(index + insertText.length);
// @
const hasUser = this.selectedAtUsers.find((u) => u.id === user.id);
if (!hasUser) {
this.selectedAtUsers.push(user);
}
},
handleTeachingClick() {
this.$emit("open-teaching");
handleTeachingClick(meetingType) {
const targetMode = this.meetingModes.find(
(item) => item.type === meetingType
);
console.log(targetMode);
//
this.$router.push({
name: targetMode.routeName,
query: {
name: this.userInfo.group,
roomId_id: this.generateRoomId(targetMode.type, this.userInfo.id),
},
handleConsultClick() {
this.$emit("open-consult");
});
},
generateRoomId(meetingType, id) {
// 1. id30
const idStr = String(id).padStart(3, "0");
// = + 3id +
const roomId = meetingType + idStr + this.config.MEETING_PREFIX;
return roomId;
},
// @ID使
@ -305,21 +431,65 @@ export default {
async handleSendText() {
if (!this.quill || !this.currentChat) return;
const html = this.quill.root.innerHTML;
const text = this.quill.getText().trim();
if (!text) return;
const textContent = this.quill.getText().trim();
if (!textContent) return;
this.sending = true;
const messageId = this.generateMessageId();
const timestamp = Date.now();
try {
const messages = this.parseEditorContent(html, textContent);
for (let i = 0; i < messages.length; i++) {
const messageData = messages[i];
const messageId = this.generateMessageId();
this.$emit("send-message", {
...messageData,
is_self: true,
sending: true,
message_id: messageId,
});
const body = {
client_id: this.getClientId(),
message: messageData,
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 });
}
this.clearEditor();
this.selectedAtUsers = [];
} catch (e) {
this.$message.error("发送失败: " + (e.message || "未知错误"));
} finally {
this.sending = false;
}
},
//
parseEditorContent(html, textContent) {
const messages = [];
const atUserIds = this.extractAtUsers(html);
const message = {
const textOnly = textContent.replace(/\s+/g, "").length > 0;
const images = this.extractImagesFromEditor();
if (textOnly) {
messages.push({
at_users: atUserIds,
message_id: 0,
payload: {
content: text,
content: textContent,
file_duration: 0,
file_ico: "",
file_name: "",
@ -332,41 +502,56 @@ export default {
target_id: this.currentChat.id,
timestamp: 0,
type: "text",
};
});
}
const body = {
client_id: this.getClientId(),
message,
images.forEach((image) => {
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",
},
scene: this.currentChat.scene,
source_id: this.userInfo.id,
target_id: this.currentChat.id,
topic: this.getTopic(),
};
this.$emit("send-message", {
...message,
is_self: true,
sending: true,
message_id: messageId,
timestamp: 0,
type: "image",
});
});
this.clearEditor();
this.selectedAtUsers = [];
return messages;
},
if (this.currentChat.scene === ContactsScene.PRIVATE) {
await sendPrivateMessage(body);
} else if (this.currentChat.scene === ContactsScene.GROUP) {
await sendMultiChatMessage(body);
}
//
extractImagesFromEditor() {
const images = [];
if (!this.quill) return images;
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,
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,
});
} finally {
this.sending = false;
}
});
return images;
},
//
@ -376,11 +561,43 @@ export default {
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;
}
await this.sendFileMessage(file, type);
if (!this.currentChat) {
this.$message.warning("请先选择聊天对象");
return;
}
try {
await this.ensureMinioInitialized();
const fileName = file.name;
const objectName = this.getObjectPath(fileName);
const config = this.$store.getters.config;
const bucket = config.MINIO_BUCKET_FILE || "remote-file-test";
const result = await uploadFile(
bucket,
objectName,
file,
{},
(percent) => {}
);
const uploadedPath = bucket + "/" + result.objectName;
this.insertImageToEditor(uploadedPath);
this.$message.success("图片上传成功");
} catch (e) {
this.$message.error("图片上传失败: " + e.message);
}
},
async handlePaste(e) {
@ -402,7 +619,8 @@ export default {
async sendFileMessage(file, type) {
if (!this.currentChat) return;
const messageId = this.generateMessageId();
const fileName = this.generateFileName(file.name);
console.log("sendFileMessage", file);
const fileName = file.name;
const objectName = this.getObjectPath(fileName);
const fileType =
@ -498,6 +716,26 @@ export default {
}
},
insertImageToEditor(imagePath) {
if (!this.quill) return;
const index = this.currentRange
? this.currentRange.index
: this.quill.getLength();
const imageUrl =
this.$store.state.user.netConfig.MINIO_ENDPOINT_HTTPS + imagePath;
console.log(imageUrl);
this.quill.insertEmbed(index, "image", imageUrl);
this.quill.setSelection(index + 1);
},
ensureMinioInitialized() {
const config = this.$store.getters.config;
if (!config || !config.MINIO_ENDPOINT) {
return this.$store.dispatch("GetNetConfig");
}
return Promise.resolve();
},
async uploadToMinIO(file, objectName) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -536,19 +774,25 @@ export default {
);
},
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;
let sceneDir;
switch (this.currentChat.scene) {
case ContactsScene.GROUP:
sceneDir = MinioPaths.FILE_MULTICHAT;
break;
case ContactsScene.NOTIFY:
sceneDir = MinioPaths.FILE_SYSTEM_NOTIFY;
break;
case ContactsScene.PRIVATE:
sceneDir = MinioPaths.FILE_PRIVATE;
break;
default:
sceneDir = MinioPaths.FILE_PRIVATE;
break;
}
const sourceId = this.userInfo.id;
const targetId = this.currentChat.id;
return `${sceneDir}${sourceId}-${targetId}/${fileName}`;
},
getTopic() {
@ -586,35 +830,18 @@ export default {
<style lang="scss" scoped>
.message-editor {
background: #fff;
border-top: 1px solid #ebeef5;
padding: 10px 16px;
position: relative;
}
.editor-toolbar {
.quill-wrap {
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;
}
flex-direction: column;
border: 1px solid #e4e7ed;
border-radius: 4px;
gap: 8px;
.quill-container {
.quill-container {
height: 100px;
padding-right: 100px;
min-height: 100px;
overflow-y: auto;
box-sizing: border-box;
::v-deep .ql-container {
@ -624,62 +851,31 @@ export default {
::v-deep .ql-editor {
min-height: 100%;
padding: 8px;
}
}
.send-area {
position: absolute;
right: 8px;
bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
.send-mode-text {
font-size: 12px;
color: #909399;
::v-deep .ql-toolbar {
display: none;
}
}
.at-panel {
position: absolute;
bottom: 80px;
left: 20px;
width: 240px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 999;
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;
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.at-panel-item {
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
.editor-toolbar-left,
.editor-toolbar-right {
display: flex;
align-items: center;
gap: 12px;
&:hover {
background: #f5f7fa;
.el-button {
margin: 0;
padding: 4px 8px;
}
}
.at-panel-empty {
padding: 12px;
text-align: center;
font-size: 13px;
color: #c0c4cc;
}
}
</style>

@ -16,6 +16,7 @@
@send-message="handleSendMessage"
@update-message-status="handleUpdateMessageStatus"
@font-size-change="handleFontSizeChange"
@refresh-list="handleRefreshList"
/>
</div>
@ -25,7 +26,7 @@
ref="groupSettingRef"
:chat="currentChat"
:group-info="groupInfo"
@quit-group="handleQuitGroup"
@quit-group="handleDismissGroup"
@dismiss-group="handleDismissGroup"
@refresh-list="handleRefreshList"
/>
@ -286,7 +287,6 @@ export default {
this.$refs.messageList.removeContact(multiChatId, ContactsScene.GROUP);
}
},
//
handleRefreshList() {
if (this.$refs.messageList) {
@ -325,9 +325,6 @@ export default {
},
handleSendMessage(message) {
if (this.$refs.messageDisplay) {
this.$refs.messageDisplay.addChatMessage(message);
}
//
if (this.$refs.messageList) {
this.$refs.messageList.updateContactLastMessage(
@ -337,6 +334,11 @@ export default {
new Date().toISOString()
);
}
if (this.$refs.messageDisplay) {
this.$refs.messageDisplay.addChatMessage(message);
}
//
this.$refs.messageList.refreshList({ selectFirst: true });
},
handleUpdateMessageStatus(messageId, updates) {
@ -369,15 +371,6 @@ export default {
handleGroupSetting() {
this.$refs.groupSettingRef.loadGroupInfo();
},
handleQuitGroup(chat) {
// 退
if (this.$refs.messageList) {
//
this.$refs.messageList.refreshList({ selectFirst: true });
}
},
handleDismissGroup(chat) {
//
if (this.$refs.messageList) {

Loading…
Cancel
Save