init
This commit is contained in:
5
src/component/audioRecorder/archives.tsx
Normal file
5
src/component/audioRecorder/archives.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
function Index() {
|
||||
return <>档案</>;
|
||||
}
|
||||
|
||||
export default Index;
|
||||
139
src/component/audioRecorder/deviceCompatibility.tsx
Normal file
139
src/component/audioRecorder/deviceCompatibility.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
// components/DeviceCompatibility.tsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { UniversalAudioRecorder } from "@/utils/audioRecorder";
|
||||
|
||||
interface DeviceInfo {
|
||||
isIOS: boolean;
|
||||
isSafari: boolean;
|
||||
supportedFormats: string[];
|
||||
hasMediaRecorder: boolean;
|
||||
hasGetUserMedia: boolean;
|
||||
}
|
||||
|
||||
const DeviceCompatibility: React.FC = () => {
|
||||
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCompatibility = () => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
const hasMediaRecorder = typeof MediaRecorder !== "undefined";
|
||||
const hasGetUserMedia = !!(
|
||||
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
);
|
||||
|
||||
let supportedFormats: string[] = [];
|
||||
if (hasMediaRecorder) {
|
||||
supportedFormats = UniversalAudioRecorder.getSupportedFormats();
|
||||
}
|
||||
|
||||
setDeviceInfo({
|
||||
isIOS,
|
||||
isSafari,
|
||||
supportedFormats,
|
||||
hasMediaRecorder,
|
||||
hasGetUserMedia,
|
||||
});
|
||||
};
|
||||
|
||||
checkCompatibility();
|
||||
}, []);
|
||||
|
||||
if (!deviceInfo) return null;
|
||||
|
||||
const getCompatibilityStatus = (): "good" | "warning" | "error" => {
|
||||
if (!deviceInfo.hasMediaRecorder || !deviceInfo.hasGetUserMedia) {
|
||||
return "error";
|
||||
}
|
||||
if (deviceInfo.supportedFormats.length === 0) {
|
||||
return "warning";
|
||||
}
|
||||
return "good";
|
||||
};
|
||||
|
||||
const status = getCompatibilityStatus();
|
||||
|
||||
return (
|
||||
<div className={`device-compatibility ${status}`}>
|
||||
<div
|
||||
className="compatibility-header"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<span className="status-icon">
|
||||
{status === "good" && "✅"}
|
||||
{status === "warning" && "⚠️"}
|
||||
{status === "error" && "❌"}
|
||||
</span>
|
||||
<span className="status-text">
|
||||
{status === "good" && "设备兼容"}
|
||||
{status === "warning" && "部分兼容"}
|
||||
{status === "error" && "不兼容"}
|
||||
</span>
|
||||
<span className="toggle-icon">{showDetails ? "▼" : "▶"}</span>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="compatibility-details">
|
||||
<div className="device-info">
|
||||
<p>
|
||||
<strong>设备信息:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>iOS设备: {deviceInfo.isIOS ? "是" : "否"}</li>
|
||||
<li>Safari浏览器: {deviceInfo.isSafari ? "是" : "否"}</li>
|
||||
<li>
|
||||
支持MediaRecorder: {deviceInfo.hasMediaRecorder ? "是" : "否"}
|
||||
</li>
|
||||
<li>
|
||||
支持getUserMedia: {deviceInfo.hasGetUserMedia ? "是" : "否"}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="supported-formats">
|
||||
<p>
|
||||
<strong>支持的音频格式:</strong>
|
||||
</p>
|
||||
{deviceInfo.supportedFormats.length > 0 ? (
|
||||
<ul>
|
||||
{deviceInfo.supportedFormats.map((format, index) => (
|
||||
<li key={index}>{format}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="no-formats">未检测到支持的格式</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "error" && (
|
||||
<div className="error-message">
|
||||
<p>
|
||||
<strong>错误:</strong> 您的设备不支持录音功能
|
||||
</p>
|
||||
<p>请尝试:</p>
|
||||
<ul>
|
||||
<li>使用最新版本的浏览器</li>
|
||||
<li>确保在HTTPS环境下访问</li>
|
||||
<li>检查浏览器权限设置</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "warning" && (
|
||||
<div className="warning-message">
|
||||
<p>
|
||||
<strong>警告:</strong> 录音功能可能不稳定
|
||||
</p>
|
||||
<p>建议使用Chrome、Safari或Firefox最新版本</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceCompatibility;
|
||||
278
src/component/audioRecorder/index copy.tsx
Normal file
278
src/component/audioRecorder/index copy.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
// components/PetTranslatorChat.tsx (添加音频控制)
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { usePetTranslator } from "@/hooks/usePetTranslator";
|
||||
import { useFileUpload } from "@/hooks/useFileUpload";
|
||||
import { useAudioControl } from "@/hooks/useAudioControl";
|
||||
import { VoiceMessage, ChatMessage } from "@/types/chat";
|
||||
import { UploadConfig } from "@/types/upload";
|
||||
import VoiceMessageComponent from "./voiceMessage";
|
||||
import VoiceRecordButton from "./voiceRecordButton";
|
||||
import RecordingStatusBar from "./recordingStatusBar";
|
||||
import AudioManager from "@/utils/audioManager";
|
||||
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
|
||||
import "./index.less";
|
||||
import { CapsuleTabs } from "antd-mobile";
|
||||
|
||||
const PetTranslatorChat: React.FC = () => {
|
||||
const {
|
||||
messages,
|
||||
currentPet,
|
||||
translateVoice,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
clearMessages,
|
||||
} = usePetTranslator();
|
||||
|
||||
const { isRecording, isPaused, recordingTime } = useVoiceRecorder();
|
||||
const { uploadFile } = useFileUpload();
|
||||
const { currentPlayingId, stopAllAudio, pauseAllAudio } = useAudioControl();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 上传配置
|
||||
const uploadConfig: UploadConfig = {
|
||||
url: "/api/upload/voice",
|
||||
method: "POST",
|
||||
fieldName: "voiceFile",
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
allowedTypes: [
|
||||
"audio/webm",
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mpeg",
|
||||
"audio/x-m4a",
|
||||
],
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
};
|
||||
|
||||
// 组件卸载时清理所有音频
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
AudioManager.getInstance().cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 开始录音时暂停所有音频播放
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
pauseAllAudio();
|
||||
}
|
||||
}, [isRecording, pauseAllAudio]);
|
||||
|
||||
const handleRecordComplete = (
|
||||
audioBlob: Blob,
|
||||
duration: number,
|
||||
uploadResponse?: any
|
||||
) => {
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
const voiceMessage: VoiceMessage = {
|
||||
id: `voice_${Date.now()}`,
|
||||
type: "voice",
|
||||
content: {
|
||||
duration,
|
||||
url: audioUrl,
|
||||
blob: audioBlob,
|
||||
uploadStatus: uploadResponse ? "success" : undefined,
|
||||
fileId: uploadResponse?.data?.fileId,
|
||||
fileName: uploadResponse?.data?.fileName,
|
||||
serverUrl: uploadResponse?.data?.fileUrl,
|
||||
},
|
||||
sender: "user",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
addMessage(voiceMessage);
|
||||
|
||||
// 自动开始翻译
|
||||
setTimeout(() => {
|
||||
translateVoice(voiceMessage);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleRetryUpload = async (messageId: string) => {
|
||||
const message = messages.find(
|
||||
(msg) => msg.id === messageId
|
||||
) as VoiceMessage;
|
||||
if (!message || !message.content.blob) return;
|
||||
|
||||
try {
|
||||
// 更新上传状态
|
||||
updateMessage(messageId, {
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
uploadStatus: "uploading",
|
||||
uploadProgress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const fileName = `voice_${Date.now()}.wav`;
|
||||
const uploadResponse = await uploadFile(
|
||||
message.content.blob,
|
||||
fileName,
|
||||
uploadConfig
|
||||
);
|
||||
|
||||
// 更新成功状态
|
||||
updateMessage(messageId, {
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
uploadStatus: "success",
|
||||
fileId: uploadResponse.data?.fileId,
|
||||
fileName: uploadResponse.data?.fileName,
|
||||
serverUrl: uploadResponse.data?.fileUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 更新失败状态
|
||||
updateMessage(messageId, {
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
uploadStatus: "error",
|
||||
},
|
||||
});
|
||||
|
||||
console.error("重新上传失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecordError = (error: Error) => {
|
||||
alert(error.message);
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 录音状态栏 */}
|
||||
<RecordingStatusBar
|
||||
isRecording={isRecording}
|
||||
isPaused={isPaused}
|
||||
duration={recordingTime}
|
||||
maxDuration={60}
|
||||
/>
|
||||
|
||||
{/* 头部 */}
|
||||
{/* <div className="chat-header">
|
||||
<div className="pet-info">
|
||||
<div className="pet-avatar">{currentPet.avatar}</div>
|
||||
<div className="pet-details">
|
||||
<div className="pet-name">{currentPet.name}</div>
|
||||
<div className="pet-status">
|
||||
{isRecording
|
||||
? "正在听你说话..."
|
||||
: currentPlayingId
|
||||
? "正在播放语音..."
|
||||
: "在线 · 等待翻译"}
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* <div className="header-controls">
|
||||
|
||||
{currentPlayingId && (
|
||||
<button
|
||||
className="audio-control-button"
|
||||
onClick={stopAllAudio}
|
||||
title="停止播放"
|
||||
>
|
||||
⏹️
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="clear-button"
|
||||
onClick={() => {
|
||||
stopAllAudio();
|
||||
clearMessages();
|
||||
}}
|
||||
disabled={isRecording}
|
||||
>
|
||||
🗑️
|
||||
</button> */}
|
||||
{/* </div>
|
||||
</div> */}
|
||||
<div className="chat-header">
|
||||
<CapsuleTabs>
|
||||
<CapsuleTabs.Tab title="宠物翻译" key="fruits">
|
||||
宠物翻译
|
||||
</CapsuleTabs.Tab>
|
||||
<CapsuleTabs.Tab title="宠物档案" key="vegetables">
|
||||
宠物档案
|
||||
</CapsuleTabs.Tab>
|
||||
</CapsuleTabs>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="messages-container">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🐾</div>
|
||||
<div className="empty-title">开始和{currentPet.name}对话吧!</div>
|
||||
<div className="empty-subtitle">
|
||||
点击下方按钮开始录音,我会帮你翻译
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className="message-wrapper">
|
||||
<div
|
||||
className={`message ${
|
||||
message.sender === "user" ? "own" : "other"
|
||||
}`}
|
||||
>
|
||||
{message.sender === "pet" && (
|
||||
<div className="avatar">{currentPet.avatar}</div>
|
||||
)}
|
||||
|
||||
<div className="message-content">
|
||||
{message.type === "voice" ? (
|
||||
<VoiceMessageComponent
|
||||
message={message}
|
||||
isOwn={message.sender === "user"}
|
||||
onTranslate={() => translateVoice(message)}
|
||||
onRetryUpload={() => handleRetryUpload(message.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-message">{message.content}</div>
|
||||
)}
|
||||
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.sender === "user" && (
|
||||
<div className="avatar user-avatar">👤</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<VoiceRecordButton
|
||||
onRecordComplete={handleRecordComplete}
|
||||
onError={handleRecordError}
|
||||
maxDuration={60}
|
||||
// uploadConfig={uploadConfig}
|
||||
// autoUpload={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PetTranslatorChat;
|
||||
774
src/component/audioRecorder/index.less
Normal file
774
src/component/audioRecorder/index.less
Normal file
@@ -0,0 +1,774 @@
|
||||
/* PetTranslatorChat.css */
|
||||
.pet-translator-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
.lef {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
&.active {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pet-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pet-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pet-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pet-status {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* 消息容器 */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 消息样式 */
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message.own {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message.other {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 语音消息样式 */
|
||||
.voice-message {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.voice-message.own {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.voice-message.own .play-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
width: 3px;
|
||||
background: #ddd;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.voice-message.own .waveform-bar {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.waveform-bar.active {
|
||||
background: #007bff;
|
||||
}
|
||||
|
||||
.voice-message.own .waveform-bar.active {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.voice-message.own .duration {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 翻译部分 */
|
||||
.translation-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.translate-button {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.translate-button:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.translating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #666;
|
||||
border-radius: 50%;
|
||||
animation: loading 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.loading-dots span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.translation-result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
.translation-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.translation-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 文本消息 */
|
||||
.text-message {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message.other .text-message {
|
||||
background: #e8f5e8;
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.input-area {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
/* 录音按钮 */
|
||||
.voice-record-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.voice-record-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voice-record-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.voice-record-button.pressed {
|
||||
background: #dc3545;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.microphone-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 录音覆盖层 */
|
||||
.recording-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.recording-modal {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.recording-animation {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sound-wave {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 4px;
|
||||
background: #007bff;
|
||||
border-radius: 2px;
|
||||
animation: wave 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar:nth-child(1) {
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
.wave-bar:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.wave-bar:nth-child(3) {
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
.wave-bar:nth-child(4) {
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
.wave-bar:nth-child(5) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
height: 10px;
|
||||
}
|
||||
20% {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-time {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.recording-hint {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancel-hint {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.normal-hint {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 480px) {
|
||||
.pet-translator-chat {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.recording-modal {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 上传进度样式 */
|
||||
.upload-progress {
|
||||
margin: 12px 0;
|
||||
padding: 8px;
|
||||
background: rgba(0, 123, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(0, 123, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-progress-fill {
|
||||
height: 100%;
|
||||
background: #007bff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 语音消息上传状态 */
|
||||
.upload-status-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.voice-message.own .upload-status-section {
|
||||
border-top-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.upload-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-status-indicator.uploading {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.upload-status-indicator.success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.upload-status-indicator.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.retry-upload-button {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.retry-upload-button:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.voice-message:not(.own) .file-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-name,
|
||||
.file-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 处理中状态 */
|
||||
.processing-hint {
|
||||
color: #007bff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
color: #28a745;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 按钮禁用状态 */
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.voice-start-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* 上传动画 */
|
||||
@keyframes uploadPulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-status-indicator.uploading .upload-icon {
|
||||
animation: uploadPulse 1s infinite;
|
||||
}
|
||||
|
||||
/* DeviceCompatibility.css */
|
||||
.device-compatibility {
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.device-compatibility.good {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.device-compatibility.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.device-compatibility.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.compatibility-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.compatibility-details {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.device-info ul,
|
||||
.supported-formats ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.device-info li,
|
||||
.supported-formats li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.no-formats {
|
||||
color: #dc3545;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.warning-message {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
border-left: 3px solid #dc3545;
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-left: 3px solid #ffc107;
|
||||
}
|
||||
|
||||
/* 播放错误样式 */
|
||||
.play-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
border: 1px solid rgba(220, 53, 69, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.retry-play-button {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.retry-play-button:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.play-button.error {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.play-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 安卓特定样式优化 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.voice-content {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.waveform {
|
||||
min-height: 24px;
|
||||
}
|
||||
}
|
||||
275
src/component/audioRecorder/index.tsx
Normal file
275
src/component/audioRecorder/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
// components/PetTranslatorChat.tsx (添加音频控制)
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { usePetTranslator } from "@/hooks/usePetTranslator";
|
||||
import { useFileUpload } from "@/hooks/useFileUpload";
|
||||
import { useAudioControl } from "@/hooks/useAudioControl";
|
||||
import { VoiceMessage, ChatMessage } from "@/types/chat";
|
||||
import { UploadConfig } from "@/types/upload";
|
||||
import VoiceMessageComponent from "./voiceMessage";
|
||||
import VoiceRecordButton from "./voiceRecordButton";
|
||||
import RecordingStatusBar from "./recordingStatusBar";
|
||||
import AudioManager from "@/utils/audioManager";
|
||||
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
|
||||
import TranslateItem from "./translateItem";
|
||||
import ArchivesItem from "./archives";
|
||||
import "./index.less";
|
||||
import { CapsuleTabs } from "antd-mobile";
|
||||
|
||||
const PetTranslatorChat: React.FC = () => {
|
||||
const {
|
||||
messages,
|
||||
currentPet,
|
||||
translateVoice,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
clearMessages,
|
||||
} = usePetTranslator();
|
||||
|
||||
const { isRecording, isPaused, recordingTime } = useVoiceRecorder();
|
||||
const { uploadFile } = useFileUpload();
|
||||
const { currentPlayingId, stopAllAudio, pauseAllAudio } = useAudioControl();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [tabValue, setTabValue] = useState<string>("translate");
|
||||
|
||||
// 上传配置
|
||||
const uploadConfig: UploadConfig = {
|
||||
url: "/api/upload/voice",
|
||||
method: "POST",
|
||||
fieldName: "voiceFile",
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
allowedTypes: [
|
||||
"audio/webm",
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mpeg",
|
||||
"audio/x-m4a",
|
||||
],
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
};
|
||||
|
||||
// 组件卸载时清理所有音频
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
AudioManager.getInstance().cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 开始录音时暂停所有音频播放
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
pauseAllAudio();
|
||||
}
|
||||
}, [isRecording, pauseAllAudio]);
|
||||
|
||||
const handleRecordComplete = (
|
||||
audioBlob: Blob,
|
||||
duration: number,
|
||||
uploadResponse?: any
|
||||
) => {
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
const voiceMessage: VoiceMessage = {
|
||||
id: `voice_${Date.now()}`,
|
||||
type: "voice",
|
||||
content: {
|
||||
duration,
|
||||
url: audioUrl,
|
||||
blob: audioBlob,
|
||||
uploadStatus: uploadResponse ? "success" : undefined,
|
||||
fileId: uploadResponse?.data?.fileId,
|
||||
fileName: uploadResponse?.data?.fileName,
|
||||
serverUrl: uploadResponse?.data?.fileUrl,
|
||||
},
|
||||
sender: "user",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
addMessage(voiceMessage);
|
||||
|
||||
// 自动开始翻译
|
||||
setTimeout(() => {
|
||||
translateVoice(voiceMessage);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleRetryUpload = async (messageId: string) => {
|
||||
const message = messages.find(
|
||||
(msg) => msg.id === messageId
|
||||
) as VoiceMessage;
|
||||
if (!message || !message.content.blob) return;
|
||||
|
||||
try {
|
||||
// 更新上传状态
|
||||
updateMessage(messageId, {
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
uploadStatus: "uploading",
|
||||
uploadProgress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const fileName = `voice_${Date.now()}.wav`;
|
||||
const uploadResponse = await uploadFile(
|
||||
message.content.blob,
|
||||
fileName,
|
||||
uploadConfig
|
||||
);
|
||||
|
||||
// 更新成功状态
|
||||
updateMessage(messageId, {
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
uploadStatus: "success",
|
||||
fileId: uploadResponse.data?.fileId,
|
||||
fileName: uploadResponse.data?.fileName,
|
||||
serverUrl: uploadResponse.data?.fileUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// 更新失败状态
|
||||
updateMessage(messageId, {
|
||||
...message,
|
||||
content: {
|
||||
...message.content,
|
||||
uploadStatus: "error",
|
||||
},
|
||||
});
|
||||
|
||||
console.error("重新上传失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecordError = (error: Error) => {
|
||||
alert(error.message);
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 录音状态栏 */}
|
||||
<RecordingStatusBar
|
||||
isRecording={isRecording}
|
||||
isPaused={isPaused}
|
||||
duration={recordingTime}
|
||||
maxDuration={60}
|
||||
/>
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="chat-header">
|
||||
<div className="lef">
|
||||
<h2
|
||||
onClick={() => setTabValue("translate")}
|
||||
className={`${tabValue === "translate" ? "active" : ""}`}
|
||||
>
|
||||
宠物翻译
|
||||
</h2>
|
||||
<h2
|
||||
onClick={() => setTabValue("archives")}
|
||||
className={`${tabValue === "archives" ? "active" : ""}`}
|
||||
>
|
||||
宠物档案
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* <div className="pet-info">
|
||||
<div className="pet-avatar">{currentPet.avatar}</div> */}
|
||||
{/* <div className="pet-details"> */}
|
||||
{/* <div className="pet-name">{currentPet.name}</div> */}
|
||||
{/* <div className="pet-status">
|
||||
{isRecording
|
||||
? "正在听你说话..."
|
||||
: currentPlayingId
|
||||
? "正在播放语音..."
|
||||
: "在线 · 等待翻译"}
|
||||
</div> */}
|
||||
{/* </div> */}
|
||||
{/* </div> */}
|
||||
|
||||
{/* <div className="header-controls">
|
||||
{currentPlayingId && (
|
||||
<button
|
||||
className="audio-control-button"
|
||||
onClick={stopAllAudio}
|
||||
title="停止播放"
|
||||
>
|
||||
⏹️
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="clear-button"
|
||||
onClick={() => {
|
||||
stopAllAudio();
|
||||
clearMessages();
|
||||
}}
|
||||
disabled={isRecording}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
{tabValue == "translate" ? <TranslateItem /> : <ArchivesItem />}
|
||||
{/* 消息列表 */}
|
||||
<div className="messages-container">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="message-wrapper">
|
||||
<div
|
||||
className={`message ${
|
||||
message.sender === "user" ? "own" : "other"
|
||||
}`}
|
||||
>
|
||||
{message.sender === "pet" && (
|
||||
<div className="avatar">{currentPet.avatar}</div>
|
||||
)}
|
||||
|
||||
<div className="message-content">
|
||||
{message.type === "voice" ? (
|
||||
<VoiceMessageComponent
|
||||
message={message}
|
||||
isOwn={message.sender === "user"}
|
||||
onTranslate={() => translateVoice(message)}
|
||||
onRetryUpload={() => handleRetryUpload(message.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-message">{message.content}</div>
|
||||
)}
|
||||
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.sender === "user" && (
|
||||
<div className="avatar user-avatar">👤</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<VoiceRecordButton
|
||||
onRecordComplete={handleRecordComplete}
|
||||
onError={handleRecordError}
|
||||
maxDuration={60}
|
||||
// uploadConfig={uploadConfig}
|
||||
// autoUpload={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PetTranslatorChat;
|
||||
54
src/component/audioRecorder/recordingStatusBar.tsx
Normal file
54
src/component/audioRecorder/recordingStatusBar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// components/RecordingStatusBar.tsx
|
||||
import React from "react";
|
||||
|
||||
interface RecordingStatusBarProps {
|
||||
isRecording: boolean;
|
||||
isPaused: boolean;
|
||||
duration: number;
|
||||
maxDuration: number;
|
||||
}
|
||||
|
||||
const RecordingStatusBar: React.FC<RecordingStatusBarProps> = ({
|
||||
isRecording,
|
||||
isPaused,
|
||||
duration,
|
||||
maxDuration,
|
||||
}) => {
|
||||
if (!isRecording) return null;
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getStatusColor = (): string => {
|
||||
if (isPaused) return "#ffc107";
|
||||
if (duration >= maxDuration * 0.9) return "#dc3545";
|
||||
return "#28a745";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recording-status-bar"
|
||||
style={{ backgroundColor: getStatusColor() }}
|
||||
>
|
||||
<div className="status-content">
|
||||
<div className="status-indicator">
|
||||
<div className={`status-dot ${isPaused ? "paused" : ""}`}></div>
|
||||
<span className="status-text">
|
||||
{isPaused ? "录音暂停中" : "正在录音"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="status-time">
|
||||
{formatTime(duration)} / {formatTime(maxDuration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordingStatusBar;
|
||||
11
src/component/audioRecorder/translateItem.tsx
Normal file
11
src/component/audioRecorder/translateItem.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
function Index() {
|
||||
return <>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</>;
|
||||
}
|
||||
|
||||
export default Index;
|
||||
322
src/component/audioRecorder/voiceMessage.tsx
Normal file
322
src/component/audioRecorder/voiceMessage.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
// 消息列表
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { VoiceMessage as VoiceMessageType } from "@/types/chat";
|
||||
import { UniversalAudioPlayer } from "@/utils/audioPlayer";
|
||||
import AudioManager from "@/utils/audioManager";
|
||||
|
||||
interface VoiceMessageProps {
|
||||
message: VoiceMessageType;
|
||||
isOwn: boolean;
|
||||
onTranslate?: () => void;
|
||||
onRetryUpload?: () => void;
|
||||
}
|
||||
|
||||
const VoiceMessage: React.FC<VoiceMessageProps> = ({
|
||||
message,
|
||||
isOwn,
|
||||
onTranslate,
|
||||
onRetryUpload,
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [playError, setPlayError] = useState<string | null>(null);
|
||||
|
||||
const playerRef = useRef<UniversalAudioPlayer | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const audioManager = AudioManager.getInstance();
|
||||
|
||||
// 使用消息ID作为音频实例的唯一标识
|
||||
const audioId = `voice_${message.id}`;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 组件卸载时清理
|
||||
stopTimer();
|
||||
audioManager.unregisterAudio(audioId);
|
||||
};
|
||||
}, [audioId]);
|
||||
|
||||
const startTimer = () => {
|
||||
timerRef.current = setInterval(() => {
|
||||
if (audioManager.isPlaying(audioId)) {
|
||||
const current = audioManager.getCurrentTime(audioId);
|
||||
setCurrentTime(current);
|
||||
} else {
|
||||
// 播放结束
|
||||
stopTimer();
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAudio = async (): Promise<void> => {
|
||||
if (playerRef.current) {
|
||||
return; // 已经加载过了
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setPlayError(null);
|
||||
|
||||
try {
|
||||
const player = new UniversalAudioPlayer();
|
||||
|
||||
// 优先使用blob,其次使用URL
|
||||
let audioBlob: Blob | null = null;
|
||||
|
||||
if (message.content.blob) {
|
||||
audioBlob = message.content.blob;
|
||||
} else if (message.content.url || message.content.serverUrl) {
|
||||
// 从URL获取blob
|
||||
const audioUrl = message.content.serverUrl || message.content.url;
|
||||
const response = await fetch(audioUrl!);
|
||||
audioBlob = await response.blob();
|
||||
}
|
||||
|
||||
if (!audioBlob) {
|
||||
throw new Error("无法获取音频数据");
|
||||
}
|
||||
|
||||
await player.loadAudio(audioBlob);
|
||||
playerRef.current = player;
|
||||
|
||||
// 注册到全局音频管理器
|
||||
audioManager.registerAudio(audioId, player, {
|
||||
onPlay: () => {
|
||||
setIsPlaying(true);
|
||||
startTimer();
|
||||
},
|
||||
onPause: () => {
|
||||
setIsPlaying(false);
|
||||
stopTimer();
|
||||
},
|
||||
onStop: () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
stopTimer();
|
||||
},
|
||||
});
|
||||
|
||||
console.log("音频加载成功:", {
|
||||
id: audioId,
|
||||
size: audioBlob.size,
|
||||
type: audioBlob.type,
|
||||
duration: player.getDuration(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("音频加载失败:", error);
|
||||
setPlayError(error instanceof Error ? error.message : "音频加载失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = async () => {
|
||||
try {
|
||||
if (!playerRef.current) {
|
||||
await loadAudio();
|
||||
if (!playerRef.current) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
// 暂停当前音频
|
||||
audioManager.pauseAudio(audioId);
|
||||
} else {
|
||||
// 播放音频(会自动停止其他正在播放的音频)
|
||||
await audioManager.playAudio(audioId);
|
||||
setPlayError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("播放控制失败:", error);
|
||||
setPlayError(error instanceof Error ? error.message : "播放失败");
|
||||
setIsPlaying(false);
|
||||
stopTimer();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
return `${Math.floor(seconds)}''`;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const getWaveformBars = (): JSX.Element[] => {
|
||||
const bars = [];
|
||||
const barCount = Math.min(
|
||||
Math.max(Math.floor(message.content.duration), 3),
|
||||
20
|
||||
);
|
||||
const duration =
|
||||
audioManager.getDuration(audioId) || message.content.duration;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const height = Math.random() * 20 + 10;
|
||||
const isActive =
|
||||
isPlaying && duration > 0 && (currentTime / duration) * barCount > i;
|
||||
|
||||
bars.push(
|
||||
<div
|
||||
key={i}
|
||||
className={`waveform-bar ${isActive ? "active" : ""}`}
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return bars;
|
||||
};
|
||||
|
||||
const getUploadStatusIcon = () => {
|
||||
switch (message.content.uploadStatus) {
|
||||
case "uploading":
|
||||
return "📤";
|
||||
case "success":
|
||||
return "✅";
|
||||
case "error":
|
||||
return "❌";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlayButtonContent = () => {
|
||||
if (isLoading) return "⏳";
|
||||
if (playError) return "❌";
|
||||
if (isPlaying) return "⏸️";
|
||||
return "▶️";
|
||||
};
|
||||
|
||||
const isPlayDisabled =
|
||||
isLoading ||
|
||||
(!message.content.url &&
|
||||
!message.content.serverUrl &&
|
||||
!message.content.blob);
|
||||
|
||||
return (
|
||||
<div className={`voice-message ${isOwn ? "own" : "other"}`}>
|
||||
<div className="voice-content">
|
||||
<button
|
||||
className={`play-button ${isPlaying ? "playing" : ""} ${
|
||||
playError ? "error" : ""
|
||||
}`}
|
||||
onClick={togglePlay}
|
||||
disabled={isPlayDisabled}
|
||||
title={playError || (isLoading ? "加载中..." : "播放/暂停")}
|
||||
>
|
||||
{getPlayButtonContent()}
|
||||
</button>
|
||||
|
||||
<div className="waveform">{getWaveformBars()}</div>
|
||||
|
||||
<span className="duration">
|
||||
{formatTime(isPlaying ? currentTime : message.content.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 播放错误提示 */}
|
||||
{playError && (
|
||||
<div className="play-error">
|
||||
<span className="error-icon">⚠️</span>
|
||||
<span className="error-text">{playError}</span>
|
||||
<button
|
||||
className="retry-play-button"
|
||||
onClick={() => {
|
||||
setPlayError(null);
|
||||
audioManager.unregisterAudio(audioId);
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy();
|
||||
playerRef.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传状态显示 */}
|
||||
{isOwn && message.content.uploadStatus && (
|
||||
<div className="upload-status-section">
|
||||
<div
|
||||
className={`upload-status-indicator ${message.content.uploadStatus}`}
|
||||
>
|
||||
<span className="upload-icon">{getUploadStatusIcon()}</span>
|
||||
<span className="upload-text">
|
||||
{message.content.uploadStatus === "uploading" &&
|
||||
`上传中 ${message.content.uploadProgress || 0}%`}
|
||||
{message.content.uploadStatus === "success" && "已上传"}
|
||||
{message.content.uploadStatus === "error" && "上传失败"}
|
||||
</span>
|
||||
|
||||
{message.content.uploadStatus === "error" && onRetryUpload && (
|
||||
<button className="retry-upload-button" onClick={onRetryUpload}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
{message.content.uploadStatus === "success" && (
|
||||
<div className="file-info">
|
||||
{message.content.fileName && (
|
||||
<span className="file-name">📁 {message.content.fileName}</span>
|
||||
)}
|
||||
{message.content.blob && (
|
||||
<span className="file-size">
|
||||
📊 {formatFileSize(message.content.blob.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 翻译按钮和结果 */}
|
||||
{isOwn && (
|
||||
<div className="translation-section">
|
||||
{!message.translation && !message.translating && (
|
||||
<button className="translate-button" onClick={onTranslate}>
|
||||
🐾 翻译
|
||||
</button>
|
||||
)}
|
||||
|
||||
{message.translating && (
|
||||
<div className="translating">
|
||||
<div className="loading-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
正在翻译中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.translation && (
|
||||
<div className="translation-result">
|
||||
<div className="translation-icon">🗣️</div>
|
||||
<div className="translation-text">{message.translation}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceMessage;
|
||||
350
src/component/audioRecorder/voiceRecordButton.less
Normal file
350
src/component/audioRecorder/voiceRecordButton.less
Normal file
@@ -0,0 +1,350 @@
|
||||
/* VoiceRecordButton.css */
|
||||
.voice-input-container {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.voice-start-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.voice-start-button:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
|
||||
}
|
||||
|
||||
.voice-start-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.microphone-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 录音中的容器 */
|
||||
.voice-recording-container {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 录音状态 */
|
||||
.recording-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.recording-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recording-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #dc3545;
|
||||
border-radius: 50%;
|
||||
animation: pulse-recording 1s infinite;
|
||||
}
|
||||
|
||||
.recording-dot.paused {
|
||||
background: #ffc107;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-recording {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recording-time {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.max-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.recording-progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #e9ecef;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #007bff, #0056b3);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 波形动画 */
|
||||
.sound-wave-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sound-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 4px;
|
||||
background: #007bff;
|
||||
border-radius: 2px;
|
||||
animation: wave-animation 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.sound-wave.paused .wave-bar {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.wave-bar:nth-child(1) {
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
.wave-bar:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.wave-bar:nth-child(3) {
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
.wave-bar:nth-child(4) {
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
.wave-bar:nth-child(5) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
@keyframes wave-animation {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
height: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
20% {
|
||||
height: 32px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 控制按钮 */
|
||||
.recording-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.control-button:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.control-button:not(:disabled):active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #f8f9fa;
|
||||
color: #dc3545;
|
||||
border: 2px solid #dc3545;
|
||||
}
|
||||
|
||||
.cancel-button:not(:disabled):hover {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pause-button {
|
||||
background: #f8f9fa;
|
||||
color: #ffc107;
|
||||
border: 2px solid #ffc107;
|
||||
}
|
||||
|
||||
.pause-button:not(:disabled):hover {
|
||||
background: #ffc107;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: 2px solid #28a745;
|
||||
}
|
||||
|
||||
.send-button:not(:disabled):hover {
|
||||
background: #218838;
|
||||
border-color: #218838;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.button-label {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.recording-hint {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.warning-hint {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.normal-hint {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.voice-recording-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 44px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.recording-time {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.voice-recording-container {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.voice-recording-container {
|
||||
background: #2c2c2e;
|
||||
border-top-color: #3a3a3c;
|
||||
}
|
||||
|
||||
.recording-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.recording-progress {
|
||||
background: #3a3a3c;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
background: #0a84ff;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: #3a3a3c;
|
||||
}
|
||||
|
||||
.recording-hint {
|
||||
color: #8e8e93;
|
||||
}
|
||||
}
|
||||
248
src/component/audioRecorder/voiceRecordButton.tsx
Normal file
248
src/component/audioRecorder/voiceRecordButton.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
// components/VoiceRecordButton.tsx (更新)
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
|
||||
import { useFileUpload } from "@/hooks/useFileUpload";
|
||||
import { UploadConfig } from "@/types/upload";
|
||||
import "./index.less";
|
||||
interface VoiceRecordButtonProps {
|
||||
onRecordComplete: (
|
||||
audioBlob: Blob,
|
||||
duration: number,
|
||||
uploadResponse?: any
|
||||
) => void;
|
||||
onError?: (error: Error) => void;
|
||||
maxDuration?: number;
|
||||
uploadConfig?: UploadConfig;
|
||||
autoUpload?: boolean;
|
||||
}
|
||||
|
||||
const VoiceRecordButton: React.FC<VoiceRecordButtonProps> = ({
|
||||
onRecordComplete,
|
||||
onError,
|
||||
maxDuration = 60,
|
||||
uploadConfig,
|
||||
autoUpload = true,
|
||||
}) => {
|
||||
const {
|
||||
isRecording,
|
||||
recordingTime,
|
||||
isPaused,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
pauseRecording,
|
||||
resumeRecording,
|
||||
cancelRecording,
|
||||
} = useVoiceRecorder();
|
||||
|
||||
const { uploadStatus, uploadFile, resetUpload } = useFileUpload();
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// 自动停止录音当达到最大时长
|
||||
useEffect(() => {
|
||||
if (recordingTime >= maxDuration && isRecording) {
|
||||
handleSendRecording();
|
||||
}
|
||||
}, [recordingTime, maxDuration, isRecording]);
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
try {
|
||||
resetUpload();
|
||||
await startRecording();
|
||||
setShowControls(true);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendRecording = async () => {
|
||||
if (recordingTime < 1) {
|
||||
onError?.(new Error("录音时间太短,至少需要1秒"));
|
||||
cancelRecording();
|
||||
setShowControls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const audioBlob = await stopRecording();
|
||||
if (!audioBlob) {
|
||||
throw new Error("录音数据获取失败");
|
||||
}
|
||||
|
||||
let uploadResponse;
|
||||
|
||||
// 如果配置了上传且启用自动上传
|
||||
if (uploadConfig && autoUpload) {
|
||||
const fileName = `voice_${Date.now()}.wav`;
|
||||
uploadResponse = await uploadFile(audioBlob, fileName, uploadConfig);
|
||||
}
|
||||
|
||||
onRecordComplete(audioBlob, recordingTime, uploadResponse);
|
||||
setShowControls(false);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRecording = () => {
|
||||
cancelRecording();
|
||||
resetUpload();
|
||||
setShowControls(false);
|
||||
};
|
||||
|
||||
const handlePauseResume = () => {
|
||||
if (isPaused) {
|
||||
resumeRecording();
|
||||
} else {
|
||||
pauseRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getProgressPercentage = (): number => {
|
||||
return Math.min((recordingTime / maxDuration) * 100, 100);
|
||||
};
|
||||
|
||||
const isUploading = uploadStatus.status === "uploading";
|
||||
const uploadProgress = uploadStatus.progress?.percentage || 0;
|
||||
|
||||
if (!showControls) {
|
||||
return (
|
||||
<div className="voice-input-container">
|
||||
<button
|
||||
className="voice-start-button"
|
||||
onClick={handleStartRecording}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<span className="microphone-icon">🎤</span>
|
||||
<span className="button-text">
|
||||
{isProcessing ? "处理中..." : "点击录音"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-recording-container">
|
||||
{/* 录音状态指示器 */}
|
||||
<div className="recording-status">
|
||||
<div className="recording-indicator">
|
||||
<div className={`recording-dot ${isPaused ? "paused" : ""}`}></div>
|
||||
<span className="recording-text">
|
||||
{isProcessing
|
||||
? "处理中..."
|
||||
: isPaused
|
||||
? "录音已暂停"
|
||||
: "正在录音..."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="recording-time">
|
||||
{formatTime(recordingTime)}
|
||||
<span className="max-time">/{formatTime(maxDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="recording-progress">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* 上传进度 */}
|
||||
{isUploading && (
|
||||
<div className="upload-progress">
|
||||
<div className="upload-status">
|
||||
<span className="upload-icon">📤</span>
|
||||
<span className="upload-text">上传中... {uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="upload-progress-bar">
|
||||
<div
|
||||
className="upload-progress-fill"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 波形动画 */}
|
||||
<div className="sound-wave-container">
|
||||
<div
|
||||
className={`sound-wave ${isPaused || isProcessing ? "paused" : ""}`}
|
||||
>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="wave-bar"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="recording-controls">
|
||||
<button
|
||||
className="control-button cancel-button"
|
||||
onClick={handleCancelRecording}
|
||||
disabled={isProcessing}
|
||||
title="取消录音"
|
||||
>
|
||||
<span className="button-icon">🗑️</span>
|
||||
<span className="button-label">取消</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="control-button pause-button"
|
||||
onClick={handlePauseResume}
|
||||
disabled={isProcessing}
|
||||
title={isPaused ? "继续录音" : "暂停录音"}
|
||||
>
|
||||
<span className="button-icon">{isPaused ? "▶️" : "⏸️"}</span>
|
||||
<span className="button-label">{isPaused ? "继续" : "暂停"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="control-button send-button"
|
||||
onClick={handleSendRecording}
|
||||
disabled={recordingTime < 1 || isProcessing}
|
||||
title="发送录音"
|
||||
>
|
||||
<span className="button-icon">{isProcessing ? "⏳" : "📤"}</span>
|
||||
<span className="button-label">
|
||||
{isProcessing ? "处理中" : "发送"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
{/* <div className="recording-hint">
|
||||
{isProcessing ? (
|
||||
<span className="processing-hint">正在处理录音...</span>
|
||||
) : isUploading ? (
|
||||
<span className="upload-hint">正在上传到服务器...</span>
|
||||
) : recordingTime < 1 ? (
|
||||
<span className="warning-hint">录音时间至少需要1秒</span>
|
||||
) : (
|
||||
<span className="normal-hint">点击发送按钮完成录音</span>
|
||||
)}
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceRecordButton;
|
||||
Reference in New Issue
Block a user