Skip to content
 

PC 端录音组件

更新: 9/5/2025字数: 0 字 时长: 0 分钟

组件概述

RecordingPanel是一个可复用的 React PC 端录音面板组件,主要功能包括:麦克风权限、实时计时、音频格式兼容、录音上传等。

录音之后把文件上传给后端进行语音转文字。

实现效果

录音组件.gif

组件实现

1、tsx

tsx
import React, { useState, useRef, useEffect } from "react";
import { AudioOutlined, AudioMutedOutlined, LoadingOutlined, CloseOutlined } from "@ant-design/icons";
import { Progress, App } from "antd";
import { fileAsr } from "@/app/lib/fileManage";
import { getSession } from "next-auth/react";
import "@/styles/RecordingPanel.scss";

interface RecordingPanelProps {
  visible: boolean;
  onClose: () => void;
  onTextUpdate: (text: string) => void;
}

/**
 * @description PC端录音面板组件,用于录音并上传到后端进行语音识别
 * @param visible 是否显示面板
 * @param onClose 关闭面板回调
 * @param onTextUpdate 识别结果回调
 */
const RecordingPanel: React.FC<RecordingPanelProps> = ({ visible, onClose, onTextUpdate }) => {
  const panelRef = useRef<HTMLDivElement>(null);
  const { message: messageApi } = App.useApp();

  // 录音相关状态
  const [isRecording, setIsRecording] = useState(false);
  const [recordingTime, setRecordingTime] = useState(0);
  const [hasPermission, setHasPermission] = useState<boolean | null>(null);
  const [isUploading, setIsUploading] = useState(false);

  // 录音技术相关引用
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
  const audioChunksRef = useRef<Blob[]>([]);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  // 面板动画状态
  const [isClosing, setIsClosing] = useState(false);

  /**
   * 处理面板关闭
   */
  const handleClose = () => {
    if (isRecording) {
      stopRecording();
    }

    setIsClosing(true);
    setTimeout(() => {
      onClose();
      setIsClosing(false);
    }, 300);
  };

  /**
   * 点击外部关闭面板
   */
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
        handleClose();
      }
    };

    if (visible) {
      document.addEventListener("mousedown", handleClickOutside);
    }

    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [visible, isRecording]);

  /**
   * 格式化时间显示
   */
  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 uploadAudio = async (blob: Blob) => {
    const session = await getSession();
    const userId = session?.user?.id || "recording";

    setIsUploading(true);
    try {
      const formData = new FormData();
      formData.append("file", blob, `${userId}.mp3`);
      const response = await fileAsr(formData);

      if (!response) {
        throw new Error("上传失败");
      }

      if (response.result) {
        onTextUpdate(response.result.text || "");
        // 修改为使用关闭动效
        handleClose();
      }
    } catch (error) {
      console.error("上传失败:", error);
      messageApi.error("音频上传失败,请重试");
    } finally {
      setIsUploading(false);
    }
  };

  /**
   * 开始录音
   */
  const startRecording = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      setHasPermission(true);

      // 检测浏览器支持的音频格式
      const mimeType = MediaRecorder.isTypeSupported("audio/mp3")
        ? "audio/mp3"
        : MediaRecorder.isTypeSupported("audio/webm")
        ? "audio/webm"
        : MediaRecorder.isTypeSupported("audio/ogg")
        ? "audio/ogg"
        : "";

      const options = mimeType ? { mimeType } : undefined;
      const mediaRecorder = new MediaRecorder(stream, options);

      mediaRecorderRef.current = mediaRecorder;
      audioChunksRef.current = [];

      mediaRecorder.ondataavailable = (e) => {
        audioChunksRef.current.push(e.data);
      };

      mediaRecorder.onstop = async () => {
        const audioBlob = new Blob(audioChunksRef.current, {
          type: mimeType || "audio/wav"
        });
        await uploadAudio(audioBlob);
      };

      mediaRecorder.start();
      setIsRecording(true);
      setRecordingTime(0);

      // 定时更新录音时间
      timerRef.current = setInterval(() => {
        setRecordingTime((prev) => prev + 1);
      }, 1000);
    } catch (error) {
      console.error("获取麦克风权限失败:", error);
      messageApi.error("获取麦克风权限失败");
      setHasPermission(false);
    }
  };

  /**
   * 停止录音
   */
  const stopRecording = () => {
    if (mediaRecorderRef.current) {
      mediaRecorderRef.current.stop();
      mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());

      if (timerRef.current) {
        clearInterval(timerRef.current);
        timerRef.current = null;
      }

      setIsRecording(false);
    }
  };

  // 组件卸载时清理资源
  useEffect(() => {
    return () => {
      if (isRecording) {
        stopRecording();
      }
    };
  }, []);

  if (!visible) return null;

  return (
    <div className={`recording-panel-container ${isClosing ? "closing" : ""}`}>
      <div className="recording-panel" ref={panelRef}>
        <div className="recording-panel-header">
          <h3>{isRecording ? "正在录音..." : "语音识别"}</h3>
          <button className="close-button" onClick={handleClose}>
            <CloseOutlined />
          </button>
        </div>

        <div className="recording-panel-content">
          <p className="recording-tip">{isUploading ? "正在识别语音..." : isRecording ? `已录制 ${formatTime(recordingTime)}` : "点击下方按钮开始录音"}</p>

          {isRecording && (
            <div className="recording-progress">
              <Progress percent={(recordingTime % 60) * (100 / 60)} showInfo={false} strokeColor="#52c41a" />
            </div>
          )}

          <button className={`voice-button-pc ${isRecording ? "recording" : ""}`} onClick={isRecording ? stopRecording : startRecording} disabled={hasPermission === false || isUploading}>
            {isUploading ? (
              <LoadingOutlined style={{ fontSize: "18px" }} />
            ) : isRecording ? (
              <AudioMutedOutlined style={{ fontSize: "18px", color: "#ff4d4f" }} />
            ) : (
              <AudioOutlined style={{ fontSize: "18px", color: "#9A5A3C" }} />
            )}
            {/* 当isUploading时显示正在识别 */}
            <span>{isUploading ? "正在识别..." : isRecording ? "停止录音" : "开始录音"}</span>
          </button>
        </div>
      </div>
    </div>
  );
};

