Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
水玉婷
ai-wechat
Commits
958ab12c
Commit
958ab12c
authored
Feb 09, 2026
by
水玉婷
Browse files
feat:语音转文字
parent
4a8d1aff
Changes
2
Hide whitespace changes
Inline
Side-by-side
src/views/components/AiChat.vue
View file @
958ab12c
...
...
@@ -42,13 +42,6 @@
<ChartComponent
:chart-data=
"item.chartData"
:chart-type=
"item.chartType || 'column'"
:title=
"item.chartData.title || '图表数据'"
/>
</div>
<!-- 音频内容块 -->
<template
v-else-if=
"item.audioData"
>
<AudioPlayer
:src=
"item.audioData.src"
:duration-time=
"item.audioData.durationTime"
/>
</
template
>
<!-- 普通内容块 -->
<div
v-else
class=
"message-inner-box"
@
click=
"msg.messageType === 'sent' ? handleMessageClick(msg, item) : null"
>
<div
v-html=
"item.content"
></div>
...
...
@@ -106,12 +99,12 @@
<div
class=
"chat-input-container"
>
<div
class=
"chat-input"
>
<!-- 语音识别按钮 -->
<VoiceRecognition
ref=
"voiceRecognitionRef"
:disabled=
"loading"
:debug=
"true"
<VoiceRecognition
Text
ref=
"voiceRecognitionRef"
:disabled=
"loading"
:debug=
"true"
:token=
"props?.token"
:appCode=
"props?.appCode"
:apiBaseUrl=
"props?.apiBaseUrl"
@
audio
=
"handleVoice
Audio
"
@
error=
"handleVoiceError"
class=
"voice-recognition-wrapper"
/>
@
text
=
"handleVoice
Text
"
@
error=
"handleVoiceError"
class=
"voice-recognition-wrapper"
/>
<textarea
ref=
"textarea"
v-model=
"messageText"
placeholder=
"输入消息..."
@
keypress=
"handleKeyPress"
@
input=
"adjustTextareaHeight"
></textarea>
...
...
@@ -133,8 +126,7 @@ import defaultAvatar from '@/assets/logo.png';
import
rightIcon
from
'
@/assets/right.svg
'
import
thinkIcon
from
'
@/assets/think.svg
'
import
ChartComponent
from
'
./ChartComponent.vue
'
;
// 导入独立的图表组件
import
VoiceRecognition
from
'
./VoiceRecognition.vue
'
;
// 导入语音识别组件
import
AudioPlayer
from
'
./AudioPlayer.vue
'
;
// 导入音频播放器组件
import
VoiceRecognitionText
from
'
./VoiceRecognitionText.vue
'
;
// 导入语音识别组件
import
{
createSSEService
,
type
SSEData
}
from
'
./utils/sseService
'
;
// 导入SSE服务
import
{
createContentTemplateService
,
type
Message
}
from
'
./utils/contentTemplateService
'
;
// 导入模板服务
...
...
@@ -308,10 +300,10 @@ const sseService = createSSEService({
// 创建模板服务实例
const
templateService
=
createContentTemplateService
();
// 语音事件处理函数
- 修改为接收服务器返回的URL
const
handleVoice
Audio
=
(
audioUrl
:
string
,
audioBlob
?:
Blob
,
durationTime
?:
number
)
=>
{
// 直接使用统一的sendMessage函数发送
音频
消息
sendMessage
(
'
audio
'
,
{
audioUrl
,
durationTim
e
});
// 语音
转文字
事件处理函数
const
handleVoice
Text
=
(
textMessage
:
string
)
=>
{
// 直接使用统一的sendMessage函数发送
文本
消息
sendMessage
(
'
text
'
,
{
message
:
textMessag
e
});
};
const
handleVoiceError
=
(
error
:
string
)
=>
{
...
...
@@ -328,7 +320,7 @@ const startConversation = () => {
};
// 定义消息类型
type
MessageType
=
'
text
'
|
'
audio
'
|
'
image
'
|
'
file
'
|
'
video
'
;
type
MessageType
=
'
text
'
|
'
audio
'
;
// 定义消息参数接口
interface
MessageParams
{
...
...
src/views/components/VoiceRecognitionText.vue
0 → 100644
View file @
958ab12c
<
template
>
<div
class=
"voice-recognition"
:class=
"
{ 'full-screen': isFullScreen }"
>
<!-- 语音按钮 -->
<button
class=
"voice-btn"
:class=
"
{
'recording': isRecording,
'disabled': disabled
}"
@click="handleClick"
@mousedown="handleMouseDown"
@mouseup="stopRecording"
@mouseleave="stopRecording"
@touchstart="handleTouchStart"
@touchend="stopRecording"
@touchcancel="stopRecording"
:disabled="disabled"
:title="getButtonTitle"
>
<!-- 默认模式显示语音图标 -->
<span
v-if=
"!isFullScreen"
class=
"voice-icon"
>
<AudioOutlined
/>
</span>
<!-- 全屏模式只显示文字 -->
<span
v-if=
"isFullScreen"
class=
"full-screen-text"
>
{{
isRecording
?
`正在说话 ${formattedDuration
}
`
:
'
按住说话
'
}}
<
/span
>
<!--
录音时显示最大时长提示
-->
<
span
v
-
if
=
"
isRecording
"
class
=
"
max-duration-hint
"
>
最长
{{
maxDuration
}}
秒
<
/span
>
<!--
录音时显示指示器
-->
<
span
v
-
if
=
"
isRecording
"
class
=
"
recording-indicator
"
>
<
span
class
=
"
wave-bar wave-bar-1
"
><
/span
>
<
span
class
=
"
wave-bar wave-bar-2
"
><
/span
>
<
span
class
=
"
wave-bar wave-bar-3
"
><
/span
>
<
span
class
=
"
wave-bar wave-bar-4
"
><
/span
>
<
span
class
=
"
wave-bar wave-bar-5
"
><
/span
>
<
/span
>
<
/button
>
<!--
移除取消按钮
-->
<
/div
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
computed
,
onUnmounted
,
nextTick
}
from
'
vue
'
import
{
AudioOutlined
}
from
'
@ant-design/icons-vue
'
// 组件属性
interface
Props
{
disabled
?:
boolean
debug
?:
boolean
maxDuration
?:
number
,
// 添加最大时长参数
token
?:
string
,
appCode
?:
string
,
apiBaseUrl
?:
string
,
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
disabled
:
false
,
debug
:
false
,
maxDuration
:
30
,
// 默认最大时长为30秒
token
:
''
,
appCode
:
''
,
apiBaseUrl
:
''
,
}
)
// 组件事件
const
emit
=
defineEmits
<
{
audio
:
[
message
:
string
]
error
:
[
error
:
string
]
recordingStart
:
[]
recordingStop
:
[]
}
>
()
// 响应式数据
const
isRecording
=
ref
(
false
)
const
isFullScreen
=
ref
(
false
)
const
recordingDuration
=
ref
(
0
)
// 录音时长(秒)
const
recordingTimer
=
ref
<
NodeJS
.
Timeout
|
null
>
(
null
)
// 计时器
// MediaRecorder相关
const
mediaRecorder
=
ref
<
MediaRecorder
|
null
>
(
null
)
const
audioChunks
=
ref
<
Blob
[]
>
([])
const
audioStream
=
ref
<
MediaStream
|
null
>
(
null
)
// 全局音频流缓存(单例模式)
let
globalAudioStream
:
MediaStream
|
null
=
null
// 获取或创建音频流(单例模式)
const
getOrCreateAudioStream
=
async
():
Promise
<
MediaStream
>
=>
{
// 检查是否有活跃的音频流
if
(
globalAudioStream
)
{
// 检查流是否仍然活跃
const
activeTracks
=
globalAudioStream
.
getAudioTracks
().
filter
(
track
=>
track
.
readyState
===
'
live
'
)
if
(
activeTracks
.
length
>
0
)
{
if
(
props
.
debug
)
{
console
.
log
(
'
复用现有音频流
'
)
}
return
globalAudioStream
}
else
{
// 流已失效,清理缓存
globalAudioStream
=
null
}
}
// 创建新的音频流
if
(
props
.
debug
)
{
console
.
log
(
'
创建新的音频流
'
)
}
const
stream
=
await
navigator
.
mediaDevices
.
getUserMedia
({
audio
:
{
echoCancellation
:
true
,
noiseSuppression
:
true
,
sampleRate
:
44100
}
}
)
// 缓存音频流
globalAudioStream
=
stream
// 监听轨道结束事件,清理缓存
stream
.
getAudioTracks
().
forEach
(
track
=>
{
track
.
addEventListener
(
'
ended
'
,
()
=>
{
if
(
globalAudioStream
===
stream
)
{
globalAudioStream
=
null
if
(
props
.
debug
)
{
console
.
log
(
'
音频流已结束,清理缓存
'
)
}
}
}
)
}
)
return
stream
}
// 格式化录音时长显示(分:秒)
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
}
// 点击事件处理
const
handleClick
=
()
=>
{
if
(
props
.
disabled
||
isRecording
.
value
)
return
// 如果不在全屏模式,进入全屏模式
if
(
!
isFullScreen
.
value
)
{
isFullScreen
.
value
=
true
// 在企业微信中,进入全屏时异步预加载音频流
// 这样用户点击"按住说话"时就不会再弹出授权对话框
if
(
props
.
debug
)
{
console
.
log
(
'
进入全屏模式,异步预加载音频流
'
)
}
// 异步预加载音频流,不阻塞UI
setTimeout
(
async
()
=>
{
try
{
// 预加载音频流,但不开始录音
const
stream
=
await
getOrCreateAudioStream
()
if
(
props
.
debug
)
{
console
.
log
(
'
音频流预加载完成
'
)
}
// 预加载成功后,立即停止音频轨道,避免自动开始录音
// 但保持流缓存,这样用户点击"按住说话"时可以直接复用
stream
.
getAudioTracks
().
forEach
(
track
=>
{
track
.
stop
()
}
)
// 清理缓存,因为轨道已停止
globalAudioStream
=
null
if
(
props
.
debug
)
{
console
.
log
(
'
音频流预加载完成,轨道已停止,缓存已清理
'
)
}
}
catch
(
error
)
{
if
(
props
.
debug
)
{
console
.
warn
(
'
音频流预加载失败:
'
,
error
)
}
// 预加载失败不影响后续操作,用户点击"按住说话"时会重新尝试
}
}
,
0
)
}
}
// 处理鼠标按下事件
const
handleMouseDown
=
(
event
:
MouseEvent
)
=>
{
if
(
props
.
debug
)
{
console
.
log
(
'
鼠标按下事件触发
'
)
}
startRecording
()
}
// 处理触摸开始事件
const
handleTouchStart
=
(
event
:
TouchEvent
)
=>
{
if
(
props
.
debug
)
{
console
.
log
(
'
触摸开始事件触发
'
)
}
startRecording
()
}
// 检查浏览器是否支持MediaRecorder
const
isMediaRecorderSupported
=
()
=>
{
const
supported
=
'
MediaRecorder
'
in
window
;
if
(
props
.
debug
)
{
console
.
log
(
'
MediaRecorder支持检查:
'
,
supported
);
}
return
supported
;
}
// 检查麦克风权限
const
checkMicrophonePermission
=
async
()
=>
{
try
{
const
permissionStatus
=
await
navigator
.
permissions
.
query
({
name
:
'
microphone
'
as
PermissionName
}
);
if
(
props
.
debug
)
{
console
.
log
(
'
麦克风权限状态:
'
,
permissionStatus
.
state
);
}
if
(
permissionStatus
.
state
===
'
denied
'
)
{
return
false
;
}
return
true
;
}
catch
(
error
)
{
if
(
props
.
debug
)
{
console
.
warn
(
'
无法检查麦克风权限:
'
,
error
);
}
return
true
;
// 如果无法检查权限,继续尝试
}
}
// 开始录音
const
startRecording
=
async
()
=>
{
if
(
props
.
disabled
||
isRecording
.
value
||
!
isFullScreen
.
value
)
return
// 检查权限
const
hasPermission
=
await
checkMicrophonePermission
();
if
(
!
hasPermission
)
{
emit
(
'
error
'
,
'
麦克风权限被拒绝
'
);
exitFullScreen
();
return
;
}
// 检查浏览器支持
if
(
!
isMediaRecorderSupported
())
{
const
errorMsg
=
'
您的浏览器不支持音频录制功能
'
;
emit
(
'
error
'
,
errorMsg
);
exitFullScreen
();
return
;
}
try
{
// 使用缓存音频流(单例模式)
const
stream
=
await
getOrCreateAudioStream
();
audioStream
.
value
=
stream
;
audioChunks
.
value
=
[];
// 创建MediaRecorder实例
mediaRecorder
.
value
=
new
MediaRecorder
(
stream
,
{
mimeType
:
'
audio/webm;codecs=opus
'
}
);
// 设置数据可用事件处理
mediaRecorder
.
value
.
ondataavailable
=
(
event
)
=>
{
if
(
event
.
data
.
size
>
0
)
{
audioChunks
.
value
.
push
(
event
.
data
);
}
}
;
// 设置停止事件处理 - 录制完成后直接发送
mediaRecorder
.
value
.
onstop
=
()
=>
{
sendRecordedAudio
();
}
;
// 开始录制
mediaRecorder
.
value
.
start
(
100
);
// 每100ms收集一次数据
isRecording
.
value
=
true
;
// 启动录音时长计时器
startRecordingTimer
();
// 通知父组件开始录音
emit
(
'
recordingStart
'
);
if
(
props
.
debug
)
{
console
.
log
(
'
开始录音,MediaRecorder状态:
'
,
mediaRecorder
.
value
.
state
);
}
}
catch
(
error
)
{
console
.
error
(
'
启动录音失败:
'
,
error
);
let
errorMessage
=
'
无法启动录音
'
;
if
(
error
&&
error
.
name
===
'
NotAllowedError
'
)
{
errorMessage
=
'
麦克风权限被拒绝
'
;
}
else
if
(
error
&&
error
.
name
===
'
NotFoundError
'
)
{
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
;
}
}
// 停止录音
const
stopRecording
=
async
()
=>
{
if
(
!
isRecording
.
value
)
return
if
(
mediaRecorder
.
value
&&
mediaRecorder
.
value
.
state
===
'
recording
'
)
{
mediaRecorder
.
value
.
stop
();
isRecording
.
value
=
false
;
// 停止录音计时器
stopRecordingTimer
();
// 停止音频轨道,释放麦克风权限
if
(
audioStream
.
value
)
{
audioStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
());
audioStream
.
value
=
null
;
}
// 等待Vue完成DOM更新后再退出全屏
await
nextTick
();
exitFullScreen
();
// 通知父组件停止录音
emit
(
'
recordingStop
'
);
if
(
props
.
debug
)
{
console
.
log
(
'
停止录音,MediaRecorder状态:
'
,
mediaRecorder
.
value
.
state
);
console
.
log
(
'
录音时长:
'
,
recordingDuration
.
value
,
'
秒
'
);
}
}
}
// 上传音频文件到服务器进行语音转文字
const
uploadAudioFile
=
async
(
audioBlob
:
Blob
):
Promise
<
string
>
=>
{
try
{
const
formData
=
new
FormData
();
formData
.
append
(
'
file
'
,
audioBlob
,
'
recording.wav
'
);
formData
.
append
(
'
fileFolder
'
,
'
AI_TEMP
'
);
const
response
=
await
fetch
(
`/agentService/index/audio2txt`
,
{
method
:
'
POST
'
,
headers
:
{
'
x-app-code
'
:
props
.
appCode
,
'
token
'
:
props
.
token
,
'
x-session-id
'
:
props
.
token
,
}
,
body
:
formData
}
);
// 解析响应体为JSON
const
result
=
await
response
.
json
();
if
(
result
.
code
===
0
)
{
const
{
question
}
=
result
.
data
;
return
question
;
}
else
{
throw
new
Error
(
'
语音转文字接口返回数据格式错误
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
语音转文字失败:
'
,
error
);
throw
error
;
}
}
// 发送录制的音频(转换为文本消息)
const
sendRecordedAudio
=
async
()
=>
{
// 防误触检查:只有录音时长超过0.5秒才发送
if
(
recordingDuration
.
value
<
0.5
)
{
if
(
props
.
debug
)
{
console
.
log
(
'
录音时长过短,视为误触,不发送数据。时长:
'
,
recordingDuration
.
value
,
'
秒
'
);
}
// 清理录音数据并退出全屏
audioChunks
.
value
=
[];
exitFullScreen
();
return
;
}
if
(
audioChunks
.
value
.
length
===
0
)
{
emit
(
'
error
'
,
'
录音数据为空
'
);
exitFullScreen
();
// 数据为空时退出全屏
return
;
}
const
audioBlob
=
new
Blob
(
audioChunks
.
value
,
{
type
:
'
audio/webm;codecs=opus
'
}
);
try
{
// 调用语音转文字接口获取转换后的文本
const
textMessage
=
await
uploadAudioFile
(
audioBlob
);
// 上传成功后触发text事件,传递转换后的文本
emit
(
'
text
'
,
textMessage
);
if
(
props
.
debug
)
{
console
.
log
(
'
语音转文字成功,文本内容:
'
,
textMessage
);
console
.
log
(
'
音频大小:
'
,
Math
.
round
(
audioBlob
.
size
/
1024
),
'
KB
'
);
console
.
log
(
'
音频时长:
'
,
recordingDuration
.
value
,
'
秒
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
语音转文字失败:
'
,
error
);
const
errorMsg
=
'
语音转文字失败,请重试
'
;
emit
(
'
error
'
,
errorMsg
);
}
finally
{
// 清理录音数据并退出全屏
audioChunks
.
value
=
[];
// 清理音频流,释放麦克风权限(重要:解决企业微信中麦克风一直开启的问题)
// 但保留全局缓存,以便后续录音可以复用
if
(
audioStream
.
value
)
{
// 只停止轨道,不清理全局缓存,以便后续复用
audioStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
());
audioStream
.
value
=
null
;
if
(
props
.
debug
)
{
console
.
log
(
'
消息发送完成:音频流轨道已停止,麦克风权限已释放
'
);
}
}
exitFullScreen
();
}
}
// 组件卸载时清理资源
onUnmounted
(()
=>
{
if
(
isRecording
.
value
)
{
stopRecording
();
}
// 清理当前录音音频流
if
(
audioStream
.
value
)
{
audioStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
());
}
// 清理全局缓存音频流(重要:防止权限占用)
if
(
globalAudioStream
)
{
globalAudioStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
());
globalAudioStream
=
null
;
if
(
props
.
debug
)
{
console
.
log
(
'
组件卸载:全局音频流已清理
'
);
}
}
// 清理计时器
stopRecordingTimer
();
}
)
// 暴露方法给父组件
defineExpose
({
startRecording
,
stopRecording
,
isRecording
:
()
=>
isRecording
.
value
,
enterFullScreen
:
toggleFullScreen
,
exitFullScreen
}
)
<
/script
>
<
style
scoped
lang
=
"
less
"
>
@
import
'
./style.less
'
;
.
voice
-
recognition
{
position
:
relative
;
display
:
inline
-
flex
;
align
-
items
:
center
;
gap
:
10
px
;
transition
:
all
0.3
s
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
:
32
px
;
flex
-
direction
:
row
;
// 改为水平排列
justify
-
content
:
center
;
align
-
items
:
center
;
// 垂直居中
gap
:
12
px
;
// 水平间距
background
:
@
primary
-
color
!
important
;
// 按钮使用主色调蓝色
border
:
none
;
border
-
radius
:
12
px
;
right
:
0
;
.
full
-
screen
-
text
{
opacity
:
1
;
font
-
size
:
18
px
;
color
:
white
;
text
-
shadow
:
0
2
px
4
px
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.2
s
ease
-
in
-
out
infinite
;
&
.
wave
-
bar
-
1
{
animation
-
delay
:
0
s
;
animation
-
duration
:
1.4
s
;
}
&
.
wave
-
bar
-
2
{
animation
-
delay
:
0.2
s
;
animation
-
duration
:
1.2
s
;
}
&
.
wave
-
bar
-
3
{
animation
-
delay
:
0.4
s
;
animation
-
duration
:
1.0
s
;
}
}
}
.
recording
-
indicator
{
display
:
flex
;
align
-
items
:
flex
-
end
;
gap
:
1
px
;
height
:
16
px
;
.
wave
-
bar
{
width
:
3
px
;
background
:
#
fff
;
border
-
radius
:
2
px
2
px
0
0
;
transition
:
all
0.3
s
ease
;
&
.
wave
-
bar
-
1
{
height
:
4
px
;
border
-
radius
:
3
px
3
px
0
0
;
}
&
.
wave
-
bar
-
2
{
height
:
8
px
;
border
-
radius
:
3
px
3
px
0
0
;
}
&
.
wave
-
bar
-
3
{
height
:
12
px
;
border
-
radius
:
3
px
3
px
0
0
;
}
&
.
wave
-
bar
-
4
{
height
:
8
px
;
border
-
radius
:
3
px
3
px
0
0
;
}
&
.
wave
-
bar
-
5
{
height
:
4
px
;
border
-
radius
:
3
px
3
px
0
0
;
}
}
}
}
}
.
voice
-
btn
{
width
:
40
px
;
height
:
40
px
;
border
:
none
;
border
-
radius
:
50
%
;
right
:
14
px
;
color
:
@
primary
-
color
;
cursor
:
pointer
;
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
position
:
relative
;
z
-
index
:
100
;
background
:
transparent
!
important
;
// 禁止用户选择文本
-
webkit
-
user
-
select
:
none
;
-
moz
-
user
-
select
:
none
;
-
ms
-
user
-
select
:
none
;
user
-
select
:
none
;
&
.
recording
{
background
:
@
primary
-
color
;
top
:
0
!
important
;
transform
:
none
;
}
&
.
disabled
{
color
:
@
gray
-
4
;
cursor
:
not
-
allowed
;
}
}
.
voice
-
icon
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
center
;
width
:
100
%
;
height
:
100
%
;
font
-
size
:
18
px
;
}
.
full
-
screen
-
text
{
font
-
size
:
16
px
;
opacity
:
0
;
transition
:
opacity
0.3
s
ease
;
}
.
recording
-
indicator
{
gap
:
2
px
;
}
// 波纹动画 - 与AiChat.vue中的动画保持一致
@
keyframes
wechatWaveAnimation
{
0
%
,
100
%
{
transform
:
scaleY
(
0.3
);
opacity
:
0.6
;
}
25
%
{
transform
:
scaleY
(
0.7
);
opacity
:
0.8
;
}
50
%
{
transform
:
scaleY
(
1
);
opacity
:
1
;
}
75
%
{
transform
:
scaleY
(
0.7
);
opacity
:
0.8
;
}
}
// 最大时长提示样式
.
max
-
duration
-
hint
{
font
-
size
:
12
px
;
color
:
rgba
(
255
,
255
,
255
,
0.7
);
margin
-
top
:
4
px
;
text
-
shadow
:
0
1
px
2
px
rgba
(
0
,
0
,
0
,
0.3
);
}
<
/style>
\ No newline at end of file
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment