feat: init

This commit is contained in:
2025-09-05 16:44:12 +08:00
parent 85244a451e
commit 242a15c589
27 changed files with 191 additions and 168 deletions

View File

@@ -0,0 +1,46 @@
.message {
flex: 1 auto;
overflow-y: auto;
padding: 12px;
.item {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 40px;
background: #eee;
}
.voice-container {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
margin-top: 8px;
padding-right: 12px;
.time {
color: rgba(0, 0, 0, 0.88);
}
.tips {
// align-self: stretch;
// display: grid;
// align-items: center;
color: rgba(0, 0, 0, 0.45);
}
}
.translate {
display: flex;
gap: 12px;
padding: 12px;
align-items: center;
background: rgba(0, 0, 0, 0.02);
border-radius: 8px;
margin-top: 12px;
}
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useRef, useState } from "react";
import { Divider, Image, SpinLoading, Toast } from "antd-mobile";
import { VoiceIcon } from "@workspace/shared";
import dogSvg from "@/assets/translate/dog.svg";
import catSvg from "@/assets/translate/cat.svg";
import pigSvg from "@/assets/translate/pig.svg";
import { Message } from "../../../types";
import "./index.less";
interface DefinedProps {
data: Message[];
isRecording: boolean;
}
function Index(props: DefinedProps) {
const { data, isRecording } = props;
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
const [isPlaying, setIsPlating] = useState(false);
const [currentPlayingId, setCurrentPlayingId] = useState<number>();
useEffect(() => {
if (isRecording) {
stopAllAudio();
}
}, [isRecording]);
const onVoiceChange = () => {
setIsPlating(!isPlaying);
};
const playAudio = (messageId: number, audioUrl: string) => {
if (isRecording) {
Toast.show("录音中,无法播放音频");
return;
}
if (currentPlayingId === messageId) {
if (audioRefs.current[messageId]) {
audioRefs.current[messageId].pause();
audioRefs.current[messageId].currentTime = 0;
}
setCurrentPlayingId(undefined);
setIsPlating(false);
return;
}
stopAllAudio();
if (!audioRefs.current[messageId]) {
audioRefs.current[messageId] = new Audio(audioUrl);
}
const audio = audioRefs.current[messageId];
audio.currentTime = 0;
audio.onended = () => {
setCurrentPlayingId(undefined);
setIsPlating(false);
};
audio.onerror = (error) => {
console.error("音频播放错误:", error);
Toast.show("音频播放失败");
setIsPlating(false);
};
audio
.play()
.then(() => {
setCurrentPlayingId(messageId);
setIsPlating(true);
})
.catch((error) => {
console.error("音频播放失败:", error);
Toast.show("音频播放失败");
});
};
const stopAllAudio = () => {
if (currentPlayingId && audioRefs.current[currentPlayingId]) {
audioRefs.current[currentPlayingId].pause();
audioRefs.current[currentPlayingId].currentTime = 0;
setIsPlating(false);
setCurrentPlayingId(undefined);
}
Object.values(audioRefs.current).forEach((audio) => {
if (!audio.paused) {
audio.pause();
audio.currentTime = 0;
}
});
};
const renderAvatar = (type?: "pig" | "cat" | "dog") => {
if (type === "pig") {
<Image src={pigSvg} width={40} height={40} fit="cover" style={{ borderRadius: 32 }} />;
}
if (type === "cat") {
return <Image src={catSvg} width={40} height={40} fit="cover" style={{ borderRadius: 32 }} />;
}
return <Image src={dogSvg} width={40} height={40} fit="cover" style={{ borderRadius: 32 }} />;
};
return (
<div className="message">
{data.map((item, index) => (
<div className="item" key={index} onClick={() => playAudio(item.id, item.audioUrl)}>
{renderAvatar(item.type)}
<div className="rig">
<div>
<span className="name">{item.name}</span>
<Divider direction="vertical" style={{ margin: "0px 8px" }} />
<span className="">{item.timestamp}</span>
</div>
<div className="voice-container">
<VoiceIcon
onChange={onVoiceChange}
isPlaying={isPlaying && currentPlayingId === item.id}
/>
<div className="time">{item.duration}''</div>
</div>
{item.isTranslating ? (
<div className="translate">
<SpinLoading color="default" style={{ "--size": "12px" }} />
<span>...</span>
</div>
) : (
<div className="translate">{item.translatedText}</div>
)}
</div>
</div>
))}
<div className="item">
<div className="avatar"></div>
<div className="rig">
<div>
<span className="name"></span>
<Divider direction="vertical" style={{ margin: "0px 8px" }} />
<span className="">15:00</span>
</div>
<div className="voice-container">
<VoiceIcon isPlaying={false} />
<div className="tips">{isRecording ? "录制中..." : "轻点麦克风录制"}</div>
</div>
</div>
</div>
</div>
);
}
export default Index;

