5 changed files with 827 additions and 5 deletions
@ -0,0 +1,338 @@ |
|||||
|
import React, { useState, useRef, useEffect } from 'react'; |
||||
|
import { Button, Modal, message } from 'antd'; |
||||
|
import { AudioOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons'; |
||||
|
import './index.less'; |
||||
|
|
||||
|
interface AudioSegment { |
||||
|
blob: Blob; |
||||
|
duration: number; |
||||
|
} |
||||
|
|
||||
|
interface AudioRecorderProps { |
||||
|
maxDuration?: number; // 最大录音时长,默认60秒
|
||||
|
onFinish?: (filePath: string) => void; // 录音完成回调
|
||||
|
onCancel?: () => void; // 取消录音回调
|
||||
|
modalMode?: boolean; // 是否为弹框模式
|
||||
|
triggerButton?: React.ReactNode; // 弹框模式下的触发按钮
|
||||
|
} |
||||
|
|
||||
|
const AudioRecorder: React.FC<AudioRecorderProps> = ({ |
||||
|
maxDuration = 60, |
||||
|
onFinish, |
||||
|
onCancel, |
||||
|
modalMode = false, |
||||
|
triggerButton, |
||||
|
}) => { |
||||
|
const [isRecording, setIsRecording] = useState(false); |
||||
|
const [segments, setSegments] = useState<AudioSegment[]>([]); |
||||
|
const [currentDuration, setCurrentDuration] = useState(0); |
||||
|
const [totalDuration, setTotalDuration] = useState(0); |
||||
|
const [isModalVisible, setIsModalVisible] = useState(false); |
||||
|
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null); |
||||
|
|
||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null); |
||||
|
const audioChunksRef = useRef<Blob[]>([]); |
||||
|
const startTimeRef = useRef<number>(0); |
||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null); |
||||
|
const streamRef = useRef<MediaStream | null>(null); |
||||
|
|
||||
|
// 请求麦克风权限
|
||||
|
const requestMicrophonePermission = async () => { |
||||
|
try { |
||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
||||
|
setPermissionGranted(true); |
||||
|
streamRef.current = stream; |
||||
|
return stream; |
||||
|
} catch (error) { |
||||
|
console.error('麦克风权限请求失败:', error); |
||||
|
setPermissionGranted(false); |
||||
|
showMessage('error', '麦克风权限被拒绝,请检查浏览器设置'); |
||||
|
return null; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 开始录音
|
||||
|
const startRecording = async () => { |
||||
|
if (isRecording) return; |
||||
|
|
||||
|
// 如果还没有权限,先请求权限
|
||||
|
if (permissionGranted === null) { |
||||
|
const stream = await requestMicrophonePermission(); |
||||
|
if (!stream) return; |
||||
|
} else if (!permissionGranted) { |
||||
|
showMessage('error', '麦克风权限被拒绝,请检查浏览器设置'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setIsRecording(true); |
||||
|
audioChunksRef.current = []; |
||||
|
startTimeRef.current = Date.now(); |
||||
|
|
||||
|
try { |
||||
|
// 如果之前的stream已经关闭,重新请求
|
||||
|
let stream = streamRef.current; |
||||
|
if (!stream || stream.active === false) { |
||||
|
stream = await requestMicrophonePermission(); |
||||
|
if (!stream) return; |
||||
|
} |
||||
|
|
||||
|
const mediaRecorder = new MediaRecorder(stream, { |
||||
|
mimeType: 'audio/webm;codecs=opus', |
||||
|
}); |
||||
|
|
||||
|
mediaRecorder.ondataavailable = (event) => { |
||||
|
if (event.data.size > 0) { |
||||
|
audioChunksRef.current.push(event.data); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
mediaRecorder.start(100); // 每100ms收集一次数据
|
||||
|
mediaRecorderRef.current = mediaRecorder; |
||||
|
|
||||
|
// 启动定时器更新当前时长
|
||||
|
timerRef.current = setInterval(() => { |
||||
|
const duration = Math.floor((Date.now() - startTimeRef.current) / 1000); |
||||
|
setCurrentDuration(duration); |
||||
|
|
||||
|
// 检查是否超过最大时长
|
||||
|
if (duration >= maxDuration) { |
||||
|
stopRecording(); |
||||
|
showMessage('warning', `已达到最大录音时长${maxDuration}秒`); |
||||
|
} |
||||
|
}, 100); |
||||
|
} catch (error) { |
||||
|
console.error('开始录音失败:', error); |
||||
|
setIsRecording(false); |
||||
|
showMessage('error', '开始录音失败,请重试'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 停止录音
|
||||
|
const stopRecording = () => { |
||||
|
if (!isRecording || !mediaRecorderRef.current) return; |
||||
|
|
||||
|
setIsRecording(false); |
||||
|
clearInterval(timerRef.current!); |
||||
|
|
||||
|
mediaRecorderRef.current.stop(); |
||||
|
|
||||
|
// 计算当前分段的时长
|
||||
|
const duration = Math.floor((Date.now() - startTimeRef.current) / 1000); |
||||
|
|
||||
|
// 保存当前分段
|
||||
|
mediaRecorderRef.current.onstop = () => { |
||||
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); |
||||
|
const newSegment: AudioSegment = { blob: audioBlob, duration }; |
||||
|
setSegments((prev) => [...prev, newSegment]); |
||||
|
setTotalDuration((prev) => prev + duration); |
||||
|
setCurrentDuration(0); |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
// 合并所有录音分段
|
||||
|
const mergeSegments = async () => { |
||||
|
if (segments.length === 0) { |
||||
|
showMessage('warning', '没有录音数据'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// 创建一个新的Blob,包含所有分段的数据
|
||||
|
const allBlobs = segments.map((segment) => segment.blob); |
||||
|
const mergedBlob = new Blob(allBlobs, { type: 'audio/webm' }); |
||||
|
|
||||
|
// 创建一个临时URL
|
||||
|
const url = URL.createObjectURL(mergedBlob); |
||||
|
|
||||
|
// 调用完成回调
|
||||
|
if (onFinish) { |
||||
|
onFinish(url); |
||||
|
} |
||||
|
|
||||
|
showMessage('success', '录音完成'); |
||||
|
resetRecorder(); |
||||
|
|
||||
|
// 如果是弹框模式,关闭弹框
|
||||
|
if (modalMode) { |
||||
|
setIsModalVisible(false); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('合并录音分段失败:', error); |
||||
|
showMessage('error', '合并录音失败,请重试'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 清空所有录音数据
|
||||
|
const clearSegments = () => { |
||||
|
resetRecorder(); |
||||
|
if (onCancel) { |
||||
|
onCancel(); |
||||
|
} |
||||
|
showMessage('info', '录音已取消'); |
||||
|
|
||||
|
// 如果是弹框模式,关闭弹框
|
||||
|
if (modalMode) { |
||||
|
setIsModalVisible(false); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 重置录音器状态
|
||||
|
const resetRecorder = () => { |
||||
|
setIsRecording(false); |
||||
|
setSegments([]); |
||||
|
setCurrentDuration(0); |
||||
|
setTotalDuration(0); |
||||
|
clearInterval(timerRef.current!); |
||||
|
|
||||
|
// 停止所有轨道
|
||||
|
if (streamRef.current) { |
||||
|
streamRef.current.getTracks().forEach((track) => track.stop()); |
||||
|
streamRef.current = null; |
||||
|
} |
||||
|
|
||||
|
mediaRecorderRef.current = null; |
||||
|
audioChunksRef.current = []; |
||||
|
setPermissionGranted(null); |
||||
|
}; |
||||
|
|
||||
|
// 显示提示消息
|
||||
|
const showMessage = (type: 'success' | 'error' | 'warning' | 'info', content: string) => { |
||||
|
message[type](content, 1); // 1秒后自动消失
|
||||
|
}; |
||||
|
|
||||
|
// 组件卸载时清理资源
|
||||
|
useEffect(() => { |
||||
|
return () => { |
||||
|
resetRecorder(); |
||||
|
}; |
||||
|
}, []); |
||||
|
|
||||
|
// 渲染录音按钮
|
||||
|
const renderRecordButton = () => { |
||||
|
return ( |
||||
|
<div |
||||
|
className={`audio-recorder-button ${isRecording ? 'recording' : ''}`} |
||||
|
onMouseDown={startRecording} |
||||
|
onMouseUp={stopRecording} |
||||
|
onMouseLeave={stopRecording} |
||||
|
onTouchStart={startRecording} |
||||
|
onTouchEnd={stopRecording} |
||||
|
> |
||||
|
<AudioOutlined /> |
||||
|
<span>{isRecording ? '松开停止' : '长按录音'}</span> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
// 渲染进度条
|
||||
|
const renderProgressBar = () => { |
||||
|
const currentProgress = isRecording ? (currentDuration / maxDuration) * 100 : 0; |
||||
|
|
||||
|
// 计算每个已录制分段的起始位置
|
||||
|
const segmentPositions = segments.reduce((acc, segment, index) => { |
||||
|
const previousTotalDuration = segments.slice(0, index).reduce((sum, s) => sum + s.duration, 0); |
||||
|
const startPosition = (previousTotalDuration / maxDuration) * 100; |
||||
|
const segmentProgress = (segment.duration / maxDuration) * 100; |
||||
|
acc.push({ startPosition, segmentProgress }); |
||||
|
return acc; |
||||
|
}, [] as { startPosition: number; segmentProgress: number }[]); |
||||
|
|
||||
|
// 计算当前正在录制的分段的起始位置
|
||||
|
const currentStartPosition = totalDuration > 0 ? (totalDuration / maxDuration) * 100 : 0; |
||||
|
|
||||
|
return ( |
||||
|
<div className="audio-recorder-progress"> |
||||
|
<div className="audio-recorder-progress-bar"> |
||||
|
{/* 已录制的分段 */} |
||||
|
{segmentPositions.map((position, index) => ( |
||||
|
<div |
||||
|
key={index} |
||||
|
className="audio-recorder-progress-segment" |
||||
|
style={{ |
||||
|
width: `${position.segmentProgress}%`, |
||||
|
left: `${position.startPosition}%`, |
||||
|
}} |
||||
|
/> |
||||
|
))} |
||||
|
{/* 当前正在录制的分段 */} |
||||
|
{isRecording && ( |
||||
|
<div |
||||
|
className="audio-recorder-progress-current" |
||||
|
style={{ |
||||
|
width: `${currentProgress}%`, |
||||
|
left: `${currentStartPosition}%`, |
||||
|
}} |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
<div className="audio-recorder-duration"> |
||||
|
<span>{totalDuration + currentDuration}秒</span> |
||||
|
<span>/</span> |
||||
|
<span>{maxDuration}秒</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
// 渲染操作按钮
|
||||
|
const renderActionButtons = () => { |
||||
|
const hasData = segments.length > 0 || isRecording; |
||||
|
|
||||
|
return ( |
||||
|
<div className="audio-recorder-actions"> |
||||
|
<Button |
||||
|
type="default" |
||||
|
icon={<CloseOutlined />} |
||||
|
onClick={clearSegments} |
||||
|
disabled={!hasData} |
||||
|
> |
||||
|
取消录音 |
||||
|
</Button> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
icon={<CheckOutlined />} |
||||
|
onClick={mergeSegments} |
||||
|
disabled={!hasData} |
||||
|
> |
||||
|
完成录音 |
||||
|
</Button> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
// 渲染录音组件内容
|
||||
|
const renderRecorderContent = () => { |
||||
|
return ( |
||||
|
<div className="audio-recorder-container"> |
||||
|
<h3>录音组件</h3> |
||||
|
{renderRecordButton()} |
||||
|
{renderProgressBar()} |
||||
|
{renderActionButtons()} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
// 如果是弹框模式,渲染弹框
|
||||
|
if (modalMode) { |
||||
|
return ( |
||||
|
<> |
||||
|
<Button type="primary" onClick={() => setIsModalVisible(true)}> |
||||
|
{triggerButton || '开始录音'} |
||||
|
</Button> |
||||
|
<Modal |
||||
|
title="录音" |
||||
|
visible={isModalVisible} |
||||
|
onCancel={() => setIsModalVisible(false)} |
||||
|
footer={null} |
||||
|
> |
||||
|
{renderRecorderContent()} |
||||
|
</Modal> |
||||
|
</> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 否则,直接渲染组件
|
||||
|
return renderRecorderContent(); |
||||
|
}; |
||||
|
|
||||
|
export default AudioRecorder; |
||||
@ -0,0 +1,152 @@ |
|||||
|
import React, { useState } from 'react'; |
||||
|
import AudioRecorder from './AudioRecorder'; |
||||
|
import { Card, Tabs, Button, message } from 'antd'; |
||||
|
import { PlayCircleOutlined } from '@ant-design/icons'; |
||||
|
import './index.less'; |
||||
|
|
||||
|
const { TabPane } = Tabs; |
||||
|
|
||||
|
const Example: React.FC = () => { |
||||
|
const [recordedFile, setRecordedFile] = useState<string | null>(null); |
||||
|
const [modalRecordedFile, setModalRecordedFile] = useState<string | null>(null); |
||||
|
|
||||
|
// 处理录音完成
|
||||
|
const handleRecordFinish = (filePath: string) => { |
||||
|
setRecordedFile(filePath); |
||||
|
message.success('录音完成,已保存录音文件'); |
||||
|
}; |
||||
|
|
||||
|
// 处理录音取消
|
||||
|
const handleRecordCancel = () => { |
||||
|
setRecordedFile(null); |
||||
|
message.info('录音已取消'); |
||||
|
}; |
||||
|
|
||||
|
// 处理弹框模式录音完成
|
||||
|
const handleModalRecordFinish = (filePath: string) => { |
||||
|
setModalRecordedFile(filePath); |
||||
|
message.success('弹框模式录音完成,已保存录音文件'); |
||||
|
}; |
||||
|
|
||||
|
// 处理弹框模式录音取消
|
||||
|
const handleModalRecordCancel = () => { |
||||
|
setModalRecordedFile(null); |
||||
|
message.info('弹框模式录音已取消'); |
||||
|
}; |
||||
|
|
||||
|
// 播放录音
|
||||
|
const playRecording = (filePath: string | null) => { |
||||
|
if (!filePath) { |
||||
|
message.warning('没有录音文件可以播放'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const audio = new Audio(filePath); |
||||
|
audio.play(); |
||||
|
message.info('开始播放录音'); |
||||
|
} catch (error) { |
||||
|
console.error('播放录音失败:', error); |
||||
|
message.error('播放录音失败,请重试'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div className="audio-recorder-example"> |
||||
|
<h2>录音组件使用示例</h2> |
||||
|
|
||||
|
<Tabs defaultActiveKey="1" className="example-tabs"> |
||||
|
{/* 直接嵌入模式 */} |
||||
|
<TabPane tab="直接嵌入模式" key="1"> |
||||
|
<Card title="录音组件(直接嵌入)" bordered={false} className="example-card"> |
||||
|
<AudioRecorder |
||||
|
maxDuration={60} // 最大录音时长,默认60秒
|
||||
|
onFinish={handleRecordFinish} |
||||
|
onCancel={handleRecordCancel} |
||||
|
modalMode={false} // 直接嵌入模式
|
||||
|
/> |
||||
|
|
||||
|
{/* 录音结果展示 */} |
||||
|
{recordedFile && ( |
||||
|
<div className="recording-result"> |
||||
|
<h4>录音结果</h4> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
icon={<PlayCircleOutlined />} |
||||
|
onClick={() => playRecording(recordedFile)} |
||||
|
> |
||||
|
播放录音 |
||||
|
</Button> |
||||
|
<p>录音文件路径: {recordedFile}</p> |
||||
|
</div> |
||||
|
)} |
||||
|
</Card> |
||||
|
</TabPane> |
||||
|
|
||||
|
{/* 弹框模式 */} |
||||
|
<TabPane tab="弹框模式" key="2"> |
||||
|
<Card title="录音组件(弹框模式)" bordered={false} className="example-card"> |
||||
|
<AudioRecorder |
||||
|
maxDuration={60} |
||||
|
onFinish={handleModalRecordFinish} |
||||
|
onCancel={handleModalRecordCancel} |
||||
|
modalMode={true} // 弹框模式
|
||||
|
triggerButton="打开录音弹框" // 自定义触发按钮文本
|
||||
|
/> |
||||
|
|
||||
|
{/* 弹框模式录音结果展示 */} |
||||
|
{modalRecordedFile && ( |
||||
|
<div className="recording-result"> |
||||
|
<h4>弹框模式录音结果</h4> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
icon={<PlayCircleOutlined />} |
||||
|
onClick={() => playRecording(modalRecordedFile)} |
||||
|
> |
||||
|
播放录音 |
||||
|
</Button> |
||||
|
<p>录音文件路径: {modalRecordedFile}</p> |
||||
|
</div> |
||||
|
)} |
||||
|
</Card> |
||||
|
</TabPane> |
||||
|
|
||||
|
{/* 自定义配置模式 */} |
||||
|
<TabPane tab="自定义配置模式" key="3"> |
||||
|
<Card title="录音组件(自定义配置)" bordered={false} className="example-card"> |
||||
|
<AudioRecorder |
||||
|
maxDuration={30} // 自定义最大录音时长为30秒
|
||||
|
onFinish={(filePath) => { |
||||
|
setRecordedFile(filePath); |
||||
|
message.success('自定义配置模式录音完成'); |
||||
|
}} |
||||
|
onCancel={() => { |
||||
|
setRecordedFile(null); |
||||
|
message.info('自定义配置模式录音已取消'); |
||||
|
}} |
||||
|
modalMode={false} |
||||
|
/> |
||||
|
|
||||
|
{/* 自定义配置模式录音结果展示 */} |
||||
|
{recordedFile && ( |
||||
|
<div className="recording-result"> |
||||
|
<h4>自定义配置模式录音结果</h4> |
||||
|
<Button |
||||
|
type="primary" |
||||
|
icon={<PlayCircleOutlined />} |
||||
|
onClick={() => playRecording(recordedFile)} |
||||
|
> |
||||
|
播放录音 |
||||
|
</Button> |
||||
|
<p>录音文件路径: {recordedFile}</p> |
||||
|
<p>自定义最大录音时长: 30秒</p> |
||||
|
</div> |
||||
|
)} |
||||
|
</Card> |
||||
|
</TabPane> |
||||
|
</Tabs> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default Example; |
||||
@ -0,0 +1,178 @@ |
|||||
|
# 录音组件 (AudioRecorder) |
||||
|
|
||||
|
一个功能完善、界面美观的前端录音组件,支持长按录音、分段录制、进度可视化等功能,适配移动端和桌面端。 |
||||
|
|
||||
|
## 功能特性 |
||||
|
|
||||
|
### 1. 录音交互 |
||||
|
- 长按录音按钮开始录音 |
||||
|
- 松手自动暂停录音 |
||||
|
- 支持移动端触摸事件 |
||||
|
|
||||
|
### 2. 分段录制 |
||||
|
- 支持多次长按-松手的分段录音 |
||||
|
- 自动保存每段录音数据 |
||||
|
- 以进度条长度分割的方式显示分段 |
||||
|
|
||||
|
### 3. 进度可视化 |
||||
|
- 录音时显示实时进度条 |
||||
|
- 同步展示当前录音时长和最大录音时长 |
||||
|
- 进度条随录音时长线性增长 |
||||
|
|
||||
|
### 4. 配置灵活性 |
||||
|
- 支持自定义最大录音时长(默认60秒) |
||||
|
- 超出最大时长自动停止录音并提示 |
||||
|
|
||||
|
### 5. 操作控件功能 |
||||
|
- 取消录音:清空所有分段数据 |
||||
|
- 完成录音:合并所有分段,返回完整录音文件路径 |
||||
|
- 按钮状态随录音状态动态禁用/启用 |
||||
|
|
||||
|
### 6. 提示消息 |
||||
|
- 录音完成、取消、权限拒绝时显示浮动提示 |
||||
|
- 提示1秒后自动消失 |
||||
|
|
||||
|
### 7. 使用模式 |
||||
|
- 直接嵌入模式:可直接嵌入页面作为独立组件使用 |
||||
|
- 弹框模式:支持通过触发按钮打开弹框 |
||||
|
|
||||
|
## 技术实现 |
||||
|
|
||||
|
- **React + TypeScript**:组件化开发,类型安全 |
||||
|
- **MediaRecorder API**:浏览器原生录音API |
||||
|
- **Ant Design**:UI组件库,提供按钮、弹框等基础组件 |
||||
|
- **Less**:CSS预处理器,实现组件样式 |
||||
|
|
||||
|
## 安装和使用 |
||||
|
|
||||
|
### 1. 直接使用组件 |
||||
|
|
||||
|
```tsx |
||||
|
import React from 'react'; |
||||
|
import AudioRecorder from './AudioRecorder'; |
||||
|
|
||||
|
const App: React.FC = () => { |
||||
|
// 处理录音完成 |
||||
|
const handleRecordFinish = (filePath: string) => { |
||||
|
console.log('录音完成,文件路径:', filePath); |
||||
|
// 可以在这里处理录音文件,比如上传到服务器 |
||||
|
}; |
||||
|
|
||||
|
// 处理录音取消 |
||||
|
const handleRecordCancel = () => { |
||||
|
console.log('录音已取消'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div> |
||||
|
<AudioRecorder |
||||
|
maxDuration={60} // 最大录音时长,默认60秒 |
||||
|
onFinish={handleRecordFinish} |
||||
|
onCancel={handleRecordCancel} |
||||
|
modalMode={false} // 直接嵌入模式 |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default App; |
||||
|
``` |
||||
|
|
||||
|
### 2. 弹框模式使用 |
||||
|
|
||||
|
```tsx |
||||
|
import React from 'react'; |
||||
|
import AudioRecorder from './AudioRecorder'; |
||||
|
|
||||
|
const App: React.FC = () => { |
||||
|
// 处理录音完成 |
||||
|
const handleRecordFinish = (filePath: string) => { |
||||
|
console.log('弹框模式录音完成,文件路径:', filePath); |
||||
|
}; |
||||
|
|
||||
|
// 处理录音取消 |
||||
|
const handleRecordCancel = () => { |
||||
|
console.log('弹框模式录音已取消'); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<div> |
||||
|
<AudioRecorder |
||||
|
maxDuration={60} |
||||
|
onFinish={handleRecordFinish} |
||||
|
onCancel={handleRecordCancel} |
||||
|
modalMode={true} // 弹框模式 |
||||
|
triggerButton="开始录音" // 自定义触发按钮文本 |
||||
|
/> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default App; |
||||
|
``` |
||||
|
|
||||
|
## API 文档 |
||||
|
|
||||
|
### AudioRecorder Props |
||||
|
|
||||
|
| 参数名 | 类型 | 默认值 | 描述 | |
||||
|
|--------|------|--------|------| |
||||
|
| maxDuration | number | 60 | 最大录音时长(秒) | |
||||
|
| onFinish | (filePath: string) => void | - | 录音完成回调,返回录音文件路径 | |
||||
|
| onCancel | () => void | - | 取消录音回调 | |
||||
|
| modalMode | boolean | false | 是否为弹框模式 | |
||||
|
| triggerButton | React.ReactNode | "开始录音" | 弹框模式下的触发按钮内容 | |
||||
|
|
||||
|
## 组件结构 |
||||
|
|
||||
|
``` |
||||
|
AudioRecorder06/ |
||||
|
├── AudioRecorder.tsx # 录音组件核心代码 |
||||
|
├── index.less # 组件样式文件 |
||||
|
├── Example.tsx # 组件使用示例 |
||||
|
└── README.md # 组件文档说明 |
||||
|
``` |
||||
|
|
||||
|
## 浏览器兼容性 |
||||
|
|
||||
|
- Chrome 66+ |
||||
|
- Firefox 65+ |
||||
|
- Safari 14+ |
||||
|
- Edge 79+ |
||||
|
|
||||
|
## 注意事项 |
||||
|
|
||||
|
1. **麦克风权限**:组件需要用户授权使用麦克风,请确保浏览器支持并正确配置麦克风权限。 |
||||
|
|
||||
|
2. **录音格式**:组件使用 `audio/webm;codecs=opus` 格式录制音频,这是现代浏览器支持的高效音频格式。 |
||||
|
|
||||
|
3. **临时文件**:录音完成后返回的是临时URL,浏览器会在页面刷新或关闭后释放该URL。如果需要长期保存录音文件,建议将其上传到服务器。 |
||||
|
|
||||
|
4. **移动端适配**:组件已针对移动端进行适配,支持触摸事件,但在某些移动浏览器中可能存在兼容性问题。 |
||||
|
|
||||
|
## 二次开发 |
||||
|
|
||||
|
### 1. 修改样式 |
||||
|
|
||||
|
组件样式使用Less编写,位于 `index.less` 文件中。您可以根据需要修改样式变量或类名。 |
||||
|
|
||||
|
### 2. 扩展功能 |
||||
|
|
||||
|
如果需要扩展组件功能,可以在 `AudioRecorder.tsx` 文件中修改或添加代码。例如: |
||||
|
|
||||
|
- 添加录音质量设置 |
||||
|
- 添加录音格式选择 |
||||
|
- 添加录音波形可视化 |
||||
|
- 添加录音上传功能 |
||||
|
|
||||
|
### 3. 自定义提示消息 |
||||
|
|
||||
|
组件使用Ant Design的 `message` 组件显示提示消息。如果需要自定义提示消息的样式或行为,可以修改 `showMessage` 方法。 |
||||
|
|
||||
|
## 测试 |
||||
|
|
||||
|
组件提供了完整的使用示例,位于 `Example.tsx` 文件中。您可以运行示例代码测试组件的各项功能。 |
||||
|
|
||||
|
## 许可证 |
||||
|
|
||||
|
MIT License |
||||
@ -0,0 +1,158 @@ |
|||||
|
.audio-recorder-container { |
||||
|
max-width: 600px; |
||||
|
margin: 0 auto; |
||||
|
padding: 24px; |
||||
|
background: #fff; |
||||
|
border-radius: 8px; |
||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
|
|
||||
|
h3 { |
||||
|
margin: 0 0 24px 0; |
||||
|
font-size: 18px; |
||||
|
font-weight: 600; |
||||
|
text-align: center; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-button { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
width: 120px; |
||||
|
height: 120px; |
||||
|
margin: 0 auto 24px; |
||||
|
background: #1890ff; |
||||
|
border: none; |
||||
|
border-radius: 50%; |
||||
|
color: #fff; |
||||
|
font-size: 16px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.3s ease; |
||||
|
user-select: none; |
||||
|
|
||||
|
&:hover { |
||||
|
background: #40a9ff; |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
|
||||
|
&:active { |
||||
|
transform: scale(0.95); |
||||
|
} |
||||
|
|
||||
|
&.recording { |
||||
|
background: #ff4d4f; |
||||
|
animation: pulse 1.5s infinite; |
||||
|
|
||||
|
&:hover { |
||||
|
background: #ff7875; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.anticon { |
||||
|
font-size: 32px; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
span { |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes pulse { |
||||
|
0% { |
||||
|
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7); |
||||
|
} |
||||
|
70% { |
||||
|
box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); |
||||
|
} |
||||
|
100% { |
||||
|
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-progress { |
||||
|
margin-bottom: 24px; |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-progress-bar { |
||||
|
position: relative; |
||||
|
height: 8px; |
||||
|
background: #f0f0f0; |
||||
|
border-radius: 4px; |
||||
|
overflow: hidden; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-progress-segment { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
height: 100%; |
||||
|
background: #1890ff; |
||||
|
transition: width 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-progress-current { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
height: 100%; |
||||
|
background: #ff4d4f; |
||||
|
transition: width 0.1s ease; |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-duration { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
font-size: 14px; |
||||
|
color: #666; |
||||
|
|
||||
|
span { |
||||
|
margin: 0 4px; |
||||
|
} |
||||
|
|
||||
|
span:first-child { |
||||
|
color: #1890ff; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-actions { |
||||
|
display: flex; |
||||
|
gap: 16px; |
||||
|
justify-content: center; |
||||
|
|
||||
|
.ant-btn { |
||||
|
min-width: 120px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/* 移动端适配 */ |
||||
|
@media (max-width: 768px) { |
||||
|
.audio-recorder-container { |
||||
|
padding: 16px; |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-button { |
||||
|
width: 100px; |
||||
|
height: 100px; |
||||
|
|
||||
|
.anticon { |
||||
|
font-size: 28px; |
||||
|
} |
||||
|
|
||||
|
span { |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.audio-recorder-actions { |
||||
|
flex-direction: column; |
||||
|
|
||||
|
.ant-btn { |
||||
|
width: 100%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue