This commit is contained in:
2025-09-03 15:06:16 +08:00
commit 39fff8c63c
96 changed files with 13215 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
function Index() {
return <></>;
}
export default Index;

View 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>使ChromeSafari或Firefox最新版本</p>
</div>
)}
</div>
)}
</div>
);
};
export default DeviceCompatibility;

View 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;

View 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;
}
}

View 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;

View 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;

View File

@@ -0,0 +1,11 @@
function Index() {
return <>
</>;
}
export default Index;

View 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;

View 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;
}
}

View 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;