View File

@@ -0,0 +1,22 @@
.search {
display: flex;
gap: 12px;
width: 100%;
.adm-search-bar-input-box-icon {
display: flex;
align-items: center;
}
.adm-search-bar {
flex: 1 auto;
}
.all {
height: 34px !important;
background: var(--adm-color-fill-content);
border-radius: 6px;
padding: 0px 12px;
display: flex;
align-items: center;
color: rgba(0, 0, 0, 0.88);
gap: 6px;
}
}

View File

@@ -0,0 +1,81 @@
import { Dropdown, type DropdownRef, Radio, SearchBar, Space } from "antd-mobile";
import { RadioValue } from "antd-mobile/es/components/radio";
import { useRef, useState } from "react";
interface PropsConfig {
handleAllAni: () => void;
value?: string;
}
const allAni = ["全部宠物", "丑丑", "胖胖", "可可"];
function SearchCom(props: PropsConfig) {
const { value } = props;
const [aniName, setAniName] = useState<string>("全部宠物");
const animenuRef = useRef<DropdownRef>(null);
const handleAniSelect = (val: RadioValue) => {
setAniName(allAni[val as number]);
animenuRef.current?.close();
};
return (
<div className="search">
<SearchBar
placeholder="请输入翻译内容"
style={{
"--border-radius": "6px",
"--height": "32px",
"--padding-left": "12px",
}}
defaultValue={value}
/>
<Dropdown className="all" ref={animenuRef}>
<Dropdown.Item key="ani" title={aniName}>
<div style={{ padding: 12 }}>
<Radio.Group defaultValue="default" onChange={handleAniSelect}>
<Space direction="vertical" block>
<Radio block value="0">
</Radio>
<Radio block value="1">
</Radio>
<Radio block value="2">
</Radio>
<Radio block value="3">
</Radio>
</Space>
</Radio.Group>
</div>
</Dropdown.Item>
</Dropdown>
{/* <div className="all" onClick={handleAllAni}>
<span>全部宠物</span>
<DownOne
theme="filled"
size="16"
strokeWidth={3}
strokeLinecap="butt"
/>
</div>
<ActionSheet
visible={visible}
actions={actions}
onClose={() => setVisible(false)}
/> */}
{/* <Popup
visible={visible}
onClose={() => {
setVisible(false);
}}
>
<div className="popup-head">
<span>宠物</span>
</div>
11111
</Popup> */}
</div>
);
}
export default SearchCom;

View File

@@ -0,0 +1,54 @@
.voice-record {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 12px 0px;
box-shadow: 1px 2px 4px 3px #eee;
.adm-progress-circle-info {
height: 32px;
}
.isRecording {
.adm-progress-circle {
border-radius: 50%;
animation: recordingButtonPulse 1s infinite;
}
}
.tips {
color: #9f9f9f;
}
.circle {
display: inline-block;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(22, 119, 255, 1);
}
.cancleBtn {
position: absolute;
border: 2px solid rgba(230, 244, 255, 1);
width: 72px;
height: 72px;
right: 60px;
border-radius: 50%;
top: 50%;
transform: translateY(-50%);
}
@keyframes recordingButtonPulse {
0% {
box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5);
}
30% {
box-shadow: 0 0 0 14px rgba(255, 77, 79, 0);
}
70% {
box-shadow: 0 0 0 14px rgba(255, 77, 79, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5);
}
}
}

View File

