...
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"name": "learn-languages",
|
"name": "learn-languages",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --experimental-https",
|
"dev": "next dev --experimental-https",
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
interface FolderSelectorProps {
|
||||||
folders: (Folder & { total: number })[];
|
folders: TSharedFolderWithTotalPairs[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
@@ -50,7 +50,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||||
>
|
>
|
||||||
{/* 文件夹图标 */}
|
{/* 文件夹图标 */}
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Fd className="text-gray-600" size={24} />
|
<Fd className="text-gray-600" size={24} />
|
||||||
</div>
|
</div>
|
||||||
{/* 文件夹信息 */}
|
{/* 文件夹信息 */}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
||||||
import { Pair } from "../../../../generated/prisma/browser";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
|
||||||
const myFont = localFont({
|
const myFont = localFont({
|
||||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MemorizeProps {
|
interface MemorizeProps {
|
||||||
textPairs: Pair[];
|
textPairs: TSharedPair[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||||
@@ -176,8 +176,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleReverse}
|
onClick={toggleReverse}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${reverse
|
||||||
reverse
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
@@ -186,8 +185,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDictation}
|
onClick={toggleDictation}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${dictation
|
||||||
dictation
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
@@ -196,8 +194,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDisorder}
|
onClick={toggleDisorder}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${disorder
|
||||||
disorder
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
|
||||||
getFoldersWithTotalPairsByUserId,
|
|
||||||
} from "@/lib/server/services/folderService";
|
|
||||||
import { isNonNegativeInteger } from "@/utils/random";
|
import { isNonNegativeInteger } from "@/utils/random";
|
||||||
import FolderSelector from "./FolderSelector";
|
import FolderSelector from "./FolderSelector";
|
||||||
import Memorize from "./Memorize";
|
import Memorize from "./Memorize";
|
||||||
import { getPairsByFolderId } from "@/lib/server/services/pairService";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder";
|
||||||
|
|
||||||
export default async function MemorizePage({
|
export default async function MemorizePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -27,13 +24,14 @@ export default async function MemorizePage({
|
|||||||
|
|
||||||
if (!folder_id) {
|
if (!folder_id) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if(!session) redirect("/auth?redirect=/memorize")
|
if (!session) redirect("/auth?redirect=/memorize");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FolderSelector
|
<FolderSelector
|
||||||
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
|
||||||
import { Dispatch, useEffect, useState } from "react";
|
|
||||||
import z from "zod";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
interface AddToFolderProps {
|
|
||||||
item: z.infer<typeof TranslationHistorySchema>;
|
|
||||||
setShow: Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const t = useTranslations("translator.add_to_folder");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session) return;
|
|
||||||
const userId = session.user.id;
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<div>{t("notAuthenticated")}</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<h1>{t("chooseFolder")}</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl">
|
|
||||||
{(loading && <span>...</span>) ||
|
|
||||||
(folders.length > 0 &&
|
|
||||||
folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
|
||||||
onClick={() => {
|
|
||||||
createPair({
|
|
||||||
text1: item.text1,
|
|
||||||
text2: item.text2,
|
|
||||||
language1: item.language1,
|
|
||||||
language2: item.language2,
|
|
||||||
folderId: folder.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(t("success"));
|
|
||||||
setShow(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(t("error"));
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{t("folderInfo", { id: folder.id, name: folder.name })}
|
|
||||||
</button>
|
|
||||||
))) || <div>{t("noFolders")}</div>}
|
|
||||||
</div>
|
|
||||||
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddToFolder;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
|
||||||
setSelectedFolderId: (id: number) => void;
|
|
||||||
userId: string;
|
|
||||||
cancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({
|
|
||||||
setSelectedFolderId,
|
|
||||||
userId,
|
|
||||||
cancel,
|
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
|
|
||||||
>
|
|
||||||
<Container className="p-6">
|
|
||||||
{(loading && <p>Loading...</p>) ||
|
|
||||||
(folders.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h1>Select a Folder</h1>
|
|
||||||
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
className="p-2 w-full flex hover:bg-gray-50 gap-2"
|
|
||||||
key={folder.id}
|
|
||||||
onClick={() => setSelectedFolderId(folder.id)}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{folder.id}. {folder.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)) || <p>No folders found</p>}
|
|
||||||
<LightButton onClick={cancel}>Cancel</LightButton>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FolderSelector;
|
|
||||||
@@ -4,22 +4,12 @@ import { LightButton } from "@/components/ui/buttons";
|
|||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
|
||||||
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { Plus, Trash } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import z from "zod";
|
import { translateText } from "@/modules/translator";
|
||||||
import AddToFolder from "./AddToFolder";
|
|
||||||
import { translateText } from "@/modules/translator/translator-action";
|
|
||||||
import type { TranslateTextOutput } from "@/lib/server/services/types";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import FolderSelector from "./FolderSelector";
|
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
|
||||||
import { shallowEqual } from "@/utils/random";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
|
import { TranslateTextOutput } from "@/modules/translator";
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
@@ -34,18 +24,10 @@ export default function TranslatorPage() {
|
|||||||
targetLanguage: string;
|
targetLanguage: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
|
|
||||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
|
||||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
|
||||||
typeof TranslationHistorySchema
|
|
||||||
> | null>(null);
|
|
||||||
const lastTTS = useRef({
|
const lastTTS = useRef({
|
||||||
text: "",
|
text: "",
|
||||||
url: "",
|
url: "",
|
||||||
});
|
});
|
||||||
const [autoSave, setAutoSave] = useState(false);
|
|
||||||
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
const tts = async (text: string, locale: string) => {
|
const tts = async (text: string, locale: string) => {
|
||||||
if (lastTTS.current.text !== text) {
|
if (lastTTS.current.text !== text) {
|
||||||
@@ -65,14 +47,13 @@ export default function TranslatorPage() {
|
|||||||
|
|
||||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||||
await load(url);
|
await load(url);
|
||||||
|
await play();
|
||||||
lastTTS.current.text = text;
|
lastTTS.current.text = text;
|
||||||
lastTTS.current.url = url;
|
lastTTS.current.url = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to generate audio");
|
toast.error("Failed to generate audio");
|
||||||
logger.error("生成音频失败", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await play();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
@@ -94,7 +75,6 @@ export default function TranslatorPage() {
|
|||||||
targetLanguage,
|
targetLanguage,
|
||||||
forceRetranslate,
|
forceRetranslate,
|
||||||
needIpa,
|
needIpa,
|
||||||
userId: session?.user?.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setTranslationResult(result);
|
setTranslationResult(result);
|
||||||
@@ -102,34 +82,6 @@ export default function TranslatorPage() {
|
|||||||
sourceText,
|
sourceText,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新本地历史记录
|
|
||||||
const historyItem = {
|
|
||||||
text1: result.sourceText,
|
|
||||||
text2: result.translatedText,
|
|
||||||
language1: result.sourceLanguage,
|
|
||||||
language2: result.targetLanguage,
|
|
||||||
};
|
|
||||||
setHistory(tlsoPush(historyItem));
|
|
||||||
|
|
||||||
// 自动保存到文件夹
|
|
||||||
if (autoSave && autoSaveFolderId) {
|
|
||||||
createPair({
|
|
||||||
text1: result.sourceText,
|
|
||||||
text2: result.translatedText,
|
|
||||||
language1: result.sourceLanguage,
|
|
||||||
language2: result.targetLanguage,
|
|
||||||
ipa1: result.sourceIpa || undefined,
|
|
||||||
ipa2: result.targetIpa || undefined,
|
|
||||||
folderId: autoSaveFolderId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(`保存失败: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("翻译失败,请重试");
|
toast.error("翻译失败,请重试");
|
||||||
console.error("翻译错误:", error);
|
console.error("翻译错误:", error);
|
||||||
@@ -261,84 +213,6 @@ export default function TranslatorPage() {
|
|||||||
{t("translate")}
|
{t("translate")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AutoSave Component */}
|
|
||||||
<div className="w-screen flex justify-center items-center">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoSave}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
if (checked === true && !session) {
|
|
||||||
toast.warning("Please login to enable auto-save");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checked === false) setAutoSaveFolderId(null);
|
|
||||||
setAutoSave(checked);
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
{t("autoSave")}
|
|
||||||
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.length > 0 && (
|
|
||||||
<div className="m-6 flex flex-col items-center">
|
|
||||||
<h1 className="text-2xl font-light">{t("history")}</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl m-4">
|
|
||||||
{history.toReversed().map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
|
|
||||||
>
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<p className="text-sm font-light">{item.text1}</p>
|
|
||||||
<p className="text-sm font-light">{item.text2}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!session?.user) {
|
|
||||||
toast.info("请先登录后再保存到文件夹");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowAddToFolder(true);
|
|
||||||
setAddToFolderItem(item);
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setHistory(
|
|
||||||
tlso.set(
|
|
||||||
tlso.get().filter((v) => !shallowEqual(v, item)),
|
|
||||||
) || [],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<Trash />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{showAddToFolder && (
|
|
||||||
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
|
||||||
)}
|
|
||||||
{autoSave && !autoSaveFolderId && (
|
|
||||||
<FolderSelector
|
|
||||||
userId={session!.user.id as string}
|
|
||||||
cancel={() => setAutoSave(false)}
|
|
||||||
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/modules/translator/index.ts
Normal file
2
src/modules/translator/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './translator-action';
|
||||||
|
export * from './translator-action-dto';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
export interface CreateTranslationHistoryInput {
|
||||||
|
userId?: string;
|
||||||
|
sourceText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
translatedText: string;
|
||||||
|
sourceIpa?: string;
|
||||||
|
targetIpa?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationHistoryQuery {
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslateTextInput {
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
forceRetranslate?: boolean; // 默认 false
|
||||||
|
needIpa?: boolean; // 默认 true
|
||||||
|
userId?: string; // 可选用户 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslateTextOutput {
|
||||||
|
sourceText: string;
|
||||||
|
translatedText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
sourceIpa: string; // 如果 needIpa=false,返回空字符串
|
||||||
|
targetIpa: string; // 如果 needIpa=false,返回空字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationLLMResponse {
|
||||||
|
translatedText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
sourceIpa?: string; // 可选,根据 needIpa 决定
|
||||||
|
targetIpa?: string; // 可选,根据 needIpa 决定
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,253 +1 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAnswer } from "@/lib/bigmodel/zhipu";
|
|
||||||
import { selectLatestTranslation, createTranslationHistory } from "./translator-service";
|
|
||||||
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "./translator-dto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genIPA = async (text: string) => {
|
|
||||||
return (
|
|
||||||
"[" +
|
|
||||||
(
|
|
||||||
await getAnswer(
|
|
||||||
`
|
|
||||||
<text>${text}</text>
|
|
||||||
|
|
||||||
请生成以上文本的严式国际音标
|
|
||||||
然后直接发给我
|
|
||||||
不要附带任何说明
|
|
||||||
不要擅自增减符号
|
|
||||||
不许用"/"或者"[]"包裹
|
|
||||||
`.trim(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.replaceAll("[", "")
|
|
||||||
.replaceAll("]", "") +
|
|
||||||
"]"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genLocale = async (text: string) => {
|
|
||||||
return await getAnswer(
|
|
||||||
`
|
|
||||||
<text>${text}</text>
|
|
||||||
|
|
||||||
推断以上文本的地区(locale)
|
|
||||||
然后直接发给我
|
|
||||||
形如如zh-CN
|
|
||||||
不要附带任何说明
|
|
||||||
不要擅自增减符号
|
|
||||||
`.trim(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genLanguage = async (text: string) => {
|
|
||||||
const language = await getAnswer([
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: `
|
|
||||||
你是一个语言检测工具。请识别文本的语言并返回语言名称。
|
|
||||||
|
|
||||||
返回语言的标准英文名称,例如:
|
|
||||||
- 中文: Chinese
|
|
||||||
- 英语: English
|
|
||||||
- 日语: Japanese
|
|
||||||
- 韩语: Korean
|
|
||||||
- 法语: French
|
|
||||||
- 德语: German
|
|
||||||
- 意大利语: Italian
|
|
||||||
- 葡萄牙语: Portuguese
|
|
||||||
- 西班牙语: Spanish
|
|
||||||
- 俄语: Russian
|
|
||||||
- 阿拉伯语: Arabic
|
|
||||||
- 印地语: Hindi
|
|
||||||
- 泰语: Thai
|
|
||||||
- 越南语: Vietnamese
|
|
||||||
- 等等...
|
|
||||||
|
|
||||||
如果无法识别语言,返回 "Unknown"
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 只返回语言的标准英文名称
|
|
||||||
2. 首字母大写,其余小写
|
|
||||||
3. 不要附带任何说明
|
|
||||||
4. 不要擅自增减符号
|
|
||||||
`.trim()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: `<text>${text}</text>`
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
return language.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genTranslation = async (text: string, targetLanguage: string) => {
|
|
||||||
|
|
||||||
return await getAnswer(
|
|
||||||
`
|
|
||||||
<text>${text}</text>
|
|
||||||
|
|
||||||
请将以上文本翻译到 <target_language>${targetLanguage}</target_language>
|
|
||||||
然后直接发给我
|
|
||||||
不要附带任何说明
|
|
||||||
不要擅自增减符号
|
|
||||||
`.trim(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一的翻译函数
|
|
||||||
* 一次调用生成所有信息,支持缓存查询
|
|
||||||
*/
|
|
||||||
export async function translateText(options: TranslateTextInput): Promise<TranslateTextOutput> {
|
|
||||||
const {
|
|
||||||
sourceText,
|
|
||||||
targetLanguage,
|
|
||||||
forceRetranslate = false,
|
|
||||||
needIpa = true,
|
|
||||||
userId,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// 1. 检查缓存(如果未强制重新翻译)并获取翻译数据
|
|
||||||
let translatedData: TranslationLLMResponse | null = null;
|
|
||||||
let fromCache = false;
|
|
||||||
|
|
||||||
if (!forceRetranslate) {
|
|
||||||
const cached = await selectLatestTranslation({
|
|
||||||
sourceText,
|
|
||||||
targetLanguage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cached && cached.translatedText && cached.sourceLanguage) {
|
|
||||||
// 如果不需要 IPA,或缓存已有 IPA,使用缓存
|
|
||||||
if (!needIpa || (cached.sourceIpa && cached.targetIpa)) {
|
|
||||||
console.log("✅ 翻译缓存命中");
|
|
||||||
translatedData = {
|
|
||||||
translatedText: cached.translatedText,
|
|
||||||
sourceLanguage: cached.sourceLanguage,
|
|
||||||
targetLanguage: cached.targetLanguage,
|
|
||||||
sourceIpa: cached.sourceIpa || undefined,
|
|
||||||
targetIpa: cached.targetIpa || undefined,
|
|
||||||
};
|
|
||||||
fromCache = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果缓存未命中,调用 LLM 生成翻译
|
|
||||||
if (!fromCache) {
|
|
||||||
translatedData = await callTranslationLLM({
|
|
||||||
sourceText,
|
|
||||||
targetLanguage,
|
|
||||||
needIpa,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 保存到数据库(不管缓存是否命中都保存)
|
|
||||||
if (translatedData) {
|
|
||||||
try {
|
|
||||||
await createTranslationHistory({
|
|
||||||
userId,
|
|
||||||
sourceText,
|
|
||||||
sourceLanguage: translatedData.sourceLanguage,
|
|
||||||
targetLanguage: translatedData.targetLanguage,
|
|
||||||
translatedText: translatedData.translatedText,
|
|
||||||
sourceIpa: needIpa ? translatedData.sourceIpa : undefined,
|
|
||||||
targetIpa: needIpa ? translatedData.targetIpa : undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("保存翻译历史失败:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sourceText,
|
|
||||||
translatedText: translatedData!.translatedText,
|
|
||||||
sourceLanguage: translatedData!.sourceLanguage,
|
|
||||||
targetLanguage: translatedData!.targetLanguage,
|
|
||||||
sourceIpa: needIpa ? (translatedData!.sourceIpa || "") : "",
|
|
||||||
targetIpa: needIpa ? (translatedData!.targetIpa || "") : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用 LLM 生成翻译和相关数据
|
|
||||||
*/
|
|
||||||
async function callTranslationLLM(params: {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
needIpa: boolean;
|
|
||||||
}): Promise<TranslationLLMResponse> {
|
|
||||||
const { sourceText, targetLanguage, needIpa } = params;
|
|
||||||
|
|
||||||
console.log("🤖 调用 LLM 翻译");
|
|
||||||
|
|
||||||
let systemPrompt = "你是一个专业的翻译助手。请根据用户的要求翻译文本,并返回 JSON 格式的结果。\n\n返回的 JSON 必须严格符合以下格式:\n{\n \"translatedText\": \"翻译后的文本\",\n \"sourceLanguage\": \"源语言的标准英文名称(如 Chinese, English, Japanese)\",\n \"targetLanguage\": \"目标语言的标准英文名称\"";
|
|
||||||
|
|
||||||
if (needIpa) {
|
|
||||||
systemPrompt += ",\n \"sourceIpa\": \"源文本的严式国际音标(用方括号包裹,如 [tɕɪn˥˩]\",\n \"targetIpa\": \"译文的严式国际音标(用方括号包裹)\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPrompt += "}\n\n规则:\n1. 只返回 JSON,不要包含任何其他文字说明\n2. 语言名称必须是标准英文名称,首字母大写\n";
|
|
||||||
|
|
||||||
if (needIpa) {
|
|
||||||
systemPrompt += "3. 国际音标必须用方括号 [] 包裹,使用严式音标\n";
|
|
||||||
} else {
|
|
||||||
systemPrompt += "3. 本次请求不需要生成国际音标\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPrompt += needIpa ? "4. 确保翻译准确、自然" : "4. 确保翻译准确、自然";
|
|
||||||
|
|
||||||
const userPrompt = `请将以下文本翻译成 ${targetLanguage}:\n\n<text>${sourceText}</text>\n\n返回 JSON 格式的翻译结果。`;
|
|
||||||
|
|
||||||
const response = await getAnswer([
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: systemPrompt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: userPrompt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 解析 LLM 返回的 JSON
|
|
||||||
try {
|
|
||||||
// 清理响应:移除 markdown 代码块标记和多余空白
|
|
||||||
let cleanedResponse = response
|
|
||||||
.replace(/```json\s*\n/g, "") // 移除 ```json 开头
|
|
||||||
.replace(/```\s*\n/g, "") // 移除 ``` 结尾
|
|
||||||
.replace(/```\s*$/g, "") // 移除末尾的 ```
|
|
||||||
.replace(/```json\s*$/g, "") // 移除末尾的 ```json
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const parsed = JSON.parse(cleanedResponse) as TranslationLLMResponse;
|
|
||||||
|
|
||||||
// 验证必需字段
|
|
||||||
if (!parsed.translatedText || !parsed.sourceLanguage || !parsed.targetLanguage) {
|
|
||||||
throw new Error("LLM 返回的数据缺少必需字段");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("LLM 翻译成功");
|
|
||||||
return parsed;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("LLM 翻译失败:", error);
|
|
||||||
console.error("原始响应:", response);
|
|
||||||
throw new Error("翻译失败:无法解析 LLM 响应");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
export interface CreateTranslationHistoryInput {
|
|
||||||
userId?: string;
|
|
||||||
sourceText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
translatedText: string;
|
|
||||||
sourceIpa?: string;
|
|
||||||
targetIpa?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslationHistoryQuery {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslateTextInput {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
forceRetranslate?: boolean; // 默认 false
|
|
||||||
needIpa?: boolean; // 默认 true
|
|
||||||
userId?: string; // 可选用户 ID
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslateTextOutput {
|
|
||||||
sourceText: string;
|
|
||||||
translatedText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
sourceIpa: string; // 如果 needIpa=false,返回空字符串
|
|
||||||
targetIpa: string; // 如果 needIpa=false,返回空字符串
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslationLLMResponse {
|
|
||||||
translatedText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
sourceIpa?: string; // 可选,根据 needIpa 决定
|
|
||||||
targetIpa?: string; // 可选,根据 needIpa 决定
|
|
||||||
}
|
|
||||||
0
src/modules/translator/translator-repository-dto.ts
Normal file
0
src/modules/translator/translator-repository-dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./translator-action-dto";
|
||||||
|
import prisma from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建翻译历史记录
|
||||||
|
*/
|
||||||
|
export async function repoCreateTranslationHistory(data: CreateTranslationHistoryInput) {
|
||||||
|
return prisma.translationHistory.create({
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询最新的翻译记录
|
||||||
|
* @param sourceText 源文本
|
||||||
|
* @param targetLanguage 目标语言
|
||||||
|
* @returns 最新的翻译记录,如果不存在则返回 null
|
||||||
|
*/
|
||||||
|
export async function repoSelectLatestTranslation(query: TranslationHistoryQuery) {
|
||||||
|
return prisma.translationHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
sourceText: query.sourceText,
|
||||||
|
targetLanguage: query.targetLanguage,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./translator-dto";
|
|
||||||
import prisma from "@/lib/db";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建翻译历史记录
|
|
||||||
*/
|
|
||||||
export async function createTranslationHistory(data: CreateTranslationHistoryInput) {
|
|
||||||
return prisma.translationHistory.create({
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询最新的翻译记录
|
|
||||||
* @param sourceText 源文本
|
|
||||||
* @param targetLanguage 目标语言
|
|
||||||
* @returns 最新的翻译记录,如果不存在则返回 null
|
|
||||||
*/
|
|
||||||
export async function selectLatestTranslation(query: TranslationHistoryQuery) {
|
|
||||||
return prisma.translationHistory.findFirst({
|
|
||||||
where: {
|
|
||||||
sourceText: query.sourceText,
|
|
||||||
targetLanguage: query.targetLanguage,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user