Files
im-uniapp/pages/conversation/chating/components/ChatingFooter/index.vue
2025-07-04 22:34:07 +08:00

651 lines
18 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.

<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>