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

View File

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

View File

@@ -0,0 +1,139 @@
// components/DeviceCompatibility.tsx
import React, { useEffect, useState } from "react";
import { UniversalAudioRecorder } from "@/utils/audioRecorder";
interface DeviceInfo {
isIOS: boolean;
isSafari: boolean;
supportedFormats: string[];
hasMediaRecorder: boolean;
hasGetUserMedia: boolean;
}
const DeviceCompatibility: React.FC = () => {
const [deviceInfo, setDeviceInfo] = useState<DeviceInfo | null>(null);
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
const checkCompatibility = () => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
);
const hasMediaRecorder = typeof MediaRecorder !== "undefined";
const hasGetUserMedia = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
);
let supportedFormats: string[] = [];
if (hasMediaRecorder) {
supportedFormats = UniversalAudioRecorder.getSupportedFormats();
}
setDeviceInfo({
isIOS,
isSafari,
supportedFormats,
hasMediaRecorder,
hasGetUserMedia,
});
};
checkCompatibility();
}, []);
if (!deviceInfo) return null;
const getCompatibilityStatus = (): "good" | "warning" | "error" => {
if (!deviceInfo.hasMediaRecorder || !deviceInfo.hasGetUserMedia) {
return "error";
}
if (deviceInfo.supportedFormats.length === 0) {
return "warning";
}
return "good";
};
const status = getCompatibilityStatus();
return (
<div className={`device-compatibility ${status}`}>
<div
className="compatibility-header"
onClick={() => setShowDetails(!showDetails)}
>
<span className="status-icon">
{status === "good" && "✅"}
{status === "warning" && "⚠️"}
{status === "error" && "❌"}
</span>
<span className="status-text">
{status === "good" && "设备兼容"}
{status === "warning" && "部分兼容"}
{status === "error" && "不兼容"}
</span>
<span className="toggle-icon">{showDetails ? "▼" : "▶"}</span>
</div>
{showDetails && (
<div className="compatibility-details">
<div className="device-info">
<p>
<strong>:</strong>
</p>
<ul>
<li>iOS设备: {deviceInfo.isIOS ? "是" : "否"}</li>
<li>Safari浏览器: {deviceInfo.isSafari ? "是" : "否"}</li>
<li>
MediaRecorder: {deviceInfo.hasMediaRecorder ? "是" : "否"}
</li>
<li>
getUserMedia: {deviceInfo.hasGetUserMedia ? "是" : "否"}
</li>
</ul>
</div>
<div className="supported-formats">
<p>
<strong>:</strong>
</p>
{deviceInfo.supportedFormats.length > 0 ? (
<ul>
{deviceInfo.supportedFormats.map((format, index) => (
<li key={index}>{format}</li>
))}
</ul>
) : (
<p className="no-formats"></p>
)}
</div>
{status === "error" && (
<div className="error-message">
<p>
<strong>:</strong>
</p>
<p>:</p>
<ul>
<li>使</li>
<li>HTTPS环境下访问</li>
<li></li>
</ul>
</div>
)}
{status === "warning" && (
<div className="warning-message">
<p>
<strong>:</strong>
</p>
<p>使ChromeSafari或Firefox最新版本</p>
</div>
)}
</div>
)}
</div>
);
};
export default DeviceCompatibility;

View File

