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

feat:优化语音时长

parent 1dd66d7e
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
<textarea ref="textarea" v-model="messageText" placeholder="输入消息..." @keypress="handleKeyPress" <textarea ref="textarea" v-model="messageText" placeholder="输入消息..." @keypress="handleKeyPress"
@input="adjustTextareaHeight" :disabled="loading"></textarea> @input="adjustTextareaHeight" :disabled="loading"></textarea>
<button @click="sendMessage" :disabled="loading"> <button @click="sendMessage" :disabled="loading" class="send-button">
<send-outlined /> <send-outlined />
</button> </button>
</div> </div>
...@@ -980,7 +980,7 @@ const processHistoryData = (dataArray: any[]) => { ...@@ -980,7 +980,7 @@ const processHistoryData = (dataArray: any[]) => {
// 处理音频消息 // 处理音频消息
questionContent = contentTemplates.audio({ questionContent = contentTemplates.audio({
audioUrl: data.audioPath, audioUrl: data.audioPath,
durationTime: data.autioTime || '0"', durationTime: data.audioTime || '0"',
}); });
} else { } else {
// 处理文本消息 // 处理文本消息
......
<template> <template>
<div class="voice-recognition"> <div
class="voice-recognition"
:class="{ 'full-screen': isFullScreen }"
>
<!-- 语音按钮 --> <!-- 语音按钮 -->
<button <button
class="voice-btn" class="voice-btn"
:class="{ 'recording': isRecording, 'disabled': disabled }" :class="{
'recording': isRecording,
'disabled': disabled
}"
@click="toggleFullScreen"
@mousedown="startRecording" @mousedown="startRecording"
@mouseup="stopRecording" @mouseup="stopRecording"
@mouseleave="stopRecording" @mouseleave="stopRecording"
...@@ -11,25 +18,39 @@ ...@@ -11,25 +18,39 @@
@touchend="stopRecording" @touchend="stopRecording"
@touchcancel="stopRecording" @touchcancel="stopRecording"
:disabled="disabled" :disabled="disabled"
:title="isRecording ? '松开停止' : '按住说话'" :title="getButtonTitle"
> >
<!-- 语音图标始终显示 --> <!-- 默认模式显示语音图标 -->
<span class="voice-icon"> <span v-if="!isFullScreen" class="voice-icon">
<AudioOutlined /> <AudioOutlined />
</span> </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 v-if="isRecording" class="recording-indicator">
<span class="pulse"></span> <span class="wave-bar wave-bar-1"></span>
<span class="pulse"></span> <span class="wave-bar wave-bar-2"></span>
<span class="pulse"></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> </span>
</button> </button>
<!-- 移除取消按钮 -->
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { AudioOutlined } from '@ant-design/icons-vue'
import { post } from '@/utils/axios' // 导入项目中的axios import { post } from '@/utils/axios' // 导入项目中的axios
...@@ -37,16 +58,18 @@ import { post } from '@/utils/axios' // 导入项目中的axios ...@@ -37,16 +58,18 @@ import { post } from '@/utils/axios' // 导入项目中的axios
interface Props { interface Props {
disabled?: boolean disabled?: boolean
debug?: boolean debug?: boolean
maxDuration?: number // 添加最大时长参数
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false,
debug: false debug: false,
maxDuration: 30 // 默认最大时长为30秒
}) })
// 组件事件 // 组件事件
const emit = defineEmits<{ const emit = defineEmits<{
audio: [audioUrl: string, audioBlob: Blob] audio: [audioUrl: string, audioBlob: Blob, durationTime: number]
error: [error: string] error: [error: string]
recordingStart: [] recordingStart: []
recordingStop: [] recordingStop: []
...@@ -54,12 +77,58 @@ const emit = defineEmits<{ ...@@ -54,12 +77,58 @@ const emit = defineEmits<{
// 响应式数据 // 响应式数据
const isRecording = ref(false) const isRecording = ref(false)
const isFullScreen = ref(false)
const recordingDuration = ref(0) // 录音时长(秒)
const recordingTimer = ref<NodeJS.Timeout | null>(null) // 计时器
// MediaRecorder相关 // MediaRecorder相关
const mediaRecorder = ref<MediaRecorder | null>(null) const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([]) const audioChunks = ref<Blob[]>([])
const audioStream = ref<MediaStream | null>(null) 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 // 检查浏览器是否支持MediaRecorder
const isMediaRecorderSupported = () => { const isMediaRecorderSupported = () => {
const supported = 'MediaRecorder' in window; const supported = 'MediaRecorder' in window;
...@@ -93,12 +162,13 @@ const checkMicrophonePermission = async () => { ...@@ -93,12 +162,13 @@ const checkMicrophonePermission = async () => {
// 开始录音 // 开始录音
const startRecording = async () => { const startRecording = async () => {
if (props.disabled || isRecording.value) return if (props.disabled || isRecording.value || !isFullScreen.value) return
// 检查权限 // 检查权限
const hasPermission = await checkMicrophonePermission(); const hasPermission = await checkMicrophonePermission();
if (!hasPermission) { if (!hasPermission) {
emit('error', '麦克风权限被拒绝'); emit('error', '麦克风权限被拒绝');
exitFullScreen();
return; return;
} }
...@@ -106,6 +176,7 @@ const startRecording = async () => { ...@@ -106,6 +176,7 @@ const startRecording = async () => {
if (!isMediaRecorderSupported()) { if (!isMediaRecorderSupported()) {
const errorMsg = '您的浏览器不支持音频录制功能'; const errorMsg = '您的浏览器不支持音频录制功能';
emit('error', errorMsg); emit('error', errorMsg);
exitFullScreen();
return; return;
} }
...@@ -143,6 +214,9 @@ const startRecording = async () => { ...@@ -143,6 +214,9 @@ const startRecording = async () => {
mediaRecorder.value.start(100); // 每100ms收集一次数据 mediaRecorder.value.start(100); // 每100ms收集一次数据
isRecording.value = true; isRecording.value = true;
// 启动录音时长计时器
startRecordingTimer();
// 通知父组件开始录音 // 通知父组件开始录音
emit('recordingStart'); emit('recordingStart');
...@@ -161,6 +235,28 @@ const startRecording = async () => { ...@@ -161,6 +235,28 @@ const startRecording = async () => {
} }
emit('error', errorMessage); 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 = () => { ...@@ -172,6 +268,9 @@ const stopRecording = () => {
mediaRecorder.value.stop(); mediaRecorder.value.stop();
isRecording.value = false; isRecording.value = false;
// 停止录音计时器
stopRecordingTimer();
// 停止所有音频轨道 // 停止所有音频轨道
if (audioStream.value) { if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop()); audioStream.value.getTracks().forEach(track => track.stop());
...@@ -183,6 +282,7 @@ const stopRecording = () => { ...@@ -183,6 +282,7 @@ const stopRecording = () => {
if (props.debug) { if (props.debug) {
console.log('停止录音,MediaRecorder状态:', mediaRecorder.value.state); console.log('停止录音,MediaRecorder状态:', mediaRecorder.value.state);
console.log('录音时长:', recordingDuration.value, '');
} }
} }
} }
...@@ -205,7 +305,7 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura ...@@ -205,7 +305,7 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura
if (result.data.code === 0) { if (result.data.code === 0) {
const filePath = result.data.data.filePath; const filePath = result.data.data.filePath;
// 计算音频时长(秒),四舍五入取整 // 计算音频时长(秒),四舍五入取整
const durationTime = result.data.data.durationTime; const durationTime = recordingDuration.value;
return {filePath,durationTime}; return {filePath,durationTime};
} else { } else {
throw new Error('上传接口返回数据格式错误'); throw new Error('上传接口返回数据格式错误');
...@@ -220,6 +320,7 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura ...@@ -220,6 +320,7 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura
const sendRecordedAudio = async () => { const sendRecordedAudio = async () => {
if (audioChunks.value.length === 0) { if (audioChunks.value.length === 0) {
emit('error', '录音数据为空'); emit('error', '录音数据为空');
exitFullScreen(); // 数据为空时退出全屏
return; return;
} }
...@@ -242,8 +343,9 @@ const sendRecordedAudio = async () => { ...@@ -242,8 +343,9 @@ const sendRecordedAudio = async () => {
const errorMsg = '音频上传失败,请重试'; const errorMsg = '音频上传失败,请重试';
emit('error', errorMsg); emit('error', errorMsg);
} finally { } finally {
// 清理录音数据 // 清理录音数据并退出全屏
audioChunks.value = []; audioChunks.value = [];
exitFullScreen();
} }
} }
...@@ -256,13 +358,18 @@ onUnmounted(() => { ...@@ -256,13 +358,18 @@ onUnmounted(() => {
if (audioStream.value) { if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop()); audioStream.value.getTracks().forEach(track => track.stop());
} }
// 清理计时器
stopRecordingTimer();
}) })
// 暴露方法给父组件 // 暴露方法给父组件
defineExpose({ defineExpose({
startRecording, startRecording,
stopRecording, stopRecording,
isRecording: () => isRecording.value isRecording: () => isRecording.value,
enterFullScreen: toggleFullScreen,
exitFullScreen
}) })
</script> </script>
...@@ -271,7 +378,114 @@ defineExpose({ ...@@ -271,7 +378,114 @@ defineExpose({
.voice-recognition { .voice-recognition {
position: relative; 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 { .voice-btn {
...@@ -279,34 +493,25 @@ defineExpose({ ...@@ -279,34 +493,25 @@ defineExpose({
height: 40px; height: 40px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: @primary-color; right:14px;
color: @white; color: @primary-color;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease;
position: relative; position: relative;
z-index: 100; /* 大幅提高按钮的z-index,确保始终在最上层 */ z-index: 100;
background: transparent !important;
&:hover:not(.disabled) {
background: @primary-hover;
transform: scale(1.05);
}
&:active:not(.disabled) {
transform: scale(0.95);
}
&.recording { &.recording {
background: @error-color; background: @primary-color;
animation: pulse 1.5s infinite; top:0 !important;
transform: none;
} }
&.disabled { &.disabled {
background: @gray-4; color: @gray-4;
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6;
} }
} }
...@@ -316,45 +521,45 @@ defineExpose({ ...@@ -316,45 +521,45 @@ defineExpose({
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 18px;
}
.full-screen-text {
font-size: 14px;
opacity: 0;
transition: opacity 0.3s ease;
} }
.recording-indicator { .recording-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 2px; 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 { // 波纹动画 - 与AiChat.vue中的动画保持一致
0% { @keyframes wechatWaveAnimation {
opacity: 1; 0%, 100% {
transform: scale(1); transform: scaleY(0.3);
opacity: 0.6;
} }
50% { 25% {
opacity: 0.5; transform: scaleY(0.7);
transform: scale(0.8); opacity: 0.8;
} }
100% { 50% {
transform: scaleY(1);
opacity: 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> </style>
\ No newline at end of file
...@@ -380,8 +380,7 @@ li { ...@@ -380,8 +380,7 @@ li {
cursor: not-allowed; cursor: not-allowed;
} }
} }
.send-button {
button {
position: absolute; position: absolute;
right: 12px; right: 12px;
top: 50%; 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