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