@@ -0,0 +1,265 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder";
import { Button, Dialog, Image, ProgressCircle, Toast } from "antd-mobile";
import microphoneSvg from "@/assets/translate/microphone.svg";
import microphoneDisabledSvg from "@/assets/translate/microphoneDisabledSvg.svg";
import { createStartRecordSound, createSendSound } from "@/utils/voice";
import "./index.less";
interface DefinedProps {
onRecordingComplete: (url: string, finalDuration: number) => void;
isRecording: boolean;
onSetIsRecording: (flag: boolean) => void;
}
function Index(props: DefinedProps) {
const { isRecording } = props;
const [hasPermission, setHasPermission] = useState<boolean>(false); //是否有权限
const [isPermissioning, setIsPermissioning] = useState<boolean>(true); //获取权限中
const [recordingDuration, setRecordingDuration] = useState<number>(0); //录音时长进度
const [isModal, setIsModal] = useState<boolean>(false);
const recordingTimerRef = useRef<NodeJS.Timeout>();
const isCancelledRef = useRef(false);
const recordingStartTimeRef = useRef<number>(0); //录音时长
// 音效相关
const sendSoundRef = useRef<HTMLAudioElement | null>(null);
const startRecordSoundRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
initializeSounds();
checkMicrophonePermission();
}, [hasPermission]);
useEffect(() => {
if (isRecording) {
recorderControls.startRecording();
} else {
}
}, [isRecording]);
//重置状态
const onResetRecordingState = () => {
props.onSetIsRecording(false);
setRecordingDuration(0);
recordingStartTimeRef.current = 0;
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = undefined;
}
};
const initializeSounds = () => {
try {
// 发送音效 - 使用Web Audio API生成
sendSoundRef.current = createSendSound();
// 开始录音音效
startRecordSoundRef.current = createStartRecordSound();
console.log("音效初始化完成");
} catch (error) {
console.error("音效初始化失败:", error);
}
};
const renderBtn = useCallback(() => {
if (!hasPermission) {
//没有权限
return (
<>
<Image height={80} width={80} src={microphoneDisabledSvg} />
{isPermissioning ? (
<div className="tips">...</div>
) : (
<div className="tips"></div>
)}
</>
);
}
if (isRecording) {
//正在录音中
return (
<div onClick={onStopRecording} className="isRecording">
<ProgressCircle percent={recordingDuration} style={{ "--size": "80px" }}>
<div className="recording-dot">
<span className="circle"></span>
</div>
</ProgressCircle>
<Button fill="none" onClick={cancelRecording} className="cancleBtn">
</Button>
</div>
);
} else {
//麦克风状态
return <Image height={80} width={80} src={microphoneSvg} onClick={onStartRecording} />;
}
}, [hasPermission, isRecording, recordingDuration]);
const checkMicrophonePermission = useCallback(async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setHasPermission(false);
setIsPermissioning(false);
Toast.show("浏览器不支持录音功能");
return false;
}
if (navigator.permissions && navigator.permissions.query) {
try {
const permissionStatus = await navigator.permissions.query({
name: "microphone" as PermissionName,
});
if (permissionStatus.state === "denied") {
setHasPermission(false);
setIsPermissioning(false);
setIsModal(true);
return false;
}
if (permissionStatus.state === "granted") {
setHasPermission(true);
setIsModal(false);
return true;
}
} catch (permError) {
console.log("权限查询不支持继续使用getUserMedia检查");
}
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
stream.getTracks().forEach((track) => track.stop());
setHasPermission(true);
return true;
} catch (error: any) {
if (error.message.includes("user denied permission")) {
setIsModal(true);
}
setHasPermission(false);
return false;
}
}, []);
useEffect(() => {
if (isModal) {
Dialog.confirm({
content: "重新获取麦克风权限",
onConfirm: async () => {
setIsModal(true);
await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
setHasPermission(true);
},
});
}
}, [isModal]);
const recorderControls = useAudioRecorder(
{
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
},
(err) => {
console.error("录音错误:", err);
Toast.show("录音失败,请重试");
onResetRecordingState();
}
);
// 播放音效
const playSound = async (soundRef: React.RefObject<HTMLAudioElement>) => {
try {
if (soundRef.current) {
await soundRef.current.play();
}
} catch (error: any) {
Toast.show(`播放音效失败:${error.message}`);
}
};
//开始录音
const onStartRecording = () => {
isCancelledRef.current = false;
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = undefined;
}
props.onSetIsRecording(true);
// recorderControls.startRecording();
recordingStartTimeRef.current = Date.now();
// 立即开始计时
recordingTimerRef.current = setInterval(() => {
setRecordingDuration((prev) => prev + 1);
}, 1000);
};
const onStopRecording = useCallback(() => {
recorderControls.stopRecording();
onResetRecordingState();
}, [recorderControls, recordingDuration]);
//录音完成
// 在发送时检查录音时长
const onRecordingComplete = useCallback(
(blob: Blob) => {
if (isCancelledRef.current) {
Toast.show("已取消");
return;
}
// 检查blob有效性
if (!blob || blob.size === 0) {
Toast.show("录音数据无效,请重新录音");
return;
}
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio();
audio.src = audioUrl;
// 计算实际录音时长
audio.addEventListener("loadedmetadata", () => {
if (audio.duration < 1) {
Toast.show("录音时间太短,请重新录音");
return;
}
alert(audio.duration);
playSound(sendSoundRef);
props.onRecordingComplete?.(audioUrl, Math.floor(audio.duration));
});
},
[isCancelledRef, isRecording, sendSoundRef]
);
const cancelRecording = useCallback(() => {
isCancelledRef.current = true;
recorderControls.stopRecording();
onResetRecordingState();
}, []);
return (
<>
<AudioRecorder
onRecordingComplete={onRecordingComplete}
recorderControls={recorderControls}
audioTrackConstraints={{
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
}}
showVisualizer={false}
/>
<div className={` voice-record`}>{renderBtn()}</div>
</>
);
}
export default React.memo(Index);

