diff --git a/config/routes.ts b/config/routes.ts index 57ff2a61..df0898cb 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -65,9 +65,5 @@ export default [ path: '/spoken-practice', component: './spokenPages', }, - { - component: '404', - layout: false, - path: './*', - }, + { name: 'audio-recorder', icon: 'audio', path: '/audio-recorder', component: './AudioRecorder06/Example', }, { component: '404', layout: false, path: './*', }, ]; diff --git a/src/pages/AudioRecorder06/AudioRecorder.tsx b/src/pages/AudioRecorder06/AudioRecorder.tsx new file mode 100644 index 00000000..6ce2d438 --- /dev/null +++ b/src/pages/AudioRecorder06/AudioRecorder.tsx @@ -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 = ({ + maxDuration = 60, + onFinish, + onCancel, + modalMode = false, + triggerButton, +}) => { + const [isRecording, setIsRecording] = useState(false); + const [segments, setSegments] = useState([]); + const [currentDuration, setCurrentDuration] = useState(0); + const [totalDuration, setTotalDuration] = useState(0); + const [isModalVisible, setIsModalVisible] = useState(false); + const [permissionGranted, setPermissionGranted] = useState(null); + + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const startTimeRef = useRef(0); + const timerRef = useRef(null); + const streamRef = useRef(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 ( +
+ + {isRecording ? '松开停止' : '长按录音'} +
+ ); + }; + + // 渲染进度条 + 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 ( +
+
+ {/* 已录制的分段 */} + {segmentPositions.map((position, index) => ( +
+ ))} + {/* 当前正在录制的分段 */} + {isRecording && ( +
+ )} +
+
+ {totalDuration + currentDuration}秒 + / + {maxDuration}秒 +
+
+ ); + }; + + // 渲染操作按钮 + const renderActionButtons = () => { + const hasData = segments.length > 0 || isRecording; + + return ( +
+ + +
+ ); + }; + + // 渲染录音组件内容 + const renderRecorderContent = () => { + return ( +
+

录音组件

+ {renderRecordButton()} + {renderProgressBar()} + {renderActionButtons()} +
+ ); + }; + + // 如果是弹框模式,渲染弹框 + if (modalMode) { + return ( + <> + + setIsModalVisible(false)} + footer={null} + > + {renderRecorderContent()} + + + ); + } + + // 否则,直接渲染组件 + return renderRecorderContent(); +}; + +export default AudioRecorder; \ No newline at end of file diff --git a/src/pages/AudioRecorder06/Example.tsx b/src/pages/AudioRecorder06/Example.tsx new file mode 100644 index 00000000..8f887bd4 --- /dev/null +++ b/src/pages/AudioRecorder06/Example.tsx @@ -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(null); + const [modalRecordedFile, setModalRecordedFile] = useState(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 ( +
+

录音组件使用示例

+ + + {/* 直接嵌入模式 */} + + + + + {/* 录音结果展示 */} + {recordedFile && ( +
+

录音结果

+ +

录音文件路径: {recordedFile}

+
+ )} +
+
+ + {/* 弹框模式 */} + + + + + {/* 弹框模式录音结果展示 */} + {modalRecordedFile && ( +
+

弹框模式录音结果

+ +

录音文件路径: {modalRecordedFile}

+
+ )} +
+
+ + {/* 自定义配置模式 */} + + + { + setRecordedFile(filePath); + message.success('自定义配置模式录音完成'); + }} + onCancel={() => { + setRecordedFile(null); + message.info('自定义配置模式录音已取消'); + }} + modalMode={false} + /> + + {/* 自定义配置模式录音结果展示 */} + {recordedFile && ( +
+

自定义配置模式录音结果

+ +

录音文件路径: {recordedFile}

+

自定义最大录音时长: 30秒

+
+ )} +
+
+
+
+ ); +}; + +export default Example; \ No newline at end of file diff --git a/src/pages/AudioRecorder06/README.md b/src/pages/AudioRecorder06/README.md new file mode 100644 index 00000000..2ab6548a --- /dev/null +++ b/src/pages/AudioRecorder06/README.md @@ -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 ( +
+ +
+ ); +}; + +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 ( +
+ +
+ ); +}; + +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 \ No newline at end of file diff --git a/src/pages/AudioRecorder06/index.less b/src/pages/AudioRecorder06/index.less new file mode 100644 index 00000000..fd5515bc --- /dev/null +++ b/src/pages/AudioRecorder06/index.less @@ -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%; + } + } +} \ No newline at end of file