增加翻译缓存
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "translation_history" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"source_text" TEXT NOT NULL,
|
||||||
|
"source_language" VARCHAR(20) NOT NULL,
|
||||||
|
"target_language" VARCHAR(20) NOT NULL,
|
||||||
|
"translated_text" TEXT NOT NULL,
|
||||||
|
"source_ipa" TEXT,
|
||||||
|
"target_ipa" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -8,17 +8,18 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
dictionaryLookUps DictionaryLookUp[]
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
|
translationHistories TranslationHistory[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -188,3 +189,24 @@ model DictionaryPhraseEntry {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("dictionary_phrase_entries")
|
@@map("dictionary_phrase_entries")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TranslationHistory {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId String? @map("user_id")
|
||||||
|
sourceText String @map("source_text")
|
||||||
|
sourceLanguage String @map("source_language") @db.VarChar(20)
|
||||||
|
targetLanguage String @map("target_language") @db.VarChar(20)
|
||||||
|
translatedText String @map("translated_text")
|
||||||
|
sourceIpa String? @map("source_ipa")
|
||||||
|
targetIpa String? @map("target_ipa")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([sourceText, targetLanguage])
|
||||||
|
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||||
|
@@map("translation_history")
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,11 +85,7 @@ export function SearchResult({
|
|||||||
language1: queryLang,
|
language1: queryLang,
|
||||||
language2: definitionLang,
|
language2: definitionLang,
|
||||||
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
|
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
|
||||||
folder: {
|
folderId: selectedFolderId,
|
||||||
connect: {
|
|
||||||
id: selectedFolderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
|
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
|
||||||
|
|||||||
@@ -59,11 +59,7 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
|||||||
text2: item.text2,
|
text2: item.text2,
|
||||||
language1: item.language1,
|
language1: item.language1,
|
||||||
language2: item.language2,
|
language2: item.language2,
|
||||||
folder: {
|
folderId: folder.id,
|
||||||
connect: {
|
|
||||||
id: folder.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(t("success"));
|
toast.success(t("success"));
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import AddToFolder from "./AddToFolder";
|
import AddToFolder from "./AddToFolder";
|
||||||
import {
|
import { translateText } from "@/lib/server/bigmodel/translatorActions";
|
||||||
genIPA,
|
import type { TranslateTextOutput } from "@/lib/server/services/types";
|
||||||
genLocale,
|
|
||||||
genTranslation,
|
|
||||||
} from "@/lib/server/bigmodel/translatorActions";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import FolderSelector from "./FolderSelector";
|
import FolderSelector from "./FolderSelector";
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
@@ -28,11 +25,14 @@ export default function TranslatorPage() {
|
|||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
const [lang, setLang] = useState<string>("chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
const [tresult, setTresult] = useState<string>("");
|
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | null>(null);
|
||||||
const [genIpa, setGenIpa] = useState(true);
|
const [needIpa, setNeedIpa] = useState(true);
|
||||||
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [lastTranslation, setLastTranslation] = useState<{
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
} | null>(null);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
|
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
|
||||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||||
@@ -76,108 +76,66 @@ export default function TranslatorPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
if (!taref.current) return;
|
if (!taref.current || processing) return;
|
||||||
if (processing) return;
|
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
const text1 = taref.current.value;
|
const sourceText = taref.current.value;
|
||||||
|
|
||||||
const llmres: {
|
// 判断是否需要强制重新翻译
|
||||||
text1: string | null;
|
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
||||||
text2: string | null;
|
const forceRetranslate =
|
||||||
language1: string | null;
|
lastTranslation?.sourceText === sourceText &&
|
||||||
language2: string | null;
|
lastTranslation?.targetLanguage === targetLanguage;
|
||||||
ipa1: string | null;
|
|
||||||
ipa2: string | null;
|
|
||||||
} = {
|
|
||||||
text1: text1,
|
|
||||||
text2: null,
|
|
||||||
language1: null,
|
|
||||||
language2: null,
|
|
||||||
ipa1: null,
|
|
||||||
ipa2: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let historyUpdated = false;
|
try {
|
||||||
|
const result = await translateText({
|
||||||
// 检查更新历史记录
|
sourceText,
|
||||||
const checkUpdateLocalStorage = () => {
|
targetLanguage,
|
||||||
if (historyUpdated) return;
|
forceRetranslate,
|
||||||
if (llmres.text1 && llmres.text2 && llmres.language1 && llmres.language2) {
|
needIpa,
|
||||||
setHistory(
|
userId: session?.user?.id,
|
||||||
tlsoPush({
|
|
||||||
text1: llmres.text1,
|
|
||||||
text2: llmres.text2,
|
|
||||||
language1: llmres.language1,
|
|
||||||
language2: llmres.language2,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (autoSave && autoSaveFolderId) {
|
|
||||||
createPair({
|
|
||||||
text1: llmres.text1,
|
|
||||||
text2: llmres.text2,
|
|
||||||
language1: llmres.language1,
|
|
||||||
language2: llmres.language2,
|
|
||||||
folder: {
|
|
||||||
connect: {
|
|
||||||
id: autoSaveFolderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
llmres.text1 +
|
|
||||||
"保存到文件夹" +
|
|
||||||
autoSaveFolderId +
|
|
||||||
"失败:" +
|
|
||||||
error.message,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
historyUpdated = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 更新局部翻译状态
|
|
||||||
const updateState = (stateName: keyof typeof llmres, value: string) => {
|
|
||||||
llmres[stateName] = value;
|
|
||||||
checkUpdateLocalStorage();
|
|
||||||
};
|
|
||||||
|
|
||||||
genTranslation(text1, lang)
|
|
||||||
.then(async (text2) => {
|
|
||||||
updateState("text2", text2);
|
|
||||||
setTresult(text2);
|
|
||||||
// 生成两个locale
|
|
||||||
genLocale(text1).then((locale) => {
|
|
||||||
updateState("language1", locale);
|
|
||||||
});
|
|
||||||
genLocale(text2).then((locale) => {
|
|
||||||
updateState("language2", locale);
|
|
||||||
});
|
|
||||||
// 生成俩IPA
|
|
||||||
if (genIpa) {
|
|
||||||
genIPA(text1).then((ipa1) => {
|
|
||||||
setIpaTexts((prev) => [ipa1, prev[1]]);
|
|
||||||
updateState("ipa1", ipa1);
|
|
||||||
});
|
|
||||||
genIPA(text2).then((ipa2) => {
|
|
||||||
setIpaTexts((prev) => [prev[0], ipa2]);
|
|
||||||
updateState("ipa2", ipa2);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Translation failed");
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setProcessing(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTranslationResult(result);
|
||||||
|
setLastTranslation({
|
||||||
|
sourceText,
|
||||||
|
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) {
|
||||||
|
toast.error("翻译失败,请重试");
|
||||||
|
console.error("翻译错误:", error);
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -196,7 +154,7 @@ export default function TranslatorPage() {
|
|||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{ipaTexts[0]}
|
{translationResult?.sourceIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
@@ -214,7 +172,7 @@ export default function TranslatorPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const t = taref.current?.value;
|
const t = taref.current?.value;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
tts(t, tlso.get().find((v) => v.text1 === t)?.language1 || "");
|
tts(t, translationResult?.sourceLanguage || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,8 +180,8 @@ export default function TranslatorPage() {
|
|||||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||||
<span>{t("detectLanguage")}</span>
|
<span>{t("detectLanguage")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={genIpa}
|
selected={needIpa}
|
||||||
onClick={() => setGenIpa((prev) => !prev)}
|
onClick={() => setNeedIpa((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{t("generateIPA")}
|
{t("generateIPA")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
@@ -234,25 +192,26 @@ export default function TranslatorPage() {
|
|||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard2 Component */}
|
{/* ICard2 Component */}
|
||||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||||
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
<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">
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||||
{ipaTexts[1]}
|
{translationResult?.targetIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1/6 w-full flex justify-end items-center">
|
<div className="h-1/6 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.copy_all}
|
src={IMAGES.copy_all}
|
||||||
alt="copy"
|
alt="copy"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(tresult);
|
await navigator.clipboard.writeText(translationResult?.translatedText || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.play_arrow}
|
src={IMAGES.play_arrow}
|
||||||
alt="play"
|
alt="play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!translationResult) return;
|
||||||
tts(
|
tts(
|
||||||
tresult,
|
translationResult.translatedText,
|
||||||
tlso.get().find((v) => v.text2 === tresult)?.language2 || "",
|
translationResult.targetLanguage,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
@@ -261,29 +220,29 @@ export default function TranslatorPage() {
|
|||||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||||
<span>{t("translateInto")}</span>
|
<span>{t("translateInto")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "chinese"}
|
selected={targetLanguage === "Chinese"}
|
||||||
onClick={() => setLang("chinese")}
|
onClick={() => setTargetLanguage("Chinese")}
|
||||||
>
|
>
|
||||||
{t("chinese")}
|
{t("chinese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "english"}
|
selected={targetLanguage === "English"}
|
||||||
onClick={() => setLang("english")}
|
onClick={() => setTargetLanguage("English")}
|
||||||
>
|
>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "italian"}
|
selected={targetLanguage === "Italian"}
|
||||||
onClick={() => setLang("italian")}
|
onClick={() => setTargetLanguage("Italian")}
|
||||||
>
|
>
|
||||||
{t("italian")}
|
{t("italian")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={!["chinese", "english", "italian"].includes(lang)}
|
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newLang = prompt(t("enterLanguage"));
|
const newLang = prompt(t("enterLanguage"));
|
||||||
if (newLang) {
|
if (newLang) {
|
||||||
setLang(newLang);
|
setTargetLanguage(newLang);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -341,6 +300,10 @@ export default function TranslatorPage() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!session?.user) {
|
||||||
|
toast.info("请先登录后再保存到文件夹");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowAddToFolder(true);
|
setShowAddToFolder(true);
|
||||||
setAddToFolderItem(item);
|
setAddToFolderItem(item);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
try {
|
try {
|
||||||
await createFolder({
|
await createFolder({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
user: { connect: { id: userId } },
|
userId: userId,
|
||||||
});
|
});
|
||||||
await updateFolders();
|
await updateFolders();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -148,11 +148,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
text2: text2,
|
text2: text2,
|
||||||
language1: language1,
|
language1: language1,
|
||||||
language2: language2,
|
language2: language2,
|
||||||
folder: {
|
folderId: folderId,
|
||||||
connect: {
|
|
||||||
id: folderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { updatePairById } from "@/lib/server/services/pairService";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
import { UpdatePairInput } from "@/lib/server/services/types";
|
||||||
|
|
||||||
interface TextPairCardProps {
|
interface TextPairCardProps {
|
||||||
textPair: TextPair;
|
textPair: TextPair;
|
||||||
@@ -66,7 +66,7 @@ export default function TextPairCard({
|
|||||||
<UpdateTextPairModal
|
<UpdateTextPairModal
|
||||||
isOpen={openUpdateModal}
|
isOpen={openUpdateModal}
|
||||||
onClose={() => setOpenUpdateModal(false)}
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
onUpdate={async (id: number, data: PairUpdateInput) => {
|
onUpdate={async (id: number, data: UpdatePairInput) => {
|
||||||
await updatePairById(id, data);
|
await updatePairById(id, data);
|
||||||
setOpenUpdateModal(false);
|
setOpenUpdateModal(false);
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Input from "@/components/ui/Input";
|
|||||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
import { UpdatePairInput } from "@/lib/server/services/types";
|
||||||
import { TextPair } from "./InFolder";
|
import { TextPair } from "./InFolder";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ interface UpdateTextPairModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
textPair: TextPair;
|
textPair: TextPair;
|
||||||
onUpdate: (id: number, tp: PairUpdateInput) => void;
|
onUpdate: (id: number, tp: UpdatePairInput) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdateTextPairModal({
|
export default function UpdateTextPairModal({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
|||||||
return {
|
return {
|
||||||
get: (): z.infer<T> => {
|
get: (): z.infer<T> => {
|
||||||
try {
|
try {
|
||||||
|
if (!globalThis.localStorage) return [] as z.infer<T>;
|
||||||
const item = globalThis.localStorage.getItem(key);
|
const item = globalThis.localStorage.getItem(key);
|
||||||
|
|
||||||
if (!item) return [] as z.infer<T>;
|
if (!item) return [] as z.infer<T>;
|
||||||
|
|||||||
@@ -1,57 +1,63 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { executeDictionaryLookup } from "./dictionary";
|
import { executeDictionaryLookup } from "./dictionary";
|
||||||
import { createLookUp, createPhrase, createWord, selectLastLookUp } from "../services/dictionaryService";
|
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
|
||||||
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
|
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
|
||||||
|
|
||||||
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
|
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
|
||||||
if (isDictErrorResponse(res)) return;
|
if (isDictErrorResponse(res)) return;
|
||||||
else if (isDictPhraseResponse(res)) {
|
else if (isDictPhraseResponse(res)) {
|
||||||
return createPhrase({
|
// 先创建 Phrase
|
||||||
|
const phrase = await createPhrase({
|
||||||
standardForm: res.standardForm,
|
standardForm: res.standardForm,
|
||||||
queryLang: req.queryLang,
|
queryLang: req.queryLang,
|
||||||
definitionLang: req.definitionLang,
|
definitionLang: req.definitionLang,
|
||||||
lookups: {
|
|
||||||
create: {
|
|
||||||
user: req.userId ? {
|
|
||||||
connect: {
|
|
||||||
id: req.userId
|
|
||||||
}
|
|
||||||
} : undefined,
|
|
||||||
text: req.text,
|
|
||||||
queryLang: req.queryLang,
|
|
||||||
definitionLang: req.definitionLang
|
|
||||||
}
|
|
||||||
},
|
|
||||||
entries: {
|
|
||||||
createMany: {
|
|
||||||
data: res.entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建 Lookup
|
||||||
|
await createLookUp({
|
||||||
|
userId: req.userId,
|
||||||
|
text: req.text,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
dictionaryPhraseId: phrase.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Entries
|
||||||
|
for (const entry of res.entries) {
|
||||||
|
await createPhraseEntry({
|
||||||
|
phraseId: phrase.id,
|
||||||
|
definition: entry.definition,
|
||||||
|
example: entry.example,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (isDictWordResponse(res)) {
|
} else if (isDictWordResponse(res)) {
|
||||||
return createWord({
|
// 先创建 Word
|
||||||
|
const word = await createWord({
|
||||||
standardForm: (res as DictWordResponse).standardForm,
|
standardForm: (res as DictWordResponse).standardForm,
|
||||||
queryLang: req.queryLang,
|
queryLang: req.queryLang,
|
||||||
definitionLang: req.definitionLang,
|
definitionLang: req.definitionLang,
|
||||||
lookups: {
|
|
||||||
create: {
|
|
||||||
user: req.userId ? {
|
|
||||||
connect: {
|
|
||||||
id: req.userId
|
|
||||||
}
|
|
||||||
} : undefined,
|
|
||||||
text: req.text,
|
|
||||||
queryLang: req.queryLang,
|
|
||||||
definitionLang: req.definitionLang
|
|
||||||
}
|
|
||||||
},
|
|
||||||
entries: {
|
|
||||||
createMany: {
|
|
||||||
data: (res as DictWordResponse).entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 创建 Lookup
|
||||||
|
await createLookUp({
|
||||||
|
userId: req.userId,
|
||||||
|
text: req.text,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
dictionaryWordId: word.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Entries
|
||||||
|
for (const entry of (res as DictWordResponse).entries) {
|
||||||
|
await createWordEntry({
|
||||||
|
wordId: word.id,
|
||||||
|
ipa: entry.ipa,
|
||||||
|
definition: entry.definition,
|
||||||
|
partOfSpeech: entry.partOfSpeech,
|
||||||
|
example: entry.example,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,19 +108,11 @@ export const lookUp = async ({
|
|||||||
// 从数据库返回缓存的结果
|
// 从数据库返回缓存的结果
|
||||||
if (lastLookUp.dictionaryWordId) {
|
if (lastLookUp.dictionaryWordId) {
|
||||||
createLookUp({
|
createLookUp({
|
||||||
user: userId ? {
|
userId: userId,
|
||||||
connect: {
|
|
||||||
id: userId
|
|
||||||
}
|
|
||||||
} : undefined,
|
|
||||||
text: text,
|
text: text,
|
||||||
queryLang: queryLang,
|
queryLang: queryLang,
|
||||||
definitionLang: definitionLang,
|
definitionLang: definitionLang,
|
||||||
dictionaryWord: {
|
dictionaryWordId: lastLookUp.dictionaryWordId,
|
||||||
connect: {
|
|
||||||
id: lastLookUp.dictionaryWordId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
standardForm: lastLookUp.dictionaryWord!.standardForm,
|
standardForm: lastLookUp.dictionaryWord!.standardForm,
|
||||||
@@ -122,19 +120,11 @@ export const lookUp = async ({
|
|||||||
};
|
};
|
||||||
} else if (lastLookUp.dictionaryPhraseId) {
|
} else if (lastLookUp.dictionaryPhraseId) {
|
||||||
createLookUp({
|
createLookUp({
|
||||||
user: userId ? {
|
userId: userId,
|
||||||
connect: {
|
|
||||||
id: userId
|
|
||||||
}
|
|
||||||
} : undefined,
|
|
||||||
text: text,
|
text: text,
|
||||||
queryLang: queryLang,
|
queryLang: queryLang,
|
||||||
definitionLang: definitionLang,
|
definitionLang: definitionLang,
|
||||||
dictionaryPhrase: {
|
dictionaryPhraseId: lastLookUp.dictionaryPhraseId
|
||||||
connect: {
|
|
||||||
id: lastLookUp.dictionaryPhraseId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
standardForm: lastLookUp.dictionaryPhrase!.standardForm,
|
standardForm: lastLookUp.dictionaryPhrase!.standardForm,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAnswer } from "./zhipu";
|
import { getAnswer } from "./zhipu";
|
||||||
|
import { selectLatestTranslation, createTranslationHistory } from "../services/translatorService";
|
||||||
|
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "../services/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
export const genIPA = async (text: string) => {
|
export const genIPA = async (text: string) => {
|
||||||
return (
|
return (
|
||||||
"[" +
|
"[" +
|
||||||
@@ -24,6 +30,10 @@ export const genIPA = async (text: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
export const genLocale = async (text: string) => {
|
export const genLocale = async (text: string) => {
|
||||||
return await getAnswer(
|
return await getAnswer(
|
||||||
`
|
`
|
||||||
@@ -38,6 +48,10 @@ export const genLocale = async (text: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
export const genLanguage = async (text: string) => {
|
export const genLanguage = async (text: string) => {
|
||||||
const language = await getAnswer([
|
const language = await getAnswer([
|
||||||
{
|
{
|
||||||
@@ -79,7 +93,12 @@ export const genLanguage = async (text: string) => {
|
|||||||
return language.trim();
|
return language.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
export const genTranslation = async (text: string, targetLanguage: string) => {
|
export const genTranslation = async (text: string, targetLanguage: string) => {
|
||||||
|
|
||||||
return await getAnswer(
|
return await getAnswer(
|
||||||
`
|
`
|
||||||
<text>${text}</text>
|
<text>${text}</text>
|
||||||
@@ -91,3 +110,144 @@ export const genTranslation = async (text: string, targetLanguage: string) => {
|
|||||||
`.trim(),
|
`.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,10 +1,17 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { DictionaryLookUpCreateInput, DictionaryLookUpWhereInput, DictionaryPhraseCreateInput, DictionaryPhraseEntryCreateInput, DictionaryWordCreateInput, DictionaryWordEntryCreateInput } from "../../../../generated/prisma/models";
|
import {
|
||||||
|
CreateDictionaryLookUpInput,
|
||||||
|
DictionaryLookUpQuery,
|
||||||
|
CreateDictionaryPhraseInput,
|
||||||
|
CreateDictionaryPhraseEntryInput,
|
||||||
|
CreateDictionaryWordInput,
|
||||||
|
CreateDictionaryWordEntryInput
|
||||||
|
} from "./types";
|
||||||
import prisma from "../../db";
|
import prisma from "../../db";
|
||||||
|
|
||||||
export async function selectLastLookUp(content: DictionaryLookUpWhereInput) {
|
export async function selectLastLookUp(content: DictionaryLookUpQuery) {
|
||||||
const lookUp = await prisma.dictionaryLookUp.findFirst({
|
return prisma.dictionaryLookUp.findFirst({
|
||||||
where: content,
|
where: content,
|
||||||
include: {
|
include: {
|
||||||
dictionaryPhrase: {
|
dictionaryPhrase: {
|
||||||
@@ -22,35 +29,34 @@ export async function selectLastLookUp(content: DictionaryLookUpWhereInput) {
|
|||||||
createdAt: 'desc'
|
createdAt: 'desc'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return lookUp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPhraseEntry(content: DictionaryPhraseEntryCreateInput) {
|
export async function createPhraseEntry(content: CreateDictionaryPhraseEntryInput) {
|
||||||
return await prisma.dictionaryPhraseEntry.create({
|
return prisma.dictionaryPhraseEntry.create({
|
||||||
data: content
|
data: content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWordEntry(content: DictionaryWordEntryCreateInput) {
|
export async function createWordEntry(content: CreateDictionaryWordEntryInput) {
|
||||||
return await prisma.dictionaryWordEntry.create({
|
return prisma.dictionaryWordEntry.create({
|
||||||
data: content
|
data: content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPhrase(content: DictionaryPhraseCreateInput) {
|
export async function createPhrase(content: CreateDictionaryPhraseInput) {
|
||||||
return await prisma.dictionaryPhrase.create({
|
return prisma.dictionaryPhrase.create({
|
||||||
data: content
|
data: content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWord(content: DictionaryWordCreateInput) {
|
export async function createWord(content: CreateDictionaryWordInput) {
|
||||||
return await prisma.dictionaryWord.create({
|
return prisma.dictionaryWord.create({
|
||||||
data: content
|
data: content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLookUp(content: DictionaryLookUpCreateInput) {
|
export async function createLookUp(content: CreateDictionaryLookUpInput) {
|
||||||
return await prisma.dictionaryLookUp.create({
|
return prisma.dictionaryLookUp.create({
|
||||||
data: content
|
data: content
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
|
import { CreateFolderInput, UpdateFolderInput } from "./types";
|
||||||
import prisma from "../../db";
|
import prisma from "../../db";
|
||||||
|
|
||||||
export async function getFoldersByUserId(userId: string) {
|
export async function getFoldersByUserId(userId: string) {
|
||||||
const folders = await prisma.folder.findMany({
|
return prisma.folder.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return folders;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameFolderById(id: number, newName: string) {
|
export async function renameFolderById(id: number, newName: string) {
|
||||||
await prisma.folder.update({
|
return prisma.folder.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
@@ -32,29 +31,28 @@ export async function getFoldersWithTotalPairsByUserId(userId: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return folders.map(folder => ({
|
return folders.map(folder => ({
|
||||||
...folder,
|
...folder,
|
||||||
total: folder._count?.pairs ?? 0,
|
total: folder._count?.pairs ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFolder(folder: FolderCreateInput) {
|
export async function createFolder(folder: CreateFolderInput) {
|
||||||
await prisma.folder.create({
|
return prisma.folder.create({
|
||||||
data: folder,
|
data: folder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFolderById(id: number) {
|
export async function deleteFolderById(id: number) {
|
||||||
await prisma.folder.delete({
|
return prisma.folder.delete({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateFolderById(id: number, data: FolderUpdateInput) {
|
export async function updateFolderById(id: number, data: UpdateFolderInput) {
|
||||||
await prisma.folder.update({
|
return prisma.folder.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { PairCreateInput, PairUpdateInput } from "../../../../generated/prisma/models";
|
import { CreatePairInput, UpdatePairInput } from "./types";
|
||||||
import prisma from "../../db";
|
import prisma from "../../db";
|
||||||
|
|
||||||
export async function createPair(data: PairCreateInput) {
|
export async function createPair(data: CreatePairInput) {
|
||||||
await prisma.pair.create({
|
return prisma.pair.create({
|
||||||
data: data,
|
data: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePairById(id: number) {
|
export async function deletePairById(id: number) {
|
||||||
await prisma.pair.delete({
|
return prisma.pair.delete({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
@@ -19,9 +19,9 @@ export async function deletePairById(id: number) {
|
|||||||
|
|
||||||
export async function updatePairById(
|
export async function updatePairById(
|
||||||
id: number,
|
id: number,
|
||||||
data: PairUpdateInput,
|
data: UpdatePairInput,
|
||||||
) {
|
) {
|
||||||
await prisma.pair.update({
|
return prisma.pair.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
@@ -30,19 +30,17 @@ export async function updatePairById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPairCountByFolderId(folderId: number) {
|
export async function getPairCountByFolderId(folderId: number) {
|
||||||
const count = await prisma.pair.count({
|
return prisma.pair.count({
|
||||||
where: {
|
where: {
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPairsByFolderId(folderId: number) {
|
export async function getPairsByFolderId(folderId: number) {
|
||||||
const textPairs = await prisma.pair.findMany({
|
return prisma.pair.findMany({
|
||||||
where: {
|
where: {
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return textPairs;
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/lib/server/services/translatorService.ts
Normal file
31
src/lib/server/services/translatorService.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./types";
|
||||||
|
import prisma from "../../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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
122
src/lib/server/services/types.ts
Normal file
122
src/lib/server/services/types.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Service 层的自定义业务类型
|
||||||
|
*
|
||||||
|
* 这些类型用于替换 Prisma 生成的类型,提高代码的可维护性和抽象层次
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Folder 相关
|
||||||
|
export interface CreateFolderInput {
|
||||||
|
name: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFolderInput {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair 相关
|
||||||
|
export interface CreatePairInput {
|
||||||
|
text1: string;
|
||||||
|
text2: string;
|
||||||
|
language1: string;
|
||||||
|
language2: string;
|
||||||
|
ipa1?: string;
|
||||||
|
ipa2?: string;
|
||||||
|
folderId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePairInput {
|
||||||
|
text1?: string;
|
||||||
|
text2?: string;
|
||||||
|
language1?: string;
|
||||||
|
language2?: string;
|
||||||
|
ipa1?: string;
|
||||||
|
ipa2?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation 相关
|
||||||
|
export interface CreateTranslationHistoryInput {
|
||||||
|
userId?: string;
|
||||||
|
sourceText: string;
|
||||||
|
sourceLanguage?: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
translatedText: string;
|
||||||
|
sourceIpa?: string;
|
||||||
|
targetIpa?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationHistoryQuery {
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dictionary 相关
|
||||||
|
export interface CreateDictionaryLookUpInput {
|
||||||
|
userId?: string;
|
||||||
|
text: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
dictionaryWordId?: number;
|
||||||
|
dictionaryPhraseId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryLookUpQuery {
|
||||||
|
userId?: string;
|
||||||
|
text?: string;
|
||||||
|
queryLang?: string;
|
||||||
|
definitionLang?: string;
|
||||||
|
dictionaryWordId?: number;
|
||||||
|
dictionaryPhraseId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryWordInput {
|
||||||
|
standardForm: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryPhraseInput {
|
||||||
|
standardForm: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryWordEntryInput {
|
||||||
|
wordId: number;
|
||||||
|
ipa: string;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech: string;
|
||||||
|
example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryPhraseEntryInput {
|
||||||
|
phraseId: number;
|
||||||
|
definition: string;
|
||||||
|
example: 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,5 +1,5 @@
|
|||||||
import prisma from "@/lib/db";
|
import prisma from "@/lib/db";
|
||||||
import { UserCreateInput } from "../../../../generated/prisma/models";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export async function createUserIfNotExists(email: string, name?: string | null) {
|
export async function createUserIfNotExists(email: string, name?: string | null) {
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
@@ -8,9 +8,10 @@ export async function createUserIfNotExists(email: string, name?: string | null)
|
|||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
|
id: randomUUID(),
|
||||||
email: email,
|
email: email,
|
||||||
name: name || "New User",
|
name: name || "New User",
|
||||||
} as UserCreateInput,
|
},
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user