RecordingPanel.displayName = "RecordingPanel";
export default RecordingPanel;

2、scss

scss
.recording-panel-container {
  position: absolute;
  bottom: 0;
  left: -2px;
  right: 0;
  // width: 100%;
  width: calc(100% + 4px);
  border-radius: 8px;
  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
  z-index: 10;
  animation: slideIn 0.3s ease-out forwards;
  overflow-y: auto;
  border: 1px solid rgba(207, 194, 210, 0.36);
  border-bottom: none;
  margin-bottom: -1px;
  min-height: 170px;
  box-sizing: border-box;
  border: 2px solid transparent;
  background-color: vars.$fan-global-white;
  &.closing {
    animation: slideOut 0.3s ease-in forwards;
  }
}

.recording-panel {
  width: 100%;
  height: 100%;
  padding: 8px 12px;
  @include mixin.fan-flex($direction: column);
  .recording-panel-header {
    @include mixin.fan-flex($direction: row, $justify: between, $align: center);
    margin-bottom: 6px;
    h3 {
      margin: 0;
      font-size: vars.$fan-global-font-size-13;
      font-weight: normal;
      color: #666;
    }

    .close-button {
      background: none;
      border: none;
      cursor: pointer;
      padding: 2px;
      color: #888;
      font-size: vars.$fan-global-font-size-13;

      &:hover {
        color: #333;
      }
    }
  }

  .recording-panel-content {
    @include mixin.fan-flex($direction: column, $align: center, $justify: center, $gap: 6px);
    flex: 1;
    padding: 6px 0;
    margin-top: 10px;

    .recording-icon {
      @include mixin.fan-flex($direction: row, $align: center, $justify: center);
      height: 48px;
      font-size: 48px;
      margin-bottom: 12px;
      color: vars.$fan-global-color;
    }

    .recording-tip {
      margin-bottom: 8px;
      text-align: center;
      min-height: 16px;
      font-size: vars.$fan-global-font-size-13;
      color: #666;
    }

    .recording-progress {
      width: 80%;
      margin-bottom: 10px;

      .ant-progress {
        .ant-progress-bg {
          height: 3px !important;
        }
      }

      .recording-time {
        text-align: center;
        font-size: 12px;
        color: vars.$fan-global-delete-color;
      }
    }

    .voice-button-pc {
      @include mixin.fan-flex($direction: row, $align: center, $justify: center);
      border-radius: 14px;
      padding: 5px 10px;
      cursor: pointer;
      transition: all 0.3s;
      min-width: 90px;
      background-color: #f8f8f8;
      border: 1px solid #eee;

      &:hover {
        background-color: #f0f0f0;
      }

      &.recording {
        background-color: #fff1f0;
        border-color: #ffccc7;
      }

      &:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }

      span {
        margin-left: 6px;
        font-size: 12px;
      }
    }
  }
}

/* 响应式调整 */
@media (max-width: 576px) {
  .recording-panel {
    padding: 6px 8px;

    .recording-panel-content {
      padding: 4px 0;

      .recording-icon {
        font-size: 36px;
        height: 36px;
        margin-bottom: 8px;
      }

      .recording-progress {
        width: 90%;
      }

      .voice-button-pc {
        padding: 4px 8px;

        span {
          font-size: 12px;
        }
      }
    }
  }
}

@keyframes slideIn {
  from {
    transform: translateY(100%);
  }
  to {
    transform: translateY(0);
  }
}

@keyframes slideOut {
  from {
    transform: translateY(0);
  }
  to {
    transform: translateY(100%);
  }
}

组件使用

tsx
const [showRecordingPanel, setShowRecordingPanel] = useState(false);
<RecordingPanel
  visible={showRecordingPanel}
  onClose={() => setShowRecordingPanel(false)}
  onTextUpdate={(text) => {
    setShowRecordingPanel(false);
  }}
/>;

我见青山多妩媚,料青山见我应如是。