在Django+Nginx+uwsgi网站Channels+redis+daphne多人在线的基础上(详见Django+Nginx+uwsgi网站使用Channels+redis+daphne实现简单的多人在线聊天及消息存储功能-CSDN博客),实现在输入框粘贴或打开本地图片,上传到网站后返回图片路径,以链接的形式将图片插入到输入框显示,并实现异步发送消息。具体效果如下图所示:
一、实现图片上传
实现图片上传客户端和服务器两边都要配置。
1. 客户端使用fetch实现图片上传
使用嵌入页面的javascript脚本实现fetch上传图片,主要代码如下:
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
fetch('/chatjson/upload_image/',{
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: imgformData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
2. 服务器端配置
(1) urls.py设置
客户端fetch的路径为'/chatjson/upload_image/',需要在urls.py中配置路径解析,包括聊天页面的路径解析
from myapp import views as channelsview
urlpatterns = [
....
path('chatexp/<str:room_name>/', channelsview.chatexp, name='chatexp'),
path('chatjson/upload_image/', channelsview.upload_image_json, name='upload_json'),
]
(2) 视图设置 myapp/views.py
包括聊天页面视图响应函数chatexp和文件上传响应upload_image_json
from django.contrib.auth.decorators import login_required
@login_required(login_url='/login/')
def chatexp(request,room_name):
username = request.session.get('username','游客')
msgs = ChatMessage.objects.filter(room=room_name).order_by('-create_time')[0:20]
if request.method == 'POST':
form = chatimgsForm(request.POST, request.FILES)
if form.is_valid():
image = form.save()
#图片路径
image_path = image.image.url
return render(request,"channels/chattingexp.html",{'room_name':room_name,'form':form, 'image_path':image_path, 'username':username, 'msgs':msgs})
form = chatimgsForm()
return render(request,"channels/chattingexp.html",{'room_name':room_name,'form':form, 'image_path':'未上传', 'username':username, 'msgs':msgs})
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from django.conf import settings
import os
@require_POST
@csrf_exempt
def upload_image_json(request):
image_file = request.FILES['image']
if image_file :
upimg = chatimgs(image=image_file)
upimg.save()
#返回图片的绝对路径/home/...
#image_path = upimg.image.path
# 返回图片的相对路径/media/...
image_path = upimg.image.url
return JsonResponse({'image_url': image_path})
else:
return JsonResponse({'error': 'No image received!!'}, status=400)
二、客户端配置
1. 聊天页面设置
chatexp视图函数调用聊天页面chattingexp.html,聊天页面输入框由可编辑的div实现,页面内javascript脚本监听输入框的粘贴事件,将其中的图片上传,返回路径,将图片以img元素的形式插入到输入框,字符串转换成文本插入。脚本还实现了打开本地图片文件,同样上传后返回路径,将图片以img元素的形式插入到输入框。然后发送消息,消息文本通过channels异步传输,因文本只有图片链接,提高了传输效率。主要代码如下:
<script>
const editor = document.getElementById('chat-message-input');
editor.addEventListener('paste', function(event) {
// 阻止默认粘贴操作
event.preventDefault();
const clipboardData = (event.clipboardData||window.clipboardData);
let items = clipboardData.items;
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
for (const item of items) {
if (item.kind === 'string') {
item.getAsString((text) => {
const regex = /<img src="(.*?)"/;
const match = text.match(regex);
if (match) {
document.execCommand('insertText', false, "link:<img src='"+match[1]+"'/>");
//网页图片复制粘贴除了图片还带有图片链接,如果识别img链接插入图片会出现图片插入两次的问题
} else {
document.execCommand('insertText', false, text);
}})
} else if (item.kind === 'file' && item.type.indexOf('image/') !== -1) {
var imgfile = item.getAsFile();
const imgformData = new FormData();
imgformData.append('image',imgfile);
imgformData.append('csrfmiddlewaretoken', csrftoken);
fetch('/chatjson/upload_image/',{
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: imgformData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
}}})
window.onload = function() {
var scrollableDiv = document.getElementById('chat-record');
// 设置scrollTop使得滚动条向下翻
scrollableDiv.scrollTop = scrollableDiv.scrollHeight;
};
const roomName = JSON.parse(document.getElementById('room-name').textContent);
const username = JSON.parse(document.getElementById('username').textContent);
const chatSocket = new WebSocket(
'wss://abc.com/ws/chat/' + roomName + '/'
);
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
//data为收到的后端发出来的数据
//console.log(data);
if (data['message']) {
if(data['username'] == username){
document.querySelector('#chat-record').innerHTML += ('<div class="chat-message right"><div class="user-content">' + data['username'] + '</div><div class="message-content"><span>' +data['message'] + '</span></div></div><br>');
}else{
document.querySelector('#chat-record').innerHTML += ('<div class="chat-message left"><div class="user-content">' + data['username'] + '</div><div class="message-content"><span>' + data['message'] + '</span></div></div><br>');
}
} else {
alert('消息为空!')
}
var scrollableDiv = document.getElementById('chat-record');
// 设置scrollTop使得滚动条向下翻
scrollableDiv.scrollTop = scrollableDiv.scrollHeight;
};
chatSocket.onclose = function(e) {
console.error('聊天端口非正常关闭!');
};
document.querySelector('#chat-message-input').focus();
document.querySelector('#chat-message-input').onkeyup = function(e) {
if (e.keyCode === 13) { // enter, return
document.querySelector('#chat-message-submit').click();
}
};
document.querySelector('#chat-message-submit').onclick = function(e) {
const messageDivDom = document.querySelector('#chat-message-input');
const message = messageDivDom.innerHTML;
chatSocket.send(JSON.stringify({
'message': message,
'username':username
}));
messageDivDom.innerHTML = '';
};
//打开并上传本地文件
document.getElementById('upload-btn').addEventListener('click', function () {
const editor = document.getElementById('chat-message-input');
const fileInput = document.getElementById('file-input');
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
const file = fileInput.files[0];
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrftoken);
formData.append('image', file);
fetch('/chatjson/upload_image/', {
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
});
</script>
2. 网页内容的粘贴处理
在Windows下从网页复制粘贴涉及剪切和粘贴 HTML 文档的片段。Windows采用 CF_HTML
剪贴板格式,其将原始 HTML 文本及其上下文(即外部 HTML)片段作为 ASCII 存储在剪贴板上。 这允许应用程序检查 HTML 片段的上下文,该片段由前面所有的周围标记组成,以便可以使用其属性来记录 HTML 片段的周围标记。 CF_HTML
剪贴板的常规布局或语法,如下所示:
<cf-html> ::= <description-header> <context>
<context> ::= [<preceding-context>] <fragment> [<trailing-context>]
<description-header> ::= "Version:" <version> <br> ( <header-offset-keyword> ":" <header-offset-value> <br> )*
<header-offset-keyword> ::= "StartHTML" | "EndHTML" | "StartFragment" | "EndFragment" | "StartSelection" | "EndSelection"
<header-offset-value> ::= { Base 10 (decimal) integer string with optional _multiple_ leading zero digits (see "Offset syntax" below) }
<version> ::= "0.9" | "1.0"
<fragment> ::= <fragment-start-comment> <fragment-text> <fragment-end-comment>
<fragment-start-comment> ::= "<!--StartFragment -->"
<fragment-end-comment> ::= "<!--EndFragment -->"
<preceding-context> ::= { Arbitrary HTML }
<trailing-context> ::= { Arbitrary HTML }
<fragment-text> ::= { Arbitrary HTML }
<br> ::= "\r" | "\n" | "\r\n"
所以从网页单独粘贴一张图片时,会带入上下文,譬如:
<html>
<body>
<!--StartFragment--><img src="https://pics6.baidu.com/feed/a2cc7cd98d1001e9472d4193277785e255e797a5.jpeg@f_auto?token=475ae4ac49d50e247ac05e958799fc88"/><!--EndFragment-->
</body>
</html>
如果既上传照片,又解析其中的<img>链接,会出现从网页上拷贝的文字图片在粘贴时,图片会被识别两次。所以对网页拷贝的内容,解决办法有两种:
(1)将图片以链接形式插入到文本中
chattingexp.html中Javascript脚本的主要代码如下:
<script>
const editor = document.getElementById('chat-message-input');
editor.addEventListener('paste', function(event) {
// 阻止默认粘贴操作
event.preventDefault();
const clipboardData = (event.clipboardData||window.clipboardData);
let items = clipboardData.items;
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
for (const item of items) {
let htmlimglink = false;
if(item.kind === 'string'&& item.type === 'text/html') {
item.getAsString((text) => {
const regex = /<img src="(.*?)"/;
const match = text.match(regex);
if (match) {
//网页图片以链接的形式存在,不上传服务器
//document.execCommand('insertText', false, "link:<img src='"+match[1]+"'/>");
const img = document.createElement('img');
img.src = match[1];
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
htmlimglink = true;
} else {
//console.log(text);
//新建一个div
var divElement = document.createElement( "div" );
divElement.innerHTML = text;//获取文本内容
//如果divElement是null或undefined,那么返回为""(一个空字符串)。
//如果divElement非null且存在textContent或innerText属性,那么将会返回该属性的值。如果两者都不存在,将会返回""。
document.execCommand('insertText', false, divElement.textContent || divElement.innerText || "");
}})
} else if (item.kind === 'string'&& item.type === 'text/plain'){
item.getAsString((text) => {
document.execCommand('insertText', false, text);
})
}else if(htmimglink=false && item.kind === 'file' && item.type.indexOf('image/') !== -1) {
var imgfile = item.getAsFile();
const imgformData = new FormData();
imgformData.append('image',imgfile);
imgformData.append('csrfmiddlewaretoken', csrftoken);
fetch('/chatjson/upload_image/',{
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: imgformData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
}}})
//本地文件上传按钮事件
document.getElementById('upload-btn').addEventListener('click', function () {
const editor = document.getElementById('chat-message-input');
const fileInput = document.getElementById('file-input');
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
const file = fileInput.files[0];
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrftoken);
formData.append('image', file);
fetch('/chatjson/upload_image/', {
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
});
</script>
(2)使用fetch获取图片Blob数据后上传到服务器
在前面的基础上,得到图片的链接后,利用正则表达式得到图片名和图片的格式,然后fetch到图片Blob数据后,使用FormData创建一个新的File对象,再使用fetch上传到服务器,返回路径,插入到输入框中。使用效果如下:
3. 聊天页面设计及功能实现
至此,多人在线聊天页面基本实现了粘贴图片和文字混合内容的功能。汇总之前的代码,chattingexp.html的主要内容如下:
{% extends "newdesign/newbase.html" %}
{# 自定义过滤器startswith #}
{% load django_bootstrap5 %}
{% block mytitle %}
<title>{{room_name}}号聊天室</title>
<style>
.chat-window {
max-width: 900px;
height: 500px;
overflow-y: scroll; /* 添加垂直滚动条 */
margin: auto;
background-color: #f1f1f1;
border: 2px solid #09e3f7;
border-radius: 5px;
padding: 10px;
}
.chat-message {
clear: both;
overflow: hidden;
margin-bottom: 10px;
text-align: left;
}
.chat-message .message-content {
border-radius: 5px;
padding: 8px;
max-width: 500px;
float: left;
clear: both;
}
.chat-message.right .message-content {
background-color: #428bca;
color: white;
float: right;
width:420px;
}
.chat-message.right .user-content {
background-color: #f7e91d;
border-radius:4px;
color: black;
float: right;
width: auto;
text-align: right;
padding-left:10px;
padding-right:10px;
}
.chat-message.left .message-content {
background-color: #2ef3be;
border-color: #ddd;
float:left;
width:420px;
}
.chat-message.left .user-content {
background-color: #f7e91d;
border-radius:4px;
border-color: #ddd;
float: left;
width: auto;
text-align: left;
padding-left:8px;
padding-right:8px;
}
.inputarea {
display:flex;
flex-direction: column;
justify-content: center;
align-items: center;
width:900px;
margin: 0 auto;
}
.replyinput {
display: inline-block;
width: 900px;
min-height:120px;
background-color: rgb(169, 228, 250);
border:2px solid #09e3f7;
border-radius: 10px;
padding: 10px;
font-size: 14px;
text-align: left;
}
.replyarea {
width: 900px;
height:50px;
margin:0 auto;
}
.sendImg-btn {
float:left;
border: 0px;
background-color: transparent;
}
.reply-btn {
float:right;
}
</style>
{% endblock %}
{% block maincontent %}
<div class="container">
<div id="chat-record" class="chat-window">
{% for m in msgs reversed %}
{% if m.username == request.user.username %}
<div class="chat-message right"><div class="user-content">{{m.username}}</div><div class="message-content"><span>{{m.content|safe}}</span></div></div>
<br>
{% else %}
<div class="chat-message left"><div class="user-content">{{m.username}}</div><div class="message-content"><span>{{m.content|safe}}</span></div></div>
<br>
{% endif %}
{% endfor %}
</div>
</div>
<br>
<form method='post' enctype="multipart/form-data"></form>
{% csrf_token %}
<div class="inputarea">
<div
class="replyinput"
contenteditable="true"
id="chat-message-input"
@focus="onFocusEditableDiv"
>
</div>
<br>
<div class="replyarea">
<button id="upload-btn">上传本地图片</button> <input type="file" id="file-input" accept="image/*"/>
<button class="reply-btn" id="chat-message-submit" type="primary" style="height:40px;background-color: #0d4de1;color:white;border-radius: 4px;">发送消息</button>
</div>
</div>
</form>
{{ room_name|json_script:"room-name" }}
{{ username|json_script:"username" }}
<script>
const editor = document.getElementById('chat-message-input');
editor.addEventListener('paste', function(event) {
// 阻止默认粘贴操作
event.preventDefault();
const clipboardData = (event.clipboardData||window.clipboardData);
let items = clipboardData.items;
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
var htmlimglink = false;
for (const item of items) {
if(item.kind === 'string'&& item.type === 'text/html'){
//如果是网页内容,因粘贴板格式带有图片链接,直接根据链接下载图片上传,不需要再次上传照片文件
htmlimglink = true;
}};
for (const item of items) {
if(item.kind === 'string'&& item.type === 'text/html') {
item.getAsString((text) => {
const regex = /<img src="(.*?)"/g;
let matches = '';
matches = text.matchAll(regex);
if (matches) {
for (const match of matches) {
//网页图片以链接的形式存在,不上传服务器
//match[0]为整个匹配组,match[1]为第一个捕获组
//document.execCommand('insertHTML', false, "<img style='width:300px;height:auto;object-fit:contain;float:none;' src='"+match[1]+"'/>");
fetch(match[1])
.then(response =>{
if (!response.ok) {
throw new Error('Network response was not ok ' + response.statusText);
}
return response.blob(); // 转换响应为Blob对象
})
.then(blob => {
const fileNameextra = match[1].split('/').pop(); // URL的最后一部分包含文件名和查询参数
const regex = /^(.*)\.(png|jpeg|jpg|gif|bmp|webp|svg|tiff|avif)(?:\?|\@|#|$)/i ;
let filematches = '';
filematches = fileNameextra.match(regex);
if(filematches){
const fileName = filematches[1]+'.'+ filematches[2];
const contentType = 'image/' + filematches[2];
//console.log("fileName:::",fileName);
const imgformData = new FormData();
imgformData.append('image',new File([blob], fileName, { type: contentType }));
imgformData.append('csrfmiddlewaretoken', csrftoken);
fetch('/chatjson/upload_image/',{
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: imgformData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
document.execCommand('insertHTML', false, "<img style='width:300px;height:auto;object-fit:contain;float:none;' src='"+data.image_url+"'/>");
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
}else{
throw new Error('图片格式不正确!');
}}
)
.catch(error => console.error('Error fetching or processing the image:', error));
};
} else {
//新建一个div
var divElement = document.createElement( "div" );
divElement.innerHTML = text;//获取文本内容
//如果divElement是null或undefined,那么返回为""(一个空字符串)。
//如果divElement非null且存在textContent或innerText属性,那么将会返回该属性的值。如果两者都不存在,将会返回""。
document.execCommand('insertText', false, divElement.textContent || divElement.innerText || "");
}})
} else if (item.kind === 'string'&& item.type === 'text/plain'){
item.getAsString((text) => {
document.execCommand('insertText', false, text);
})
}else if(htmlimglink === false && item.kind === 'file' && item.type.indexOf('image/') !== -1) {
var imgfile = item.getAsFile();
const imgformData = new FormData();
imgformData.append('image',imgfile);
imgformData.append('csrfmiddlewaretoken', csrftoken);
fetch('/chatjson/upload_image/',{
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: imgformData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
}}})
window.onload = function() {
var scrollableDiv = document.getElementById('chat-record');
// 设置scrollTop使得滚动条向下翻
scrollableDiv.scrollTop = scrollableDiv.scrollHeight;
};
const roomName = JSON.parse(document.getElementById('room-name').textContent);
const username = JSON.parse(document.getElementById('username').textContent);
const chatSocket = new WebSocket(
'wss://abc.com/ws/chat/' + roomName + '/'
);
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
//data为收到的后端发出来的数据
//console.log(data);
if (data['message']) {
if(data['username'] == username){
document.querySelector('#chat-record').innerHTML += ('<div class="chat-message right"><div class="user-content">' + data['username'] + '</div><div class="message-content"><span>' +data['message'] + '</span></div></div><br>');
}else{
document.querySelector('#chat-record').innerHTML += ('<div class="chat-message left"><div class="user-content">' + data['username'] + '</div><div class="message-content"><span>' + data['message'] + '</span></div></div><br>');
}
} else {
alert('消息为空!')
}
var scrollableDiv = document.getElementById('chat-record');
// 设置scrollTop使得滚动条向下翻
scrollableDiv.scrollTop = scrollableDiv.scrollHeight;
};
chatSocket.onclose = function(e) {
console.error('聊天端口非正常关闭!');
};
document.querySelector('#chat-message-input').focus();
document.querySelector('#chat-message-input').onkeyup = function(e) {
if (e.keyCode === 13) { // enter, return
document.querySelector('#chat-message-submit').click();
}
};
document.querySelector('#chat-message-submit').onclick = function(e) {
const messageDivDom = document.querySelector('#chat-message-input');
const message = messageDivDom.innerHTML;
chatSocket.send(JSON.stringify({
'message': message,
'username':username
}));
messageDivDom.innerHTML = '';
};
document.getElementById('upload-btn').addEventListener('click', function () {
const editor = document.getElementById('chat-message-input');
const fileInput = document.getElementById('file-input');
const csrftoken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
const file = fileInput.files[0];
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrftoken);
formData.append('image', file);
fetch('/chatjson/upload_image/', {
method: 'POST',
headers: {'X-CSRFToken': csrftoken},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.image_url) {
//console.log('data image path::',data.image_url);
const img = document.createElement('img');
img.src = data.image_url;
editor.appendChild(img);
img.style.width = '300px';
img.style.height = 'auto';
img.style.objectFit = 'contain';
img.style.float = 'none';
} else {
console.error('Error uploading image:', data.error);
}})
.catch((error) => {
console.error('Error:', error);
alert('Error uploading the image.');
});
});
</script>
{% endblock %}
4. 存在的问题
无法正确处理word内容,拷贝粘贴文字和图片混合内容粘贴显示不太正常,单独复制粘贴图片没问题,文本内容粘贴时需要粘贴为纯文本。