Commit 250352be authored by 水玉婷's avatar 水玉婷
Browse files

feat:优化语音时长

parent 1dd66d7e
......@@ -75,7 +75,7 @@
<textarea ref="textarea" v-model="messageText" placeholder="输入消息..." @keypress="handleKeyPress"
@input="adjustTextareaHeight" :disabled="loading"></textarea>
<button @click="sendMessage" :disabled="loading">
<button @click="sendMessage" :disabled="loading" class="send-button">
<send-outlined />
</button>
</div>
......@@ -980,7 +980,7 @@ const processHistoryData = (dataArray: any[]) => {
// 处理音频消息
questionContent = contentTemplates.audio({
audioUrl: data.audioPath,
durationTime: data.autioTime || '0"',
durationTime: data.audioTime || '0"',
});
} else {
// 处理文本消息
......
<template>
<div class="voice-recognition">
<div
class="voice-recognition"
:class="{ 'full-screen': isFullScreen }"
>
<!-- 语音按钮 -->
<button
class="voice-btn"
:class="{ 'recording': isRecording, 'disabled': disabled }"
:class="{
'recording': isRecording,
'disabled': disabled
}"
@click="toggleFullScreen"
@mousedown="startRecording"
@mouseup="stopRecording"
@mouseleave="stopRecording"
......@@ -11,25 +18,39 @@
@touchend="stopRecording"
@touchcancel="stopRecording"
:disabled="disabled"
:title="isRecording ? '松开停止' : '按住说话'"
:title="getButtonTitle"
>
<!-- 语音图标始终显示 -->
<span class="voice-icon">
<!-- 默认模式显示语音图标 -->
<span v-if="!isFullScreen" class="voice-icon">
<AudioOutlined />
</span>
<!-- 全屏模式只显示文字 -->
<span v-if="isFullScreen" class="full-screen-text">
{{ isRecording ? `正在说话 ${formattedDuration}` : '按住说话' }}
</span>
<!-- 录音时显示最大时长提示 -->
<span v-if="isRecording" class="max-duration-hint">
最长{{ maxDuration }}
</span>
<!-- 录音时显示指示器 -->
<span v-if="isRecording" class="recording-indicator">
<span class="pulse"></span>
<span class="pulse"></span>
<span class="pulse"></span>
<span class="wave-bar wave-bar-1"></span>
<span class="wave-bar wave-bar-2"></span>
<span class="wave-bar wave-bar-3"></span>
<span class="wave-bar wave-bar-4"></span>
<span class="wave-bar wave-bar-5"></span>
</span>
</button>
<!-- 移除取消按钮 -->
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onUnmounted } from 'vue'
import { AudioOutlined } from '@ant-design/icons-vue'
import { post } from '@/utils/axios' // 导入项目中的axios
......@@ -37,16 +58,18 @@ import { post } from '@/utils/axios' // 导入项目中的axios
interface Props {
disabled?: boolean
debug?: boolean
maxDuration?: number // 添加最大时长参数
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
debug: false
debug: false,
maxDuration: 30 // 默认最大时长为30秒
})
// 组件事件
const emit = defineEmits<{
audio: [audioUrl: string, audioBlob: Blob]
audio: [audioUrl: string, audioBlob: Blob, durationTime: number]
error: [error: string]
recordingStart: []
recordingStop: []
......@@ -54,12 +77,58 @@ const emit = defineEmits<{
// 响应式数据
const isRecording = ref(false)
const isFullScreen = ref(false)
const recordingDuration = ref(0) // 录音时长(秒)
const recordingTimer = ref<NodeJS.Timeout | null>(null) // 计时器
// MediaRecorder相关
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
const audioStream = ref<MediaStream | null>(null)
// 格式化录音时长显示(分:秒)
const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
// 计算属性:格式化录音时长显示
const formattedDuration = computed(() => {
return formatDuration(recordingDuration.value)
})
// 计算属性:最大录音时长显示
const maxDuration = computed(() => {
return props.maxDuration
})
// 计算按钮标题
const getButtonTitle = computed(() => {
if (isRecording.value) return '松开停止'
if (isFullScreen.value) return '按住说话'
return '点击开始说话'
})
// 切换全屏模式
const toggleFullScreen = () => {
if (props.disabled || isRecording.value) return
if (!isFullScreen.value) {
// 进入全屏模式
isFullScreen.value = true
}
// 如果已经在全屏模式,点击事件由mousedown处理
}
// 退出全屏模式
const exitFullScreen = () => {
if (isRecording.value) {
stopRecording()
}
isFullScreen.value = false
}
// 检查浏览器是否支持MediaRecorder
const isMediaRecorderSupported = () => {
const supported = 'MediaRecorder' in window;
......@@ -93,12 +162,13 @@ const checkMicrophonePermission = async () => {
// 开始录音
const startRecording = async () => {
if (props.disabled || isRecording.value) return
if (props.disabled || isRecording.value || !isFullScreen.value) return
// 检查权限
const hasPermission = await checkMicrophonePermission();
if (!hasPermission) {
emit('error', '麦克风权限被拒绝');
exitFullScreen();
return;
}
......@@ -106,6 +176,7 @@ const startRecording = async () => {
if (!isMediaRecorderSupported()) {
const errorMsg = '您的浏览器不支持音频录制功能';
emit('error', errorMsg);
exitFullScreen();
return;
}
......@@ -143,6 +214,9 @@ const startRecording = async () => {
mediaRecorder.value.start(100); // 每100ms收集一次数据
isRecording.value = true;
// 启动录音时长计时器
startRecordingTimer();
// 通知父组件开始录音
emit('recordingStart');
......@@ -161,6 +235,28 @@ const startRecording = async () => {
}
emit('error', errorMessage);
exitFullScreen(); // 出错时退出全屏
}
}
// 启动录音计时器
const startRecordingTimer = () => {
recordingDuration.value = 0;
recordingTimer.value = setInterval(() => {
recordingDuration.value += 1;
// 检查是否达到最大录音时长
if (recordingDuration.value >= props.maxDuration) {
stopRecording(); // 达到最大时长自动停止录音
}
}, 1000);
}
// 停止录音计时器
const stopRecordingTimer = () => {
if (recordingTimer.value) {
clearInterval(recordingTimer.value);
recordingTimer.value = null;
}
}
......@@ -172,6 +268,9 @@ const stopRecording = () => {
mediaRecorder.value.stop();
isRecording.value = false;
// 停止录音计时器
stopRecordingTimer();
// 停止所有音频轨道
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop());
......@@ -183,6 +282,7 @@ const stopRecording = () => {
if (props.debug) {
console.log('停止录音,MediaRecorder状态:', mediaRecorder.value.state);
console.log('录音时长:', recordingDuration.value, '');
}
}
}
......@@ -205,7 +305,7 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura
if (result.data.code === 0) {
const filePath = result.data.data.filePath;
// 计算音频时长(秒),四舍五入取整
const durationTime = result.data.data.durationTime;
const durationTime = recordingDuration.value;
return {filePath,durationTime};
} else {
throw new Error('上传接口返回数据格式错误');
......@@ -220,6 +320,7 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura
const sendRecordedAudio = async () => {
if (audioChunks.value.length === 0) {
emit('error', '录音数据为空');
exitFullScreen(); // 数据为空时退出全屏
return;
}
......@@ -242,8 +343,9 @@ const sendRecordedAudio = async () => {
const errorMsg = '音频上传失败,请重试';
emit('error', errorMsg);
} finally {
// 清理录音数据
// 清理录音数据并退出全屏
audioChunks.value = [];
exitFullScreen();
}
}
......@@ -256,13 +358,18 @@ onUnmounted(() => {
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop());
}
// 清理计时器
stopRecordingTimer();
})
// 暴露方法给父组件
defineExpose({
startRecording,
stopRecording,
isRecording: () => isRecording.value
isRecording: () => isRecording.value,
enterFullScreen: toggleFullScreen,
exitFullScreen
})
</script>
......@@ -271,7 +378,114 @@ defineExpose({
.voice-recognition {
position: relative;
display: inline-block;
display: inline-flex;
align-items: center;
gap: 10px;
transition: all 0.3s ease;
// 全屏模式样式 - 浅色系蓝色圆角主题
&.full-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: @blue-light-1; // 使用浅蓝色背景
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999 !important;
.voice-btn {
width: 100%;
height: 100%;
font-size: 32px;
flex-direction: row; // 改为水平排列
justify-content: center;
align-items: center; // 垂直居中
gap: 12px; // 水平间距
background: @primary-color !important; // 按钮使用主色调蓝色
border: none;
border-radius: 12px;
right:0;
.full-screen-text {
opacity: 1;
font-size: 18px;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
white-space: nowrap; // 防止文字换行
}
}
// 录音状态样式 - 使用波纹动画
.voice-btn.recording {
background: @primary-hover; // 使用hover颜色
.full-screen-text {
color: #fff;
}
.recording-indicator .wave-bar {
animation: wechatWaveAnimation 1.2s ease-in-out infinite;
&.wave-bar-1 {
animation-delay: 0s;
animation-duration: 1.4s;
}
&.wave-bar-2 {
animation-delay: 0.2s;
animation-duration: 1.2s;
}
&.wave-bar-3 {
animation-delay: 0.4s;
animation-duration: 1.0s;
}
}
}
.recording-indicator {
display: flex;
align-items: flex-end;
gap: 1px;
height: 16px;
.wave-bar {
width: 3px;
background: #fff;
border-radius: 2px 2px 0 0;
transition: all 0.3s ease;
&.wave-bar-1 {
height: 4px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-2 {
height: 8px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-3 {
height: 12px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-4 {
height: 8px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-5 {
height: 4px;
border-radius: 3px 3px 0 0;
}
}
}
}
}
.voice-btn {
......@@ -279,34 +493,25 @@ defineExpose({
height: 40px;
border: none;
border-radius: 50%;
background: @primary-color;
color: @white;
right:14px;
color: @primary-color;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
position: relative;
z-index: 100; /* 大幅提高按钮的z-index,确保始终在最上层 */
&:hover:not(.disabled) {
background: @primary-hover;
transform: scale(1.05);
}
&:active:not(.disabled) {
transform: scale(0.95);
}
z-index: 100;
background: transparent !important;
&.recording {
background: @error-color;
animation: pulse 1.5s infinite;
background: @primary-color;
top:0 !important;
transform: none;
}
&.disabled {
background: @gray-4;
color: @gray-4;
cursor: not-allowed;
opacity: 0.6;
}
}
......@@ -316,45 +521,45 @@ defineExpose({
justify-content: center;
width: 100%;
height: 100%;
font-size: 18px;
}
.full-screen-text {
font-size: 14px;
opacity: 0;
transition: opacity 0.3s ease;
}
.recording-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 2px;
.pulse {
width: 4px;
height: 4px;
background: @white;
border-radius: 50%;
animation: pulse 1.5s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
// 波纹动画 - 与AiChat.vue中的动画保持一致
@keyframes wechatWaveAnimation {
0%, 100% {
transform: scaleY(0.3);
opacity: 0.6;
}
50% {
opacity: 0.5;
transform: scale(0.8);
25% {
transform: scaleY(0.7);
opacity: 0.8;
}
100% {
50% {
transform: scaleY(1);
opacity: 1;
transform: scale(1);
}
75% {
transform: scaleY(0.7);
opacity: 0.8;
}
}
// 最大时长提示样式
.max-duration-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
</style>
\ No newline at end of file
......@@ -380,8 +380,7 @@ li {
cursor: not-allowed;
}
}
button {
.send-button {
position: absolute;
right: 12px;
top: 50%;
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment