Files
ckb-SuperAdmin/Cunkebao/src/components/Upload/AudioRecorder/index.tsx
超级老白兔 03056186c6 feat(微信聊天): 添加语音消息功能支持
实现语音消息的录制、播放和发送功能,包括:
1. 新增AudioRecorder组件用于录音
2. 添加AudioMessage组件展示语音消息
3. 修改消息输入组件支持语音消息类型
4. 调整样式适配语音消息展示
2025-09-09 18:09:38 +08:00

392 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;