651 lines
18 KiB
Vue
651 lines
18 KiB
Vue
<template>
|
||
<view>
|
||
<view>
|
||
<view class="chat_footer">
|
||
|
||
<view class="footer_action_area">
|
||
<image v-if="inputBox"
|
||
@click.prevent="updateInput"
|
||
src="@/static/images/chating_footer_recording.png"
|
||
alt=""
|
||
srcset=""
|
||
/>
|
||
|
||
<image v-if="!inputBox"
|
||
@click.prevent="updateInput"
|
||
src="@/static/images/chating_footer_audio_recording.png"
|
||
alt=""
|
||
srcset=""
|
||
/>
|
||
</view>
|
||
|
||
<view class="input_content" v-if="inputBox">
|
||
<CustomEditor class="custom_editor" ref="customEditor" @ready="editorReady" @focus="editorFocus"
|
||
@blur="editorBlur" @input="editorInput"/>
|
||
</view>
|
||
|
||
<view class="input_content"
|
||
v-if="!inputBox"
|
||
style="position:relative;"
|
||
>
|
||
<!-- 录音提示区域 -->
|
||
<view v-if="isRecording"
|
||
style="position:absolute;left:50%;top:-120rpx;transform:translateX(-50%);z-index:10;display:flex;flex-direction:column;align-items:center;">
|
||
<view :style="{
|
||
background: willCancel ? 'rgba(255,80,80,0.95)' : 'rgba(0,0,0,0.85)',
|
||
color: '#fff',
|
||
borderRadius: '12rpx',
|
||
padding: '18rpx 32rpx',
|
||
fontSize: '28rpx',
|
||
minWidth: '260rpx',
|
||
textAlign: 'center',
|
||
marginBottom: '10rpx',
|
||
}">
|
||
<text v-if="willCancel">松开手指,取消发送</text>
|
||
<text v-else>正在讲话,已录制 {{recordingTime}} 秒,松开发送</text>
|
||
</view>
|
||
</view>
|
||
<!-- 蒙版 -->
|
||
<view v-if="isRecording" style="position:absolute;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.08);z-index:5;border-radius:16rpx;"></view>
|
||
<button
|
||
class="record_btn"
|
||
@touchstart="startRecord"
|
||
@touchend="stopRecord"
|
||
@touchcancel="cancelRecord"
|
||
@touchmove="moveRecord"
|
||
style="width:100%;height:100%;background:transparent;border:none;outline:none;display:flex;align-items:center;justify-content:center;z-index:20;position:relative;"
|
||
>
|
||
<text v-if="!isRecording">按住说话</text>
|
||
<text v-else :style="willCancel ? 'color:#ff5050;' : 'color:#4a9cfc;'">{{ willCancel ? '松开手指,取消发送' : '正在说话,松开发送' }}</text>
|
||
</button>
|
||
</view>
|
||
|
||
<view class="footer_action_area">
|
||
<image
|
||
class="emoji_action"
|
||
v-if="inputBox && !showEmojiPicker"
|
||
@click.prevent="toggleEmojiPicker"
|
||
src="@/static/images/chating_footer_emoji.png"
|
||
alt=""
|
||
srcset=""
|
||
/>
|
||
<image
|
||
class="emoji_action"
|
||
v-if="inputBox && showEmojiPicker"
|
||
@click.prevent="closeEmojiAndFocusInput"
|
||
src="@/static/images/chating_footer_audio_recording.png"
|
||
alt=""
|
||
srcset=""
|
||
/>
|
||
<image
|
||
v-show="!hasContent"
|
||
@click.prevent="updateActionBar"
|
||
src="@/static/images/chating_footer_add.png"
|
||
alt=""
|
||
srcset=""
|
||
/>
|
||
<image v-show="hasContent" @touchend.prevent="sendTextMessage" src="@/static/images/send_btn.png"
|
||
alt="" srcset=""/>
|
||
</view>
|
||
</view>
|
||
<chating-action-bar @sendMessage="sendMessage" @prepareMediaMessage="prepareMediaMessage"
|
||
v-show="actionBarVisible"/>
|
||
<u-action-sheet :safeAreaInsetBottom="true" round="12" :actions="actionSheetMenu" @select="selectClick"
|
||
:closeOnClickOverlay="true" :closeOnClickAction="true" :show="showActionSheet"
|
||
@close="showActionSheet = false">
|
||
</u-action-sheet>
|
||
<!-- Emoji 选择面板 -->
|
||
<view v-if="showEmojiPicker">
|
||
<view class="emoji-mask" @touchstart="showEmojiPicker = false" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:998;"></view>
|
||
<view class="emoji-picker" style="position:relative;z-index:999;">
|
||
<EmojiPicker @select="onEmojiSelect" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import {
|
||
mapGetters,
|
||
mapActions
|
||
} from "vuex";
|
||
import {
|
||
getPurePath,
|
||
html2Text
|
||
} from "@/util/common";
|
||
import {
|
||
offlinePushInfo
|
||
} from "@/util/imCommon";
|
||
import {
|
||
ChatingFooterActionTypes,
|
||
UpdateMessageTypes,
|
||
} from "@/constant";
|
||
import IMSDK, {
|
||
IMMethods,
|
||
MessageStatus,
|
||
MessageType,
|
||
} from "openim-uniapp-polyfill";
|
||
import UParse from "@/components/gaoyia-parse/parse.vue";
|
||
import CustomEditor from "./CustomEditor.vue";
|
||
import ChatingActionBar from "./ChatingActionBar.vue";
|
||
import EmojiPicker from '@/components/EmojiPicker/index.vue';
|
||
|
||
const needClearTypes = [MessageType.TextMessage];
|
||
|
||
const albumChoose = [{
|
||
name: "图片",
|
||
type: ChatingFooterActionTypes.Album,
|
||
idx: 0,
|
||
},
|
||
{
|
||
name: "拍照",
|
||
type: ChatingFooterActionTypes.Camera,
|
||
idx: 1,
|
||
},
|
||
];
|
||
|
||
export default {
|
||
components: {
|
||
CustomEditor,
|
||
ChatingActionBar,
|
||
UParse,
|
||
EmojiPicker,
|
||
},
|
||
props: {
|
||
footerOutsideFlag: Number,
|
||
},
|
||
data() {
|
||
return {
|
||
inputBox: true,
|
||
customEditorCtx: null,
|
||
inputHtml: "",
|
||
actionBarVisible: false,
|
||
isInputFocus: false,
|
||
actionSheetMenu: [],
|
||
showActionSheet: false,
|
||
isRecording: false,
|
||
willCancel: false,
|
||
recordingTime: 0,
|
||
recordingTimer: null,
|
||
showEmojiPicker: false,
|
||
recorderManager: null,
|
||
recordFilePath: '',
|
||
recordFileDuration: 0,
|
||
};
|
||
},
|
||
computed: {
|
||
...mapGetters([
|
||
"storeCurrentConversation",
|
||
"storeCurrentGroup",
|
||
"storeBlackList",
|
||
]),
|
||
hasContent() {
|
||
return html2Text(this.inputHtml) !== "";
|
||
},
|
||
},
|
||
watch: {
|
||
footerOutsideFlag(newVal) {
|
||
this.onClickActionBarOutside();
|
||
},
|
||
},
|
||
mounted() {
|
||
this.setKeyboardListener();
|
||
document.addEventListener('plusready', function() {
|
||
// plus 相关代码
|
||
});
|
||
},
|
||
beforeDestroy() {
|
||
this.disposeKeyboardListener();
|
||
},
|
||
methods: {
|
||
updateInput() {
|
||
this.inputBox = !this.inputBox;
|
||
this.showEmojiPicker = false;
|
||
},
|
||
startRecord(e) {
|
||
this.isRecording = true;
|
||
this.willCancel = false;
|
||
this.recordingTime = 0;
|
||
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
||
this.recordingTimer = setInterval(() => {
|
||
this.recordingTime++;
|
||
}, 1000);
|
||
// 录音开始
|
||
if (!this.recorderManager) {
|
||
this.recorderManager = uni.getRecorderManager();
|
||
this.recorderManager.onStop(this.onRecordStop);
|
||
}
|
||
this.recordFilePath = '';
|
||
this.recordFileDuration = 0;
|
||
this.recorderManager.start({ format: 'mp3' });
|
||
},
|
||
stopRecord(e) {
|
||
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
||
this.recordingTimer = null;
|
||
this.isRecording = false;
|
||
if (this.recorderManager) {
|
||
this.recorderManager.stop();
|
||
}
|
||
this._startY = null;
|
||
},
|
||
cancelRecord(e) {
|
||
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
||
this.recordingTimer = null;
|
||
this.isRecording = false;
|
||
this.willCancel = false;
|
||
this.recordingTime = 0;
|
||
if (this.recorderManager) {
|
||
this.recorderManager.stop();
|
||
}
|
||
this._startY = null;
|
||
},
|
||
moveRecord(e) {
|
||
// 判断手指是否上滑,通常通过e.touches[0].clientY与起始Y比较
|
||
if (!this.isRecording) return;
|
||
const touch = e.touches[0];
|
||
if (!this._startY && touch) {
|
||
this._startY = touch.clientY;
|
||
}
|
||
if (touch && this._startY) {
|
||
// 上滑距离大于40px则判定为取消
|
||
this.willCancel = (this._startY - touch.clientY) > 40;
|
||
}
|
||
},
|
||
|
||
...mapActions("message", ["pushNewMessage", "updateOneMessage"]),
|
||
async createTextMessage() {
|
||
let message = "";
|
||
const text = html2Text(this.inputHtml);
|
||
message = await IMSDK.asyncApi(
|
||
IMMethods.CreateTextMessage,
|
||
IMSDK.uuid(),
|
||
text
|
||
);
|
||
return message;
|
||
},
|
||
async sendTextMessage() {
|
||
if (!this.hasContent) return;
|
||
const message = await this.createTextMessage();
|
||
this.sendMessage(message);
|
||
},
|
||
sendMessage(message) {
|
||
this.pushNewMessage(message);
|
||
if (needClearTypes.includes(message.contentType)) {
|
||
this.customEditorCtx.clear();
|
||
}
|
||
this.$emit("scrollToBottom");
|
||
IMSDK.asyncApi(IMMethods.SendMessage, IMSDK.uuid(), {
|
||
recvID: this.storeCurrentConversation.userID,
|
||
groupID: this.storeCurrentConversation.groupID,
|
||
message,
|
||
offlinePushInfo,
|
||
})
|
||
.then(({
|
||
data
|
||
}) => {
|
||
this.updateOneMessage({
|
||
message: data,
|
||
isSuccess: true,
|
||
});
|
||
})
|
||
.catch(({
|
||
data,
|
||
errCode,
|
||
errMsg
|
||
}) => {
|
||
this.updateOneMessage({
|
||
message: data,
|
||
type: UpdateMessageTypes.KeyWords,
|
||
keyWords: [{
|
||
key: "status",
|
||
value: MessageStatus.Failed,
|
||
},
|
||
{
|
||
key: "errCode",
|
||
value: errCode,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
},
|
||
|
||
// action
|
||
onClickActionBarOutside() {
|
||
if (this.actionBarVisible) {
|
||
this.actionBarVisible = false;
|
||
}
|
||
},
|
||
updateActionBar() {
|
||
this.actionBarVisible = !this.actionBarVisible;
|
||
this.showEmojiPicker = false;
|
||
},
|
||
editorReady(e) {
|
||
this.customEditorCtx = e.context;
|
||
this.customEditorCtx.clear();
|
||
},
|
||
editorFocus() {
|
||
this.isInputFocus = true;
|
||
this.showEmojiPicker = false;
|
||
this.$emit("scrollToBottom");
|
||
},
|
||
editorBlur() {
|
||
this.isInputFocus = false;
|
||
},
|
||
editorInput(e) {
|
||
this.inputHtml = e.detail.html;
|
||
},
|
||
prepareMediaMessage(type) {
|
||
if (type === ChatingFooterActionTypes.Album) {
|
||
this.actionSheetMenu = [...albumChoose];
|
||
this.showActionSheet = true;
|
||
} else if (type === ChatingFooterActionTypes.File) {
|
||
this.chooseFileAndSend();
|
||
}
|
||
},
|
||
async chooseFileAndSend() {
|
||
if (!uni.chooseMessageFile) {
|
||
return;
|
||
}
|
||
uni.chooseMessageFile({
|
||
count: 1,
|
||
type: 'file',
|
||
success: async (res) => {
|
||
const file = res.tempFiles[0];
|
||
try {
|
||
const nameIdx = file.name.lastIndexOf("/") + 1;
|
||
const fileName = file.name.slice(nameIdx);
|
||
const typeIdx = file.name.lastIndexOf(".") + 1;
|
||
const fileType = file.name.slice(typeIdx);
|
||
const { data: { url } } = await IMSDK.asyncApi(IMMethods.UploadFile, IMSDK.uuid(), {
|
||
filepath: file.path,
|
||
name: fileName,
|
||
contentType: fileType,
|
||
uuid: IMSDK.uuid(),
|
||
});
|
||
// 创建文件消息
|
||
const message = await IMSDK.asyncApi(IMMethods.CreateFileMessage, IMSDK.uuid(), {
|
||
fileName: fileName,
|
||
fileSize: file.size,
|
||
sourceUrl: url,
|
||
});
|
||
this.sendMessage(message);
|
||
} catch (e) {
|
||
}
|
||
},
|
||
fail: function (err) {
|
||
},
|
||
});
|
||
},
|
||
|
||
// from comp
|
||
batchCreateImageMesage(paths) {
|
||
paths.forEach(async (path) => {
|
||
const message = await IMSDK.asyncApi(
|
||
IMMethods.CreateImageMessageFromFullPath,
|
||
IMSDK.uuid(),
|
||
getPurePath(path)
|
||
);
|
||
this.sendMessage(message);
|
||
});
|
||
},
|
||
selectClick({
|
||
idx
|
||
}) {
|
||
if (idx === 0) {
|
||
this.chooseOrShotImage(["album"]).then((paths) =>
|
||
this.batchCreateImageMesage(paths)
|
||
);
|
||
} else {
|
||
this.chooseOrShotImage(["camera"]).then((paths) =>
|
||
this.batchCreateImageMesage(paths)
|
||
);
|
||
}
|
||
},
|
||
chooseOrShotImage(sourceType) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.chooseImage({
|
||
count: 9,
|
||
sizeType: ["compressed"],
|
||
sourceType,
|
||
success: function ({
|
||
tempFilePaths
|
||
}) {
|
||
resolve(tempFilePaths);
|
||
},
|
||
fail: function (err) {
|
||
reject(err);
|
||
},
|
||
});
|
||
});
|
||
},
|
||
|
||
// keyboard
|
||
keyboardChangeHander({
|
||
height
|
||
}) {
|
||
if (height > 0) {
|
||
if (this.actionBarVisible) {
|
||
this.actionBarVisible = false;
|
||
}
|
||
}
|
||
},
|
||
setKeyboardListener() {
|
||
uni.onKeyboardHeightChange(this.keyboardChangeHander);
|
||
},
|
||
disposeKeyboardListener() {
|
||
uni.offKeyboardHeightChange(this.keyboardChangeHander);
|
||
},
|
||
toggleEmojiPicker() {
|
||
this.showEmojiPicker = !this.showEmojiPicker;
|
||
if (!this.showEmojiPicker && this.$refs.customEditor && this.$refs.customEditor.editorCtx) {
|
||
// 关闭emoji面板时再focus
|
||
this.$refs.customEditor.editorCtx.focus();
|
||
}
|
||
},
|
||
closeEmojiAndFocusInput() {
|
||
this.showEmojiPicker = false;
|
||
// 只在这里主动focus
|
||
if (this.$refs.customEditor && this.$refs.customEditor.editorCtx) {
|
||
this.$refs.customEditor.editorCtx.focus();
|
||
}
|
||
},
|
||
onEmojiSelect(emoji) {
|
||
this.inputHtml += emoji;
|
||
if (this.$refs.customEditor && this.$refs.customEditor.editorCtx) {
|
||
this.$refs.customEditor.editorCtx.setContents({
|
||
html: this.inputHtml
|
||
});
|
||
}
|
||
// 不做任何focus/blur
|
||
},
|
||
async onRecordStop(res) {
|
||
let tempFilePath = res.tempFilePath;
|
||
let fileName = tempFilePath.split('/').pop();
|
||
let fileType = fileName.split('.').pop();
|
||
let uuid = IMSDK.uuid();
|
||
let duration = this.recordingTime;
|
||
if (!duration || isNaN(duration) || duration < 1) {
|
||
this.recordingTime = 0;
|
||
return;
|
||
}
|
||
// 先保存到本地
|
||
uni.saveFile({
|
||
tempFilePath,
|
||
success: (saveRes) => {
|
||
let savedFilePath = saveRes.savedFilePath;
|
||
// 用 plus.io 转换为原生绝对路径
|
||
if (typeof plus !== 'undefined') {
|
||
plus.io.resolveLocalFileSystemURL(savedFilePath, (entry) => {
|
||
IMSDK.asyncApi('uploadFile', uuid, {
|
||
name: fileName,
|
||
contentType: fileType,
|
||
uuid: uuid,
|
||
filepath: entry.fullPath
|
||
})
|
||
.then(({ data }) => {
|
||
let sourceUrl = data.url;
|
||
IMSDK.asyncApi('createSoundMessageByURL', IMSDK.uuid(), {
|
||
uuid: uuid,
|
||
soundPath: entry.fullPath,
|
||
sourceUrl: sourceUrl,
|
||
dataSize: res.fileSize || 0,
|
||
duration: duration,
|
||
soundType: fileType
|
||
})
|
||
.then((message) => {
|
||
IMSDK.asyncApi('sendMessageNotOss', IMSDK.uuid(), {
|
||
recvID: this.storeCurrentConversation.userID,
|
||
groupID: this.storeCurrentConversation.groupID,
|
||
message: message,
|
||
offlinePushInfo: {
|
||
title: '你有新消息',
|
||
desc: '新消息',
|
||
ex: '',
|
||
iOSPushSound: '+1',
|
||
iOSBadgeCount: true,
|
||
}
|
||
})
|
||
.then(({ data }) => {
|
||
// 1. 本地新增消息
|
||
this.pushNewMessage(message);
|
||
// 2. 更新消息状态
|
||
this.updateOneMessage({
|
||
message: data,
|
||
isSuccess: true,
|
||
});
|
||
this.$emit("scrollToBottom");
|
||
})
|
||
.catch(({ errCode, errMsg }) => {
|
||
// 发送失败时也要更新消息状态为失败
|
||
this.updateOneMessage({
|
||
message: message,
|
||
type: UpdateMessageTypes.KeyWords,
|
||
keyWords: [
|
||
{ key: "status", value: MessageStatus.Failed },
|
||
{ key: "errCode", value: errCode },
|
||
],
|
||
});
|
||
});
|
||
})
|
||
.catch(() => {
|
||
// 忽略错误提示
|
||
});
|
||
})
|
||
.catch(() => {
|
||
// 忽略错误提示
|
||
});
|
||
}, () => {
|
||
// 忽略路径转换失败
|
||
});
|
||
}
|
||
},
|
||
fail: () => {
|
||
// 忽略保存失败
|
||
}
|
||
});
|
||
this.recordingTime = 0;
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.custom_editor {
|
||
img {
|
||
vertical-align: sub;
|
||
}
|
||
}
|
||
|
||
.forbidden_footer {
|
||
width: 100%;
|
||
height: 112rpx;
|
||
color: #8e9ab0;
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: center;
|
||
align-items: center;
|
||
background: #f0f2f6;
|
||
}
|
||
|
||
.chat_footer {
|
||
display: flex;
|
||
align-items: center;
|
||
// background-color: #e9f4ff;
|
||
background: #f0f2f6;
|
||
// height: 50px;
|
||
max-height: 120px;
|
||
padding: 24rpx 20rpx;
|
||
|
||
.input_content {
|
||
flex: 1;
|
||
min-height: 30px;
|
||
max-height: 120px;
|
||
margin: 0 24rpx;
|
||
border-radius: 16rpx;
|
||
position: relative;
|
||
|
||
.record_btn {
|
||
// background-color: #3c9cff;
|
||
background: #fff;
|
||
color: black;
|
||
height: 30px;
|
||
font-size: 24rpx;
|
||
}
|
||
}
|
||
|
||
.quote_message {
|
||
@include vCenterBox();
|
||
justify-content: space-between;
|
||
margin-top: 12rpx;
|
||
padding: 8rpx;
|
||
// padding-top: 20rpx;
|
||
border-radius: 6rpx;
|
||
background-color: #fff;
|
||
color: #666;
|
||
|
||
.content {
|
||
/deep/ uni-view {
|
||
@include ellipsisWithLine(2);
|
||
}
|
||
}
|
||
}
|
||
|
||
.footer_action_area {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.emoji_action {
|
||
margin-right: 24rpx;
|
||
}
|
||
|
||
image {
|
||
width: 26px;
|
||
height: 26px;
|
||
}
|
||
}
|
||
|
||
.send_btn {
|
||
height: 30px;
|
||
line-height: 30px;
|
||
background-color: #4a9cfc;
|
||
padding: 0 8px;
|
||
border-radius: 4px;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.emoji-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: transparent;
|
||
z-index: 998;
|
||
}
|
||
.emoji-picker {
|
||
position: relative;
|
||
z-index: 999;
|
||
}
|
||
</style> |