Browse Source

1.修复会话记录丢失的bug

2.app端增加消息复制菜单
3.移除多人通话相关代码
master
xsx 2 years ago
parent
commit
48acf02dbb
  1. 16
      .gitignore
  2. 4
      im-uniapp/.env.js
  3. 2
      im-uniapp/App.vue
  4. 21
      im-uniapp/components/chat-message-item/chat-message-item.vue
  5. 6
      im-uniapp/components/pop-menu/pop-menu.vue
  6. 12
      im-uniapp/hybrid/html/rtc-group/index.html
  7. 12
      im-uniapp/hybrid/html/rtc-private/index.html
  8. 98
      im-uniapp/pages/chat/chat-box.vue
  9. BIN
      im-uniapp/static/logo/logo.png
  10. 9
      im-uniapp/store/chatStore.js
  11. 19
      im-web/src/components/chat/ChatBox.vue
  12. 2
      im-web/src/components/rtc/RtcGroupVideo.vue
  13. 7
      im-web/src/store/chatStore.js

16
.gitignore

@ -3,16 +3,12 @@
/im-server/im-server.iml /im-server/im-server.iml
/im-platform/im-platform.iml /im-platform/im-platform.iml
/im-client/im-client.iml /im-client/im-client.iml
/im-platform/src/main/resources/application-prod.yml
/im-platform/src/main/resources/logback-prod.xml
/im-server/src/main/resources/application-prod.yml
/im-server/src/main/resources/logback-prod.xml
/im-common/im-common.iml /im-common/im-common.iml
/im-server/target/
/im-platform/target/
/im-client/target/
/im-common/itarget/
/im-web/node_modules/
/im-uniapp/node_modules/ /im-uniapp/node_modules/
/im-web/package-lock.json
/im-uniapp/unpackage/
/im-uniapp/hybrid/
/im-uniapp/package-lock.json /im-uniapp/package-lock.json
/im-web/src/components/rtc/LocalVideo.vue /im-uniapp/unpackage/
/im-web/src/components/rtc/RemoteVideo.vue
/im-web/src/components/rtc/RtcGroupAcceptor.vue

4
im-uniapp/.env.js

