Files
learn-languages/src/app/(features)/translator/page.tsx

224 lines
7.6 KiB
TypeScript

"use client";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { actionTranslateText } from "@/modules/translator/translator-action";
import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type";
export default function TranslatorPage() {
const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null);
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{
sourceText: string;
targetLanguage: string;
} | null>(null);
const { load, play } = useAudioPlayer();
const lastTTS = useRef({
text: "",
url: "",
});
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
try {
// Map language name to TTS format
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// Check if language is in TTS supported list
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
}
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
await load(url);
await play();
lastTTS.current.text = text;
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
}
}
};
const translate = async () => {
if (!taref.current || processing) return;
setProcessing(true);
const sourceText = taref.current.value;
// 判断是否需要强制重新翻译
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate =
lastTranslation?.sourceText === sourceText &&
lastTranslation?.targetLanguage === targetLanguage;
try {
const result = await actionTranslateText({
sourceText,
targetLanguage,
forceRetranslate,
needIpa,
});
if (result.success && result.data) {
setTranslationResult(result.data);
setLastTranslation({
sourceText,
targetLanguage,
});
} else {
toast.error(result.message || "翻译失败,请重试");
}
} catch (error) {
toast.error("翻译失败,请重试");
console.error("翻译错误:", error);
} finally {
setProcessing(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-white">
{/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
{/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
<textarea
className="resize-none h-8/12 w-full focus:outline-0"
ref={taref}
onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate();
}}
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{translationResult?.sourceIpa || ""}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(
taref.current?.value || "",
);
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, translationResult?.sourceLanguage || "");
}}
></IconClick>
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<LightButton
selected={needIpa}
onClick={() => setNeedIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
</div>
</div>
{/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<div className="bg-gray-100 rounded-lg w-full h-64 p-2">
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
{translationResult?.targetIpa || ""}
</div>
<div className="h-1/6 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(translationResult?.translatedText || "");
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
if (!translationResult) return;
tts(
translationResult.translatedText,
translationResult.targetLanguage,
);
}}
></IconClick>
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<LightButton
selected={targetLanguage === "Chinese"}
onClick={() => setTargetLanguage("Chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")}
>
{t("english")}
</LightButton>
<LightButton
selected={targetLanguage === "Italian"}
onClick={() => setTargetLanguage("Italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
onClick={() => {
const newLang = prompt(t("enterLanguage"));
if (newLang) {
setTargetLanguage(newLang);
}
}}
>
{t("other")}
</LightButton>
</div>
</div>
</div>
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<PrimaryButton
onClick={translate}
disabled={processing}
size="lg"
className="text-xl"
>
{t("translate")}
</PrimaryButton>
</div>
</div>
);
}