View File

@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from "react";
import { Image, Toast } from "antd-mobile";
import MessageCom from "./component/message";
import VoiceRecord from "./component/voice";
import { XPopup, FloatingMenu, type FloatMenuItemConfig } from "@workspace/shared";
import type { Message } from "../types";
import { mockTranslateAudio } from "@/utils/voice";
import dogSvg from "@/assets/translate/dog.svg";
import catSvg from "@/assets/translate/cat.svg";
import pigSvg from "@/assets/translate/pig.svg";
import { MoreTwo } from "@icon-park/react";
import SearchCom from "./component/search";
interface DefinedProps {
searchVisible: boolean;
}
const menuItems: FloatMenuItemConfig[] = [
{ icon: <Image src={dogSvg} />, type: "dog" },
{ icon: <Image src={catSvg} />, type: "cat" },
{ icon: <Image src={pigSvg} />, type: "pig" },
{
icon: <MoreTwo theme="outline" size="24" fill="#666" strokeWidth={3} strokeLinecap="butt" />,
type: "add",
},
];
function Index(props: DefinedProps) {
const { searchVisible } = props;
const [messages, setMessages] = useState<Message[]>([]);
const [isRecording, setIsRecording] = useState(false); //是否录音中
const [currentLanguage, setCurrentLanguage] = useState<FloatMenuItemConfig>();
const [visible, setVisible] = useState<boolean>(false);
useEffect(() => {
setCurrentLanguage(menuItems[0]);
}, []);
//完成录音
const onRecordingComplete = useCallback(
(audioUrl: string, actualDuration: number) => {
const newMessage: Message = {
id: Date.now(),
type: "dog",
audioUrl,
name: "生无可恋喵",
duration: actualDuration,
timestamp: Date.now(),
isTranslating: true,
};
setMessages((prev) => [...prev, newMessage]);
setTimeout(() => {
onTranslateAudio(newMessage.id);
}, 1000);
Toast.show("语音已发送");
},
[messages]
);
//翻译
const onTranslateAudio = useCallback(
async (messageId: number) => {
try {
const translatedText = await mockTranslateAudio();
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
)
);
}
},
[messages]
);
const onSetIsRecording = (flag: boolean) => {
setIsRecording(flag);
};
const onLanguage = (item: FloatMenuItemConfig) => {
if (item.type === "add") {
setVisible(true);
} else {
setCurrentLanguage(item);
}
};
return (
<div className="translate-container">
{searchVisible && (
<div className="header">
<SearchCom handleAllAni={() => {}} />
</div>
)}
<MessageCom data={messages} isRecording={isRecording}></MessageCom>
<VoiceRecord
onRecordingComplete={onRecordingComplete}
isRecording={isRecording}
onSetIsRecording={onSetIsRecording}
/>
<FloatingMenu menuItems={menuItems} value={currentLanguage} onChange={onLanguage} />
<XPopup
title="选择翻译语种"
visible={visible}
onClose={() => {
setVisible(false);
}}
>
<div className="card">
<span></span>
<div></div>
</div>
<div className="card">
<span></span>
</div>
</XPopup>
</div>
);
}
export default Index;