feat: 语音输入交互完善

This commit is contained in:
duanshuwen
2025-08-10 19:40:47 +08:00
parent 5b1566fc33
commit 9c7063196c
6 changed files with 287 additions and 474 deletions

View File

@@ -1,107 +0,0 @@
<template>
<uni-popup position="center" :mask-click-able="false" ref="popupRef" type="center" :safe-area="false" :custom-style="{width: '100%', height: '100vh', borderRadius: 0, position: 'fixed', top: '0', left: '0', zIndex: 9999}">
<view class="recording-popup">
<view class="recording-wave">
<!-- 波形动画 -->
<view class="wave-animation"></view>
</view>
<view class="recording-text">
{{ isSlideToText ? '松开发送 转文字' : '松开发送' }}
</view>
<view class="recording-cancel" @click="handleCancel">
取消
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
isSlideToText: Boolean
})
const emit = defineEmits(['cancel'])
const popupRef = ref(null)
// 打开弹窗
const open = () => {
if (popupRef.value) {
popupRef.value.open()
}
}
// 关闭弹窗
const close = () => {
if (popupRef.value) {
popupRef.value.close()
}
}
// 处理取消
const handleCancel = () => {
emit('cancel')
close()
}
// 暴露方法
defineExpose({
open,
close
})
</script>
<style scoped lang="scss">
/* 录音弹窗样式 */
.recording-popup {
width: 100% !important;
height: 100vh !important;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
padding: 40px 0;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
}
.recording-wave {
width: 240px;
height: 240px;
border-radius: 50%;
background-color: rgba(76, 217, 100, 0.3);
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 40px;
}
.wave-animation {
width: 160px;
height: 160px;
/* 这里可以添加波形动画 */
background-image: url('/static/wave_icon.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.recording-text {
font-size: 20px;
margin-bottom: 40px;
}
.recording-cancel {
font-size: 18px;
color: #CCCCCC;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<view class="recording-wave-btn">
<view class="audio-visualizer">
<view
v-for="(bar, index) in audioBars"
:key="index"
class="audio-bar"
:style="{
height: bar.height + 'px',
transition: 'height 0.1s ease-out',
}"
></view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const animationTimer = ref(null);
const isAnimating = ref(false);
// 音频条数据
const audioBars = ref([]);
const BAR_COUNT = 30;
// 初始化音频条
const initAudioBars = () => {
audioBars.value = [];
for (let i = 0; i < BAR_COUNT; i++) {
audioBars.value.push({
height: 4 + Math.random() * 8,
});
}
};
// 更新音频条动画
const updateAudioBars = () => {
if (!isAnimating.value) return;
// 使用 for 循环随机修改每个音频条的高度
for (let i = 0; i < audioBars.value.length; i++) {
const bar = audioBars.value[i];
// 生成随机高度值
const minHeight = 4;
const maxHeight = 20;
const randomHeight = minHeight + Math.random() * (maxHeight - minHeight);
// 添加一些变化幅度,让高度变化更自然
const variation = (Math.random() - 0.5) * 10;
bar.height = Math.max(
minHeight,
Math.min(maxHeight, randomHeight + variation)
);
}
};
// 开始动画
const startAnimation = () => {
if (!isAnimating.value) {
isAnimating.value = true;
const animate = () => {
if (isAnimating.value) {
updateAudioBars();
animationTimer.value = setTimeout(animate, 200); // 每200ms更新一次模仿人类说话语速
}
};
animate();
}
};
// 停止动画
const stopAnimation = () => {
isAnimating.value = false;
if (animationTimer.value) {
clearTimeout(animationTimer.value);
animationTimer.value = null;
}
};
// 组件挂载时初始化并自动开始动画
onMounted(() => {
initAudioBars();
startAnimation();
});
// 组件卸载时停止动画
onUnmounted(() => {
stopAnimation();
});
// 暴露方法给父组件
defineExpose({
startAnimation,
stopAnimation,
});
</script>
<style scoped lang="scss">
.recording-wave-btn {
box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05);
margin: 0 12px;
margin-bottom: 8px;
display: flex;
justify-content: center;
align-items: center;
background-color: #00a6ff;
height: 44px;
border-radius: 50px;
}
.audio-visualizer {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
gap: 2px;
}
.audio-bar {
width: 2px;
height: 4px;
border-radius: 2px;
background-color: #fff;
}
</style>

