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;
|
||||
7
src/component/carousel/index.less
Normal file
7
src/component/carousel/index.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.carousel {
|
||||
.carousel-item {
|
||||
.carousel-image {
|
||||
border-radius: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/component/carousel/index.tsx
Normal file
49
src/component/carousel/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import Slider, {Settings} from 'react-slick';
|
||||
import 'slick-carousel/slick/slick.css';
|
||||
import 'slick-carousel/slick/slick-theme.css';
|
||||
import './index.less';
|
||||
|
||||
export interface CarouselComponentProps {
|
||||
images: string[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮播图组件
|
||||
* @param images 图片地址数组
|
||||
* @param height 图片高度
|
||||
* @constructor Carousel
|
||||
*/
|
||||
const Carousel: React.FC<CarouselComponentProps> = ({images, height = 180}) => {
|
||||
const settings: Settings = {
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 3000,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 2000,
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768,
|
||||
settings: {
|
||||
arrows: false,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<Slider {...settings} className="carousel">
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="carousel-item">
|
||||
<img className="carousel-image" src={image} alt={`Slide ${index}`}
|
||||
style={{width: '100%', height: `${height}px`}}/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
||||
42
src/component/floatingMenu/index.less
Normal file
42
src/component/floatingMenu/index.less
Normal file
@@ -0,0 +1,42 @@
|
||||
/* FloatingFanMenu.css */
|
||||
|
||||
@keyframes menuItemPop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-180deg);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1) rotate(-10deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.menu-item:hover {
|
||||
transform: scale(1.1) !important;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.adm-floating-bubble-button {
|
||||
z-index: 999;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
overflow: visible;
|
||||
.cat {
|
||||
position: absolute;
|
||||
width: 70px;
|
||||
font-size: 12px;
|
||||
|
||||
bottom: -10px;
|
||||
background: rgba(255, 204, 199, 1);
|
||||
padding: 4px 0px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
196
src/component/floatingMenu/index.tsx
Normal file
196
src/component/floatingMenu/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { FloatingBubble, Image } from "antd-mobile";
|
||||
import {
|
||||
AddOutline,
|
||||
MessageOutline,
|
||||
UserOutline,
|
||||
SetOutline,
|
||||
HeartOutline,
|
||||
CheckOutline,
|
||||
} from "antd-mobile-icons";
|
||||
import { createPortal } from "react-dom";
|
||||
import dogSvg from "@/assets/translate/dog.svg";
|
||||
import catSvg from "@/assets/translate/cat.svg";
|
||||
import pigSvg from "@/assets/translate/pig.svg";
|
||||
import "./index.less";
|
||||
import { MoreTwo } from "@icon-park/react";
|
||||
|
||||
export interface FloatMenuItemConfig {
|
||||
icon: React.ReactNode;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const FloatingFanMenu: React.FC<{
|
||||
menuItems: FloatMenuItemConfig[];
|
||||
value?: FloatMenuItemConfig;
|
||||
onChange?: (item: FloatMenuItemConfig) => void;
|
||||
}> = (props) => {
|
||||
const { menuItems = [] } = props;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击时获取FloatingBubble的位置
|
||||
const handleMainClick = () => {
|
||||
if (!visible) {
|
||||
// 显示菜单时,获取当前FloatingBubble的位置
|
||||
if (bubbleRef.current) {
|
||||
const bubble = bubbleRef.current.querySelector(
|
||||
".adm-floating-bubble-button"
|
||||
);
|
||||
if (bubble) {
|
||||
const rect = bubble.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
setVisible(!visible);
|
||||
};
|
||||
|
||||
const handleItemClick = (item: FloatMenuItemConfig) => {
|
||||
props.onChange?.(item);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
// 计算菜单项位置
|
||||
const getMenuItemPosition = (index: number) => {
|
||||
const positions = [
|
||||
{ x: 0, y: -80 }, // 上方
|
||||
{ x: -60, y: -60 }, // 左上
|
||||
{ x: -80, y: 0 }, // 左方
|
||||
{ x: -60, y: 60 }, // 左下
|
||||
];
|
||||
|
||||
const pos = positions[index] || { x: 0, y: 0 };
|
||||
let x = menuPosition.x + pos.x;
|
||||
let y = menuPosition.y + pos.y;
|
||||
|
||||
// // 边界检测
|
||||
// const itemSize = 48;
|
||||
// const margin = 20;
|
||||
// 边界检测
|
||||
const itemSize = 48;
|
||||
// const margin = 0;
|
||||
|
||||
// x = Math.max(
|
||||
// // margin + itemSize / 2,
|
||||
// Math.min(window.innerWidth - margin - itemSize / 2, x)
|
||||
// );
|
||||
// y = Math.max(
|
||||
// // margin + itemSize / 2,
|
||||
// Math.min(window.innerHeight - margin - itemSize / 2, y)
|
||||
// );
|
||||
|
||||
return {
|
||||
left: x - itemSize / 2,
|
||||
top: y - itemSize / 2,
|
||||
};
|
||||
};
|
||||
|
||||
// 菜单组件
|
||||
const MenuComponent = () => (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onClick={() => setVisible(false)}
|
||||
/>
|
||||
|
||||
{/* 菜单项 */}
|
||||
{menuItems.map((item, index) => {
|
||||
const position = getMenuItemPosition(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: "fixed",
|
||||
...position,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: 20,
|
||||
cursor: "pointer",
|
||||
zIndex: 9999,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
animation: `menuItemPop 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
opacity: 0,
|
||||
transform: "scale(0)",
|
||||
}}
|
||||
onClick={() => handleItemClick(item)}
|
||||
// title={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 主按钮 */}
|
||||
<div ref={bubbleRef}>
|
||||
<FloatingBubble
|
||||
style={{
|
||||
"--initial-position-bottom": "200px",
|
||||
"--initial-position-right": "24px",
|
||||
"--edge-distance": "24px",
|
||||
}}
|
||||
onClick={handleMainClick}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 24,
|
||||
color: "white",
|
||||
background: visible
|
||||
? "linear-gradient(135deg, #ff4d4f, #ff7875)"
|
||||
: "linear-gradient(135deg, #fff, #fff)",
|
||||
borderRadius: "50%",
|
||||
transition: "all 0.3s ease",
|
||||
transform: visible ? "rotate(45deg)" : "rotate(0deg)",
|
||||
boxShadow: visible
|
||||
? "0 6px 16px rgba(255, 77, 79, 0.4)"
|
||||
: "0 4px 12px #eee",
|
||||
}}
|
||||
>
|
||||
{props.value?.icon}
|
||||
<div className="cat">
|
||||
{/* {!visible && <CheckOutline style={{ marginRight: "3px" }} />} */}
|
||||
切换语言
|
||||
</div>
|
||||
</div>
|
||||
</FloatingBubble>
|
||||
</div>
|
||||
|
||||
{/* 菜单 - 只在有位置信息时渲染 */}
|
||||
{visible &&
|
||||
menuPosition.x > 0 &&
|
||||
menuPosition.y > 0 &&
|
||||
createPortal(<MenuComponent />, document.body)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingFanMenu;
|
||||
662
src/component/petVoiceTranslator copy/index.less
Normal file
662
src/component/petVoiceTranslator copy/index.less
Normal file
@@ -0,0 +1,662 @@
|
||||
// PetVoiceTranslator.less
|
||||
.pet-voice-translator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #ededed;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial,
|
||||
sans-serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
// 处理iOS安全区域
|
||||
padding-top: var(--safe-area-inset-top, 0);
|
||||
padding-bottom: var(--safe-area-inset-bottom, 0);
|
||||
|
||||
// Safari 特殊处理
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f7f7f7;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
// 确保头部不被NavBar遮挡
|
||||
min-height: 64px;
|
||||
|
||||
.pet-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
flex: 1;
|
||||
|
||||
.pet-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 12px;
|
||||
position: relative;
|
||||
|
||||
// Safari 滚动优化
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
|
||||
// 确保可以滚动
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
min-height: 200px;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 16px;
|
||||
animation: messageSlideIn 0.3s ease-out;
|
||||
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
max-width: calc(100% - 80px);
|
||||
|
||||
.voice-bubble {
|
||||
background: #95ec69;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
// 防止长按选择
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
// 优化点击响应
|
||||
touch-action: manipulation;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 8px solid #95ec69;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #8de354;
|
||||
transform: scale(0.98);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.voice-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
|
||||
.playing-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: #333;
|
||||
border-radius: 1px;
|
||||
animation: voiceWave 1s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.translation-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
|
||||
&.translating {
|
||||
.translating-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.loading-dots {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
content: "...";
|
||||
animation: loadingDots 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #999;
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.translation-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.translation-content {
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
background: #f7f7f7;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding: 12px 16px 20px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
// 确保控制区域不被底部安全区域影响
|
||||
min-height: 100px;
|
||||
|
||||
// 防止在Safari中被下拉刷新影响
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.recorder-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-area {
|
||||
.recording-info {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.recording-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.recording-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
animation: recordingPulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.cancel-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
|
||||
// 优化点击响应
|
||||
touch-action: manipulation;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background: #ff7875;
|
||||
}
|
||||
|
||||
.cancel-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
border: none;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// 优化点击响应
|
||||
touch-action: manipulation;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d9d9d9;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.processing {
|
||||
background: #faad14;
|
||||
|
||||
cursor: wait;
|
||||
|
||||
.processing-animation {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 3 px;
|
||||
|
||||
span {
|
||||
width: 3 px;
|
||||
|
||||
height: 16 px;
|
||||
|
||||
background: white;
|
||||
|
||||
border-radius: 1 px;
|
||||
|
||||
animation: processingWave 1.5 s infinite ease - in - out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2 s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4 s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.recording {
|
||||
background: #ff4d4f;
|
||||
animation: recordingButtonPulse 1s infinite;
|
||||
|
||||
.recording-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 1px;
|
||||
animation: recordingWave 1s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.record-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局样式,确保页面不会滚动
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
// 防止iOS Safari的橡皮筋效果
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 动画定义
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes voiceWave {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingDots {
|
||||
0%,
|
||||
20% {
|
||||
content: ".";
|
||||
}
|
||||
40% {
|
||||
content: "..";
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
content: "...";
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingPulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingButtonPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(255, 77, 79, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingWave {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
// @media (max-width: 768px) {
|
||||
// .pet-voice-translator {
|
||||
// .messages-container {
|
||||
// padding: 12px 8px;
|
||||
|
||||
// .message-wrapper .message-item .message-content {
|
||||
// max-width: calc(100% - 60px);
|
||||
// }
|
||||
// }
|
||||
|
||||
// .recording-controls {
|
||||
// padding: 10px 12px 16px;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// iOS Safari 特殊处理
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.pet-voice-translator {
|
||||
// 确保在iOS Safari中正确显示
|
||||
// height: 100vh;
|
||||
height: -webkit-fill-available;
|
||||
|
||||
.messages-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pet-voice-translator {
|
||||
background: #1e1e1e;
|
||||
|
||||
.chat-header {
|
||||
background: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
|
||||
.chat-title .pet-name {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
.message-wrapper .message-item .message-content .translation-text {
|
||||
color: #ccc;
|
||||
|
||||
.translation-content {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
background: #2a2a2a;
|
||||
border-top-color: #3a3a3a;
|
||||
}
|
||||
}
|
||||
}
|
||||
916
src/component/petVoiceTranslator copy/index.tsx
Normal file
916
src/component/petVoiceTranslator copy/index.tsx
Normal file
@@ -0,0 +1,916 @@
|
||||
// PetVoiceTranslator.tsx
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import { Button, Toast, Dialog } from "antd-mobile";
|
||||
import { SoundOutline, CloseOutline } from "antd-mobile-icons";
|
||||
import "./index.less";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
type: "voice";
|
||||
audioUrl: string;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
translatedText?: string;
|
||||
isTranslating?: boolean;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
const PetVoiceTranslator: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||
const [currentPlayingId, setCurrentPlayingId] = useState<string | null>(null);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [permissionChecking, setPermissionChecking] = useState(false);
|
||||
const [dialogShowing, setDialogShowing] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
|
||||
const recordingTimerRef = useRef<NodeJS.Timeout>();
|
||||
const isRecordingRef = useRef(false);
|
||||
const processingTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const permissionCheckTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// 新增:防止重复初始化的标志
|
||||
const initializingRef = useRef(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const recorderControls = useAudioRecorder(
|
||||
{
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
(err) => {
|
||||
console.error("录音错误:", err);
|
||||
Toast.show("录音失败,请重试");
|
||||
resetRecordingState();
|
||||
}
|
||||
);
|
||||
|
||||
// 使用useEffect的依赖数组为空,确保只执行一次
|
||||
useEffect(() => {
|
||||
console.log("useEffect执行,初始化状态:", {
|
||||
initializing: initializingRef.current,
|
||||
initialized: initializedRef.current,
|
||||
});
|
||||
|
||||
// 防止重复初始化
|
||||
if (initializingRef.current || initializedRef.current) {
|
||||
console.log("已经初始化或正在初始化,跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
initializeApp();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
console.log("组件卸载,执行清理");
|
||||
cleanup();
|
||||
};
|
||||
}, []); // 空依赖数组确保只执行一次
|
||||
|
||||
useEffect(() => {
|
||||
isRecordingRef.current = isRecording;
|
||||
}, [isRecording]);
|
||||
|
||||
// 优化的初始化函数
|
||||
const initializeApp = useCallback(async () => {
|
||||
// 防止重复执行
|
||||
if (initializingRef.current || initializedRef.current) {
|
||||
console.log("初始化已执行或正在执行,跳过重复调用");
|
||||
return;
|
||||
}
|
||||
|
||||
initializingRef.current = true;
|
||||
console.log("=== 开始初始化应用 ===");
|
||||
|
||||
try {
|
||||
console.log("1. 检查麦克风权限...");
|
||||
await checkMicrophonePermission();
|
||||
|
||||
// console.log("2. 设置Safari兼容性...");
|
||||
// setupSafariCompatibility();
|
||||
|
||||
console.log("3. 标记初始化完成...");
|
||||
setIsInitialized(true);
|
||||
initializedRef.current = true;
|
||||
|
||||
console.log("=== 应用初始化完成 ===");
|
||||
} catch (error) {
|
||||
console.error("应用初始化失败:", error);
|
||||
Toast.show("应用初始化失败");
|
||||
// 即使失败也标记为已初始化,避免无限重试
|
||||
setIsInitialized(true);
|
||||
initializedRef.current = true;
|
||||
} finally {
|
||||
initializingRef.current = false;
|
||||
}
|
||||
}, []); // 空依赖数组,函数只创建一次
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
console.log("执行清理操作...");
|
||||
|
||||
// 重置初始化标志
|
||||
initializingRef.current = false;
|
||||
initializedRef.current = false;
|
||||
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
}
|
||||
if (processingTimeoutRef.current) {
|
||||
clearTimeout(processingTimeoutRef.current);
|
||||
}
|
||||
if (permissionCheckTimeoutRef.current) {
|
||||
clearTimeout(permissionCheckTimeoutRef.current);
|
||||
}
|
||||
|
||||
Object.values(audioRefs.current).forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = "";
|
||||
});
|
||||
|
||||
cleanupSafariCompatibility();
|
||||
}, []);
|
||||
|
||||
const resetRecordingState = useCallback(() => {
|
||||
console.log("重置录音状态");
|
||||
setIsRecording(false);
|
||||
setIsProcessing(false);
|
||||
setRecordingDuration(0);
|
||||
// isRecordingRef.current = false;
|
||||
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = undefined;
|
||||
}
|
||||
|
||||
if (processingTimeoutRef.current) {
|
||||
clearTimeout(processingTimeoutRef.current);
|
||||
processingTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 优化的权限检查函数
|
||||
const checkMicrophonePermission = useCallback(
|
||||
async (showToast = false) => {
|
||||
console.log("检查麦克风权限...", {
|
||||
permissionChecking,
|
||||
dialogShowing,
|
||||
hasPermission,
|
||||
});
|
||||
|
||||
// 防止重复检查
|
||||
if (permissionChecking) {
|
||||
console.log("权限检查正在进行中,跳过");
|
||||
return hasPermission;
|
||||
}
|
||||
|
||||
setPermissionChecking(true);
|
||||
|
||||
try {
|
||||
// 检查浏览器支持
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
console.error("浏览器不支持录音功能");
|
||||
setHasPermission(false);
|
||||
if (showToast) {
|
||||
Toast.show("浏览器不支持录音功能");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 先检查权限状态(如果浏览器支持)
|
||||
if (navigator.permissions && navigator.permissions.query) {
|
||||
try {
|
||||
const permissionStatus = await navigator.permissions.query({
|
||||
name: "microphone" as PermissionName,
|
||||
});
|
||||
|
||||
// alert(permissionStatus.state);
|
||||
// console.log("权限状态:", permissionStatus.state);
|
||||
|
||||
if (permissionStatus.state === "denied") {
|
||||
console.log("权限被拒绝");
|
||||
setHasPermission(false);
|
||||
|
||||
if (showToast && !dialogShowing) {
|
||||
showPermissionDialog();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permissionStatus.state === "granted") {
|
||||
console.log("权限已授予");
|
||||
setHasPermission(true);
|
||||
return true;
|
||||
}
|
||||
} catch (permError) {
|
||||
console.log("权限查询不支持,继续使用getUserMedia检查");
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取媒体流
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("麦克风权限获取成功");
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setHasPermission(true);
|
||||
|
||||
if (showToast) {
|
||||
Toast.show("麦克风权限已获取");
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("权限检查失败:", error);
|
||||
setHasPermission(false);
|
||||
showPermissionDialog();
|
||||
return false;
|
||||
} finally {
|
||||
setPermissionChecking(false);
|
||||
}
|
||||
},
|
||||
[permissionChecking, dialogShowing, hasPermission]
|
||||
);
|
||||
|
||||
const showPermissionDialog = useCallback(() => {
|
||||
if (dialogShowing) {
|
||||
console.log("弹窗已显示,跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("显示权限弹窗");
|
||||
setDialogShowing(true);
|
||||
|
||||
Dialog.confirm({
|
||||
title: "需要录音权限",
|
||||
content:
|
||||
'为了使用录音翻译功能,需要您授权麦克风权限。请在浏览器设置中允许访问麦克风,然后点击"重新获取权限"。',
|
||||
confirmText: "重新获取权限",
|
||||
cancelText: "取消",
|
||||
onConfirm: async () => {
|
||||
console.log("用户点击重新获取权限");
|
||||
setDialogShowing(false);
|
||||
|
||||
setTimeout(async () => {
|
||||
await checkMicrophonePermission(true);
|
||||
}, 500);
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log("用户取消权限获取");
|
||||
setDialogShowing(false);
|
||||
Toast.show("需要麦克风权限才能使用录音功能");
|
||||
},
|
||||
onClose: () => {
|
||||
console.log("弹窗关闭");
|
||||
setDialogShowing(false);
|
||||
},
|
||||
});
|
||||
}, [dialogShowing, checkMicrophonePermission]);
|
||||
|
||||
const setupSafariCompatibility = useCallback(() => {
|
||||
console.log("设置Safari兼容性...");
|
||||
|
||||
const root = document.documentElement;
|
||||
const isIPhoneX =
|
||||
/iPhone/.test(navigator.userAgent) && window.screen.height >= 812;
|
||||
|
||||
if (isIPhoneX) {
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-top",
|
||||
"env(safe-area-inset-top, 44px)"
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-bottom",
|
||||
"env(safe-area-inset-bottom, 34px)"
|
||||
);
|
||||
} else {
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-top",
|
||||
"env(safe-area-inset-top, 20px)"
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--safe-area-inset-bottom",
|
||||
"env(safe-area-inset-bottom, 0px)"
|
||||
);
|
||||
}
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.position = "fixed";
|
||||
document.body.style.width = "100%";
|
||||
document.body.style.height = "100%";
|
||||
document.body.style.top = "0";
|
||||
document.body.style.left = "0";
|
||||
|
||||
document.addEventListener("touchstart", preventPullToRefresh, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchmove", preventPullToRefresh, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", preventDoubleTapZoom, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
let viewport = document.querySelector("meta[name=viewport]");
|
||||
if (!viewport) {
|
||||
viewport = document.createElement("meta");
|
||||
viewport.setAttribute("name", "viewport");
|
||||
document.head.appendChild(viewport);
|
||||
}
|
||||
viewport.setAttribute(
|
||||
"content",
|
||||
"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
);
|
||||
}, []);
|
||||
|
||||
const cleanupSafariCompatibility = useCallback(() => {
|
||||
console.log("清理Safari兼容性设置...");
|
||||
|
||||
document.body.style.overflow = "";
|
||||
document.body.style.position = "";
|
||||
document.body.style.width = "";
|
||||
document.body.style.height = "";
|
||||
document.body.style.top = "";
|
||||
document.body.style.left = "";
|
||||
|
||||
document.removeEventListener("touchstart", preventPullToRefresh);
|
||||
document.removeEventListener("touchmove", preventPullToRefresh);
|
||||
document.removeEventListener("touchend", preventDoubleTapZoom);
|
||||
}, []);
|
||||
|
||||
const preventPullToRefresh = (e: TouchEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
|
||||
if (messagesContainer && messagesContainer.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.touches.length > 1) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = e.touches[0];
|
||||
const startY = touch.clientY;
|
||||
|
||||
if (startY < 100 && e.type === "touchstart") {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
let lastTouchEnd = 0;
|
||||
const preventDoubleTapZoom = (e: TouchEvent) => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
e.preventDefault();
|
||||
}
|
||||
lastTouchEnd = now;
|
||||
};
|
||||
|
||||
const stopAllAudio = () => {
|
||||
if (currentPlayingId && audioRefs.current[currentPlayingId]) {
|
||||
audioRefs.current[currentPlayingId].pause();
|
||||
audioRefs.current[currentPlayingId].currentTime = 0;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === currentPlayingId ? { ...msg, isPlaying: false } : msg
|
||||
)
|
||||
);
|
||||
setCurrentPlayingId(null);
|
||||
}
|
||||
|
||||
Object.values(audioRefs.current).forEach((audio) => {
|
||||
if (!audio.paused) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
console.log("=== 开始录音函数调用 ===");
|
||||
|
||||
if (isProcessing || isRecordingRef.current) {
|
||||
console.log("正在处理中或已在录音,忽略此次调用");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInitialized || !initializedRef.current) {
|
||||
console.log("应用未初始化完成");
|
||||
Toast.show("应用正在初始化,请稍后重试");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
console.log("没有录音权限,尝试获取权限");
|
||||
const granted = await checkMicrophonePermission(true);
|
||||
if (!granted) {
|
||||
console.log("权限获取失败");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setIsRecording(true);
|
||||
setRecordingDuration(0);
|
||||
|
||||
stopAllAudio();
|
||||
|
||||
await recorderControls.startRecording();
|
||||
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
Toast.show("开始录音...");
|
||||
|
||||
processingTimeoutRef.current = setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("开始录音失败:", error);
|
||||
resetRecordingState();
|
||||
|
||||
if (
|
||||
error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError"
|
||||
) {
|
||||
setHasPermission(false);
|
||||
if (!dialogShowing) {
|
||||
showPermissionDialog();
|
||||
}
|
||||
} else {
|
||||
Toast.show(`录音失败: ${error.message || "未知错误"}`);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isProcessing,
|
||||
hasPermission,
|
||||
isInitialized,
|
||||
recorderControls,
|
||||
checkMicrophonePermission,
|
||||
dialogShowing,
|
||||
showPermissionDialog,
|
||||
]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
console.log("=== 停止录音函数调用 ===");
|
||||
|
||||
if (isProcessing || !isRecordingRef.current) {
|
||||
console.log("正在处理中或未在录音,忽略此次调用");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setIsRecording(false);
|
||||
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = undefined;
|
||||
}
|
||||
|
||||
recorderControls.stopRecording();
|
||||
|
||||
processingTimeoutRef.current = setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("停止录音失败:", error);
|
||||
resetRecordingState();
|
||||
Toast.show("停止录音失败");
|
||||
}
|
||||
}, [isProcessing, recorderControls]);
|
||||
|
||||
const onRecordingComplete = useCallback(
|
||||
(blob: Blob) => {
|
||||
console.log("=== 录音完成回调 ===");
|
||||
|
||||
setIsProcessing(false);
|
||||
if (processingTimeoutRef.current) {
|
||||
clearTimeout(processingTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (recordingDuration < 1) {
|
||||
Toast.show("录音时间太短,请重新录音");
|
||||
setRecordingDuration(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const newMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
type: "voice",
|
||||
audioUrl,
|
||||
duration: recordingDuration,
|
||||
timestamp: Date.now(),
|
||||
isTranslating: true,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setRecordingDuration(0);
|
||||
|
||||
setTimeout(() => {
|
||||
translateAudio(newMessage.id, blob);
|
||||
}, 1000);
|
||||
|
||||
Toast.show("语音已发送");
|
||||
},
|
||||
[recordingDuration]
|
||||
);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
console.log("=== 取消录音 ===");
|
||||
|
||||
try {
|
||||
if (isRecordingRef.current) {
|
||||
recorderControls.stopRecording();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("取消录音失败:", error);
|
||||
}
|
||||
|
||||
resetRecordingState();
|
||||
Toast.show("已取消录音");
|
||||
}, [recorderControls, resetRecordingState]);
|
||||
|
||||
const handleRecordButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log("=== 录音按钮点击 ===");
|
||||
|
||||
if (isProcessing || permissionChecking) {
|
||||
console.log("正在处理中,忽略点击");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecordingRef.current) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
},
|
||||
[isProcessing, permissionChecking, startRecording, stopRecording]
|
||||
);
|
||||
|
||||
const translateAudio = async (messageId: string, audioBlob: Blob) => {
|
||||
try {
|
||||
const translatedText = await mockTranslateAudio(audioBlob);
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? { ...msg, translatedText, isTranslating: false }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("翻译失败:", error);
|
||||
Toast.show("翻译失败,请重试");
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
isTranslating: false,
|
||||
translatedText: "翻译失败,请重试",
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const mockTranslateAudio = async (audioBlob: Blob): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const mockTranslations = [
|
||||
"汪汪汪!我饿了,想吃东西!🍖",
|
||||
"喵喵~我想要抱抱!🤗",
|
||||
"我想出去玩耍!🎾",
|
||||
"我很开心!😊",
|
||||
"我有点害怕...😰",
|
||||
"我想睡觉了~😴",
|
||||
"主人,陪我玩一会儿吧!🎮",
|
||||
"我想喝水了💧",
|
||||
"外面有什么声音?👂",
|
||||
"我爱你,主人!❤️",
|
||||
];
|
||||
const randomTranslation =
|
||||
mockTranslations[Math.floor(Math.random() * mockTranslations.length)];
|
||||
resolve(randomTranslation);
|
||||
}, 2000 + Math.random() * 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const playAudio = (messageId: string, audioUrl: string) => {
|
||||
if (isRecording) {
|
||||
Toast.show("录音中,无法播放音频");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPlayingId === messageId) {
|
||||
if (audioRefs.current[messageId]) {
|
||||
audioRefs.current[messageId].pause();
|
||||
audioRefs.current[messageId].currentTime = 0;
|
||||
}
|
||||
setCurrentPlayingId(null);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, isPlaying: false } : msg
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
stopAllAudio();
|
||||
|
||||
if (!audioRefs.current[messageId]) {
|
||||
audioRefs.current[messageId] = new Audio(audioUrl);
|
||||
}
|
||||
|
||||
const audio = audioRefs.current[messageId];
|
||||
audio.currentTime = 0;
|
||||
|
||||
audio.onended = () => {
|
||||
setCurrentPlayingId(null);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, isPlaying: false } : msg
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
console.error("音频播放错误:", error);
|
||||
Toast.show("音频播放失败");
|
||||
setCurrentPlayingId(null);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, isPlaying: false } : msg
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
setCurrentPlayingId(messageId);
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? { ...msg, isPlaying: true }
|
||||
: { ...msg, isPlaying: false }
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("音频播放失败:", error);
|
||||
Toast.show("音频播放失败");
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pet-voice-translator">
|
||||
{/* 调试信息 */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="debug-info">
|
||||
<div>初始化中: {initializingRef.current ? "⏳" : "✅"}</div>
|
||||
<div>已初始化: {initializedRef.current ? "✅" : "❌"}</div>
|
||||
<div>状态初始化: {isInitialized ? "✅" : "❌"}</div>
|
||||
<div>
|
||||
权限: {hasPermission === null ? "⏳" : hasPermission ? "✅" : "❌"}
|
||||
</div>
|
||||
<div>录音: {isRecording ? "🔴" : "⚪"}</div>
|
||||
<div>处理中: {isProcessing ? "⏳" : "✅"}</div>
|
||||
<div>权限检查: {permissionChecking ? "⏳" : "✅"}</div>
|
||||
<div>弹窗: {dialogShowing ? "📱" : "❌"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="chat-header">
|
||||
<div className="pet-avatar">🐾</div>
|
||||
<div className="chat-title">
|
||||
<div className="pet-name">我的宠物</div>
|
||||
<div className="chat-subtitle">
|
||||
{initializingRef.current
|
||||
? "初始化中..."
|
||||
: !isInitialized
|
||||
? "等待初始化..."
|
||||
: permissionChecking
|
||||
? "检查权限中..."
|
||||
: hasPermission === null
|
||||
? "权限状态未知"
|
||||
: hasPermission
|
||||
? "宠物语音翻译器"
|
||||
: "需要麦克风权限"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="messages-container" ref={messagesContainerRef}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🎤</div>
|
||||
<div className="empty-text">
|
||||
{initializingRef.current
|
||||
? "正在初始化..."
|
||||
: !isInitialized
|
||||
? "等待初始化..."
|
||||
: permissionChecking
|
||||
? "正在检查权限..."
|
||||
: hasPermission === null
|
||||
? "权限状态未知"
|
||||
: !hasPermission
|
||||
? "请授权麦克风权限"
|
||||
: "点击下方按钮开始录音"}
|
||||
</div>
|
||||
<div className="empty-subtitle">听听你的宠物想说什么~</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className="message-wrapper">
|
||||
<div className="message-item">
|
||||
<div className="avatar">🐾</div>
|
||||
<div className="message-content">
|
||||
<div className="voice-bubble">
|
||||
<div
|
||||
className="voice-content"
|
||||
onClick={() => playAudio(message.id, message.audioUrl)}
|
||||
>
|
||||
<div className="voice-icon">
|
||||
{message.isPlaying ? (
|
||||
<div className="playing-animation">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
) : (
|
||||
<SoundOutline />
|
||||
)}
|
||||
</div>
|
||||
<div className="voice-duration">
|
||||
{formatDuration(message.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.isTranslating ? (
|
||||
<div className="translation-text translating">
|
||||
<div className="translating-content">
|
||||
<span className="loading-dots">翻译中</span>
|
||||
<div className="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : message.translatedText ? (
|
||||
<div className="translation-text">
|
||||
<div className="translation-label">🐾 宠物说:</div>
|
||||
<div className="translation-content">
|
||||
{message.translatedText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 录音控制区域 */}
|
||||
<div className="recording-controls">
|
||||
<div className="recorder-wrapper">
|
||||
<AudioRecorder
|
||||
onRecordingComplete={onRecordingComplete}
|
||||
recorderControls={recorderControls}
|
||||
audioTrackConstraints={{
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
}}
|
||||
showVisualizer={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="control-area">
|
||||
{isRecording && (
|
||||
<div className="recording-info">
|
||||
<div className="recording-indicator">
|
||||
<div className="recording-dot"></div>
|
||||
<span>录音中 {formatDuration(recordingDuration)}</span>
|
||||
</div>
|
||||
<div className="recording-tip">再次点击完成录音并发送</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-area">
|
||||
<div className="button-group">
|
||||
{/* {isRecording && (
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={cancelRecording}
|
||||
type="button"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<CloseOutline className="cancel-icon" />
|
||||
</button>
|
||||
)} */}
|
||||
|
||||
<button
|
||||
className={`record-button ${isRecording ? "recording" : ""} ${
|
||||
isProcessing ? "processing" : ""
|
||||
}`}
|
||||
onClick={handleRecordButtonClick}
|
||||
disabled={
|
||||
permissionChecking
|
||||
// isProcessing
|
||||
// !hasPermission ||
|
||||
// !isInitialized ||
|
||||
// !initializedRef.current ||
|
||||
// isProcessing ||
|
||||
// permissionChecking ||
|
||||
// initializingRef.current
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isProcessing || permissionChecking ? (
|
||||
<div className="processing-animation">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
) : isRecording ? (
|
||||
<div className="recording-animation">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
) : (
|
||||
<SoundOutline className="mic-icon" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isRecording && (
|
||||
<div className="record-hint">
|
||||
{initializingRef.current
|
||||
? "初始化中..."
|
||||
: !isInitialized
|
||||
? "等待初始化..."
|
||||
: permissionChecking
|
||||
? "检查权限中..."
|
||||
: !hasPermission
|
||||
? "需要麦克风权限"
|
||||
: isProcessing
|
||||
? "处理中..."
|
||||
: "点击录音"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PetVoiceTranslator;
|
||||
660
src/component/petVoiceTranslator/index.less
Normal file
660
src/component/petVoiceTranslator/index.less
Normal file
@@ -0,0 +1,660 @@
|
||||
// PetVoiceTranslator.less
|
||||
.pet-voice-translator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #ededed;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial,
|
||||
sans-serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
// 处理iOS安全区域
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
// Safari 特殊处理
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f7f7f7;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
// 确保头部不被NavBar遮挡
|
||||
min-height: 64px;
|
||||
|
||||
.pet-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
flex: 1;
|
||||
|
||||
.pet-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-subtitle {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 12px;
|
||||
position: relative;
|
||||
|
||||
// Safari 滚动优化
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
|
||||
// 确保可以滚动
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
min-height: 200px;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 16px;
|
||||
animation: messageSlideIn 0.3s ease-out;
|
||||
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(45deg, #ff9a9e, #fecfef);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
max-width: calc(100% - 80px);
|
||||
|
||||
.voice-bubble {
|
||||
background: #95ec69;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
// 防止长按选择
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
// 优化点击响应
|
||||
touch-action: manipulation;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 8px solid #95ec69;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #8de354;
|
||||
transform: scale(0.98);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.voice-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
|
||||
.playing-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: #333;
|
||||
border-radius: 1px;
|
||||
animation: voiceWave 1s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.translation-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-left: 4px;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
|
||||
&.translating {
|
||||
.translating-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.loading-dots {
|
||||
margin-right: 8px;
|
||||
|
||||
&::after {
|
||||
content: "...";
|
||||
animation: loadingDots 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #999;
|
||||
border-radius: 50%;
|
||||
animation: typingBounce 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.translation-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.translation-content {
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
background: #f7f7f7;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding: 12px 16px 20px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
// 确保控制区域不被底部安全区域影响
|
||||
min-height: 100px;
|
||||
|
||||
// 防止在Safari中被下拉刷新影响
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
.recorder-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-area {
|
||||
.recording-info {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.recording-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.recording-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
animation: recordingPulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.recording-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.cancel-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
|
||||
// 优化点击响应
|
||||
touch-action: manipulation;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background: #ff7875;
|
||||
}
|
||||
|
||||
.cancel-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
border: none;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
// 优化点击响应
|
||||
touch-action: manipulation;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d9d9d9;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.processing {
|
||||
background: #faad14;
|
||||
|
||||
cursor: wait;
|
||||
|
||||
.processing-animation {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 3 px;
|
||||
|
||||
span {
|
||||
width: 3 px;
|
||||
|
||||
height: 16 px;
|
||||
|
||||
background: white;
|
||||
|
||||
border-radius: 1 px;
|
||||
|
||||
animation: processingWave 1.5 s infinite ease - in - out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2 s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4 s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.recording {
|
||||
background: #ff4d4f;
|
||||
animation: recordingButtonPulse 1s infinite;
|
||||
|
||||
.recording-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-radius: 1px;
|
||||
animation: recordingWave 1s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.record-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局样式,确保页面不会滚动
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
// 防止iOS Safari的橡皮筋效果
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 动画定义
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes voiceWave {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loadingDots {
|
||||
0%,
|
||||
20% {
|
||||
content: ".";
|
||||
}
|
||||
40% {
|
||||
content: "..";
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
content: "...";
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingPulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingButtonPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(255, 77, 79, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes recordingWave {
|
||||
0%,
|
||||
40%,
|
||||
100% {
|
||||
transform: scaleY(0.4);
|
||||
}
|
||||
20% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
// @media (max-width: 768px) {
|
||||
// .pet-voice-translator {
|
||||
// .messages-container {
|
||||
// padding: 12px 8px;
|
||||
|
||||
// .message-wrapper .message-item .message-content {
|
||||
// max-width: calc(100% - 60px);
|
||||
// }
|
||||
// }
|
||||
|
||||
// .recording-controls {
|
||||
// padding: 10px 12px 16px;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// iOS Safari 特殊处理
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.pet-voice-translator {
|
||||
// 确保在iOS Safari中正确显示
|
||||
// height: 100vh;
|
||||
height: -webkit-fill-available;
|
||||
|
||||
.messages-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pet-voice-translator {
|
||||
background: #1e1e1e;
|
||||
|
||||
.chat-header {
|
||||
background: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
|
||||
.chat-title .pet-name {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
.message-wrapper .message-item .message-content .translation-text {
|
||||
color: #ccc;
|
||||
|
||||
.translation-content {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recording-controls {
|
||||
background: #2a2a2a;
|
||||
border-top-color: #3a3a3a;
|
||||
}
|
||||
}
|
||||
}
|
||||
1104
src/component/petVoiceTranslator/index.tsx
Normal file
1104
src/component/petVoiceTranslator/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
88
src/component/qr-scanner/index.tsx
Normal file
88
src/component/qr-scanner/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, {useRef, useEffect, useState} from 'react';
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
export interface QRScannerProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
onScan: (data: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码扫描组件
|
||||
* @param width 画布宽度
|
||||
* @param height 画布高度
|
||||
* @param onScan 扫描成功的回调函数
|
||||
* @constructor QRScanner
|
||||
*/
|
||||
const QRScanner: React.FC<QRScannerProps> = ({width = 300, height = 300, onScan}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isScanning, setIsScanning] = useState(true);
|
||||
const [scanSuccess, setScanSuccess] = useState(false); // 新的状态变量
|
||||
let streamRef: MediaStream | null = null; // 存储摄像头流的引用
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.getUserMedia({video: {facingMode: "environment"}})
|
||||
.then(stream => {
|
||||
streamRef = stream; // 存储对摄像头流的引用
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.addEventListener('loadedmetadata', () => {
|
||||
videoRef.current?.play().then(() => {
|
||||
requestAnimationFrame(tick);
|
||||
}).catch(err => console.error("Error playing video: ", err));
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error("Error accessing media devices: ", err);
|
||||
setIsScanning(false);
|
||||
});
|
||||
}, []);
|
||||
const tick = () => {
|
||||
// 如果已经成功扫描,就不再继续执行tick
|
||||
if (!isScanning) return;
|
||||
|
||||
if (videoRef.current && canvasRef.current) {
|
||||
if (videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) {
|
||||
let video = videoRef.current;
|
||||
let canvas = canvasRef.current;
|
||||
let ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
canvas.height = height;
|
||||
canvas.width = width;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
let code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
|
||||
if (code) {
|
||||
onScan(code.data); // 扫码成功
|
||||
setIsScanning(false); // 更新状态为不再扫描
|
||||
setScanSuccess(true); // 显示扫描成功的蒙层
|
||||
if (streamRef) {
|
||||
let tracks = streamRef.getTracks();
|
||||
tracks.forEach(track => track.stop()); // 关闭摄像头
|
||||
}
|
||||
return; // 直接返回,避免再次调用 requestAnimationFrame
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<video ref={videoRef} style={{display: 'none'}}></video>
|
||||
<canvas ref={canvasRef}
|
||||
style={{
|
||||
display: isScanning ? 'block' : 'none',
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}}></canvas>
|
||||
{scanSuccess && <div className="scan-success-overlay">识别成功!</div>} {/* 显示识别成功的蒙层 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QRScanner;
|
||||
69
src/component/voiceIcon/index.less
Normal file
69
src/component/voiceIcon/index.less
Normal file
@@ -0,0 +1,69 @@
|
||||
/* VoiceIcon.css */
|
||||
.voice-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
}
|
||||
.audio-recorder {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wave {
|
||||
width: 1px;
|
||||
height: 13px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.wave1 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.wave2 {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.wave3 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.wave3 {
|
||||
height: 8px;
|
||||
}
|
||||
.wave4 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* 播放动画 */
|
||||
.voice-icon.playing .wave {
|
||||
animation: voice-wave 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-icon.playing .wave1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.voice-icon.playing .wave2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.voice-icon.playing .wave3 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes voice-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
22
src/component/voiceIcon/index.tsx
Normal file
22
src/component/voiceIcon/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import "./index.less";
|
||||
|
||||
const VoiceIcon = (props: { isPlaying: boolean; onChange?: () => void }) => {
|
||||
const { isPlaying = false } = props;
|
||||
const onChange = useCallback(() => {
|
||||
props.onChange?.();
|
||||
}, [isPlaying]);
|
||||
return (
|
||||
<div
|
||||
className={`voice-icon ${isPlaying ? "playing" : ""}`}
|
||||
onClick={onChange}
|
||||
>
|
||||
<div className="wave wave1"></div>
|
||||
<div className="wave wave2"></div>
|
||||
<div className="wave wave3"></div>
|
||||
<div className="wave wave4"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(VoiceIcon);
|
||||
11
src/component/xpopup/index.less
Normal file
11
src/component/xpopup/index.less
Normal file
@@ -0,0 +1,11 @@
|
||||
.xpopup {
|
||||
.adm-popup-body {
|
||||
background-color: rgba(245, 245, 245, 1);
|
||||
padding: 16px;
|
||||
.header {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/component/xpopup/index.tsx
Normal file
28
src/component/xpopup/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Divider, Popup } from "antd-mobile";
|
||||
import { CloseOutline } from "antd-mobile-icons";
|
||||
import "./index.less";
|
||||
|
||||
interface DefinedProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
function XPopup(props: DefinedProps) {
|
||||
const { visible, title, children, onClose } = props;
|
||||
|
||||
return (
|
||||
<Popup visible={visible} closeOnMaskClick={true} className="xpopup">
|
||||
<div className="header">
|
||||
<h3 className="title">{title}</h3>
|
||||
<span className="closeIcon" onClick={onClose}>
|
||||
<CloseOutline style={{ fontSize: "16px" }} />
|
||||
</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="content">{children}</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export default XPopup;
|
||||
Reference in New Issue
Block a user