This commit is contained in:
X1627315083
2025-10-21 10:22:12 +08:00
6 changed files with 909 additions and 78 deletions

View File

@@ -0,0 +1,557 @@
<template>
<div class="audio-recorder">
<!-- 录制状态显示 -->
<div class="recorder-status" v-if="isRecording || hasRecording">
<div class="status-info">
<div class="status-icon">
<div v-if="isRecording" class="recording-dot"></div>
<div v-else-if="hasRecording" class="playback-icon"></div>
</div>
<div class="status-text">
<span v-if="isRecording">正在录制...</span>
<span v-else-if="hasRecording">录制完成</span>
</div>
<div class="recording-time" v-if="isRecording || hasRecording">
{{ formatTime(recordingTime) }}
</div>
</div>
</div>
<!-- 音频可视化 -->
<div class="audio-visualizer" v-if="isRecording || hasRecording">
<div class="visualizer-container">
<div
v-for="(bar, index) in audioBars"
:key="index"
class="audio-bar"
:style="{ height: bar.height + 'px' }"
></div>
</div>
</div>
<!-- 控制按钮 -->
<div class="recorder-controls">
<button
v-if="!isRecording && !hasRecording"
@click="startRecording"
class="record-btn start-btn"
:disabled="!isSupported"
>
<div class="btn-icon">🎤</div>
<span>开始录制</span>
</button>
<button
v-if="isRecording"
@click="stopRecording"
class="record-btn stop-btn"
>
<div class="btn-icon"></div>
<span>停止录制</span>
</button>
<div v-if="hasRecording" class="playback-controls">
<button @click="playRecording" class="control-btn play-btn" :disabled="isPlaying">
<div class="btn-icon">{{ isPlaying ? '⏸' : '▶' }}</div>
</button>
<button @click="pauseRecording" class="control-btn pause-btn" :disabled="!isPlaying">
<div class="btn-icon"></div>
</button>
<button @click="downloadRecording" class="control-btn download-btn">
<div class="btn-icon">💾</div>
</button>
<button @click="deleteRecording" class="control-btn delete-btn">
<div class="btn-icon">🗑</div>
</button>
</div>
</div>
<!-- 音频播放器 -->
<audio
ref="audioPlayer"
@ended="onPlaybackEnded"
@timeupdate="onTimeUpdate"
style="display: none"
></audio>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
// 定义事件
const emit = defineEmits<{
'recording-started': []
'recording-stopped': [audioBlob: Blob]
'recording-deleted': []
}>()
// 响应式数据
const isRecording = ref(false)
const hasRecording = ref(false)
const isPlaying = ref(false)
const recordingTime = ref(0)
const error = ref('')
const isSupported = ref(false)
// 音频相关
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let audioBlob: Blob | null = null
let recordingStartTime = 0
let recordingTimer: number | null = null
// 音频可视化
const audioBars = ref<Array<{ height: number }>>([])
let animationFrame: number | null = null
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let microphone: MediaStreamAudioSourceNode | null = null
let dataArray: Uint8Array | null = null
// DOM 引用
const audioPlayer = ref<HTMLAudioElement>()
// 计算属性
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 检查浏览器支持
const checkSupport = () => {
isSupported.value = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
if (!isSupported.value) {
error.value = '您的浏览器不支持音频录制功能'
}
}
// 初始化音频可视化
const initAudioVisualizer = async () => {
if (!isRecording.value) return
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
analyser = audioContext.createAnalyser()
analyser.fftSize = 256
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
microphone = audioContext.createMediaStreamSource(stream)
microphone.connect(analyser)
dataArray = new Uint8Array(analyser.frequencyBinCount)
updateVisualizer()
} catch (err) {
console.error('初始化音频可视化失败:', err)
}
}
// 更新音频可视化
const updateVisualizer = () => {
if (!analyser || !dataArray || !isRecording.value) return
analyser.getByteFrequencyData(dataArray)
// 创建音频条
const barCount = 20
const newBars: Array<{ height: number }> = []
for (let i = 0; i < barCount; i++) {
const dataIndex = Math.floor((i / barCount) * dataArray.length)
const value = dataArray[dataIndex]
const height = Math.max(4, (value / 255) * 60) // 最小高度4px最大60px
newBars.push({ height })
}
audioBars.value = newBars
animationFrame = requestAnimationFrame(updateVisualizer)
}
// 停止音频可视化
const stopAudioVisualizer = () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
}
if (microphone) {
microphone.disconnect()
microphone = null
}
if (audioContext) {
audioContext.close()
audioContext = null
}
audioBars.value = []
}
// 开始录制
const startRecording = async () => {
try {
error.value = ''
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
mediaRecorder.onstop = () => {
audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
hasRecording.value = true
emit('recording-stopped', audioBlob)
// 停止所有音频流
stream.getTracks().forEach(track => track.stop())
}
mediaRecorder.start(100) // 每100ms收集一次数据
isRecording.value = true
recordingStartTime = Date.now()
recordingTime.value = 0
// 开始计时
recordingTimer = window.setInterval(() => {
recordingTime.value = Math.floor((Date.now() - recordingStartTime) / 1000)
}, 1000)
// 初始化音频可视化
await initAudioVisualizer()
emit('recording-started')
} catch (err) {
console.error('开始录制失败:', err)
error.value = '无法访问麦克风,请检查权限设置'
}
}
// 停止录制
const stopRecording = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop()
isRecording.value = false
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
stopAudioVisualizer()
}
}
// 播放录制
const playRecording = () => {
if (audioBlob && audioPlayer.value) {
const audioUrl = URL.createObjectURL(audioBlob)
audioPlayer.value.src = audioUrl
audioPlayer.value.play()
isPlaying.value = true
}
}
// 暂停播放
const pauseRecording = () => {
if (audioPlayer.value) {
audioPlayer.value.pause()
isPlaying.value = false
}
}
// 播放结束
const onPlaybackEnded = () => {
isPlaying.value = false
}
// 时间更新
const onTimeUpdate = () => {
// 可以在这里添加播放进度更新逻辑
}
// 下载录制
const downloadRecording = () => {
if (audioBlob) {
const url = URL.createObjectURL(audioBlob)
const a = document.createElement('a')
a.href = url
a.download = `recording-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.webm`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
// 删除录制
const deleteRecording = () => {
hasRecording.value = false
audioBlob = null
isPlaying.value = false
if (audioPlayer.value) {
audioPlayer.value.src = ''
}
emit('recording-deleted')
}
// 组件挂载
onMounted(() => {
checkSupport()
})
// 组件卸载
onUnmounted(() => {
if (isRecording.value) {
stopRecording()
}
stopAudioVisualizer()
if (recordingTimer) {
clearInterval(recordingTimer)
}
})
// 暴露方法给父组件
defineExpose({
startRecording,
stopRecording,
playRecording,
pauseRecording,
downloadRecording,
deleteRecording,
hasRecording: computed(() => hasRecording.value),
isRecording: computed(() => isRecording.value),
audioBlob: computed(() => audioBlob)
})
</script>
<style scoped>
.audio-recorder {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e9ecef;
}
.recorder-status {
width: 100%;
max-width: 300px;
}
.status-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.recording-dot {
width: 12px;
height: 12px;
background: #ff4757;
border-radius: 50%;
animation: pulse 1s infinite;
}
.playback-icon {
color: #2ed573;
font-size: 16px;
}
.status-text {
flex: 1;
font-weight: 500;
color: #2c3e50;
}
.recording-time {
font-family: 'Courier New', monospace;
font-weight: 600;
color: #7f8c8d;
background: #ecf0f1;
padding: 4px 8px;
border-radius: 4px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.audio-visualizer {
width: 100%;
max-width: 300px;
height: 80px;
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.visualizer-container {
display: flex;
align-items: end;
justify-content: center;
gap: 2px;
height: 100%;
}
.audio-bar {
width: 8px;
background: linear-gradient(to top, #ff6b6b, #ffa726);
border-radius: 4px;
transition: height 0.1s ease;
min-height: 4px;
}
.recorder-controls {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.record-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
justify-content: center;
}
.start-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.start-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.stop-btn {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
}
.stop-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);
}
.record-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.playback-controls {
display: flex;
gap: 8px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
}
.play-btn {
background: linear-gradient(135deg, #2ed573 0%, #1e90ff 100%);
color: white;
}
.pause-btn {
background: linear-gradient(135deg, #ffa726 0%, #ff6b6b 100%);
color: white;
}
.download-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.delete-btn {
background: linear-gradient(135deg, #ff4757 0%, #c44569 100%);
color: white;
}
.control-btn:hover:not(:disabled) {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.control-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.btn-icon {
font-size: 16px;
}
.error-message {
color: #e74c3c;
background: #fdf2f2;
border: 1px solid #fecaca;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
text-align: center;
max-width: 300px;
}
</style>

View File

@@ -11,16 +11,13 @@
</div>
</div>
<div class="input-container">
<!-- 加号图标 -->
<div class="icon-wrapper">
<SvgIcon v-if="!isRecording" name="plus" size="40" />
<SvgIcon v-else name="pause" size="60" @click="stopRecording" />
</div>
<!-- 分隔线 -->
<div class="divider"></div>
<!-- 正常状态显示输入框 -->
<div class="input-wrapper">
<textarea
id="textarea"
@@ -34,18 +31,28 @@
ref="textareaRef"
></textarea>
<div v-show="isRecording" class="recording-wrapper">
<!-- <div class="recording-animation"> -->
<AudioVisualizer ref="audioVisualizerRef" />
<!-- </div> -->
</div>
<div v-show="showAudioRecorder" class="audio-recorder-wrapper">
<AudioRecorder
ref="audioRecorderRef"
@recording-started="onRecordingStarted"
@recording-stopped="onRecordingStopped"
@recording-deleted="onRecordingDeleted"
/>
</div>
</div>
<!-- 语音图标 -->
<div class="icon-wrapper" v-show="!isRecording" @click="handleClickAudio">
<SvgIcon name="audio" size="52" />
</div>
<!-- 发送图标 -->
<!-- 音频录制图标 -->
<!-- <div class="icon-wrapper" v-show="!isRecording" @click="toggleAudioRecorder">
<SvgIcon name="download" size="52" />
</div> -->
<div class="icon-wrapper send-icon" @click="handleSend">
<SvgIcon name="send" size="46" />
</div>
@@ -56,13 +63,17 @@
<script setup lang="ts">
import { ref, onUnmounted, nextTick } from 'vue'
import AudioVisualizer from './AudioVisualizer.vue'
import AudioRecorder from './AudioRecorder.vue'
import { showToast } from 'vant'
import { getAudioFileInfo, uploadAudioFile, prepareAudioForTTS } from '@/utils/audioUtils'
const emit = defineEmits(['send-message'])
const inputValue = ref<string>('')
const isRecording = ref<boolean>(false)
const showAudioRecorder = ref<boolean>(false)
const audioVisualizerRef = ref<InstanceType<typeof AudioVisualizer> | null>(null)
const audioRecorderRef = ref<InstanceType<typeof AudioRecorder> | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const shortcutList: string[] = [
'Suggest shoe styles',
@@ -97,14 +108,7 @@ const handleKeyDown = (event: KeyboardEvent): void => {
const handleInput = (): void => {
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
// const lineHeight = 4.8
// const maxLines = 3
// const maxHeight = lineHeight * maxLines
const scrollHeight = textareaRef.value.scrollHeight
// const newHeight = Math.min(scrollHeight, maxHeight * 10)
textareaRef.value.style.height = `${scrollHeight}px`
}
}
@@ -145,6 +149,48 @@ const handleClickAudio = async (): Promise<void> => {
}
}
// 切换音频录制器显示
const toggleAudioRecorder = () => {
showAudioRecorder.value = !showAudioRecorder.value
if (showAudioRecorder.value) {
showToast('音频录制功能已启用')
}
}
// 音频录制事件处理
const onRecordingStarted = () => {
console.log('音频录制开始')
showToast('开始录制音频')
}
const onRecordingStopped = async (audioBlob: Blob) => {
console.log('音频录制结束', audioBlob)
showToast('音频录制完成')
try {
// 获取音频文件信息
const audioFile = await getAudioFileInfo(audioBlob)
console.log('音频文件信息:', audioFile)
// 为第三方TTS服务准备数据示例
const ttsData = await prepareAudioForTTS(audioBlob, 'openai')
console.log('TTS服务数据:', ttsData)
// 这里可以将音频文件发送到服务器或进行其他处理
// 例如await uploadAudioFile(audioFile, '/api/upload-audio')
showToast(`音频文件大小: ${(audioFile.size / 1024).toFixed(2)}KB`)
} catch (error) {
console.error('处理音频文件失败:', error)
showToast('处理音频文件失败')
}
}
const onRecordingDeleted = () => {
console.log('音频录制已删除')
showToast('音频录制已删除')
}
// 开始语音识别
const startRecording = () => {
if (!speechRecognition) {
@@ -162,7 +208,7 @@ const startRecording = () => {
// 配置参数
speechRecognition.continuous = true
speechRecognition.interimResults = true
speechRecognition.lang = 'zh-CN' // 设置为
speechRecognition.lang = 'en-US' // 设置为
}
// 识别开始
@@ -336,6 +382,15 @@ const stopRecording = () => {
min-height: 4.8rem;
}
// 音频录制器样式
.audio-recorder-wrapper {
flex: 1;
display: flex;
align-items: center;
min-height: 4.8rem;
padding: 10px 0;
}
.recording-animation {
width: 100%;
height: 100%;

View File

@@ -15,14 +15,13 @@
</div>
<div class="login-container">
<form @submit.prevent="handleLogin" class="login-form">
<form @submit.prevent="handleLogin" class="login-form" novalidate>
<div class="input-group">
<input
type="email"
v-model="formData.email"
placeholder="Email"
class="input-field"
required
/>
</div>
<div class="input-group pwd">
@@ -31,7 +30,6 @@
v-model="formData.password"
placeholder="Your Password"
class="input-field"
required
/>
</div>
@@ -69,11 +67,7 @@ const formData = reactive<Record<string, string>>({
password: ''
})
// 表单验证状态
const formErrors = reactive<Record<string, string>>({
email: '',
password: ''
})
// 移除表单错误状态管理,改用 toast 提示
// 加载状态
const isLoading = ref(false)
@@ -90,31 +84,25 @@ const validatePassword = (password: string) => {
// 验证表单
const validateForm = () => {
let isValid = true
// 重置错误信息
formErrors.email = ''
formErrors.password = ''
// 验证邮箱
if (!formData.email) {
formErrors.email = '请输入邮箱地址'
isValid = false
showToast('请输入邮箱地址')
return false
} else if (!validateEmail(formData.email)) {
formErrors.email = '请输入有效的邮箱地址'
isValid = false
showToast('请输入有效的邮箱地址')
return false
}
// 验证密码
if (!formData.password) {
formErrors.password = '请输入密码'
isValid = false
showToast('请输入密码')
return false
} else if (!validatePassword(formData.password)) {
formErrors.password = '密码至少需要6位字符'
isValid = false
showToast('密码至少需要6位字符')
return false
}
return isValid
return true
}
// 返回上一页
@@ -125,7 +113,6 @@ const goBack = () => {
// 处理登录
const handleLogin = async () => {
if (!validateForm()) {
showToast('请检查输入信息')
return
}
@@ -179,22 +166,7 @@ const handleSignup = () => {
router.push('/signup')
}
// 实时验证输入
const handleEmailInput = () => {
if (formData.email && !validateEmail(formData.email)) {
formErrors.email = '请输入有效的邮箱地址'
} else {
formErrors.email = ''
}
}
const handlePasswordInput = () => {
if (formData.password && !validatePassword(formData.password)) {
formErrors.password = '密码至少需要6位字符'
} else {
formErrors.password = ''
}
}
// 移除实时验证输入的错误显示逻辑,改用表单提交时统一验证
</script>
<style scoped lang="less">