View File

@@ -1,140 +0,0 @@
<template>
<uni-popup position="center" :mask-click-able="false" ref="popupRef" type="center" :safe-area="false" :custom-style="{ width: '100%', maxWidth: '100vw', borderRadius: 0, position: 'fixed', top: '0', left: '0', zIndex: 9999 }">
<view class="voice-result-popup">
<view class="voice-result-bubble">
<textarea v-model="editedVoiceText" class="editable-textarea" placeholder="语音转换结果"></textarea>
</view>
<view class="voice-result-actions">
<view class="action-button cancel" @click="handleCancel">取消</view>
<view class="action-button send" @click="handleSendText">发送</view>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
voiceText: String
})
const emit = defineEmits(['cancel', 'sendText'])
const popupRef = ref(null)
const editedVoiceText = ref(props.voiceText || '')
// 监听props变化更新编辑框内容
watch(() => props.voiceText,
(newValue) => {
editedVoiceText.value = newValue || ''
}
)
// 打开弹窗
const open = () => {
if (popupRef.value) {
popupRef.value.open()
}
}
// 关闭弹窗
const close = () => {
if (popupRef.value) {
popupRef.value.close()
}
}
// 处理取消
const handleCancel = () => {
emit('cancel')
close()
}
// 处理发送文本
const handleSendText = () => {
emit('sendText', { text: editedVoiceText.value })
close()
}
// 暴露方法
defineExpose({
open,
close
})
</script>
<style scoped lang="scss">
/* 语音结果弹窗样式 */
.voice-result-popup {
width: 100% !important;
height: 100vh;
background-color: rgba(0, 0, 0, 0.7);
padding: 40px 20px;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
}
.voice-result-bubble {
background-color: #00A6FF;
color: white;
box-sizing: border-box;
width: 100%;
padding: 16px;
border-radius: 10px;
margin-bottom: 40px;
min-height: 120px;
max-height: 400px;
font-size: 16px;
overflow-y: auto;
box-shadow: 2px 2px 6px 0px rgba(0,0,0,0.1);
border-radius: 20px 4px 20px 20px;
border: 1px solid;
border-color: #FFFFFF;
}
.editable-textarea {
width: 100%;
height: 100%;
background: transparent;
color: white;
font-size: 16px;
}
.editable-textarea:focus {
outline: none;
}
.voice-result-actions {
display: flex;
justify-content: center;
gap: 20px;
width: 100%;
max-width: 600px;
margin-top: 20px;
padding-bottom: 20px;
}
.action-button {
padding: 12px 24px;
border-radius: 30px;
font-size: 18px;
min-width: 120px;
text-align: center;
}
.cancel {
color: #F5F5F5;
border: 1px solid;
border-color: #F5F5F5;
}
.send {
color: #333;
background-color: #F5F5F5;
}
</style>

View File

