feat: 备用音频录制组件&登录页取消原生form验证
This commit is contained in:
@@ -1,26 +1,42 @@
|
||||
// 每一个存储的模块,命名规则use开头,store结尾
|
||||
import { defineStore } from 'pinia'
|
||||
export const useUserInfoStore = defineStore({
|
||||
id: 'userInfo', // 必须指明唯一的pinia仓库的id
|
||||
state: () => {
|
||||
return {
|
||||
num: 0,
|
||||
name: '张三',
|
||||
token: ''
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
doubleCount: (state) => state.num * 2
|
||||
},
|
||||
actions: {
|
||||
changeNum() {
|
||||
this.num++
|
||||
},
|
||||
loginOut() {
|
||||
// 处理退出登录的一些逻辑
|
||||
return new Promise((rez) => {
|
||||
rez('111')
|
||||
})
|
||||
}
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useUserInfoStore = defineStore('userInfo', () => {
|
||||
// state
|
||||
const num = ref(0)
|
||||
const name = ref('张三')
|
||||
const token = ref('')
|
||||
|
||||
// getters
|
||||
const getUserInfo = computed(() => ({
|
||||
num: num.value,
|
||||
name: name.value,
|
||||
token: token.value
|
||||
}))
|
||||
|
||||
// actions
|
||||
const setUserInfo = (data: any) => {
|
||||
name.value = data.name
|
||||
token.value = data.token
|
||||
}
|
||||
|
||||
const loginOut = () => {
|
||||
// 处理退出登录的一些逻辑
|
||||
return new Promise((rez) => {
|
||||
rez('111')
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
num,
|
||||
name,
|
||||
token,
|
||||
// getters
|
||||
getUserInfo,
|
||||
// actions
|
||||
setUserInfo,
|
||||
loginOut
|
||||
}
|
||||
})
|
||||
|
||||
231
src/utils/audioUtils.ts
Normal file
231
src/utils/audioUtils.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 音频处理工具函数
|
||||
* 为后续集成第三方TTS方案做准备
|
||||
*/
|
||||
|
||||
// 音频文件类型定义
|
||||
export interface AudioFile {
|
||||
blob: Blob
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
// 音频格式转换选项
|
||||
export interface AudioConvertOptions {
|
||||
format: 'webm' | 'mp3' | 'wav' | 'ogg'
|
||||
quality?: number
|
||||
bitrate?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Blob转换为指定格式的音频文件
|
||||
* @param audioBlob 原始音频Blob
|
||||
* @param options 转换选项
|
||||
* @returns Promise<Blob> 转换后的音频Blob
|
||||
*/
|
||||
export const convertAudioFormat = async (
|
||||
audioBlob: Blob,
|
||||
options: AudioConvertOptions
|
||||
): Promise<Blob> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const audio = new Audio()
|
||||
const url = URL.createObjectURL(audioBlob)
|
||||
|
||||
audio.onloadedmetadata = () => {
|
||||
// 这里可以添加格式转换逻辑
|
||||
// 目前直接返回原始Blob,实际项目中可以使用Web Audio API或第三方库
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(audioBlob)
|
||||
}
|
||||
|
||||
audio.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('音频加载失败'))
|
||||
}
|
||||
|
||||
audio.src = url
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频文件信息
|
||||
* @param audioBlob 音频Blob
|
||||
* @returns Promise<AudioFile> 音频文件信息
|
||||
*/
|
||||
export const getAudioFileInfo = async (audioBlob: Blob): Promise<AudioFile> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio()
|
||||
const url = URL.createObjectURL(audioBlob)
|
||||
|
||||
audio.onloadedmetadata = () => {
|
||||
const audioFile: AudioFile = {
|
||||
blob: audioBlob,
|
||||
name: `audio-${Date.now()}.webm`,
|
||||
size: audioBlob.size,
|
||||
type: audioBlob.type,
|
||||
duration: audio.duration
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(audioFile)
|
||||
}
|
||||
|
||||
audio.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('无法获取音频信息'))
|
||||
}
|
||||
|
||||
audio.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩音频文件
|
||||
* @param audioBlob 原始音频Blob
|
||||
* @param quality 压缩质量 (0-1)
|
||||
* @returns Promise<Blob> 压缩后的音频Blob
|
||||
*/
|
||||
export const compressAudio = async (
|
||||
audioBlob: Blob,
|
||||
quality: number = 0.8
|
||||
): Promise<Blob> => {
|
||||
// 这里可以实现音频压缩逻辑
|
||||
// 目前直接返回原始Blob,实际项目中可以使用Web Audio API
|
||||
return audioBlob
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传音频文件到服务器
|
||||
* @param audioFile 音频文件
|
||||
* @param uploadUrl 上传地址
|
||||
* @returns Promise<string> 服务器返回的文件ID或URL
|
||||
*/
|
||||
export const uploadAudioFile = async (
|
||||
audioFile: AudioFile,
|
||||
uploadUrl: string
|
||||
): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('audio', audioFile.blob, audioFile.name)
|
||||
formData.append('duration', audioFile.duration?.toString() || '0')
|
||||
|
||||
try {
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传失败: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.fileId || result.url
|
||||
} catch (error) {
|
||||
console.error('音频上传失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为第三方TTS服务准备音频数据
|
||||
* @param audioBlob 音频Blob
|
||||
* @param serviceType TTS服务类型
|
||||
* @returns Promise<any> 准备好的音频数据
|
||||
*/
|
||||
export const prepareAudioForTTS = async (
|
||||
audioBlob: Blob,
|
||||
serviceType: 'openai' | 'azure' | 'aws' | 'google'
|
||||
): Promise<any> => {
|
||||
const audioFile = await getAudioFileInfo(audioBlob)
|
||||
|
||||
switch (serviceType) {
|
||||
case 'openai':
|
||||
return {
|
||||
file: audioFile.blob,
|
||||
model: 'whisper-1',
|
||||
language: 'zh',
|
||||
response_format: 'json'
|
||||
}
|
||||
|
||||
case 'azure':
|
||||
return {
|
||||
audio: audioFile.blob,
|
||||
language: 'zh-CN',
|
||||
format: 'json'
|
||||
}
|
||||
|
||||
case 'aws':
|
||||
return {
|
||||
Audio: audioFile.blob,
|
||||
LanguageCode: 'zh-CN',
|
||||
MediaFormat: 'webm'
|
||||
}
|
||||
|
||||
case 'google':
|
||||
return {
|
||||
audio: {
|
||||
content: await blobToBase64(audioFile.blob)
|
||||
},
|
||||
config: {
|
||||
encoding: 'WEBM_OPUS',
|
||||
languageCode: 'zh-CN',
|
||||
sampleRateHertz: 16000
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`不支持的TTS服务类型: ${serviceType}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Blob转换为Base64字符串
|
||||
* @param blob Blob对象
|
||||
* @returns Promise<string> Base64字符串
|
||||
*/
|
||||
export const blobToBase64 = (blob: Blob): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证音频文件格式
|
||||
* @param blob 音频Blob
|
||||
* @returns boolean 是否为有效的音频格式
|
||||
*/
|
||||
export const validateAudioFormat = (blob: Blob): boolean => {
|
||||
const validTypes = [
|
||||
'audio/webm',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/mpeg'
|
||||
]
|
||||
|
||||
return validTypes.includes(blob.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取音频录制权限
|
||||
* @returns Promise<boolean> 是否获得权限
|
||||
*/
|
||||
export const requestAudioPermission = async (): Promise<boolean> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('获取音频权限失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
557
src/views/asistant/components/AudioRecorder.vue
Normal file
557
src/views/asistant/components/AudioRecorder.vue
Normal 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>
|
||||
@@ -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) {
|
||||
@@ -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%;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user