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