feat(微信聊天): 添加语音消息功能支持

实现语音消息的录制、播放和发送功能,包括:
1. 新增AudioRecorder组件用于录音
2. 添加AudioMessage组件展示语音消息
3. 修改消息输入组件支持语音消息类型
4. 调整样式适配语音消息展示
This commit is contained in:
超级老白兔
2025-09-09 18:09:38 +08:00
parent 514b077da4
commit 03056186c6
9 changed files with 673 additions and 299 deletions

View File

@@ -0,0 +1,391 @@
import React, { useState, useRef, useCallback } from "react";
import { Button, message, Modal } from "antd";
import {
AudioOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
SendOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { uploadFile } from "@/api/common";
interface AudioRecorderProps {
onAudioUploaded: (filePath: string) => void;
className?: string;
disabled?: boolean;
maxDuration?: number; // 最大录音时长(秒)
}
type RecordingState =
| "idle"
| "recording"
| "recorded"
| "playing"
| "uploading";
const AudioRecorder: React.FC<AudioRecorderProps> = ({
onAudioUploaded,
className,
disabled = false,
maxDuration = 60,
}) => {
const [visible, setVisible] = useState(false);
const [state, setState] = useState<RecordingState>("idle");
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string>("");
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const chunksRef = useRef<Blob[]>([]);
// 打开弹窗
const openRecorder = () => {
setVisible(true);
};
// 关闭弹窗并重置状态
const closeRecorder = () => {
if (state === "recording") {
stopRecording();
}
if (state === "playing") {
pauseAudio();
}
deleteRecording();
setVisible(false);
};
// 开始录音
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 尝试使用MP3格式如果不支持则回退到WebM
let mimeType = "audio/mp3";
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = "audio/mpeg";
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = "audio/webm";
}
}
const mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = event => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType });
setAudioBlob(blob);
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setState("recorded");
// 停止所有音频轨道
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setState("recording");
setRecordingTime(0);
// 开始计时
timerRef.current = setInterval(() => {
setRecordingTime(prev => {
const newTime = prev + 1;
if (newTime >= maxDuration) {
stopRecording();
}
return newTime;
});
}, 1000);
} catch (error) {
console.error("录音失败:", error);
message.error("无法访问麦克风,请检查权限设置");
}
}, [maxDuration]);
// 停止录音
const stopRecording = useCallback(() => {
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
mediaRecorderRef.current.stop();
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// 播放录音
const playAudio = useCallback(() => {
if (audioRef.current && audioUrl) {
audioRef.current.play();
setState("playing");
}
}, [audioUrl]);
// 暂停播放
const pauseAudio = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
setState("recorded");
}
}, []);
// 删除录音
const deleteRecording = useCallback(() => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioBlob(null);
setAudioUrl("");
setRecordingTime(0);
setState("idle");
}, [audioUrl]);
// 发送录音
const sendAudio = useCallback(async () => {
if (!audioBlob) return;
try {
setState("uploading");
// 创建文件对象
const timestamp = Date.now();
const fileExtension =
audioBlob.type.includes("mp3") || audioBlob.type.includes("mpeg")
? "mp3"
: "webm";
const audioFile = new File(
[audioBlob],
`audio_${timestamp}.${fileExtension}`,
{
type: audioBlob.type,
},
);
// 打印文件格式信息
console.log("音频文件信息:", {
fileName: audioFile.name,
fileType: audioFile.type,
fileSize: audioFile.size,
fileExtension: fileExtension,
blobType: audioBlob.type,
});
// 上传文件
const filePath = await uploadFile(audioFile);
// 调用回调函数
onAudioUploaded(filePath);
// 重置状态并关闭弹窗
deleteRecording();
setVisible(false);
message.success("语音发送成功");
} catch (error) {
console.error("语音上传失败:", error);
message.error("语音发送失败,请重试");
setState("recorded");
}
}, [audioBlob, onAudioUploaded, deleteRecording]);
// 格式化时间显示
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
// 渲染弹窗内容
const renderModalContent = () => {
switch (state) {
case "idle":
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div
style={{ marginBottom: "20px", fontSize: "16px", color: "#666" }}
>
</div>
<Button
type="primary"
size="large"
icon={<AudioOutlined />}
onClick={startRecording}
style={{
borderRadius: "50%",
width: "80px",
height: "80px",
fontSize: "24px",
}}
/>
</div>
);
case "recording":
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div style={{ marginBottom: "20px" }}>
<div
style={{
fontSize: "24px",
color: "#ff4d4f",
fontWeight: "bold",
marginBottom: "10px",
}}
>
{formatTime(recordingTime)}
</div>
<div style={{ fontSize: "14px", color: "#999" }}>
...
</div>
</div>
<Button
type="primary"
danger
size="large"
onClick={stopRecording}
style={{
borderRadius: "50%",
width: "80px",
height: "80px",
fontSize: "24px",
}}
>
</Button>
</div>
);
case "recorded":
case "playing":
return (
<div style={{ padding: "20px" }}>
<div style={{ textAlign: "center", marginBottom: "20px" }}>
<div
style={{
fontSize: "18px",
fontWeight: "bold",
marginBottom: "10px",
}}
>
: {formatTime(recordingTime)}
</div>
<div style={{ fontSize: "14px", color: "#666" }}>
{state === "playing"
? "正在播放..."
: "录音完成,可以试听或发送"}
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "center",
gap: "12px",
marginBottom: "20px",
}}
>
<Button
type="text"
size="large"
icon={
state === "playing" ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)
}
onClick={state === "playing" ? pauseAudio : playAudio}
title={state === "playing" ? "暂停播放" : "播放预览"}
/>
<Button
type="text"
size="large"
icon={<DeleteOutlined />}
onClick={deleteRecording}
title="删除重录"
danger
/>
</div>
<div style={{ textAlign: "center" }}>
<Button
type="primary"
size="large"
icon={<SendOutlined />}
onClick={sendAudio}
loading={state === ("uploading" as RecordingState)}
style={{ minWidth: "120px" }}
>
</Button>
</div>
</div>
);
case "uploading":
return (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<Button
type="primary"
loading
size="large"
style={{ minWidth: "120px" }}
>
...
</Button>
</div>
);
default:
return null;
}
};
return (
<>
<Button
type="text"
icon={<AudioOutlined />}
onClick={openRecorder}
className={className}
disabled={disabled}
title="点击录音"
/>
<Modal
title="录音"
open={visible}
onCancel={closeRecorder}
footer={null}
width={400}
centered
maskClosable={state === "idle"}
>
{renderModalContent()}
{audioUrl && (
<audio
ref={audioRef}
src={audioUrl}
onEnded={() => setState("recorded")}
style={{ display: "none" }}
/>
)}
</Modal>
</>
);
};
export default AudioRecorder;