@@ -0,0 +1,278 @@
// components/PetTranslatorChat.tsx (添加音频控制)
import React, { useRef, useEffect } from "react";
import { usePetTranslator } from "@/hooks/usePetTranslator";
import { useFileUpload } from "@/hooks/useFileUpload";
import { useAudioControl } from "@/hooks/useAudioControl";
import { VoiceMessage, ChatMessage } from "@/types/chat";
import { UploadConfig } from "@/types/upload";
import VoiceMessageComponent from "./voiceMessage";
import VoiceRecordButton from "./voiceRecordButton";
import RecordingStatusBar from "./recordingStatusBar";
import AudioManager from "@/utils/audioManager";
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
import "./index.less";
import { CapsuleTabs } from "antd-mobile";
const PetTranslatorChat: React.FC = () => {
const {
messages,
currentPet,
translateVoice,
addMessage,
updateMessage,
clearMessages,
} = usePetTranslator();
const { isRecording, isPaused, recordingTime } = useVoiceRecorder();
const { uploadFile } = useFileUpload();
const { currentPlayingId, stopAllAudio, pauseAllAudio } = useAudioControl();
const messagesEndRef = useRef<HTMLDivElement>(null);
// 上传配置
const uploadConfig: UploadConfig = {
url: "/api/upload/voice",
method: "POST",
fieldName: "voiceFile",
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedTypes: [
"audio/webm",
"audio/mp4",
"audio/aac",
"audio/wav",
"audio/ogg",
"audio/mpeg",
"audio/x-m4a",
],
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
};
// 组件卸载时清理所有音频
useEffect(() => {
return () => {
AudioManager.getInstance().cleanup();
};
}, []);
// 开始录音时暂停所有音频播放
useEffect(() => {
if (isRecording) {
pauseAllAudio();
}
}, [isRecording, pauseAllAudio]);
const handleRecordComplete = (
audioBlob: Blob,
duration: number,
uploadResponse?: any
) => {
const audioUrl = URL.createObjectURL(audioBlob);
const voiceMessage: VoiceMessage = {
id: `voice_${Date.now()}`,
type: "voice",
content: {
duration,
url: audioUrl,
blob: audioBlob,
uploadStatus: uploadResponse ? "success" : undefined,
fileId: uploadResponse?.data?.fileId,
fileName: uploadResponse?.data?.fileName,
serverUrl: uploadResponse?.data?.fileUrl,
},
sender: "user",
timestamp: Date.now(),
};
addMessage(voiceMessage);
// 自动开始翻译
setTimeout(() => {
translateVoice(voiceMessage);
}, 500);
};
const handleRetryUpload = async (messageId: string) => {
const message = messages.find(
(msg) => msg.id === messageId
) as VoiceMessage;
if (!message || !message.content.blob) return;
try {
// 更新上传状态
updateMessage(messageId, {
...message,
content: {
...message.content,
uploadStatus: "uploading",
uploadProgress: 0,
},
});
const fileName = `voice_${Date.now()}.wav`;
const uploadResponse = await uploadFile(
message.content.blob,
fileName,
uploadConfig
);
// 更新成功状态
updateMessage(messageId, {
...message,
content: {
...message.content,
uploadStatus: "success",
fileId: uploadResponse.data?.fileId,
fileName: uploadResponse.data?.fileName,
serverUrl: uploadResponse.data?.fileUrl,
},
});
} catch (error) {
// 更新失败状态
updateMessage(messageId, {
...message,
content: {
...message.content,
uploadStatus: "error",
},
});
console.error("重新上传失败:", error);
}
};
const handleRecordError = (error: Error) => {
alert(error.message);
};
const formatTime = (timestamp: number): string => {
return new Date(timestamp).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
};
return (
<>
{/* 录音状态栏 */}
<RecordingStatusBar
isRecording={isRecording}
isPaused={isPaused}
duration={recordingTime}
maxDuration={60}
/>
{/* 头部 */}
{/* <div className="chat-header">
<div className="pet-info">
<div className="pet-avatar">{currentPet.avatar}</div>
<div className="pet-details">
<div className="pet-name">{currentPet.name}</div>
<div className="pet-status">
{isRecording
? "正在听你说话..."
: currentPlayingId
? "正在播放语音..."
: "在线 · 等待翻译"}
</div>
</div>
</div> */}
{/* <div className="header-controls">
{currentPlayingId && (
<button
className="audio-control-button"
onClick={stopAllAudio}
title="停止播放"
>
⏹️
</button>
)}
<button
className="clear-button"
onClick={() => {
stopAllAudio();
clearMessages();
}}
disabled={isRecording}
>
🗑️
</button> */}
{/* </div>
</div> */}
<div className="chat-header">
<CapsuleTabs>
<CapsuleTabs.Tab title="宠物翻译" key="fruits">
</CapsuleTabs.Tab>
<CapsuleTabs.Tab title="宠物档案" key="vegetables">
</CapsuleTabs.Tab>
</CapsuleTabs>
</div>
{/* 消息列表 */}
<div className="messages-container">
{messages.length === 0 ? (
<div className="empty-state">
<div className="empty-icon">🐾</div>
<div className="empty-title">{currentPet.name}</div>
<div className="empty-subtitle">
</div>
</div>
) : (
messages.map((message) => (
<div key={message.id} className="message-wrapper">
<div
className={`message ${
message.sender === "user" ? "own" : "other"
}`}
>
{message.sender === "pet" && (
<div className="avatar">{currentPet.avatar}</div>
)}
<div className="message-content">
{message.type === "voice" ? (
<VoiceMessageComponent
message={message}
isOwn={message.sender === "user"}
onTranslate={() => translateVoice(message)}
onRetryUpload={() => handleRetryUpload(message.id)}
/>
) : (
<div className="text-message">{message.content}</div>
)}
<div className="message-time">
{formatTime(message.timestamp)}
</div>
</div>
{message.sender === "user" && (
<div className="avatar user-avatar">👤</div>
)}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<VoiceRecordButton
onRecordComplete={handleRecordComplete}
onError={handleRecordError}
maxDuration={60}
// uploadConfig={uploadConfig}
// autoUpload={true}
/>
</>
);
};
export default PetTranslatorChat;

View File

@@ -0,0 +1,774 @@
/* PetTranslatorChat.css */
.pet-translator-chat {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 400px;
margin: 0 auto;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
/* 头部样式 */
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fff;
color: rgba(0, 0, 0, 0.25);
.lef {
display: flex;
gap: 12px;
h2 {
font-size: 20px;
&.active {
color: #000;
}
}
}
}
.pet-info {
display: flex;
align-items: center;
gap: 12px;
}
.pet-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.pet-name {
font-weight: 600;
color: #333;
font-size: 16px;
}
.pet-status {
font-size: 12px;
color: #999;
}
.clear-button {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: background 0.3s;
}
.clear-button:hover {
background: #f0f0f0;
}
/* 消息容器 */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: #666;
}
.empty-subtitle {
font-size: 14px;
line-height: 1.4;
}
/* 消息样式 */
.message {
display: flex;
gap: 8px;
max-width: 80%;
}
.message.own {
align-self: flex-end;
flex-direction: row-reverse;
}
.message.other {
align-self: flex-start;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.user-avatar {
background: #007bff;
color: white;
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-time {
font-size: 11px;
color: #999;
text-align: center;
}
/* 语音消息样式 */
.voice-message {
background: #fff;
border-radius: 18px;
padding: 12px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
min-width: 120px;
}
.voice-message.own {
background: #007bff;
color: white;
}
.voice-content {
display: flex;
align-items: center;
gap: 8px;
}
.play-button {
width: 24px;
height: 24px;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.3s;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.1);
}
.voice-message.own .play-button:hover {
background: rgba(255, 255, 255, 0.2);
}
.waveform {
display: flex;
align-items: center;
gap: 2px;
height: 20px;
flex: 1;
}
.waveform-bar {
width: 3px;
background: #ddd;
border-radius: 2px;
transition: all 0.3s;
}
.voice-message.own .waveform-bar {
background: rgba(255, 255, 255, 0.6);
}
.waveform-bar.active {
background: #007bff;
}
.voice-message.own .waveform-bar.active {
background: white;
}
.duration {
font-size: 12px;
color: #666;
min-width: 30px;
text-align: right;
}
.voice-message.own .duration {
color: rgba(255, 255, 255, 0.8);
}
/* 翻译部分 */
.translation-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #eee;
}
.translate-button {
background: #28a745;
color: white;
border: none;
border-radius: 12px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.3s;
}
.translate-button:hover {
background: #218838;
}
.translating {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 12px;
}
.loading-dots {
display: flex;
gap: 2px;
}
.loading-dots span {
width: 4px;
height: 4px;
background: #666;
border-radius: 50%;
animation: loading 1.4s infinite ease-in-out;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loading {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.translation-result {
display: flex;
align-items: flex-start;
gap: 8px;
background: rgba(40, 167, 69, 0.1);
padding: 8px;
border-radius: 8px;
border-left: 3px solid #28a745;
}
.translation-icon {
font-size: 14px;
}
.translation-text {
font-size: 13px;
line-height: 1.4;
color: #333;
}
/* 文本消息 */
.text-message {
background: #fff;
border-radius: 18px;
padding: 12px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
font-size: 14px;
line-height: 1.4;
}
.message.other .text-message {
background: #e8f5e8;
}
/* 输入区域 */
.input-area {
padding: 16px;
background: #fff;
border-top: 1px solid #e5e5e5;
}
/* 录音按钮 */
.voice-record-container {
position: relative;
}
.voice-record-button {
width: 100%;
height: 50px;
background: #007bff;
color: white;
border: none;
border-radius: 25px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
user-select: none;
}
.voice-record-button:hover {
background: #0056b3;
}
.voice-record-button.pressed {
background: #dc3545;
transform: scale(0.95);
}
.microphone-icon {
font-size: 18px;
}
.button-text {
font-weight: 500;
}
/* 录音覆盖层 */
.recording-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.recording-modal {
background: white;
border-radius: 20px;
padding: 40px;
text-align: center;
max-width: 300px;
width: 90%;
}
.recording-animation {
margin-bottom: 20px;
}
.sound-wave {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
height: 40px;
}
.wave-bar {
width: 4px;
background: #007bff;
border-radius: 2px;
animation: wave 1s infinite ease-in-out;
}
.wave-bar:nth-child(1) {
animation-delay: -0.4s;
}
.wave-bar:nth-child(2) {
animation-delay: -0.3s;
}
.wave-bar:nth-child(3) {
animation-delay: -0.2s;
}
.wave-bar:nth-child(4) {
animation-delay: -0.1s;
}
.wave-bar:nth-child(5) {
animation-delay: 0s;
}
@keyframes wave {
0%,
40%,
100% {
height: 10px;
}
20% {
height: 30px;
}
}
.recording-time {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.recording-hint {
font-size: 14px;
color: #666;
}
.cancel-hint {
color: #dc3545;
font-weight: 600;
}
.normal-hint {
color: #666;
}
/* 响应式 */
@media (max-width: 480px) {
.pet-translator-chat {
height: 100vh;
}
.message {
max-width: 85%;
}
.recording-modal {
padding: 30px 20px;
}
}
/* 上传进度样式 */
.upload-progress {
margin: 12px 0;
padding: 8px;
background: rgba(0, 123, 255, 0.1);
border-radius: 8px;
border: 1px solid rgba(0, 123, 255, 0.2);
}
.upload-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
color: #007bff;
}
.upload-icon {
font-size: 14px;
}
.upload-progress-bar {
width: 100%;
height: 4px;
background: rgba(0, 123, 255, 0.2);
border-radius: 2px;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
background: #007bff;
border-radius: 2px;
transition: width 0.3s ease;
}
/* 语音消息上传状态 */
.upload-status-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.voice-message.own .upload-status-section {
border-top-color: rgba(255, 255, 255, 0.3);
}
.upload-status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-bottom: 4px;
}
.upload-status-indicator.uploading {
color: #007bff;
}
.upload-status-indicator.success {
color: #28a745;
}
.upload-status-indicator.error {
color: #dc3545;
}
.retry-upload-button {
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
margin-left: 4px;
transition: background 0.3s;
}
.retry-upload-button:hover {
background: #c82333;
}
.file-info {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 10px;
color: rgba(255, 255, 255, 0.7);
}
.voice-message:not(.own) .file-info {
color: #666;
}
.file-name,
.file-size {
display: flex;
align-items: center;
gap: 4px;
}
/* 处理中状态 */
.processing-hint {
color: #007bff;
font-weight: 600;
}
.upload-hint {
color: #28a745;
font-weight: 600;
}
/* 按钮禁用状态 */
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.voice-start-button:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none !important;
}
/* 上传动画 */
@keyframes uploadPulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
.upload-status-indicator.uploading .upload-icon {
animation: uploadPulse 1s infinite;
}
/* DeviceCompatibility.css */
.device-compatibility {
margin: 10px 0;
border-radius: 8px;
overflow: hidden;
font-size: 12px;
}
.device-compatibility.good {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.device-compatibility.warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
}
.device-compatibility.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.compatibility-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
}
.status-icon {
font-size: 14px;
}
.status-text {
flex: 1;
font-weight: 600;
}
.toggle-icon {
font-size: 10px;
transition: transform 0.3s;
}
.compatibility-details {
padding: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
}
.device-info ul,
.supported-formats ul {
margin: 8px 0;
padding-left: 20px;
}
.device-info li,
.supported-formats li {
margin: 4px 0;
}
.no-formats {
color: #dc3545;
font-style: italic;
}
.error-message,
.warning-message {
margin-top: 12px;
padding: 8px;
border-radius: 4px;
}
.error-message {
background: rgba(220, 53, 69, 0.1);
border-left: 3px solid #dc3545;
}
.warning-message {
background: rgba(255, 193, 7, 0.1);
border-left: 3px solid #ffc107;
}
/* 播放错误样式 */
.play-error {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 8px;
background: rgba(220, 53, 69, 0.1);
border: 1px solid rgba(220, 53, 69, 0.3);
border-radius: 6px;
font-size: 11px;
}
.error-icon {
color: #dc3545;
}
.error-text {
flex: 1;
color: #dc3545;
}
.retry-play-button {
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
transition: background 0.3s;
}
.retry-play-button:hover {
background: #c82333;
}
.play-button.error {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.play-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 安卓特定样式优化 */
@media screen and (max-width: 768px) {
.voice-content {
min-height: 40px;
}
.play-button {
min-width: 32px;
min-height: 32px;
}
.waveform {
min-height: 24px;
}
}

View File

@@ -0,0 +1,275 @@
// components/PetTranslatorChat.tsx (添加音频控制)
import React, { useRef, useEffect, useState } from "react";
import { usePetTranslator } from "@/hooks/usePetTranslator";
import { useFileUpload } from "@/hooks/useFileUpload";
import { useAudioControl } from "@/hooks/useAudioControl";
import { VoiceMessage, ChatMessage } from "@/types/chat";
import { UploadConfig } from "@/types/upload";
import VoiceMessageComponent from "./voiceMessage";
import VoiceRecordButton from "./voiceRecordButton";
import RecordingStatusBar from "./recordingStatusBar";
import AudioManager from "@/utils/audioManager";
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
import TranslateItem from "./translateItem";
import ArchivesItem from "./archives";
import "./index.less";
import { CapsuleTabs } from "antd-mobile";
const PetTranslatorChat: React.FC = () => {
const {
messages,
currentPet,
translateVoice,
addMessage,
updateMessage,
clearMessages,
} = usePetTranslator();
const { isRecording, isPaused, recordingTime } = useVoiceRecorder();
const { uploadFile } = useFileUpload();
const { currentPlayingId, stopAllAudio, pauseAllAudio } = useAudioControl();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [tabValue, setTabValue] = useState<string>("translate");
// 上传配置
const uploadConfig: UploadConfig = {
url: "/api/upload/voice",
method: "POST",
fieldName: "voiceFile",
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedTypes: [
"audio/webm",
"audio/mp4",
"audio/aac",
"audio/wav",
"audio/ogg",
"audio/mpeg",
"audio/x-m4a",
],
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
};
// 组件卸载时清理所有音频
useEffect(() => {
return () => {
AudioManager.getInstance().cleanup();
};
}, []);
// 开始录音时暂停所有音频播放
useEffect(() => {
if (isRecording) {
pauseAllAudio();
}
}, [isRecording, pauseAllAudio]);
const handleRecordComplete = (
audioBlob: Blob,
duration: number,
uploadResponse?: any
) => {
const audioUrl = URL.createObjectURL(audioBlob);
const voiceMessage: VoiceMessage = {
id: `voice_${Date.now()}`,
type: "voice",
content: {
duration,
url: audioUrl,
blob: audioBlob,
uploadStatus: uploadResponse ? "success" : undefined,
fileId: uploadResponse?.data?.fileId,
fileName: uploadResponse?.data?.fileName,
serverUrl: uploadResponse?.data?.fileUrl,
},
sender: "user",
timestamp: Date.now(),
};
addMessage(voiceMessage);
// 自动开始翻译
setTimeout(() => {
translateVoice(voiceMessage);
}, 500);
};
const handleRetryUpload = async (messageId: string) => {
const message = messages.find(
(msg) => msg.id === messageId
) as VoiceMessage;
if (!message || !message.content.blob) return;
try {
// 更新上传状态
updateMessage(messageId, {
...message,
content: {
...message.content,
uploadStatus: "uploading",
uploadProgress: 0,
},
});
const fileName = `voice_${Date.now()}.wav`;
const uploadResponse = await uploadFile(
message.content.blob,
fileName,
uploadConfig
);
// 更新成功状态
updateMessage(messageId, {
...message,
content: {
...message.content,
uploadStatus: "success",
fileId: uploadResponse.data?.fileId,
fileName: uploadResponse.data?.fileName,
serverUrl: uploadResponse.data?.fileUrl,
},
});
} catch (error) {
// 更新失败状态
updateMessage(messageId, {
...message,
content: {
...message.content,
uploadStatus: "error",
},
});
console.error("重新上传失败:", error);
}
};
const handleRecordError = (error: Error) => {
alert(error.message);
};
const formatTime = (timestamp: number): string => {
return new Date(timestamp).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
};
return (
<>
{/* 录音状态栏 */}
<RecordingStatusBar
isRecording={isRecording}
isPaused={isPaused}
duration={recordingTime}
maxDuration={60}
/>
{/* 头部 */}
<div className="chat-header">
<div className="lef">
<h2
onClick={() => setTabValue("translate")}
className={`${tabValue === "translate" ? "active" : ""}`}
>
</h2>
<h2
onClick={() => setTabValue("archives")}
className={`${tabValue === "archives" ? "active" : ""}`}
>
</h2>
</div>
{/* <div className="pet-info">
<div className="pet-avatar">{currentPet.avatar}</div> */}
{/* <div className="pet-details"> */}
{/* <div className="pet-name">{currentPet.name}</div> */}
{/* <div className="pet-status">
{isRecording
? "正在听你说话..."
: currentPlayingId
? "正在播放语音..."
: "在线 · 等待翻译"}
</div> */}
{/* </div> */}
{/* </div> */}
{/* <div className="header-controls">
{currentPlayingId && (
<button
className="audio-control-button"
onClick={stopAllAudio}
title="停止播放"
>
⏹️
</button>
)}
<button
className="clear-button"
onClick={() => {
stopAllAudio();
clearMessages();
}}
disabled={isRecording}
>
🗑️
</button>
</div> */}
</div>
{tabValue == "translate" ? <TranslateItem /> : <ArchivesItem />}
{/* 消息列表 */}
<div className="messages-container">
{messages.map((message) => (
<div key={message.id} className="message-wrapper">
<div
className={`message ${
message.sender === "user" ? "own" : "other"
}`}
>
{message.sender === "pet" && (
<div className="avatar">{currentPet.avatar}</div>
)}
<div className="message-content">
{message.type === "voice" ? (
<VoiceMessageComponent
message={message}
isOwn={message.sender === "user"}
onTranslate={() => translateVoice(message)}
onRetryUpload={() => handleRetryUpload(message.id)}
/>
) : (
<div className="text-message">{message.content}</div>
)}
<div className="message-time">
{formatTime(message.timestamp)}
</div>
</div>
{message.sender === "user" && (
<div className="avatar user-avatar">👤</div>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<VoiceRecordButton
onRecordComplete={handleRecordComplete}
onError={handleRecordError}
maxDuration={60}
// uploadConfig={uploadConfig}
// autoUpload={true}
/>
</>
);
};
export default PetTranslatorChat;

View File

@@ -0,0 +1,54 @@
// components/RecordingStatusBar.tsx
import React from "react";
interface RecordingStatusBarProps {
isRecording: boolean;
isPaused: boolean;
duration: number;
maxDuration: number;
}
const RecordingStatusBar: React.FC<RecordingStatusBarProps> = ({
isRecording,
isPaused,
duration,
maxDuration,
}) => {
if (!isRecording) return null;
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const getStatusColor = (): string => {
if (isPaused) return "#ffc107";
if (duration >= maxDuration * 0.9) return "#dc3545";
return "#28a745";
};
return (
<div
className="recording-status-bar"
style={{ backgroundColor: getStatusColor() }}
>
<div className="status-content">
<div className="status-indicator">
<div className={`status-dot ${isPaused ? "paused" : ""}`}></div>
<span className="status-text">
{isPaused ? "录音暂停中" : "正在录音"}
</span>
</div>
<div className="status-time">
{formatTime(duration)} / {formatTime(maxDuration)}
</div>
</div>
</div>
);
};
export default RecordingStatusBar;

View File

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

View File

@@ -0,0 +1,322 @@
// 消息列表
import React, { useState, useRef, useEffect } from "react";
import { VoiceMessage as VoiceMessageType } from "@/types/chat";
import { UniversalAudioPlayer } from "@/utils/audioPlayer";
import AudioManager from "@/utils/audioManager";
interface VoiceMessageProps {
message: VoiceMessageType;
isOwn: boolean;
onTranslate?: () => void;
onRetryUpload?: () => void;
}
const VoiceMessage: React.FC<VoiceMessageProps> = ({
message,
isOwn,
onTranslate,
onRetryUpload,
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [playError, setPlayError] = useState<string | null>(null);
const playerRef = useRef<UniversalAudioPlayer | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const audioManager = AudioManager.getInstance();
// 使用消息ID作为音频实例的唯一标识
const audioId = `voice_${message.id}`;
useEffect(() => {
return () => {
// 组件卸载时清理
stopTimer();
audioManager.unregisterAudio(audioId);
};
}, [audioId]);
const startTimer = () => {
timerRef.current = setInterval(() => {
if (audioManager.isPlaying(audioId)) {
const current = audioManager.getCurrentTime(audioId);
setCurrentTime(current);
} else {
// 播放结束
stopTimer();
setIsPlaying(false);
setCurrentTime(0);
}
}, 100);
};
const stopTimer = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
const loadAudio = async (): Promise<void> => {
if (playerRef.current) {
return; // 已经加载过了
}
setIsLoading(true);
setPlayError(null);
try {
const player = new UniversalAudioPlayer();
// 优先使用blob其次使用URL
let audioBlob: Blob | null = null;
if (message.content.blob) {
audioBlob = message.content.blob;
} else if (message.content.url || message.content.serverUrl) {
// 从URL获取blob
const audioUrl = message.content.serverUrl || message.content.url;
const response = await fetch(audioUrl!);
audioBlob = await response.blob();
}
if (!audioBlob) {
throw new Error("无法获取音频数据");
}
await player.loadAudio(audioBlob);
playerRef.current = player;
// 注册到全局音频管理器
audioManager.registerAudio(audioId, player, {
onPlay: () => {
setIsPlaying(true);
startTimer();
},
onPause: () => {
setIsPlaying(false);
stopTimer();
},
onStop: () => {
setIsPlaying(false);
setCurrentTime(0);
stopTimer();
},
});
console.log("音频加载成功:", {
id: audioId,
size: audioBlob.size,
type: audioBlob.type,
duration: player.getDuration(),
});
} catch (error) {
console.error("音频加载失败:", error);
setPlayError(error instanceof Error ? error.message : "音频加载失败");
} finally {
setIsLoading(false);
}
};
const togglePlay = async () => {
try {
if (!playerRef.current) {
await loadAudio();
if (!playerRef.current) {
return;
}
}
if (isPlaying) {
// 暂停当前音频
audioManager.pauseAudio(audioId);
} else {
// 播放音频(会自动停止其他正在播放的音频)
await audioManager.playAudio(audioId);
setPlayError(null);
}
} catch (error) {
console.error("播放控制失败:", error);
setPlayError(error instanceof Error ? error.message : "播放失败");
setIsPlaying(false);
stopTimer();
}
};
const formatTime = (seconds: number): string => {
return `${Math.floor(seconds)}''`;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
const getWaveformBars = (): JSX.Element[] => {
const bars = [];
const barCount = Math.min(
Math.max(Math.floor(message.content.duration), 3),
20
);
const duration =
audioManager.getDuration(audioId) || message.content.duration;
for (let i = 0; i < barCount; i++) {
const height = Math.random() * 20 + 10;
const isActive =
isPlaying && duration > 0 && (currentTime / duration) * barCount > i;
bars.push(
<div
key={i}
className={`waveform-bar ${isActive ? "active" : ""}`}
style={{ height: `${height}px` }}
/>
);
}
return bars;
};
const getUploadStatusIcon = () => {
switch (message.content.uploadStatus) {
case "uploading":
return "📤";
case "success":
return "✅";
case "error":
return "❌";
default:
return null;
}
};
const getPlayButtonContent = () => {
if (isLoading) return "⏳";
if (playError) return "❌";
if (isPlaying) return "⏸️";
return "▶️";
};
const isPlayDisabled =
isLoading ||
(!message.content.url &&
!message.content.serverUrl &&
!message.content.blob);
return (
<div className={`voice-message ${isOwn ? "own" : "other"}`}>
<div className="voice-content">
<button
className={`play-button ${isPlaying ? "playing" : ""} ${
playError ? "error" : ""
}`}
onClick={togglePlay}
disabled={isPlayDisabled}
title={playError || (isLoading ? "加载中..." : "播放/暂停")}
>
{getPlayButtonContent()}
</button>
<div className="waveform">{getWaveformBars()}</div>
<span className="duration">
{formatTime(isPlaying ? currentTime : message.content.duration)}
</span>
</div>
{/* 播放错误提示 */}
{playError && (
<div className="play-error">
<span className="error-icon"></span>
<span className="error-text">{playError}</span>
<button
className="retry-play-button"
onClick={() => {
setPlayError(null);
audioManager.unregisterAudio(audioId);
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
}}
>
</button>
</div>
)}
{/* 上传状态显示 */}
{isOwn && message.content.uploadStatus && (
<div className="upload-status-section">
<div
className={`upload-status-indicator ${message.content.uploadStatus}`}
>
<span className="upload-icon">{getUploadStatusIcon()}</span>
<span className="upload-text">
{message.content.uploadStatus === "uploading" &&
`上传中 ${message.content.uploadProgress || 0}%`}
{message.content.uploadStatus === "success" && "已上传"}
{message.content.uploadStatus === "error" && "上传失败"}
</span>
{message.content.uploadStatus === "error" && onRetryUpload && (
<button className="retry-upload-button" onClick={onRetryUpload}>
</button>
)}
</div>
{/* 文件信息 */}
{message.content.uploadStatus === "success" && (
<div className="file-info">
{message.content.fileName && (
<span className="file-name">📁 {message.content.fileName}</span>
)}
{message.content.blob && (
<span className="file-size">
📊 {formatFileSize(message.content.blob.size)}
</span>
)}
</div>
)}
</div>
)}
{/* 翻译按钮和结果 */}
{isOwn && (
<div className="translation-section">
{!message.translation && !message.translating && (
<button className="translate-button" onClick={onTranslate}>
🐾
</button>
)}
{message.translating && (
<div className="translating">
<div className="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
...
</div>
)}
{message.translation && (
<div className="translation-result">
<div className="translation-icon">🗣</div>
<div className="translation-text">{message.translation}</div>
</div>
)}
</div>
)}
</div>
);
};
export default VoiceMessage;

View File

@@ -0,0 +1,350 @@
/* VoiceRecordButton.css */
.voice-input-container {
padding: 16px;
background: #fff;
border-top: 1px solid #e5e5e5;
}
.voice-start-button {
width: 100%;
height: 50px;
background: #007bff;
color: white;
border: none;
border-radius: 25px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
user-select: none;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
.voice-start-button:hover {
background: #0056b3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
}
.voice-start-button:active {
transform: translateY(0);
}
.microphone-icon {
font-size: 20px;
}
.button-text {
font-weight: 600;
}
/* 录音中的容器 */
.voice-recording-container {
padding: 20px;
background: #fff;
border-top: 1px solid #e5e5e5;
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
}
/* 录音状态 */
.recording-status {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.recording-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.recording-dot {
width: 12px;
height: 12px;
background: #dc3545;
border-radius: 50%;
animation: pulse-recording 1s infinite;
}
.recording-dot.paused {
background: #ffc107;
animation: none;
}
@keyframes pulse-recording {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.recording-text {
font-size: 14px;
font-weight: 600;
color: #333;
}
.recording-time {
font-size: 18px;
font-weight: 700;
color: #007bff;
}
.max-time {
font-size: 12px;
color: #999;
font-weight: 400;
}
/* 进度条 */
.recording-progress {
width: 100%;
height: 4px;
background: #e9ecef;
border-radius: 2px;
margin-bottom: 20px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
border-radius: 2px;
transition: width 0.3s ease;
}
/* 波形动画 */
.sound-wave-container {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.sound-wave {
display: flex;
align-items: center;
gap: 4px;
height: 40px;
}
.wave-bar {
width: 4px;
background: #007bff;
border-radius: 2px;
animation: wave-animation 1.2s infinite ease-in-out;
}
.sound-wave.paused .wave-bar {
animation-play-state: paused;
}
.wave-bar:nth-child(1) {
animation-delay: -0.4s;
}
.wave-bar:nth-child(2) {
animation-delay: -0.3s;
}
.wave-bar:nth-child(3) {
animation-delay: -0.2s;
}
.wave-bar:nth-child(4) {
animation-delay: -0.1s;
}
.wave-bar:nth-child(5) {
animation-delay: 0s;
}
@keyframes wave-animation {
0%,
40%,
100% {
height: 8px;
opacity: 0.5;
}
20% {
height: 32px;
opacity: 1;
}
}
/* 控制按钮 */
.recording-controls {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.control-button {
flex: 1;
height: 48px;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 12px;
font-weight: 600;
min-width: 0;
}
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-button:not(:disabled):hover {
transform: translateY(-2px);
}
.control-button:not(:disabled):active {
transform: translateY(0);
}
.cancel-button {
background: #f8f9fa;
color: #dc3545;
border: 2px solid #dc3545;
}
.cancel-button:not(:disabled):hover {
background: #dc3545;
color: white;
}
.pause-button {
background: #f8f9fa;
color: #ffc107;
border: 2px solid #ffc107;
}
.pause-button:not(:disabled):hover {
background: #ffc107;
color: white;
}
.send-button {
background: #28a745;
color: white;
border: 2px solid #28a745;
}
.send-button:not(:disabled):hover {
background: #218838;
border-color: #218838;
}
.button-icon {
font-size: 16px;
}
.button-label {
font-size: 11px;
line-height: 1;
}
/* 提示文字 */
.recording-hint {
text-align: center;
font-size: 13px;
color: #666;
}
.warning-hint {
color: #dc3545;
font-weight: 600;
}
.normal-hint {
color: #28a745;
}
/* 响应式设计 */
@media (max-width: 480px) {
.voice-recording-container {
padding: 16px;
}
.recording-controls {
gap: 8px;
}
.control-button {
height: 44px;
font-size: 11px;
}
.button-icon {
font-size: 14px;
}
.button-label {
font-size: 10px;
}
.recording-time {
font-size: 16px;
}
}
/* 动画效果 */
.voice-recording-container {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.voice-recording-container {
background: #2c2c2e;
border-top-color: #3a3a3c;
}
.recording-text {
color: #fff;
}
.recording-progress {
background: #3a3a3c;
}
.wave-bar {
background: #0a84ff;
}
.control-button {
background: #3a3a3c;
}
.recording-hint {
color: #8e8e93;
}
}

View File

@@ -0,0 +1,248 @@
// components/VoiceRecordButton.tsx (更新)
import React, { useState, useEffect } from "react";
import { useVoiceRecorder } from "@/hooks/useVoiceRecorder";
import { useFileUpload } from "@/hooks/useFileUpload";
import { UploadConfig } from "@/types/upload";
import "./index.less";
interface VoiceRecordButtonProps {
onRecordComplete: (
audioBlob: Blob,
duration: number,
uploadResponse?: any
) => void;
onError?: (error: Error) => void;
maxDuration?: number;
uploadConfig?: UploadConfig;
autoUpload?: boolean;
}
const VoiceRecordButton: React.FC<VoiceRecordButtonProps> = ({
onRecordComplete,
onError,
maxDuration = 60,
uploadConfig,
autoUpload = true,
}) => {
const {
isRecording,
recordingTime,
isPaused,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
cancelRecording,
} = useVoiceRecorder();
const { uploadStatus, uploadFile, resetUpload } = useFileUpload();
const [showControls, setShowControls] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// 自动停止录音当达到最大时长
useEffect(() => {
if (recordingTime >= maxDuration && isRecording) {
handleSendRecording();
}
}, [recordingTime, maxDuration, isRecording]);
const handleStartRecording = async () => {
try {
resetUpload();
await startRecording();
setShowControls(true);
} catch (error) {
onError?.(error as Error);
}
};
const handleSendRecording = async () => {
if (recordingTime < 1) {
onError?.(new Error("录音时间太短至少需要1秒"));
cancelRecording();
setShowControls(false);
return;
}
setIsProcessing(true);
try {
const audioBlob = await stopRecording();
if (!audioBlob) {
throw new Error("录音数据获取失败");
}
let uploadResponse;
// 如果配置了上传且启用自动上传
if (uploadConfig && autoUpload) {
const fileName = `voice_${Date.now()}.wav`;
uploadResponse = await uploadFile(audioBlob, fileName, uploadConfig);
}
onRecordComplete(audioBlob, recordingTime, uploadResponse);
setShowControls(false);
} catch (error) {
onError?.(error as Error);
} finally {
setIsProcessing(false);
}
};
const handleCancelRecording = () => {
cancelRecording();
resetUpload();
setShowControls(false);
};
const handlePauseResume = () => {
if (isPaused) {
resumeRecording();
} else {
pauseRecording();
}
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const getProgressPercentage = (): number => {
return Math.min((recordingTime / maxDuration) * 100, 100);
};
const isUploading = uploadStatus.status === "uploading";
const uploadProgress = uploadStatus.progress?.percentage || 0;
if (!showControls) {
return (
<div className="voice-input-container">
<button
className="voice-start-button"
onClick={handleStartRecording}
disabled={isProcessing}
>
<span className="microphone-icon">🎤</span>
<span className="button-text">
{isProcessing ? "处理中..." : "点击录音"}
</span>
</button>
</div>
);
}
return (
<div className="voice-recording-container">
{/* 录音状态指示器 */}
<div className="recording-status">
<div className="recording-indicator">
<div className={`recording-dot ${isPaused ? "paused" : ""}`}></div>
<span className="recording-text">
{isProcessing
? "处理中..."
: isPaused
? "录音已暂停"
: "正在录音..."}
</span>
</div>
<div className="recording-time">
{formatTime(recordingTime)}
<span className="max-time">/{formatTime(maxDuration)}</span>
</div>
</div>
{/* 进度条 */}
<div className="recording-progress">
<div
className="progress-bar"
style={{ width: `${getProgressPercentage()}%` }}
></div>
</div>
{/* 上传进度 */}
{isUploading && (
<div className="upload-progress">
<div className="upload-status">
<span className="upload-icon">📤</span>
<span className="upload-text">... {uploadProgress}%</span>
</div>
<div className="upload-progress-bar">
<div
className="upload-progress-fill"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
</div>
)}
{/* 波形动画 */}
<div className="sound-wave-container">
<div
className={`sound-wave ${isPaused || isProcessing ? "paused" : ""}`}
>
{[...Array(5)].map((_, index) => (
<div
key={index}
className="wave-bar"
style={{ animationDelay: `${index * 0.1}s` }}
></div>
))}
</div>
</div>
{/* 控制按钮 */}
<div className="recording-controls">
<button
className="control-button cancel-button"
onClick={handleCancelRecording}
disabled={isProcessing}
title="取消录音"
>
<span className="button-icon">🗑</span>
<span className="button-label"></span>
</button>
<button
className="control-button pause-button"
onClick={handlePauseResume}
disabled={isProcessing}
title={isPaused ? "继续录音" : "暂停录音"}
>
<span className="button-icon">{isPaused ? "▶️" : "⏸️"}</span>
<span className="button-label">{isPaused ? "继续" : "暂停"}</span>
</button>
<button
className="control-button send-button"
onClick={handleSendRecording}
disabled={recordingTime < 1 || isProcessing}
title="发送录音"
>
<span className="button-icon">{isProcessing ? "⏳" : "📤"}</span>
<span className="button-label">
{isProcessing ? "处理中" : "发送"}
</span>
</button>
</div>
{/* 提示文字 */}
{/* <div className="recording-hint">
{isProcessing ? (
<span className="processing-hint">正在处理录音...</span>
) : isUploading ? (
<span className="upload-hint">正在上传到服务器...</span>
) : recordingTime < 1 ? (
<span className="warning-hint">录音时间至少需要1秒</span>
) : (
<span className="normal-hint">点击发送按钮完成录音</span>
)}
</div> */}
</div>
);
};
export default VoiceRecordButton;

View File

@@ -0,0 +1,7 @@
.carousel {
.carousel-item {
.carousel-image {
border-radius: 15px;
}
}
}

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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