@ -2,8 +2,8 @@
const ENV = "DEV"; const ENV = "DEV";
const UNI_APP = {} const UNI_APP = {}
if(ENV=="DEV"){ if(ENV=="DEV"){
UNI_APP.BASE_URL = "http://127.0.0.1:8888"; UNI_APP.BASE_URL = "http://192.168.43.199:8888";
UNI_APP.WS_URL = "ws://127.0.0.1:8878/im"; UNI_APP.WS_URL = "ws://192.168.43.199:8878/im";
// H5 走本地代理解决跨域问题 // H5 走本地代理解决跨域问题
// #ifdef H5 // #ifdef H5
UNI_APP.BASE_URL = "/api"; UNI_APP.BASE_URL = "/api";

2
im-uniapp/App.vue

@ -1,5 +1,5 @@
<script> <script>
import App from './App' import App from './App'
import http from './common/request'; import http from './common/request';
import * as msgType from './common/messageType'; import * as msgType from './common/messageType';
import * as enums from './common/enums'; import * as enums from './common/enums';

21
im-uniapp/components/chat-message-item/chat-message-item.vue

@ -56,7 +56,7 @@
</view> </view>
</pop-menu> </pop-menu>
<pop-menu v-if="isAction" :items="menuItems" @select="onSelectMenu"> <pop-menu v-if="isAction" :items="menuItems" @select="onSelectMenu">
<view class="chat-realtime chat-msg-text" @click="$emit('call')"> <view class="chat-realtime chat-msg-text" @click="$emit('call')">
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VOICE" <text v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VOICE"
class="iconfont icon-chat-voice"></text> class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VIDEO" <text v-if="msgInfo.type==$enums.MESSAGE_TYPE.ACT_RT_VIDEO"
@ -182,12 +182,13 @@
}, },
menuItems() { menuItems() {
let items = []; let items = [];
items.push({ if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT) {
key: 'DELETE', items.push({
name: '删除', key: 'COPY',
icon: 'trash', name: '复制',
color: '#e64e4e' icon: 'bars'
}); });
}
if (this.msgInfo.selfSend && this.msgInfo.id > 0) { if (this.msgInfo.selfSend && this.msgInfo.id > 0) {
items.push({ items.push({
key: 'RECALL', key: 'RECALL',
@ -195,6 +196,12 @@
icon: 'refreshempty' icon: 'refreshempty'
}); });
} }
items.push({
key: 'DELETE',
name: '删除',
icon: 'trash',
color: '#e64e4e'
});
if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.FILE) { if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.FILE) {
items.push({ items.push({
key: 'DOWNLOAD', key: 'DOWNLOAD',

6
im-uniapp/components/pop-menu/pop-menu.vue

@ -7,7 +7,7 @@
<view v-if="isShowMenu" class="pop-menu" @tap="onClose()" @contextmenu.prevent=""></view> <view v-if="isShowMenu" class="pop-menu" @tap="onClose()" @contextmenu.prevent=""></view>
<view v-if="isShowMenu" class="menu" :style="menuStyle"> <view v-if="isShowMenu" class="menu" :style="menuStyle">
<view class="menu-item" v-for="(item) in items" :key="item.key" @click.prevent="onSelectMenu(item)"> <view class="menu-item" v-for="(item) in items" :key="item.key" @click.prevent="onSelectMenu(item)">
<uni-icons :type="item.icon" :style="itemStyle(item)" size="22"></uni-icons> <uni-icons class="menu-icon" :type="item.icon" :style="itemStyle(item)" size="22"></uni-icons>
<text :style="itemStyle(item)"> {{item.name}}</text> <text :style="itemStyle(item)"> {{item.name}}</text>
</view> </view>
</view> </view>
@ -110,6 +110,10 @@
padding: 10px; padding: 10px;
justify-content: center; justify-content: center;
border-bottom: 1px solid #d0d0d8; border-bottom: 1px solid #d0d0d8;
.menu-icon {
margin-right: 10rpx;
}
} }
} }

12
im-uniapp/hybrid/html/rtc-group/index.html

@ -8,6 +8,16 @@
<title>语音通话</title> <title>语音通话</title>
</head> </head>
<body> <body>
<div style="padding-top:10px; text-align: center;font-size: 16px;">音视频通话功能需升级至商业版,如有需要请联系作者...</div> <div style="padding-top:10px; text-align: center;font-size: 16px;">
音视频通话功能属于付费功能,如有需要请联系作者购买商业版源码...
</div>
<div style="padding-top:50px; text-align: center;font-size: 16px;">
点击下方文档了解详细信息:
</div>
<div style="padding-top:10px; text-align: center;font-size: 16px;">
<a href="https://www.yuque.com/u1475064/imk5n2/qtezcg32q1d0dr29" target="_blank">
盒子IM商业版付费说明
</a>
</div>
</body> </body>
</html> </html>

12
im-uniapp/hybrid/html/rtc-private/index.html

@ -8,6 +8,16 @@
<title>视频通话</title> <title>视频通话</title>
</head> </head>
<body> <body>
<div style="padding-top:10px; text-align: center;font-size: 16px;">音视频通话功能需升级至商业版,如有需要请联系作者...</div> <div style="padding-top:10px; text-align: center;font-size: 16px;">
音视频通话功能属于付费功能,如有需要请联系作者购买商业版源码...
</div>
<div style="padding-top:50px; text-align: center;font-size: 16px;">
点击下方文档了解详细信息:
</div>
<div style="padding-top:10px; text-align: center;font-size: 16px;">
<a href="https://www.yuque.com/u1475064/imk5n2/qtezcg32q1d0dr29" target="_blank">
盒子IM商业版付费说明
</a>
</div>
</body> </body>
</html> </html>

98
im-uniapp/pages/chat/chat-box.vue

@ -9,8 +9,8 @@
<scroll-view class="scroll-box" scroll-y="true" upper-threshold="200" @scrolltoupper="onScrollToTop" <scroll-view class="scroll-box" scroll-y="true" upper-threshold="200" @scrolltoupper="onScrollToTop"
:scroll-into-view="'chat-item-'+scrollMsgIdx"> :scroll-into-view="'chat-item-'+scrollMsgIdx">
<view v-if="chat" v-for="(msgInfo,idx) in chat.messages" :key="idx"> <view v-if="chat" v-for="(msgInfo,idx) in chat.messages" :key="idx">
<chat-message-item v-if="idx>=showMinIdx" :headImage="headImage(msgInfo)" <chat-message-item v-if="idx>=showMinIdx" :headImage="headImage(msgInfo)" @call="onRtCall(msgInfo)"
@call="onRtCall(msgInfo)" :showName="showName(msgInfo)" @recall="onRecallMessage" :showName="showName(msgInfo)" @recall="onRecallMessage" @copy="onCopyMessage"
@delete="onDeleteMessage" @longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile" @delete="onDeleteMessage" @longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile"
:id="'chat-item-'+idx" :msgInfo="msgInfo" :groupMembers="groupMembers"> :id="'chat-item-'+idx" :msgInfo="msgInfo" :groupMembers="groupMembers">
</chat-message-item> </chat-message-item>
@ -50,47 +50,46 @@
<view class="chat-tools-item"> <view class="chat-tools-item">
<image-upload :maxCount="9" sourceType="album" :onBefore="onUploadImageBefore" <image-upload :maxCount="9" sourceType="album" :onBefore="onUploadImageBefore"
:onSuccess="onUploadImageSuccess" :onError="onUploadImageFail"> :onSuccess="onUploadImageSuccess" :onError="onUploadImageFail">
<view class="tool-icon iconfont icon-picture" ></view> <view class="tool-icon iconfont icon-picture"></view>
</image-upload> </image-upload>
<view class="tool-name">相册</view> <view class="tool-name">相册</view>
</view> </view>
<view class="chat-tools-item"> <view class="chat-tools-item">
<image-upload sourceType="camera" :onBefore="onUploadImageBefore" :onSuccess="onUploadImageSuccess" <image-upload sourceType="camera" :onBefore="onUploadImageBefore" :onSuccess="onUploadImageSuccess"
:onError="onUploadImageFail"> :onError="onUploadImageFail">
<view class="tool-icon iconfont icon-camera" ></view> <view class="tool-icon iconfont icon-camera"></view>
</image-upload> </image-upload>
<view class="tool-name">拍摄</view> <view class="tool-name">拍摄</view>
</view> </view>
<view class="chat-tools-item"> <view class="chat-tools-item">
<file-upload :onBefore="onUploadFileBefore" :onSuccess="onUploadFileSuccess" <file-upload :onBefore="onUploadFileBefore" :onSuccess="onUploadFileSuccess"
:onError="onUploadFileFail"> :onError="onUploadFileFail">
<view class="tool-icon iconfont icon-folder" ></view> <view class="tool-icon iconfont icon-folder"></view>
</file-upload> </file-upload>
<view class="tool-name">文件</view> <view class="tool-name">文件</view>
</view> </view>
<view class="chat-tools-item" @click="onRecorderInput()"> <view class="chat-tools-item" @click="onRecorderInput()">
<view class="tool-icon iconfont icon-microphone" ></view> <view class="tool-icon iconfont icon-microphone"></view>
<view class="tool-name">语音消息</view> <view class="tool-name">语音消息</view>
</view> </view>
<view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="switchReceipt()"> <view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="switchReceipt()">
<view class="tool-icon iconfont icon-receipt" <view class="tool-icon iconfont icon-receipt" :class="isReceipt?'active':''"></view>
:class="isReceipt?'active':''"></view>
<view class="tool-name">回执消息</view> <view class="tool-name">回执消息</view>
</view> </view>
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<!-- 音视频不支持小程序 --> <!-- 音视频不支持小程序 -->
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onPriviteVideo()"> <view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onPriviteVideo()">
<view class="tool-icon iconfont icon-video" ></view> <view class="tool-icon iconfont icon-video"></view>
<view class="tool-name">视频通话</view> <view class="tool-name">视频通话</view>
</view> </view>
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onPriviteVoice()"> <view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onPriviteVoice()">
<view class="tool-icon iconfont icon-call" ></view> <view class="tool-icon iconfont icon-call"></view>
<view class="tool-name">语音通话</view> <view class="tool-name">语音通话</view>
</view> </view>
<view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="onGroupVideo()"> <view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="onGroupVideo()">
<view class="tool-icon iconfont icon-call" ></view> <view class="tool-icon iconfont icon-call"></view>
<view class="tool-name">语音通话</view> <view class="tool-name">语音通话</view>
</view> </view>
<!-- #endif --> <!-- #endif -->
@ -109,8 +108,7 @@
@complete="onAtComplete"></chat-at-box> @complete="onAtComplete"></chat-at-box>
<!-- 群语音通话时选择成员 --> <!-- 群语音通话时选择成员 -->
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<group-member-selector ref="selBox" :members="groupMembers" <group-member-selector ref="selBox" :members="groupMembers" :maxSize="configStore.webrtc.maxChannel"
:maxSize="configStore.webrtc.maxChannel"
@complete="onInviteOk"></group-member-selector> @complete="onInviteOk"></group-member-selector>
<group-rtc-join ref="rtcJoin" :groupId="group.id"></group-rtc-join> <group-rtc-join ref="rtcJoin" :groupId="group.id"></group-rtc-join>
<!-- #endif --> <!-- #endif -->
@ -136,8 +134,8 @@
keyboardHeight: 322, keyboardHeight: 322,
atUserIds: [], atUserIds: [],
needScrollToBottom: false, // needScrollToBottom: false, //
showMinIdx: 0, // showMinIdx showMinIdx: 0, // showMinIdx
reqQueue: [], // reqQueue: [], //
isSending: false // isSending: false //
} }
}, },
@ -189,23 +187,13 @@
}) })
}, },
onGroupVideo() { onGroupVideo() {
this.$http({ //
url: "/webrtc/group/info?groupId="+this.group.id, let ids = [this.mine.id];
method: 'GET' this.$refs.selBox.init(ids, ids);
}).then((rtcInfo)=>{ this.$refs.selBox.open();
if(rtcInfo.isChating){
//
this.$refs.rtcJoin.open(rtcInfo);
}else {
//
let ids = [this.mine.id];
this.$refs.selBox.init(ids, ids);
this.$refs.selBox.open();
}
})
}, },
onInviteOk(ids) { onInviteOk(ids) {
if(ids.length < 2){ if (ids.length < 2) {
return; return;
} }
let users = []; let users = [];
@ -264,7 +252,6 @@
} }
}, },
sendTextMessage() { sendTextMessage() {
const timeStamp = new Date().getTime();
if (!this.sendText.trim() && this.atUserIds.length == 0) { if (!this.sendText.trim() && this.atUserIds.length == 0) {
return uni.showToast({ return uni.showToast({
title: "不能发送空白信息", title: "不能发送空白信息",
@ -283,10 +270,8 @@
// id // id
this.fillTargetId(msgInfo, this.chat.targetId); this.fillTargetId(msgInfo, this.chat.targetId);
this.sendMessageRequest(msgInfo).then((m) => { this.sendMessageRequest(msgInfo).then((m) => {
console.log("请求耗时:",new Date().getTime()-timeStamp)
m.selfSend = true; m.selfSend = true;
this.chatStore.insertMessage(m); this.chatStore.insertMessage(m);
console.log("insertMessage耗时:",new Date().getTime()-timeStamp)
// //
this.moveChatToTop(); this.moveChatToTop();
}).finally(() => { }).finally(() => {
@ -368,6 +353,7 @@
this.showKeyBoard = true; this.showKeyBoard = true;
this.switchChatTabBox('none', false) this.switchChatTabBox('none', false)
this.keyboardHeight = this.rpxTopx(e.detail.height); this.keyboardHeight = this.rpxTopx(e.detail.height);
this.scrollToBottom();
} else { } else {
this.showKeyBoard = false; this.showKeyBoard = false;
} }
@ -505,6 +491,17 @@
} }
}) })
}, },
onCopyMessage(msgInfo) {
uni.setClipboardData({
data: msgInfo.content,
success: () => {
uni.showToast({ title: '已复制', icon: 'none' });
},
fail: () => {
uni.showToast({ title: '复制失败', icon: 'none' });
}
});
},
onDownloadFile(msgInfo) { onDownloadFile(msgInfo) {
let url = JSON.parse(msgInfo.content).url; let url = JSON.parse(msgInfo.content).url;
uni.downloadFile({ uni.downloadFile({
@ -622,14 +619,14 @@
let px = info.windowWidth * rpx / 750; let px = info.windowWidth * rpx / 750;
return Math.floor(rpx); return Math.floor(rpx);
}, },
sendMessageRequest(msgInfo){ sendMessageRequest(msgInfo) {
return new Promise((resolve,reject)=>{ return new Promise((resolve, reject) => {
// "" // ""
this.reqQueue.push({msgInfo,resolve,reject}); this.reqQueue.push({ msgInfo, resolve, reject });
this.processReqQueue(); this.processReqQueue();
}) })
}, },
processReqQueue(){ processReqQueue() {
if (this.reqQueue.length && !this.isSending) { if (this.reqQueue.length && !this.isSending) {
this.isSending = true; this.isSending = true;
const reqData = this.reqQueue.shift(); const reqData = this.reqQueue.shift();
@ -637,18 +634,18 @@
url: this.messageAction, url: this.messageAction,
method: 'post', method: 'post',
data: reqData.msgInfo data: reqData.msgInfo
}).then((res)=>{ }).then((res) => {
reqData.resolve(res) reqData.resolve(res)
}).catch((e)=>{ }).catch((e) => {
reqData.reject(e) reqData.reject(e)
}).finally(()=>{ }).finally(() => {
this.isSending = false; this.isSending = false;
// //
this.processReqQueue(); this.processReqQueue();
}) })
} }
}, },
generateId(){ generateId() {
// id // id
return String(new Date().getTime()) + String(Math.floor(Math.random() * 1000)); return String(new Date().getTime()) + String(Math.floor(Math.random() * 1000));
} }
@ -706,10 +703,10 @@
// //
if (newSize > oldSize) { if (newSize > oldSize) {
let pages = getCurrentPages(); let pages = getCurrentPages();
let curPage = pages[pages.length-1].route; let curPage = pages[pages.length - 1].route;
if(curPage == "pages/chat/chat-box"){ if (curPage == "pages/chat/chat-box") {
this.scrollToBottom(); this.scrollToBottom();
}else { } else {
this.needScrollToBottom = true; this.needScrollToBottom = true;
} }
} }
@ -743,8 +740,8 @@
// //
this.isReceipt = false; this.isReceipt = false;
}, },
onShow(){ onShow() {
if(this.needScrollToBottom){ if (this.needScrollToBottom) {
// //
this.scrollToBottom(); this.scrollToBottom();
this.needScrollToBottom = false; this.needScrollToBottom = false;
@ -839,6 +836,7 @@
border: #dddddd solid 1px; border: #dddddd solid 1px;
background-color: #f7f8fd; background-color: #f7f8fd;
height: 80rpx; height: 80rpx;
.iconfont { .iconfont {
font-size: 68rpx; font-size: 68rpx;
margin: 6rpx; margin: 6rpx;
@ -857,6 +855,7 @@
border-radius: 20rpx; border-radius: 20rpx;
font-size: 30rpx; font-size: 30rpx;
box-sizing: border-box; box-sizing: border-box;
.send-text-area { .send-text-area {
width: 100%; width: 100%;
} }
@ -883,13 +882,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
.tool-icon { .tool-icon {
padding: 28rpx; padding: 28rpx;
font-size: 60rpx; font-size: 60rpx;
border-radius: 20%; border-radius: 20%;
background-color: white; background-color: white;
color: black; color: black;
&.active { &.active {
background-color: #ddd; background-color: #ddd;
} }

BIN
im-uniapp/static/logo/logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 162 KiB

9
im-uniapp/store/chatStore.js

@ -292,15 +292,18 @@ export default defineStore('chatStore', {
this.refreshChats() this.refreshChats()
} }
}, },
refreshChats(state) { refreshChats() {
if(!cacheChats){
return;
}
// 排序 // 排序
cacheChats.sort((chat1, chat2) => { cacheChats.sort((chat1, chat2) => {
return chat2.lastSendTime - chat1.lastSendTime; return chat2.lastSendTime - chat1.lastSendTime;
}); });
// 将消息一次性装载回来 // 将消息一次性装载回来
this.chats = cacheChats; this.chats = cacheChats;
// 断线重连后不能使用缓存模式,否则会导致聊天窗口的消息不刷新 // 清空缓存
cacheChats = this.chats; cacheChats = null;
this.saveToStorage(); this.saveToStorage();
}, },
saveToStorage(state) { saveToStorage(state) {

19
im-web/src/components/chat/ChatBox.vue

@ -293,21 +293,10 @@
this.$eventBus.$emit("openPrivateVideo", rtcInfo); this.$eventBus.$emit("openPrivateVideo", rtcInfo);
}, },
onGroupVideo() { onGroupVideo() {
this.$http({ //
url: "/webrtc/group/info?groupId=" + this.group.id, let ids = [this.mine.id];
method: 'GET' let maxChannel = this.$store.state.configStore.webrtc.maxChannel;
}).then((rtcInfo) => { this.$refs.rtcSel.open(maxChannel, ids, ids);
if (rtcInfo.isChating) {
//
this.$refs.rtcJoin.open(rtcInfo);
} else {
//
let ids = [this.mine.id];
let maxChannel = this.$store.state.configStore.webrtc.maxChannel;
this.$refs.rtcSel.open(maxChannel, ids, ids);
}
})
}, },
onInviteOk(members) { onInviteOk(members) {
if (members.length < 2) { if (members.length < 2) {

2
im-web/src/components/rtc/RtcGroupVideo.vue

@ -3,7 +3,7 @@
:visible.sync="isShow" width="50%"> :visible.sync="isShow" width="50%">
<div class='rtc-group-video'> <div class='rtc-group-video'>
<div style="padding-top:30px;font-weight: 600; text-align: center;font-size: 16px;"> <div style="padding-top:30px;font-weight: 600; text-align: center;font-size: 16px;">
多人音视频通话需升级至商业版如有需要请联系作者购买... 多人音视频通话属于付费功能如有需要请联系作者购买商业版源码...
</div> </div>
<div style="padding-top:50px; text-align: center;font-size: 16px;"> <div style="padding-top:50px; text-align: center;font-size: 16px;">
点击下方文档了解详细信息: 点击下方文档了解详细信息:

7
im-web/src/store/chatStore.js

@ -280,14 +280,17 @@ export default {
} }
}, },
refreshChats(state) { refreshChats(state) {
if(!cacheChats){
return;
}
// 排序 // 排序
cacheChats.sort((chat1, chat2) => { cacheChats.sort((chat1, chat2) => {
return chat2.lastSendTime - chat1.lastSendTime; return chat2.lastSendTime - chat1.lastSendTime;
}); });
// 将消息一次性装载回来 // 将消息一次性装载回来
state.chats = cacheChats; state.chats = cacheChats;
// 断线重连后不能使用缓存模式,否则会导致聊天窗口的消息不刷新 // 清空缓存
cacheChats = state.chats; cacheChats = null;
this.commit("saveToStorage"); this.commit("saveToStorage");
}, },
saveToStorage(state) { saveToStorage(state) {

Loading…
Cancel
Save