Merge branch 'master' of https://gitee.com/lvYeJu/lane-crawford-3
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="return" @click="handleClickReturn"><SvgIcon name="return" size="34" /></div>
|
<div class="return" @click="handleClickReturn"><SvgIcon name="return" size="34" /></div>
|
||||||
<span class="title">{{ title }}</span>
|
<span class="title">{{ title }}</span>
|
||||||
<div class="setting" v-if="hasSetting"><SvgIcon name="setting" size="34" /></div>
|
<div class="setting" v-if="hasSetting"><SvgIcon name="setting" size="44" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="placeholder"></div>
|
<div class="placeholder"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
// 每一个存储的模块,命名规则use开头,store结尾
|
// 每一个存储的模块,命名规则use开头,store结尾
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
export const useUserInfoStore = defineStore({
|
import { ref, computed } from 'vue'
|
||||||
id: 'userInfo', // 必须指明唯一的pinia仓库的id
|
|
||||||
state: () => {
|
export const useUserInfoStore = defineStore('userInfo', () => {
|
||||||
return {
|
// state
|
||||||
num: 0,
|
const num = ref(0)
|
||||||
name: '张三',
|
const name = ref('张三')
|
||||||
token: ''
|
const token = ref('')
|
||||||
}
|
|
||||||
},
|
// getters
|
||||||
getters: {
|
const getUserInfo = computed(() => ({
|
||||||
doubleCount: (state) => state.num * 2
|
num: num.value,
|
||||||
},
|
name: name.value,
|
||||||
actions: {
|
token: token.value
|
||||||
changeNum() {
|
}))
|
||||||
this.num++
|
|
||||||
},
|
// actions
|
||||||
loginOut() {
|
const setUserInfo = (data: any) => {
|
||||||
// 处理退出登录的一些逻辑
|
name.value = data.name
|
||||||
return new Promise((rez) => {
|
token.value = data.token
|
||||||
rez('111')
|
}
|
||||||
})
|
|
||||||
}
|
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>
|
</div>
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<!-- 加号图标 -->
|
|
||||||
<div class="icon-wrapper">
|
<div class="icon-wrapper">
|
||||||
<SvgIcon v-if="!isRecording" name="plus" size="40" />
|
<SvgIcon v-if="!isRecording" name="plus" size="40" />
|
||||||
<SvgIcon v-else name="pause" size="60" @click="stopRecording" />
|
<SvgIcon v-else name="pause" size="60" @click="stopRecording" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分隔线 -->
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<!-- 正常状态:显示输入框 -->
|
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<textarea
|
<textarea
|
||||||
id="textarea"
|
id="textarea"
|
||||||
@@ -34,18 +31,28 @@
|
|||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div v-show="isRecording" class="recording-wrapper">
|
<div v-show="isRecording" class="recording-wrapper">
|
||||||
<!-- <div class="recording-animation"> -->
|
|
||||||
<AudioVisualizer ref="audioVisualizerRef" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 语音图标 -->
|
|
||||||
<div class="icon-wrapper" v-show="!isRecording" @click="handleClickAudio">
|
<div class="icon-wrapper" v-show="!isRecording" @click="handleClickAudio">
|
||||||
<SvgIcon name="audio" size="52" />
|
<SvgIcon name="audio" size="52" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 发送图标 -->
|
<!-- 音频录制图标 -->
|
||||||
|
<!-- <div class="icon-wrapper" v-show="!isRecording" @click="toggleAudioRecorder">
|
||||||
|
<SvgIcon name="download" size="52" />
|
||||||
|
</div> -->
|
||||||
|
|
||||||
<div class="icon-wrapper send-icon" @click="handleSend">
|
<div class="icon-wrapper send-icon" @click="handleSend">
|
||||||
<SvgIcon name="send" size="46" />
|
<SvgIcon name="send" size="46" />
|
||||||
</div>
|
</div>
|
||||||
@@ -56,13 +63,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onUnmounted, nextTick } from 'vue'
|
import { ref, onUnmounted, nextTick } from 'vue'
|
||||||
import AudioVisualizer from './AudioVisualizer.vue'
|
import AudioVisualizer from './AudioVisualizer.vue'
|
||||||
|
import AudioRecorder from './AudioRecorder.vue'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
|
import { getAudioFileInfo, uploadAudioFile, prepareAudioForTTS } from '@/utils/audioUtils'
|
||||||
|
|
||||||
const emit = defineEmits(['send-message'])
|
const emit = defineEmits(['send-message'])
|
||||||
|
|
||||||
const inputValue = ref<string>('')
|
const inputValue = ref<string>('')
|
||||||
const isRecording = ref<boolean>(false)
|
const isRecording = ref<boolean>(false)
|
||||||
|
const showAudioRecorder = ref<boolean>(false)
|
||||||
const audioVisualizerRef = ref<InstanceType<typeof AudioVisualizer> | null>(null)
|
const audioVisualizerRef = ref<InstanceType<typeof AudioVisualizer> | null>(null)
|
||||||
|
const audioRecorderRef = ref<InstanceType<typeof AudioRecorder> | null>(null)
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
const shortcutList: string[] = [
|
const shortcutList: string[] = [
|
||||||
'Suggest shoe styles',
|
'Suggest shoe styles',
|
||||||
@@ -97,14 +108,7 @@ const handleKeyDown = (event: KeyboardEvent): void => {
|
|||||||
const handleInput = (): void => {
|
const handleInput = (): void => {
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value) {
|
||||||
textareaRef.value.style.height = 'auto'
|
textareaRef.value.style.height = 'auto'
|
||||||
|
|
||||||
// const lineHeight = 4.8
|
|
||||||
// const maxLines = 3
|
|
||||||
// const maxHeight = lineHeight * maxLines
|
|
||||||
|
|
||||||
const scrollHeight = textareaRef.value.scrollHeight
|
const scrollHeight = textareaRef.value.scrollHeight
|
||||||
// const newHeight = Math.min(scrollHeight, maxHeight * 10)
|
|
||||||
|
|
||||||
textareaRef.value.style.height = `${scrollHeight}px`
|
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 = () => {
|
const startRecording = () => {
|
||||||
if (!speechRecognition) {
|
if (!speechRecognition) {
|
||||||
@@ -162,7 +208,7 @@ const startRecording = () => {
|
|||||||
// 配置参数
|
// 配置参数
|
||||||
speechRecognition.continuous = true
|
speechRecognition.continuous = true
|
||||||
speechRecognition.interimResults = true
|
speechRecognition.interimResults = true
|
||||||
speechRecognition.lang = 'zh-CN' // 设置为中文
|
speechRecognition.lang = 'en-US' // 设置为英文
|
||||||
}
|
}
|
||||||
|
|
||||||
// 识别开始
|
// 识别开始
|
||||||
@@ -336,6 +382,15 @@ const stopRecording = () => {
|
|||||||
min-height: 4.8rem;
|
min-height: 4.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 音频录制器样式
|
||||||
|
.audio-recorder-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 4.8rem;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.recording-animation {
|
.recording-animation {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -15,14 +15,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<form @submit.prevent="handleLogin" class="login-form">
|
<form @submit.prevent="handleLogin" class="login-form" novalidate>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
v-model="formData.email"
|
v-model="formData.email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group pwd">
|
<div class="input-group pwd">
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
v-model="formData.password"
|
v-model="formData.password"
|
||||||
placeholder="Your Password"
|
placeholder="Your Password"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,11 +67,7 @@ const formData = reactive<Record<string, string>>({
|
|||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证状态
|
// 移除表单错误状态管理,改用 toast 提示
|
||||||
const formErrors = reactive<Record<string, string>>({
|
|
||||||
email: '',
|
|
||||||
password: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -90,31 +84,25 @@ const validatePassword = (password: string) => {
|
|||||||
|
|
||||||
// 验证表单
|
// 验证表单
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
let isValid = true
|
|
||||||
|
|
||||||
// 重置错误信息
|
|
||||||
formErrors.email = ''
|
|
||||||
formErrors.password = ''
|
|
||||||
|
|
||||||
// 验证邮箱
|
// 验证邮箱
|
||||||
if (!formData.email) {
|
if (!formData.email) {
|
||||||
formErrors.email = '请输入邮箱地址'
|
showToast('请输入邮箱地址')
|
||||||
isValid = false
|
return false
|
||||||
} else if (!validateEmail(formData.email)) {
|
} else if (!validateEmail(formData.email)) {
|
||||||
formErrors.email = '请输入有效的邮箱地址'
|
showToast('请输入有效的邮箱地址')
|
||||||
isValid = false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密码
|
// 验证密码
|
||||||
if (!formData.password) {
|
if (!formData.password) {
|
||||||
formErrors.password = '请输入密码'
|
showToast('请输入密码')
|
||||||
isValid = false
|
return false
|
||||||
} else if (!validatePassword(formData.password)) {
|
} else if (!validatePassword(formData.password)) {
|
||||||
formErrors.password = '密码至少需要6位字符'
|
showToast('密码至少需要6位字符')
|
||||||
isValid = false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回上一页
|
// 返回上一页
|
||||||
@@ -125,7 +113,6 @@ const goBack = () => {
|
|||||||
// 处理登录
|
// 处理登录
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
showToast('请检查输入信息')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,22 +166,7 @@ const handleSignup = () => {
|
|||||||
router.push('/signup')
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
|
|||||||
Reference in New Issue
Block a user