eacychatting/src/App.vue
2024-11-04 13:37:28 +08:00

502 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import {onMounted, reactive, ref, watch} from "vue";
import useClipboard from 'vue-clipboard3';
// import.meta.env
let ws = new WebSocket(`${import.meta.env.VITE_APP_SOCKET_BASE_URl}`);
ws.onerror = (event) => {
console.error("ws链接错误", event);
}
ws.onopen = (event) => {
heartCheck.reset().start();
console.log("ws链接成功", event)
}
ws.onmessage = (messageEvent) => {
heartCheck.reset().start();
const message = JSON.parse(messageEvent.data);
console.log(message)
switch (message.messageType) {
case "heartbeat":
if (message.status) {
state.sessionId = message.id;
}else {
ws = new WebSocket(`${import.meta.env.VITE_APP_SOCKET_BASE_URl}`);
return
}
if (message.onlineNumber) {
state.onlineNumber = message.onlineNumber;
}
break
case "receive":
state.messageList.push({
...message
})
setTimeout( () => {
const div = document.querySelector(".message-list");
div.scrollTop = div.scrollHeight;
}, 100)
break
case "refresh":
getMessageList()
}
}
ws.onclose = (event) => {
console.log("ws链接断开", event)
}
// 心跳检测, 每隔一段时间检测连接状态如果处于连接中就向server端主动发送消息来重置server端与客户端的最大连接时间如果已经断开了发起重连。
const heartCheck = {
timeout: 30000,
serverTimeoutObj: null,
reset: function () {
clearTimeout(this.serverTimeoutObj);
return this;
},
start: function () {
this.serverTimeoutObj = setInterval(function () {
if (ws.readyState === 1) {
ws.send(JSON.stringify({
messageType:'heartbeat',
sessionId: state.sessionId
}));
heartCheck.reset().start(); // 如果获取到消息,说明连接是正常的,重置心跳检测
} else {
ws = new WebSocket("/socket/message");
}
}, this.timeout)
}
};
const rightMenuVisible = ref(false);
const state = reactive({
sessionId:"",
messageList:[],
message:"",
visible:false,
left:0,
top:0,
rightMenuItem:{},
onlineNumber:0,
showModal:false,
modalImageUUID:'',
})
onMounted(() => {
getMessageList()
})
const getMessageList = () => {
let url = '/papi/message/get';
let options = {
method: 'GET',
};
fetch(url, options)
.then(res => res.json())
.then(json => {
state.messageList = json.data
setTimeout( () => {
const div = document.querySelector(".message-list");
if (div) {
div.scrollTop = div.scrollHeight;
}
}, 200)
})
.catch(err => console.error('error:' + err));
}
const handleSendMessage = () => {
const message = {
"messageType":"send",
"sessionId": state.sessionId,
"content": state.message,
"type": "text",
"time": Date.now()
}
ws.send(JSON.stringify(message))
state.message = ""
}
const handleDeleteMsg = () => {
const message = {
...state.rightMenuItem,
sessionId:state.sessionId,
messageType: 'delete',
isDelete:1
}
const index = state.messageList.findIndex(item => item.id === state.rightMenuItem.id)
state.messageList.splice(index, 1)
ws.send(JSON.stringify(message))
}
const keyDown = (e) => {
if(e.ctrlKey && e.keyCode === 13) { //用户点击了ctrl+enter触发
state.message += '\n';
}else if (state.message.trim().length > 0){ //用户点击了enter触发
handleSendMessage();
}
e.preventDefault();
}
const formatDate = (dateForm) => {
if (dateForm === "") {
return "";
}else{
return new Date(+new Date(new Date(dateForm).toJSON()) + 8 * 3600 * 1000).toISOString().replace(/T/g, ' ').replace(/\.[\d]{3}Z/, '');
}
}
const openMenu = (event, item) => {
rightMenuVisible.value = true;
state.top = event.pageY;
state.left = event.pageX;
state.rightMenuItem = item
}
watch(rightMenuVisible, (newVisible) => {
if (newVisible) {
document.body.addEventListener('click', () => {rightMenuVisible.value = false})
document.querySelector(".message-list").addEventListener('scroll', () => {rightMenuVisible.value = false})
} else {
document.body.removeEventListener('click', () => {rightMenuVisible.value = false})
}
})
const handleFileUpload = (event) => {
const file = event.target.files[0];
uploadFileMessage(file)
}
const handleInputPaste = (event) => {
if (event.clipboardData.files.length === 0) {
return
}
event.preventDefault();
const file = event.clipboardData.files[0];
uploadFileMessage(file)
}
const uploadFileMessage = (file) => {
if (file.name.indexOf(".") === -1) {
window.alert("该类型文件禁止上传!")
return
}
if (file.size >= 1024 * 1024 * 20) {
window.alert("文件上传最大20M")
return
}
const formData = new FormData();
formData.append('file', file);
fetch('/papi/file/upload', {
method: 'POST',
body: formData
}).then(res => res.json())
.then(json => {
console.log(json)
const uuid = json.uuid;
const message = {
"messageType":"send",
"sessionId": state.sessionId,
"type": "file",
"fileMimeType": json.fileMimeType,
"time": Date.now(),
"file": uuid
}
ws.send(JSON.stringify(message))
})
.catch(err => console.error('error:' + err));
}
const handleInputDrop = (event) => {
event.preventDefault();
if (event.dataTransfer.files.length === 0) {
return
}
const file = event.dataTransfer.files[0];
uploadFileMessage(file)
}
const isImageFile = (fileMimetype) => {
if (fileMimetype) {
return fileMimetype.startsWith("image/");
}
return false;
}
const isVideoFile = (fileMimetype) => {
if (fileMimetype) {
return fileMimetype.startsWith("video/");
}
return false;
}
const isAudioFile = (fileMimetype) => {
if (fileMimetype) {
return fileMimetype.startsWith("audio/");
}
return false;
}
const downloadFile = (message) => {
message ? window.open('/papi/file/' + message.file, '_blank') : window.open('/papi/file/' + state.rightMenuItem.file, '_blank')
}
const getIconUrl = (message) => {
const baseUrl = '/images/'
if (message.fileMimeType.indexOf('doc') !== -1) {
return new URL(baseUrl + 'doc.png', import.meta.url).href
} else if (message.fileMimeType.indexOf('pdf') !== -1) {
return new URL(baseUrl + 'pdf.png', import.meta.url).href
} else if (message.fileMimeType.indexOf('xls') !== -1) {
return new URL(baseUrl + 'xls.png', import.meta.url).href
} else{
return new URL(baseUrl + 'default.png', import.meta.url).href
}
}
const openModal = (message) => {
console.log(message)
state.showModal = true;
state.modalImageUUID = message.file
}
</script>
<template>
<div class="chat">
<div class="chat-header">
<h3>文件传输助手</h3>
<p>在线人数{{ state.onlineNumber }}</p>
</div>
<div class="chat-body">
<p v-if="state.messageList.length === 0">你可以使用「手机微信>文件传输助手」与电脑互传文件</p>
<div class="message-list">
<div :class="message.sessionId != null && message.sessionId === state.sessionId ? 'message-content message-content-right' : 'message-content'" v-for="message in state.messageList"
@contextmenu.prevent="openMenu($event, message)">
<span v-if="message.sessionId != null && message.sessionId === state.sessionId" class="message-text-time">{{formatDate(message.time)}}</span>
<div :class="message.sessionId != null && message.sessionId === state.sessionId ? 'message-text-right' : 'message-text-left'"
class="message-text"
v-if="message.type === 'text'"
v-html="message.content"
></div>
<div :class="message.sessionId != null && message.sessionId === state.sessionId ? 'message-text-right' : 'message-text-left'"
class="message-text"
v-if="message.type === 'file'">
<img v-if="isImageFile(message.fileMimeType)" @click="openModal(message)" :src="'/papi/file/' + message.file" alt="picture"/>
<video width="300" controls v-else-if="isVideoFile(message.fileMimeType)" :src="'/papi/file/' + message.file"/>
<audio preload width="300" controls v-else-if="isAudioFile(message.fileMimeType)" :src="'/papi/file/' + message.file"/>
<div v-else class="message-file" @click="downloadFile(message)">
<img width="60" :src="getIconUrl(message)" :alt="getIconUrl(message)">
<span v-html="message.fileName"></span>
</div>
</div>
<span v-if="message.sessionId == null || message.sessionId !== state.sessionId" class="message-text-time">{{formatDate(message.time)}}</span>
</div>
<div v-show="rightMenuVisible" :style="{left:state.left+'px',top:state.top+'px'}" class="right-menu">
<button v-if="state.rightMenuItem?.type === 'text'" @click="useClipboard().toClipboard(state.rightMenuItem.content)">复制</button>
<button v-if="state.rightMenuItem?.type === 'file' || state.rightMenuItem?.type === 'image'" @click="downloadFile(null)">下载</button>
<button @click="handleDeleteMsg">删除</button>
</div>
</div>
</div>
<div class="chat-input">
<div class="chat-input-operation">
<div class="operation-item">
<input class="file-input" title="上传文件" type="file" @change="handleFileUpload">
<img class="file-upload" src="/src/assets/file.svg" alt="file"/>
</div>
</div>
<textarea @drop="handleInputDrop" placeholder="回车发送消息ctrl+enter换行支持粘贴、拖拽上传附件" @paste="handleInputPaste" rows="4" @keydown.enter="keyDown" v-model="state.message"/>
<div class="message-send">
<button @click="handleSendMessage" :disabled="state.message.length === 0">发送</button>
</div>
</div>
</div>
<div v-show="state.showModal" class="modal" @click="state.showModal = false">
<img v-if="state.modalImageUUID !== ''" class="modal-image" :src="'/papi/file/' + state.modalImageUUID" alt="message"/>
</div>
</template>
<style scoped>
.chat {
height: 90vh;
display: flex;
flex-direction: column;
background-color: #ffffff;
border-radius: 4px;
margin: 0 auto;
max-width: 960px;
transition: width .2s ease-in;
width: 960px;
}
.chat-body {
height: 68vh;
}
.message-list {
height: 100%;
overflow-y: auto;
scrollbar-gutter: stable;
}
.chat-body p {
align-items: center;
color: #9e9e9e;
display: flex;
font-size: 14px;
height: 100%;
justify-content: center;
}
.chat-input {
border-top: 1px solid #e3e4e5;
display: flex;
flex-direction: column;
}
.chat-header {
border-bottom: 1px solid #e3e4e5;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-input-operation {
display: flex;
padding: 10px 16px;
}
.file-input {
font-size: 0;
height: 100%;
opacity: 0;
position: absolute;
width: 100%;
cursor: pointer;
}
.operation-item {
display: inline-block;
height: 15px;
position: relative;
width: 17px;
}
textarea {
border: none;
flex: 1;
font-size: 14px;
padding: 0 18px;
resize: none;
outline: none;
color:black;
background-color: #ffffff;
max-height: 10vh;
}
button {
outline: none;
}
.message-send {
display: flex;
justify-content: flex-end;
padding: 8px 16px 8px;
}
.message-content {
display: flex;
padding: 8px 24px 8px;
gap: 14px;
}
.message-text {
background-color: #95ec69;
max-width: 400px;
text-align: left;
border-radius: 4px;
box-sizing: border-box;
padding: 7px 12px;
position: relative;
height: fit-content;
font-family: monospace;
unicode-bidi: isolate;
white-space: break-spaces;
word-break: break-word;
min-height: 35px;
}
.message-text:before {
background-color: #95ec69;
border-radius: 0 2px 0 0;
content: "";
height: 10px;
margin-right: -5px;
position: absolute;
right: 0;
top: 12px;
transform: rotate(45deg);
transform-origin: 50%;
width: 10px;
}
.message-text-left:before {
left: 0;
margin-left: -5px;
}
.message-content-right {
justify-content: flex-end;
}
.message-text-time {
font-size: 12px;
color: #999;
display: flex;
align-items: flex-end;
}
.right-menu {
border: 1px solid #eee;
border-radius: 10px;
z-index: 999;
position: fixed;
background: #fff;
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px;
}
@media (max-width: 960px) {
.chat {
width: 80vw !important;
}
}
.message-list img {
max-width: 376px;
cursor: zoom-in;
}
.message-file {
display: flex;
gap: 12px;
flex-direction: row;
align-items: flex-end;
cursor: pointer;
}
.modal {
position: fixed;
z-index: 9999;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
}
.modal-image {
display: flex;
max-width: 90%;
margin: 5vh auto;
height: 90vh;
object-fit: contain;
cursor: zoom-out;
}
</style>