@@ -1,94 +1,100 @@
{ {
"name": "YGTianmuCS", "name": "YGTianmuCS",
"appid": "__UNI__BB03E8A", "appid": "__UNI__BB03E8A",
"description": "", "description": "",
"versionName": "1.0.0", "versionName": "1.0.0",
"versionCode": "100", "versionCode": "100",
"transformPx": false, "transformPx": false,
/* 5+App */ /* 5+App */
"app-plus": { "app-plus": {
"usingComponents": true, "usingComponents": true,
"nvueStyleCompiler": "uni-app", "nvueStyleCompiler": "uni-app",
"compilerVersion": 3, "compilerVersion": 3,
"splashscreen": { "splashscreen": {
"alwaysShowBeforeRender": true, "alwaysShowBeforeRender": true,
"waiting": true, "waiting": true,
"autoclose": true, "autoclose": true,
"delay": 0 "delay": 0
},
"safearea": {
"bottom": {
"offset": "auto" // 自动适配安全区域
}
},
/* */
"modules": {},
/* */
"distribute": {
/* android */
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios": {},
/* SDK */
"sdkConfigs": {
"oauth": {}
}
}
}, },
"safearea": { /* */
"bottom": { "quickapp": {},
"offset": "auto" // 自动适配安全区域 /* */
} "mp-weixin": {
"appid": "wx5e79df5996572539",
"setting": {
"urlCheck": false
},
"usingComponents": true,
"requiredPrivateInfos": ["getLocation"],
"permission": {
"scope.userLocation": {
"desc": "用于获取当前所在城市信息"
}
},
"plugins": {
"WechatSI": {
"version": "0.3.6",
"provider": "wx069ba97219f66d99"
}
}
}, },
/* */ "mp-alipay": {
"modules": {}, "usingComponents": true
/* */ },
"distribute": { "mp-baidu": {
/* android */ "usingComponents": true
"android": { },
"permissions": [ "mp-toutiao": {
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>", "usingComponents": true
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>", },
"<uses-permission android:name=\"android.permission.VIBRATE\"/>", "uniStatistics": {
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>", "enable": false
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>", },
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>", "vueVersion": "3",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>", "h5": {
"<uses-permission android:name=\"android.permission.CAMERA\"/>", "router": {
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>", "base": "./",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>", "mode": "hash"
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>", },
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>", "devServer": {
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>", "https": false
"<uses-feature android:name=\"android.hardware.camera\"/>", }
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios": {},
/* SDK */
"sdkConfigs": {
"oauth": {}
}
} }
},
/* */
"quickapp": {},
/* */
"mp-weixin": {
"appid": "wx5e79df5996572539",
"setting": {
"urlCheck": false
},
"usingComponents": true,
"requiredPrivateInfos": ["getLocation"],
"permission": {
"scope.userLocation": {
"desc": "用于获取当前所在城市信息"
}
}
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"h5": {
"router": {
"base": "./",
"mode": "hash"
},
"devServer": {
"https": false
}
}
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<view class="input-area-wrapper"> <view class="input-area-wrapper">
<view class="area-input"> <view v-if="!visibleWaveBtn" class="area-input">
<!-- 语音/键盘切换 --> <!-- 语音/键盘切换 -->
<view class="input-container-voice" @click="toggleVoiceMode"> <view class="input-container-voice" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" src="/static/input_voice_icon.png"></image> <image v-if="!isVoiceMode" src="/static/input_voice_icon.png"></image>
@@ -11,6 +11,7 @@
<view class="input-button-container"> <view class="input-button-container">
<textarea <textarea
ref="textareaRef" ref="textareaRef"
v-if="!isVoiceMode"
:class="['textarea', ios ? 'ios' : 'android']" :class="['textarea', ios ? 'ios' : 'android']"
type="text" type="text"
cursor-spacing="65" cursor-spacing="65"
@@ -33,9 +34,11 @@
<view <view
v-if="isVoiceMode" v-if="isVoiceMode"
class="hold-to-talk-button" class="hold-to-talk-button"
@click.stop="startRecording" @longpress="handleVoiceTouchStart"
@touchend="handleVoiceTouchEnd"
@touchcancel="handleVoiceTouchEnd"
> >
按住说话 按住 说话
</view> </view>
</view> </view>
@@ -50,27 +53,17 @@
</view> </view>
</view> </view>
<!-- 使用封装的弹窗组件 --> <!-- 录音按钮 -->
<RecordingPopup <RecordingWaveBtn v-if="visibleWaveBtn" ref="recordingWaveBtnRef" />
ref="recordingPopupRef"
:is-slide-to-text="isSlideToText"
@cancel="handleRecordingCancel"
/>
<VoiceResultPopup
ref="voiceResultPopupRef"
:voice-text="voiceText"
@cancel="cancelVoice"
@sendVoice="handleSendVoice"
@sendText="handleSendText"
/>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, watch, nextTick, onMounted, computed } from "vue"; import { ref, watch, nextTick, onMounted, computed, defineExpose } from "vue";
import RecordingPopup from "@/components/Speech/RecordingPopup.vue"; import RecordingWaveBtn from "@/components/Speech/RecordingWaveBtn.vue";
import VoiceResultPopup from "@/components/Speech/VoiceResultPopup.vue";
const plugin = requirePlugin("WechatSI");
const manager = plugin.getRecordRecognitionManager();
const props = defineProps({ const props = defineProps({
modelValue: String, modelValue: String,
@@ -88,19 +81,13 @@ const emit = defineEmits([
]); ]);
const textareaRef = ref(null); const textareaRef = ref(null);
const recordingWaveBtnRef = ref(null);
const placeholder = ref("快告诉朵朵您在想什么~"); const placeholder = ref("快告诉朵朵您在想什么~");
const inputMessage = ref(props.modelValue || ""); const inputMessage = ref(props.modelValue || "");
const isFocused = ref(false); const isFocused = ref(false);
const keyboardHeight = ref(0); const keyboardHeight = ref(0);
const isVoiceMode = ref(false); const isVoiceMode = ref(false);
const isRecording = ref(false); const visibleWaveBtn = ref(false);
const recordingTime = ref(0);
const recordingTimer = ref(null);
const voiceText = ref("");
const showVoiceResult = ref(false);
const isSlideToText = ref(false);
const recordingPopupRef = ref(null);
const voiceResultPopupRef = ref(null);
// 判断当前平台是否为iOS // 判断当前平台是否为iOS
const ios = computed(() => { const ios = computed(() => {
@@ -128,106 +115,52 @@ const toggleVoiceMode = () => {
isVoiceMode.value = !isVoiceMode.value; isVoiceMode.value = !isVoiceMode.value;
}; };
// 开始录音 // 处理语音按钮长按开始
const startRecording = () => { const handleVoiceTouchStart = () => {
console.log("startRecording"); manager.start({ lang: "zh_CN" });
isRecording.value = true;
recordingTime.value = 0;
// 启动录音计时器
recordingTimer.value = setInterval(() => {
recordingTime.value += 1;
}, 1000);
// 打开录音弹窗 visibleWaveBtn.value = true;
if (recordingPopupRef.value) {
recordingPopupRef.value.open();
}
// 调用uni-app录音API // 启动音频条动画
uni.startRecord({ nextTick(() => {
success: (res) => { if (recordingWaveBtnRef.value) {
// 录音成功,处理录音文件 recordingWaveBtnRef.value.startAnimation();
const tempFilePath = res.tempFilePath; }
// 这里可以添加语音转文字的逻辑
// 模拟语音转文字
setTimeout(() => {
voiceText.value = "这是语音转文字的结果";
showVoiceResult.value = true;
// 打开语音结果弹窗
if (voiceResultPopupRef.value) {
voiceResultPopupRef.value.open();
}
}, 1000);
},
fail: (err) => {
console.error("录音失败:", err);
isRecording.value = false;
clearInterval(recordingTimer.value);
if (recordingPopupRef.value) {
recordingPopupRef.value.close();
}
},
}); });
}; };
// 处理录音弹窗取消 // 处理语音按钮长按结束
const handleRecordingCancel = () => { const handleVoiceTouchEnd = () => {
isRecording.value = false; manager.stop();
clearInterval(recordingTimer.value);
uni.stopRecord(); // 停止音频条动画
if (recordingWaveBtnRef.value) {
recordingWaveBtnRef.value.stopAnimation();
}
visibleWaveBtn.value = false;
}; };
// 处理发送原语音 // 处理发送原语音
const handleSendVoice = (data) => { const initRecord = () => {
// 发送语音逻辑 manager.onRecognize = (res) => {
emit("sendVoice", { let text = res.result;
text: data.text, inputMessage.value = text;
// 可以添加语音文件路径等信息 };
}); // 识别结束事件
showVoiceResult.value = false; manager.onStop = (res) => {
isVoiceMode.value = false; console.log(res, 37);
}; let text = res.result;
// 处理发送文本 if (text == "") {
const handleSendText = (data) => { console.log("没有说话");
// 发送文本逻辑 return;
emit("sendVoice", {
text: data.text,
// 可以添加语音文件路径等信息
});
showVoiceResult.value = false;
isVoiceMode.value = false;
};
// 取消语音
const cancelVoice = () => {
showVoiceResult.value = false;
isVoiceMode.value = false;
};
// 测试弹窗方法
const testPopup = () => {
// 模拟开始录音,打开录音弹窗
isRecording.value = true;
console.log("===========1");
if (recordingPopupRef.value) {
console.log("===========2");
recordingPopupRef.value.open();
}
// 2秒后关闭录音弹窗打开语音结果弹窗
setTimeout(() => {
if (recordingPopupRef.value) {
recordingPopupRef.value.close();
} }
voiceText.value = "测试语音转文字结果";
showVoiceResult.value = true; inputMessage.value = text;
if (voiceResultPopupRef.value) { // 在语音识别完成后发送消息
voiceResultPopupRef.value.open(); emit("send", text);
} };
}, 3000);
}; };
// 监听键盘高度变化 // 监听键盘高度变化
@@ -235,20 +168,17 @@ onMounted(() => {
// 监听键盘弹起 // 监听键盘弹起
uni.onKeyboardHeightChange((res) => { uni.onKeyboardHeightChange((res) => {
keyboardHeight.value = res.height; keyboardHeight.value = res.height;
if (res.height > 0) { if (res.height) {
emit("keyboardShow", res.height); emit("keyboardShow", res.height);
} else { } else {
emit("keyboardHide"); emit("keyboardHide");
} }
}); });
initRecord();
}); });
const sendMessage = () => { const sendMessage = () => {
if (isVoiceMode.value) {
testPopup();
return;
}
if (props.isSessionActive) { if (props.isSessionActive) {
// 如果会话进行中,调用停止请求函数 // 如果会话进行中,调用停止请求函数
if (props.stopRequest) { if (props.stopRequest) {
@@ -300,18 +230,13 @@ const blurInput = () => {
}; };
// 暴露方法给父组件 // 暴露方法给父组件
defineExpose({ defineExpose({ focusInput });
focusInput,
blurInput,
isFocused,
toggleVoiceMode,
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.area-input { .area-input {
display: flex; display: flex;
align-items: center; align-items: flex-end;
border-radius: 22px; border-radius: 22px;
background-color: #ffffff; background-color: #ffffff;
box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05); box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05);
@@ -338,13 +263,16 @@ defineExpose({
.hold-to-talk-button { .hold-to-talk-button {
width: 100%; width: 100%;
height: 100%; height: 44px;
color: #333333; color: #333333;
font-size: 16px; font-size: 16px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: #ffffff; background-color: #ffffff;
transition: all 0.2s ease;
user-select: none;
-webkit-user-select: none;
} }
.input-container-send { .input-container-send {
@@ -378,11 +306,11 @@ defineExpose({
line-height: normal; line-height: normal;
&.android { &.android {
padding: 11px 0; padding: 12px 0;
} }
&.ios { &.ios {
padding: 4px 0; padding: 6px 0;
} }
&::placeholder { &::placeholder {

View File

@@ -1,14 +1,11 @@
<template> <template>
<view class="container"> <view class="container">
<ChatMainList/> <ChatMainList />
</view> </view>
</template> </template>
<script setup> <script setup>
import ChatMainList from "../chat/ChatMainList.vue"; import ChatMainList from "../chat/ChatMainList.vue";
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>