Compare commits

..

7 Commits

Author SHA1 Message Date
9715844eae 宽松化pairs表约束
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-06 19:23:35 +08:00
504ecd259d 增加翻译缓存 2026-01-06 19:11:21 +08:00
06e90687f1 优化词典生成效果 2026-01-06 16:45:52 +08:00
b093ed2b4f 补全翻译 2026-01-06 16:04:53 +08:00
37e221d8b8 ... 2026-01-06 15:41:11 +08:00
f1dcd5afaa ... 2026-01-05 18:37:12 +08:00
66d17df59d 补全翻译
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-05 18:35:12 +08:00
47 changed files with 2289 additions and 771 deletions

View File

@@ -42,6 +42,7 @@
"text2": "Text 2",
"language1": "Sprache 1",
"language2": "Sprache 2",
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
"edit": "Bearbeiten",
"delete": "Löschen"
},
@@ -189,5 +190,33 @@
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
},
"autoSave": "Automatisch speichern"
},
"dictionary": {
"title": "Wörterbuch",
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
"searching": "Suche...",
"search": "Suchen",
"languageSettings": "Spracheinstellungen",
"queryLanguage": "Abfragesprache",
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
"definitionLanguage": "Definitionssprache",
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
"relookup": "Neu suchen",
"saveToFolder": "In Ordner speichern",
"loading": "Laden...",
"noResults": "Keine Ergebnisse gefunden",
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
"welcomeTitle": "Willkommen beim Wörterbuch",
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
"relookupSuccess": "Erfolgreich neu gesucht",
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
"savedToFolder": "Im Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "Text 2",
"language1": "Locale 1",
"language2": "Locale 2",
"enterLanguageName": "Please enter language name",
"edit": "Edit",
"delete": "Delete"
},
@@ -189,5 +190,33 @@
"error": "Failed to add text pair to folder"
},
"autoSave": "Auto Save"
},
"dictionary": {
"title": "Dictionary",
"description": "Look up words and phrases with detailed definitions and examples",
"searchPlaceholder": "Enter a word or phrase to look up...",
"searching": "Searching...",
"search": "Search",
"languageSettings": "Language Settings",
"queryLanguage": "Query Language",
"queryLanguageHint": "What language is the word/phrase you want to look up",
"definitionLanguage": "Definition Language",
"definitionLanguageHint": "What language do you want the definitions in",
"otherLanguagePlaceholder": "Or enter another language...",
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
"relookup": "Re-search",
"saveToFolder": "Save to folder",
"loading": "Loading...",
"noResults": "No results found",
"tryOtherWords": "Try other words or phrases",
"welcomeTitle": "Welcome to Dictionary",
"welcomeHint": "Enter a word or phrase in the search box above to start looking up",
"lookupFailed": "Search failed, please try again later",
"relookupSuccess": "Re-searched successfully",
"relookupFailed": "Dictionary re-search failed",
"pleaseLogin": "Please log in first",
"pleaseCreateFolder": "Please create a folder first",
"savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "Texte 2",
"language1": "Langue 1",
"language2": "Langue 2",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"edit": "Modifier",
"delete": "Supprimer"
},
@@ -189,5 +190,33 @@
"error": "Échec de l'ajout de la paire de textes au dossier"
},
"autoSave": "Sauvegarde automatique"
},
"dictionary": {
"title": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
"searching": "Recherche...",
"search": "Rechercher",
"languageSettings": "Paramètres linguistiques",
"queryLanguage": "Langue de requête",
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
"definitionLanguage": "Langue de définition",
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
"relookup": "Rechercher à nouveau",
"saveToFolder": "Enregistrer dans le dossier",
"loading": "Chargement...",
"noResults": "Aucun résultat trouvé",
"tryOtherWords": "Essayez d'autres mots ou phrases",
"welcomeTitle": "Bienvenue dans le dictionnaire",
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
"relookupSuccess": "Recherche répétée avec succès",
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
"pleaseLogin": "Veuillez d'abord vous connecter",
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
"savedToFolder": "Enregistré dans le dossier : {folderName}",
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "Testo 2",
"language1": "Lingua 1",
"language2": "Lingua 2",
"enterLanguageName": "Inserisci il nome della lingua",
"edit": "Modifica",
"delete": "Elimina"
},
@@ -189,5 +190,33 @@
"error": "Impossibile aggiungere la coppia di testi alla cartella"
},
"autoSave": "Salvataggio automatico"
},
"dictionary": {
"title": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi",
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
"searching": "Ricerca...",
"search": "Cerca",
"languageSettings": "Impostazioni lingua",
"queryLanguage": "Lingua di interrogazione",
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
"definitionLanguage": "Lingua di definizione",
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
"relookup": "Ricerca di nuovo",
"saveToFolder": "Salva nella cartella",
"loading": "Caricamento...",
"noResults": "Nessun risultato trovato",
"tryOtherWords": "Prova altre parole o frasi",
"welcomeTitle": "Benvenuto nel dizionario",
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
"lookupFailed": "Ricerca fallita, riprova più tardi",
"relookupSuccess": "Ricerca ripetuta con successo",
"relookupFailed": "Nuova ricerca del dizionario fallita",
"pleaseLogin": "Accedi prima",
"pleaseCreateFolder": "Crea prima una cartella",
"savedToFolder": "Salvato nella cartella: {folderName}",
"saveFailed": "Salvataggio fallito, riprova più tardi"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "テキスト2",
"language1": "言語1",
"language2": "言語2",
"enterLanguageName": "言語名を入力してください",
"edit": "編集",
"delete": "削除"
},
@@ -189,5 +190,33 @@
"error": "テキストペアの追加に失敗しました"
},
"autoSave": "自動保存"
},
"dictionary": {
"title": "辞書",
"description": "詳細な定義と例で単語やフレーズを検索",
"searchPlaceholder": "検索する単語やフレーズを入力...",
"searching": "検索中...",
"search": "検索",
"languageSettings": "言語設定",
"queryLanguage": "クエリ言語",
"queryLanguageHint": "検索する単語/フレーズの言語",
"definitionLanguage": "定義言語",
"definitionLanguageHint": "定義を表示する言語",
"otherLanguagePlaceholder": "または他の言語を入力...",
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
"relookup": "再検索",
"saveToFolder": "フォルダに保存",
"loading": "読み込み中...",
"noResults": "結果が見つかりません",
"tryOtherWords": "他の単語やフレーズを試してください",
"welcomeTitle": "辞書へようこそ",
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
"relookupSuccess": "再検索しました",
"relookupFailed": "辞書の再検索に失敗しました",
"pleaseLogin": "まずログインしてください",
"pleaseCreateFolder": "まずフォルダを作成してください",
"savedToFolder": "フォルダに保存しました:{folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "텍스트 2",
"language1": "언어 1",
"language2": "언어 2",
"enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집",
"delete": "삭제"
},
@@ -189,5 +190,33 @@
"error": "텍스트 쌍 추가 실패"
},
"autoSave": "자동 저장"
},
"dictionary": {
"title": "사전",
"description": "상세한 정의와 예제로 단어 및 구문 검색",
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
"searching": "검색 중...",
"search": "검색",
"languageSettings": "언어 설정",
"queryLanguage": "쿼리 언어",
"queryLanguageHint": "검색하려는 단어/구문의 언어",
"definitionLanguage": "정의 언어",
"definitionLanguageHint": "정의를 표시할 언어",
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
"relookup": "재검색",
"saveToFolder": "폴더에 저장",
"loading": "로드 중...",
"noResults": "결과를 찾을 수 없습니다",
"tryOtherWords": "다른 단어나 구문을 시도하세요",
"welcomeTitle": "사전에 오신 것을 환영합니다",
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
"relookupSuccess": "재검색했습니다",
"relookupFailed": "사전 재검색 실패",
"pleaseLogin": "먼저 로그인하세요",
"pleaseCreateFolder": "먼저 폴더를 만드세요",
"savedToFolder": "폴더에 저장됨: {folderName}",
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "تېكىست 2",
"language1": "تىل 1",
"language2": "تىل 2",
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش"
},
@@ -189,5 +190,33 @@
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
},
"autoSave": "ئاپتوماتىك ساقلاش"
},
"dictionary": {
"title": "لۇغەت",
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
"searching": "ئىزدەۋاتىدۇ...",
"search": "ئىزدە",
"languageSettings": "تىل تەڭشىكى",
"queryLanguage": "سۈرەشتۈرۈش تىلى",
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
"definitionLanguage": "ئىلمىيى تىلى",
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
"relookup": "قايتا ئىزدە",
"saveToFolder": "قىسقۇچقا ساقلا",
"loading": "يۈكلىۋاتىدۇ...",
"noResults": "نەتىجە تېپىلمىدى",
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
"welcomeTitle": "لۇغەتكە مەرھەمەت",
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
}
}

View File

@@ -42,6 +42,7 @@
"text2": "文本2",
"language1": "语言1",
"language2": "语言2",
"enterLanguageName": "请输入语言名称",
"edit": "编辑",
"delete": "删除"
},
@@ -189,5 +190,33 @@
"error": "添加文本对到文件夹失败"
},
"autoSave": "自动保存"
},
"dictionary": {
"title": "词典",
"description": "查询单词和短语,提供详细的释义和例句",
"searchPlaceholder": "输入要查询的单词或短语...",
"searching": "查询中...",
"search": "查询",
"languageSettings": "语言设置",
"queryLanguage": "查询语言",
"queryLanguageHint": "你要查询的单词/短语是什么语言",
"definitionLanguage": "释义语言",
"definitionLanguageHint": "你希望用什么语言查看释义",
"otherLanguagePlaceholder": "或输入其他语言...",
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
"relookup": "重新查询",
"saveToFolder": "保存到文件夹",
"loading": "加载中...",
"noResults": "未找到结果",
"tryOtherWords": "尝试其他单词或短语",
"welcomeTitle": "欢迎使用词典",
"welcomeHint": "在上方搜索框中输入单词或短语开始查询",
"lookupFailed": "查询失败,请稍后重试",
"relookupSuccess": "已重新查询",
"relookupFailed": "词典重新查询失败",
"pleaseLogin": "请先登录",
"pleaseCreateFolder": "请先创建文件夹",
"savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试"
}
}

View File

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
-- DropIndex
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
-- RenameIndex
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";

View File

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

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");

View File

@@ -19,6 +19,7 @@ model User {
accounts Account[]
folders Folder[]
dictionaryLookUps DictionaryLookUp[]
translationHistories TranslationHistory[]
@@unique([email])
@@map("user")
@@ -86,7 +87,7 @@ model Pair {
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, language1, language2, text1])
@@unique([folderId, language1, language2, text1, text2])
@@index([folderId])
@@map("pairs")
}
@@ -136,7 +137,6 @@ model DictionaryWord {
lookups DictionaryLookUp[]
entries DictionaryWordEntry[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_words")
@@ -153,7 +153,6 @@ model DictionaryPhrase {
lookups DictionaryLookUp[]
entries DictionaryPhraseEntry[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_phrases")
@@ -190,3 +189,24 @@ model DictionaryPhraseEntry {
@@index([createdAt])
@@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")
}

View File

@@ -1,96 +0,0 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
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 { Folder as Fd } from "lucide-react";
import { createPair } from "@/lib/server/services/pairService";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps {
definitionLang: string;
queryLang: string;
standardForm: string;
definition: string;
ipa?: string;
setShow: (show: boolean) => void;
}
const AddToFolder: React.FC<AddToFolderProps> = ({
definitionLang,
queryLang,
standardForm,
definition,
ipa,
setShow,
}) => {
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!session) return;
const userId = session.user.id as string;
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [session]);
if (!session) {
return null;
}
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 className="text-xl font-bold mb-4"></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: standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: ipa || undefined,
folder: {
connect: {
id: folder.id,
},
},
})
.then(() => {
toast.success(`已保存到文件夹:${folder.name}`);
setShow(false);
})
.catch(() => {
toast.error("保存失败,请稍后重试");
});
}}
>
<Fd />
{folder.name}
</button>
))
) : (
<div className="p-4 text-gray-500"></div>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<LightButton onClick={() => setShow(false)}></LightButton>
</div>
</Container>
</div>
);
};
export default AddToFolder;

View File

@@ -0,0 +1,78 @@
import { DictWordEntry, DictPhraseEntry } from "./types";
interface DictionaryEntryProps {
entry: DictWordEntry | DictPhraseEntry;
}
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
// 检查是否有 ipa 字段来判断是否为单词条目
const isWordEntry = "ipa" in entry && "partOfSpeech" in entry;
if (isWordEntry) {
// 单词条目
const wordEntry = entry as DictWordEntry;
return (
<div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{wordEntry.ipa && (
<span className="text-gray-600 text-lg">
[{wordEntry.ipa}]
</span>
)}
{wordEntry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{wordEntry.partOfSpeech}
</span>
)}
</div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{wordEntry.definition}</p>
</div>
{/* 例句 */}
{wordEntry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{wordEntry.example}
</p>
</div>
)}
</div>
);
}
// 短语条目
const phraseEntry = entry as DictPhraseEntry;
return (
<div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{phraseEntry.definition}</p>
</div>
{/* 例句 */}
{phraseEntry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{phraseEntry.example}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { useState, useEffect } from "react";
import Container from "@/components/ui/Container";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { DictLookUpResponse, isDictErrorResponse } from "./types";
import { SearchForm } from "./SearchForm";
import { SearchResult } from "./SearchResult";
import { useTranslations } from "next-intl";
import { POPULAR_LANGUAGES } from "./constants";
export default function Dictionary() {
const t = useTranslations("dictionary");
const [searchQuery, setSearchQuery] = useState("");
const [searchResult, setSearchResult] = useState<DictLookUpResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese");
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [folders, setFolders] = useState<Folder[]>([]);
const { data: session } = authClient.useSession();
// 加载用户的文件夹列表
useEffect(() => {
if (session) {
getFoldersByUserId(session.user.id as string)
.then((loadedFolders) => {
setFolders(loadedFolders);
// 如果有文件夹且未选择,默认选择第一个
if (loadedFolders.length > 0 && !selectedFolderId) {
setSelectedFolderId(loadedFolders[0].id);
}
});
}
}, [session, selectedFolderId]);
// 将 code 转换为 nativeName
const getNativeName = (code: string) => {
return POPULAR_LANGUAGES.find(l => l.code === code)?.nativeName || code;
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
setHasSearched(true);
setSearchResult(null);
try {
// 使用查询语言和释义语言的 nativeName
const result = await lookUp({
text: searchQuery,
definitionLang: getNativeName(definitionLang),
queryLang: getNativeName(queryLang),
forceRelook: false
})
// 检查是否为错误响应
if (isDictErrorResponse(result)) {
toast.error(result.error);
setSearchResult(null);
} else {
setSearchResult(result);
}
} catch (error) {
console.error("词典查询失败:", error);
toast.error(t("lookupFailed"));
setSearchResult(null);
} finally {
setIsSearching(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
{/* 搜索区域 */}
<div className="flex items-center justify-center px-4 py-12">
<Container className="max-w-3xl w-full p-4">
<SearchForm
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
isSearching={isSearching}
onSearch={handleSearch}
queryLang={queryLang}
onQueryLangChange={setQueryLang}
definitionLang={definitionLang}
onDefinitionLangChange={setDefinitionLang}
/>
</Container>
</div>
{/* 搜索结果区域 */}
<div className="flex-1 px-4 pb-12">
<Container className="max-w-3xl w-full p-4">
{isSearching && (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p className="mt-4 text-white">{t("loading")}</p>
</div>
)}
{!isSearching && hasSearched && !searchResult && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">{t("noResults")}</p>
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
</div>
)}
{!isSearching && searchResult && !isDictErrorResponse(searchResult) && (
<SearchResult
searchResult={searchResult}
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
folders={folders}
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
onResultUpdate={setSearchResult}
onSearchingChange={setIsSearching}
getNativeName={getNativeName}
/>
)}
{!hasSearched && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
<p className="text-gray-600">{t("welcomeHint")}</p>
</div>
)}
</Container>
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { LightButton } from "@/components/ui/buttons";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface SearchFormProps {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
isSearching: boolean;
onSearch: (e: React.FormEvent) => void;
queryLang: string;
onQueryLangChange: (lang: string) => void;
definitionLang: string;
onDefinitionLangChange: (lang: string) => void;
}
export function SearchForm({
searchQuery,
onSearchQueryChange,
isSearching,
onSearch,
queryLang,
onQueryLangChange,
definitionLang,
onDefinitionLangChange,
}: SearchFormProps) {
const t = useTranslations("dictionary");
return (
<>
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
{/* 搜索表单 */}
<form onSubmit={onSearch} className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)}
placeholder={t("searchPlaceholder")}
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
<LightButton
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-3"
>
{isSearching ? t("searching") : t("search")}
</LightButton>
</form>
{/* 语言设置 */}
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="mb-3">
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
</div>
<div className="space-y-4">
{/* 查询语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("queryLanguage")} ({t("queryLanguageHint")})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={queryLang === lang.code}
onClick={() => onQueryLangChange(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
<input
type="text"
value={queryLang}
onChange={(e) => onQueryLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("definitionLanguage")} ({t("definitionLanguageHint")})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={definitionLang === lang.code}
onClick={() => onDefinitionLangChange(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
<input
type="text"
value={definitionLang}
onChange={(e) => onDefinitionLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 当前设置显示 */}
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
{t("currentSettings", {
queryLang: POPULAR_LANGUAGES.find(l => l.code === queryLang)?.nativeName || queryLang,
definitionLang: POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.nativeName || definitionLang
})}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,155 @@
import { Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { createPair } from "@/lib/server/services/pairService";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import {
DictWordResponse,
DictPhraseResponse,
isDictWordResponse,
DictWordEntry,
isDictErrorResponse,
} from "./types";
import { DictionaryEntry } from "./DictionaryEntry";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface SearchResultProps {
searchResult: DictWordResponse | DictPhraseResponse;
searchQuery: string;
queryLang: string;
definitionLang: string;
folders: Folder[];
selectedFolderId: number | null;
onFolderSelect: (folderId: number | null) => void;
onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void;
onSearchingChange: (isSearching: boolean) => void;
getNativeName: (code: string) => string;
}
export function SearchResult({
searchResult,
searchQuery,
queryLang,
definitionLang,
folders,
selectedFolderId,
onFolderSelect,
onResultUpdate,
onSearchingChange,
getNativeName,
}: SearchResultProps) {
const t = useTranslations("dictionary");
const { data: session } = authClient.useSession();
const handleRelookup = async () => {
onSearchingChange(true);
try {
const result = await lookUp({
text: searchQuery,
definitionLang: getNativeName(definitionLang),
queryLang: getNativeName(queryLang),
forceRelook: true
});
if (isDictErrorResponse(result)) {
toast.error(result.error);
} else {
onResultUpdate(result);
toast.success(t("relookupSuccess"));
}
} catch (error) {
console.error("词典重新查询失败:", error);
toast.error(t("lookupFailed"));
} finally {
onSearchingChange(false);
}
};
const handleSave = () => {
if (!session) {
toast.error(t("pleaseLogin"));
return;
}
if (!selectedFolderId) {
toast.error(t("pleaseCreateFolder"));
return;
}
const entry = searchResult.entries[0];
createPair({
text1: searchResult.standardForm,
text2: entry.definition,
language1: queryLang,
language2: definitionLang,
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
folderId: selectedFolderId,
})
.then(() => {
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName }));
})
.catch(() => {
toast.error(t("saveFailed"));
});
};
return (
<div className="space-y-6">
<div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标题和保存按钮 */}
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
value={selectedFolderId || ""}
onChange={(e) => onFolderSelect(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<button
onClick={handleSave}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center shrink-0"
title={t("saveToFolder")}
>
<Plus />
</button>
</div>
</div>
{/* 条目列表 */}
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
<DictionaryEntry entry={entry} />
</div>
))}
</div>
{/* 重新查询按钮 */}
<div className="border-t border-gray-200 pt-4 mt-4">
<button
onClick={handleRelookup}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
{t("relookup")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export const POPULAR_LANGUAGES = [
{ code: "english", name: "英语", nativeName: "English" },
{ code: "chinese", name: "中文", nativeName: "中文" },
{ code: "japanese", name: "日语", nativeName: "日本語" },
{ code: "korean", name: "韩语", nativeName: "한국어" },
{ code: "italian", name: "意大利语", nativeName: "Italiano" },
{ code: "uyghur", name: "维吾尔语", nativeName: "ئۇيغۇرچە" },
] as const;

View File

@@ -0,0 +1,11 @@
// 类型定义
export * from "./types";
// 常量
export * from "./constants";
// 组件
export { default as DictionaryPage } from "./DictionaryPage";
export { SearchForm } from "./SearchForm";
export { SearchResult } from "./SearchResult";
export { DictionaryEntry } from "./DictionaryEntry";

View File

@@ -1,398 +1 @@
"use client";
import { useState, useEffect } from "react";
import Container from "@/components/ui/Container";
import { LightButton } from "@/components/ui/buttons";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { createPair } from "@/lib/server/services/pairService";
// 主流语言列表
const POPULAR_LANGUAGES = [
{ code: "english", name: "英语" },
{ code: "chinese", name: "中文" },
{ code: "japanese", name: "日语" },
{ code: "korean", name: "韩语" },
{ code: "french", name: "法语" },
{ code: "german", name: "德语" },
{ code: "italian", name: "意大利语" },
{ code: "spanish", name: "西班牙语" },
];
type DictionaryWordEntry = {
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
};
type DictionaryPhraseEntry = {
definition: string;
example: string;
};
type DictionaryErrorResponse = {
error: string;
};
type DictionarySuccessResponse = {
standardForm: string;
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
};
type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse;
// 类型守卫:判断是否为单词条目
function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry {
return "ipa" in entry && "partOfSpeech" in entry;
}
// 类型守卫:判断是否为错误响应
function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse {
return "error" in response;
}
export default function Dictionary() {
const [searchQuery, setSearchQuery] = useState("");
const [searchResult, setSearchResult] = useState<DictionaryResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese");
const [showLangSettings, setShowLangSettings] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [folders, setFolders] = useState<Folder[]>([]);
const { data: session } = authClient.useSession();
// 加载用户的文件夹列表
useEffect(() => {
if (session) {
getFoldersByUserId(session.user.id as string)
.then((loadedFolders) => {
setFolders(loadedFolders);
// 如果有文件夹且未选择,默认选择第一个
if (loadedFolders.length > 0 && !selectedFolderId) {
setSelectedFolderId(loadedFolders[0].id);
}
});
}
}, [session, selectedFolderId]);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
setHasSearched(true);
setSearchResult(null);
try {
// 使用查询语言和释义语言
const result = await lookUp(searchQuery, queryLang, definitionLang);
// 检查是否为错误响应
if (isErrorResponse(result)) {
toast.error(result.error);
setSearchResult(null);
} else {
setSearchResult(result);
}
} catch (error) {
console.error("词典查询失败:", error);
toast.error("查询失败,请稍后重试");
setSearchResult(null);
} finally {
setIsSearching(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
{/* 搜索区域 */}
<div className="flex items-center justify-center px-4 py-12">
<Container className="max-w-3xl w-full p-4">
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
</h1>
<p className="text-gray-700 text-lg">
</p>
</div>
{/* 搜索表单 */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
placeholder="输入要查询的单词或短语..."
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
<LightButton
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-3"
>
{isSearching ? "查询中..." : "查询"}
</LightButton>
</form>
{/* 语言设置 */}
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-gray-800 font-semibold"></span>
<LightButton
onClick={() => setShowLangSettings(!showLangSettings)}
className="text-sm px-4 py-2"
>
{showLangSettings ? "收起" : "展开"}
</LightButton>
</div>
{showLangSettings && (
<div className="space-y-4">
{/* 查询语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
(/)
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={queryLang === lang.code}
onClick={() => setQueryLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.name}
</LightButton>
))}
</div>
<input
type="text"
value={queryLang}
onChange={(e) => setQueryLang(e.target.value)}
placeholder="或输入其他语言..."
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
()
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={definitionLang === lang.code}
onClick={() => setDefinitionLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.name}
</LightButton>
))}
</div>
<input
type="text"
value={definitionLang}
onChange={(e) => setDefinitionLang(e.target.value)}
placeholder="或输入其他语言..."
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 当前设置显示 */}
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
<span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang}</span>
<span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang}</span>
</div>
</div>
)}
</div>
{/* 搜索提示 */}
<div className="mt-4 text-center text-gray-700 text-sm">
<p>hello, look up, dictionary</p>
</div>
</Container>
</div>
{/* 搜索结果区域 */}
<div className="flex-1 px-4 pb-12">
<Container className="max-w-3xl w-full p-4">
{isSearching && (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p className="mt-4 text-white">...</p>
</div>
)}
{!isSearching && hasSearched && !searchResult && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl"></p>
<p className="text-gray-600 mt-2"></p>
</div>
)}
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
<div className="space-y-6">
<div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标题和保存按钮 */}
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
{searchResult.standardForm !== searchQuery && (
<p className="text-gray-500 text-sm">
: {searchQuery}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
value={selectedFolderId || ""}
onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<button
onClick={() => {
if (!session) {
toast.error("请先登录");
return;
}
if (!selectedFolderId) {
toast.error("请先创建文件夹");
return;
}
if (!searchResult || isErrorResponse(searchResult)) return;
const entry = searchResult.entries[0];
createPair({
text1: searchResult.standardForm,
text2: entry.definition,
language1: queryLang,
language2: definitionLang,
ipa1: isWordEntry(entry) ? entry.ipa : undefined,
folder: {
connect: {
id: selectedFolderId,
},
},
})
.then(() => {
const folderName = folders.find(f => f.id === selectedFolderId)?.name;
toast.success(`已保存到文件夹:${folderName}`);
})
.catch(() => {
toast.error("保存失败,请稍后重试");
});
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center flex-shrink-0"
title="保存到文件夹"
>
<Plus />
</button>
</div>
</div>
{/* 条目列表 */}
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
{isWordEntry(entry) ? (
// 单词条目
<div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{entry.ipa && (
<span className="text-gray-600 text-lg">
{entry.ipa}
</span>
)}
{entry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{entry.partOfSpeech}
</span>
)}
</div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{entry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{entry.example}
</p>
</div>
)}
</div>
) : (
// 短语条目
<div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{entry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{entry.example}
</p>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{!hasSearched && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">使</p>
<p className="text-gray-600"></p>
</div>
)}
</Container>
</div>
</div>
);
}
export { default } from "./DictionaryPage";

View File

@@ -0,0 +1,2 @@
// 从 shared 文件夹导出所有词典类型和类型守卫
export * from "@/lib/shared";

View File

@@ -59,11 +59,7 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
text2: item.text2,
language1: item.language1,
language2: item.language2,
folder: {
connect: {
id: folder.id,
},
},
folderId: folder.id,
})
.then(() => {
toast.success(t("success"));

View File

@@ -12,11 +12,8 @@ import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import z from "zod";
import AddToFolder from "./AddToFolder";
import {
genIPA,
genLocale,
genTranslation,
} from "@/lib/server/bigmodel/translatorActions";
import { translateText } from "@/lib/server/bigmodel/translatorActions";
import type { TranslateTextOutput } from "@/lib/server/services/types";
import { toast } from "sonner";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
@@ -28,11 +25,14 @@ export default function TranslatorPage() {
const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null);
const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>("");
const [genIpa, setGenIpa] = useState(true);
const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | 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 [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
const [showAddToFolder, setShowAddToFolder] = useState(false);
@@ -76,108 +76,66 @@ export default function TranslatorPage() {
};
const translate = async () => {
if (!taref.current) return;
if (processing) return;
if (!taref.current || processing) return;
setProcessing(true);
const text1 = taref.current.value;
const sourceText = taref.current.value;
const llmres: {
text1: string | null;
text2: string | null;
language1: string | null;
language2: string | null;
ipa1: string | null;
ipa2: string | null;
} = {
text1: text1,
text2: null,
language1: null,
language2: null,
ipa1: null,
ipa2: null,
// 判断是否需要强制重新翻译
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate =
lastTranslation?.sourceText === sourceText &&
lastTranslation?.targetLanguage === targetLanguage;
try {
const result = await translateText({
sourceText,
targetLanguage,
forceRetranslate,
needIpa,
userId: session?.user?.id,
});
setTranslationResult(result);
setLastTranslation({
sourceText,
targetLanguage,
});
// 更新本地历史记录
const historyItem = {
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
};
setHistory(tlsoPush(historyItem));
let historyUpdated = false;
// 检查更新历史记录
const checkUpdateLocalStorage = () => {
if (historyUpdated) return;
if (llmres.text1 && llmres.text2 && llmres.language1 && llmres.language2) {
setHistory(
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,
},
},
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(
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
);
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`);
})
.catch((error) => {
toast.error(
llmres.text1 +
"保存到文件夹" +
autoSaveFolderId +
"失败:" +
error.message,
);
toast.error(`保存失败: ${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(() => {
} catch (error) {
toast.error("翻译失败,请重试");
console.error("翻译错误:", error);
} finally {
setProcessing(false);
});
}
};
return (
@@ -196,7 +154,7 @@ export default function TranslatorPage() {
}}
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{ipaTexts[0]}
{translationResult?.sourceIpa || ""}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
@@ -214,7 +172,7 @@ export default function TranslatorPage() {
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, tlso.get().find((v) => v.text1 === t)?.language1 || "");
tts(t, translationResult?.sourceLanguage || "");
}}
></IconClick>
</div>
@@ -222,8 +180,8 @@ export default function TranslatorPage() {
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<LightButton
selected={genIpa}
onClick={() => setGenIpa((prev) => !prev)}
selected={needIpa}
onClick={() => setNeedIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
@@ -234,25 +192,26 @@ export default function TranslatorPage() {
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<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">
{ipaTexts[1]}
{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(tresult);
await navigator.clipboard.writeText(translationResult?.translatedText || "");
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
if (!translationResult) return;
tts(
tresult,
tlso.get().find((v) => v.text2 === tresult)?.language2 || "",
translationResult.translatedText,
translationResult.targetLanguage,
);
}}
></IconClick>
@@ -261,29 +220,29 @@ export default function TranslatorPage() {
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<LightButton
selected={lang === "chinese"}
onClick={() => setLang("chinese")}
selected={targetLanguage === "Chinese"}
onClick={() => setTargetLanguage("Chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={lang === "english"}
onClick={() => setLang("english")}
selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")}
>
{t("english")}
</LightButton>
<LightButton
selected={lang === "italian"}
onClick={() => setLang("italian")}
selected={targetLanguage === "Italian"}
onClick={() => setTargetLanguage("Italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["chinese", "english", "italian"].includes(lang)}
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
onClick={() => {
const newLang = prompt(t("enterLanguage"));
if (newLang) {
setLang(newLang);
setTargetLanguage(newLang);
}
}}
>
@@ -341,6 +300,10 @@ export default function TranslatorPage() {
<div className="flex gap-2">
<button
onClick={() => {
if (!session?.user) {
toast.info("请先登录后再保存到文件夹");
return;
}
setShowAddToFolder(true);
setAddToFolderItem(item);
}}

View File

@@ -129,7 +129,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
try {
await createFolder({
name: folderName,
user: { connect: { id: userId } },
userId: userId,
});
await updateFolders();
} finally {

View File

@@ -148,11 +148,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
text2: text2,
language1: language1,
language2: language2,
folder: {
connect: {
id: folderId,
},
},
folderId: folderId,
});
refreshTextPairs();
}}

View File

@@ -4,7 +4,7 @@ import { updatePairById } from "@/lib/server/services/pairService";
import { useState } from "react";
import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl";
import { PairUpdateInput } from "../../../../generated/prisma/models";
import { UpdatePairInput } from "@/lib/server/services/types";
interface TextPairCardProps {
textPair: TextPair;
@@ -66,7 +66,7 @@ export default function TextPairCard({
<UpdateTextPairModal
isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: PairUpdateInput) => {
onUpdate={async (id: number, data: UpdatePairInput) => {
await updatePairById(id, data);
setOpenUpdateModal(false);
refreshTextPairs();

View File

@@ -3,7 +3,7 @@ import Input from "@/components/ui/Input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { PairUpdateInput } from "../../../../generated/prisma/models";
import { UpdatePairInput } from "@/lib/server/services/types";
import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl";
@@ -11,7 +11,7 @@ interface UpdateTextPairModalProps {
isOpen: boolean;
onClose: () => void;
textPair: TextPair;
onUpdate: (id: number, tp: PairUpdateInput) => void;
onUpdate: (id: number, tp: UpdatePairInput) => void;
}
export default function UpdateTextPairModal({

View File

@@ -1,17 +1,18 @@
import { useTranslations } from "next-intl";
import { useState } from "react";
const COMMON_LANGUAGES = [
{ label: "中文", value: "chinese" },
{ label: "英文", value: "english" },
{ label: "意大利语", value: "italian" },
{ label: "日语", value: "japanese" },
{ label: "韩语", value: "korean" },
{ label: "法语", value: "french" },
{ label: "德语", value: "german" },
{ label: "西班牙语", value: "spanish" },
{ label: "葡萄牙语", value: "portuguese" },
{ label: "俄语", value: "russian" },
{ label: "其他", value: "other" },
{ label: "chinese", value: "chinese" },
{ label: "english", value: "english" },
{ label: "italian", value: "italian" },
{ label: "japanese", value: "japanese" },
{ label: "korean", value: "korean" },
{ label: "french", value: "french" },
{ label: "german", value: "german" },
{ label: "spanish", value: "spanish" },
{ label: "portuguese", value: "portuguese" },
{ label: "russian", value: "russian" },
{ label: "other", value: "other" },
];
interface LocaleSelectorProps {
@@ -20,6 +21,7 @@ interface LocaleSelectorProps {
}
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
const t = useTranslations();
const [customInput, setCustomInput] = useState("");
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
const showCustomInput = value === "other" || !isCommonLanguage;
@@ -52,7 +54,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
>
{COMMON_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
{t(`translator.${lang.label}`)}
</option>
))}
</select>
@@ -61,7 +63,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
type="text"
value={inputValue}
onChange={(e) => handleCustomInputChange(e.target.value)}
placeholder="请输入语言名称"
placeholder={t("folder_id.enterLanguageName")}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
/>
)}

View File

@@ -15,6 +15,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
return {
get: (): z.infer<T> => {
try {
if (!globalThis.localStorage) return [] as z.infer<T>;
const item = globalThis.localStorage.getItem(key);
if (!item) return [] as z.infer<T>;

View File

@@ -0,0 +1,206 @@
# 词典查询模块化架构
本目录包含词典查询系统的**多阶段 LLM 调用**实现,将查询过程拆分为 4 个独立的 LLM 调用,每个阶段之间有代码层面的数据验证,只要有一环失败,直接返回错误。
## 目录结构
```
dictionary/
├── index.ts # 主导出文件
├── orchestrator.ts # 主编排器,串联所有阶段
├── types.ts # 类型定义
├── stage1-inputAnalysis.ts # 阶段1输入解析与语言识别
├── stage2-semanticMapping.ts # 阶段2跨语言语义映射决策
├── stage3-standardForm.ts # 阶段3standardForm 生成与规范化
└── stage4-entriesGeneration.ts # 阶段4释义与词条生成
```
## 工作流程
```
用户输入
[阶段1] 输入分析 → 代码验证 → 失败则返回错误
[阶段2] 语义映射 → 代码验证 → 失败则保守处理(不映射)
[阶段3] 标准形式 → 代码验证 → 失败则返回错误
[阶段4] 词条生成 → 代码验证 → 失败则返回错误
最终结果
```
## 各阶段详细说明
### 阶段 1输入分析
**文件**: `stage1-inputAnalysis.ts`
**目的**:
- 判断输入是否有效
- 判断是「单词」还是「短语」
- 识别输入语言
**返回**: `InputAnalysisResult`
**代码验证**:
- `isValid` 必须是 boolean
- 输入为空或无效时立即返回错误
### 阶段 2语义映射
**文件**: `stage2-semanticMapping.ts`
**目的**:
- 决定是否启用"语义级查询"
- **严格条件**:只有输入符合"明确、基础、可词典化的语义概念"且语言不一致时才映射
- 不符合条件则**直接失败**(快速失败)
**返回**: `SemanticMappingResult`
**代码验证**:
- `shouldMap` 必须是 boolean
- 如果 `shouldMap=true`,必须有 `mappedQuery`
- 如果不应该映射,**抛出异常**(不符合条件直接失败)
- **失败则直接返回错误响应**,不继续后续阶段
**映射条件**(必须同时满足):
a) 输入语言 ≠ 查询语言
b) 输入是明确、基础、可词典化的语义概念(如常见动词、名词、形容词)
**不符合条件的例子**
- 复杂句子:"我喜欢吃苹果"
- 专业术语
- 无法确定语义的词汇
### 阶段 3标准形式生成
**文件**: `stage3-standardForm.ts`
**目的**:
- 确定最终词条的"标准形"(整个系统的锚点)
- 修正拼写错误
- 还原为词典形式(动词原形、辞书形等)
- **如果进行了语义映射**:基于映射结果生成标准形式,同时参考原始输入的语义上下文
**参数**:
- `inputText`: 用于生成标准形式的文本(可能是映射后的结果)
- `queryLang`: 查询语言
- `originalInput`: (可选)原始用户输入,用于语义参考
**返回**: `StandardFormResult`
**代码验证**:
- `standardForm` 不能为空
- `confidence` 必须是 "high" | "medium" | "low"
- 失败时使用原输入作为标准形式
**特殊逻辑**:
- 当进行了语义映射时(即提供了 `originalInput`),阶段 3 会:
1. 基于 `inputText`(映射结果)生成标准形式
2. 参考 `originalInput` 的语义上下文,确保标准形式符合用户的真实查询意图
3. 例如:原始输入 "吃"(中文)→ 映射为 "to eat"(英语)→ 标准形式 "eat"
### 阶段 4词条生成
**文件**: `stage4-entriesGeneration.ts`
**目的**:
- 生成真正的词典内容
- 根据类型生成单词或短语条目
**返回**: `EntriesGenerationResult`
**代码验证**:
- `entries` 必须是非空数组
- 每个条目必须有 `definition``example`
- 单词条目必须有 `partOfSpeech`
- **失败则抛出异常**(核心阶段)
## 使用方式
### 基本使用
```typescript
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
const result = await lookUp({
text: "hello",
queryLang: "English",
definitionLang: "中文"
});
```
### 高级使用(直接调用编排器)
```typescript
import { executeDictionaryLookup } from "@/lib/server/bigmodel/dictionary";
const result = await executeDictionaryLookup(
"hello",
"English",
"中文"
);
```
### 单独测试某个阶段
```typescript
import { analyzeInput } from "@/lib/server/bigmodel/dictionary";
const analysis = await analyzeInput("hello");
console.log(analysis);
```
## 设计优势
### 1. 代码层面的数据验证
每个阶段完成后都有严格的类型检查和数据验证,确保数据质量。
### 2. 快速失败
只要有一个阶段失败,立即返回错误,不浪费后续的 LLM 调用。
### 3. 可观测性
每个阶段都有 console.log 输出,方便调试和追踪问题。
### 4. 模块化
每个阶段独立文件,可以单独测试、修改或替换。
### 5. 容错性
非核心阶段阶段2、3失败时有降级策略不会导致整个查询失败。
## 日志示例
```
[阶段1] 开始输入分析...
[阶段1] 输入分析完成: { isValid: true, inputType: 'word', inputLanguage: 'English' }
[阶段2] 开始语义映射...
[阶段2] 语义映射完成: { shouldMap: false }
[阶段3] 开始生成标准形式...
[阶段3] 标准形式生成完成: { standardForm: 'hello', confidence: 'high' }
[阶段4] 开始生成词条...
[阶段4] 词条生成完成: { entries: [...] }
[完成] 词典查询成功
```
## 扩展建议
### 添加缓存
对阶段1、3的结果进行缓存避免重复调用 LLM。
### 添加指标
记录每个阶段的耗时和成功率,用于性能优化。
### 并行化
某些阶段可以并行执行(如果有依赖关系允许的话)。
### A/B 测试
为某个阶段创建不同版本的实现,进行效果对比。
## 注意事项
- 每个阶段都是独立的 LLM 调用,会增加总耗时
- 需要控制 token 使用量,避免成本过高
- 错误处理要完善,避免某个阶段卡住整个流程
- 日志记录要清晰,方便问题排查

View File

@@ -0,0 +1,18 @@
/**
* 词典查询模块 - 多阶段 LLM 调用架构
*
* 将词典查询拆分为 4 个独立的 LLM 调用阶段,每个阶段都有代码层面的数据验证
* 只要有一环失败,直接返回错误
*/
// 导出主编排器
export { executeDictionaryLookup } from "./orchestrator";
// 导出各阶段的独立函数(可选,用于调试或单独使用)
export { analyzeInput } from "./stage1-inputAnalysis";
export { determineSemanticMapping } from "./stage2-semanticMapping";
export { generateStandardForm } from "./stage3-standardForm";
export { generateEntries } from "./stage4-entriesGeneration";
// 导出类型定义
export * from "./types";

View File

@@ -0,0 +1,106 @@
import { DictLookUpResponse } from "@/lib/shared";
import { analyzeInput } from "./stage1-inputAnalysis";
import { determineSemanticMapping } from "./stage2-semanticMapping";
import { generateStandardForm } from "./stage3-standardForm";
import { generateEntries } from "./stage4-entriesGeneration";
/**
* 词典查询主编排器
*
* 将多个独立的 LLM 调用串联起来,每个阶段都有代码层面的数据验证
* 只要有一环失败,直接返回错误
*/
export async function executeDictionaryLookup(
text: string,
queryLang: string,
definitionLang: string
): Promise<DictLookUpResponse> {
try {
// ========== 阶段 1输入分析 ==========
console.log("[阶段1] 开始输入分析...");
const analysis = await analyzeInput(text);
// 代码层面验证:输入是否有效
if (!analysis.isValid) {
console.log("[阶段1] 输入无效:", analysis.reason);
return {
error: analysis.reason || "无效输入",
};
}
if (analysis.isEmpty) {
console.log("[阶段1] 输入为空");
return {
error: "输入为空",
};
}
console.log("[阶段1] 输入分析完成:", analysis);
// ========== 阶段 2语义映射 ==========
console.log("[阶段2] 开始语义映射...");
const semanticMapping = await determineSemanticMapping(
text,
queryLang,
analysis.inputLanguage || text
);
console.log("[阶段2] 语义映射完成:", semanticMapping);
// ========== 阶段 3生成标准形式 ==========
console.log("[阶段3] 开始生成标准形式...");
// 如果进行了语义映射,标准形式要基于映射后的结果
// 同时传递原始输入作为语义参考
const shouldUseMapping = semanticMapping.shouldMap && semanticMapping.mappedQuery;
const inputForStandardForm = shouldUseMapping ? semanticMapping.mappedQuery! : text;
const standardFormResult = await generateStandardForm(
inputForStandardForm,
queryLang,
shouldUseMapping ? text : undefined // 如果进行了映射,传递原始输入作为语义参考
);
// 代码层面验证:标准形式不能为空
if (!standardFormResult.standardForm) {
console.error("[阶段3] 标准形式为空");
return {
error: "无法生成标准形式",
};
}
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
// ========== 阶段 4生成词条 ==========
console.log("[阶段4] 开始生成词条...");
const entriesResult = await generateEntries(
standardFormResult.standardForm,
queryLang,
definitionLang,
analysis.inputType === "unknown"
? (standardFormResult.standardForm.includes(" ") ? "phrase" : "word")
: analysis.inputType
);
console.log("[阶段4] 词条生成完成:", entriesResult);
// ========== 组装最终结果 ==========
const finalResult: DictLookUpResponse = {
standardForm: standardFormResult.standardForm,
entries: entriesResult.entries,
};
console.log("[完成] 词典查询成功");
return finalResult;
} catch (error) {
console.error("[错误] 词典查询失败:", error);
// 任何阶段失败都返回错误(包含 reason
const errorMessage = error instanceof Error ? error.message : "未知错误";
return {
error: errorMessage,
};
}
}

View File

@@ -0,0 +1,66 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/lib/utils";
import { InputAnalysisResult } from "./types";
/**
* 阶段 1输入解析与语言识别
*
* 独立的 LLM 调用,分析输入文本
*/
export async function analyzeInput(text: string): Promise<InputAnalysisResult> {
const prompt = `
你是一个输入分析器。分析用户输入并返回 JSON 结果。
用户输入位于 <text> 标签内:
<text>${text}</text>
你的任务是:
1. 判断输入是否为空或明显非法
2. 判断输入是「单词」还是「短语」
3. 识别输入所属语言
返回 JSON 格式:
{
"isValid": true/false,
"isEmpty": true/false,
"isNaturalLanguage": true/false,
"inputLanguage": "检测到的语言名称(如 English、中文、日本語等",
"inputType": "word/phrase/unknown",
"reason": "错误原因,成功时为空字符串\"\""
}
若输入为空、非自然语言或无法识别语言,设置 isValid 为 false并在 reason 中说明原因。
若输入有效,设置 isValid 为 truereason 为空字符串 ""。
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: "你是一个输入分析器,只返回 JSON 格式的分析结果。",
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<InputAnalysisResult>);
// 代码层面的数据验证
if (typeof result.isValid !== "boolean") {
throw new Error("阶段1isValid 字段类型错误");
}
// 确保 reason 字段存在
if (typeof result.reason !== "string") {
result.reason = "";
}
return result;
} catch (error) {
console.error("阶段1失败", error);
// 失败时抛出错误,包含 reason
throw new Error("输入分析失败:无法识别输入类型或语言");
}
}

View File

@@ -0,0 +1,106 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/lib/utils";
import { SemanticMappingResult } from "./types";
/**
* 阶段 2跨语言语义映射决策
*
* 独立的 LLM 调用,决定是否需要语义映射
* 如果输入不符合"明确、基础、可词典化的语义概念"且语言不一致,直接返回失败
*/
export async function determineSemanticMapping(
text: string,
queryLang: string,
inputLanguage: string
): Promise<SemanticMappingResult> {
// 如果输入语言就是查询语言,不需要映射
if (inputLanguage.toLowerCase() === queryLang.toLowerCase()) {
return {
shouldMap: false,
reason: "输入语言与查询语言一致",
};
}
const prompt = `
你是一个语义映射决策器。判断是否需要对输入进行跨语言语义映射。
查询语言:${queryLang}
输入语言:${inputLanguage}
用户输入:${text}
判断规则:
1. 若输入表达一个**明确、基础、可词典化的语义概念**(如常见动词、名词、形容词),则应该映射
2. 若输入不符合上述条件(如复杂句子、专业术语、无法确定语义的词汇),则不应该映射
映射条件必须同时满足:
a) 输入语言 ≠ 查询语言
b) 输入是明确、基础、可词典化的语义概念
例如:
- 查询语言=English输入="吃"(中文)→ 应该映射 → coreSemantic="to eat"
- 查询语言=Italiano输入="run"English→ 应该映射 → coreSemantic="correre"
- 查询语言=中文,输入="hello"English→ 应该映射 → coreSemantic="你好"
- 查询语言=English输入="我喜欢吃苹果"(中文,复杂句子)→ 不应该映射 → canMap=false
返回 JSON 格式:
{
"shouldMap": true/false,
"canMap": true/false,
"coreSemantic": "提取的核心语义(用${queryLang}表达)",
"mappedQuery": "映射到${queryLang}的标准表达",
"reason": "错误原因,成功时为空字符串\"\""
}
- canMap=true 表示输入符合"明确、基础、可词典化的语义概念"
- shouldMap=true 表示需要进行映射
- 只有 canMap=true 且语言不一致时shouldMap 才为 true
- 如果 shouldMap=false在 reason 中说明原因
- 如果 shouldMap=truereason 为空字符串 ""
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: `你是一个语义映射决策器,只返回 JSON 格式的结果。`,
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<any>);
// 代码层面的数据验证
if (typeof result.shouldMap !== "boolean") {
throw new Error("阶段2shouldMap 字段类型错误");
}
// 确保 reason 字段存在
if (typeof result.reason !== "string") {
result.reason = "";
}
// 如果不应该映射,返回错误
if (!result.shouldMap) {
throw new Error(result.reason || "输入不符合可词典化的语义概念,无法进行跨语言查询");
}
if (!result.mappedQuery || result.mappedQuery.trim().length === 0) {
throw new Error("语义映射失败:映射结果为空");
}
return {
shouldMap: result.shouldMap,
coreSemantic: result.coreSemantic,
mappedQuery: result.mappedQuery,
reason: result.reason,
};
} catch (error) {
console.error("阶段2失败", error);
// 失败时直接抛出错误,让编排器返回错误响应
throw error;
}
}

View File

@@ -0,0 +1,97 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/lib/utils";
import { StandardFormResult } from "./types";
/**
* 阶段 3standardForm 生成与规范化
*
* 独立的 LLM 调用,生成标准形式
*/
export async function generateStandardForm(
inputText: string,
queryLang: string,
originalInput?: string
): Promise<StandardFormResult> {
const prompt = `
你是一个词典标准形式生成器。为输入生成该语言下的标准形式。
查询语言:${queryLang}
当前输入:${inputText}
${originalInput ? `原始输入(语义参考):${originalInput}` : ''}
${originalInput ? `
**重要说明**
- 当前输入是经过语义映射后的结果(从原始语言映射到查询语言)
- 原始输入提供了语义上下文,帮助你理解用户的真实查询意图
- 你需要基于**当前输入**生成标准形式,但要参考**原始输入的语义**以确保准确性
例如:
- 原始输入:"吃"(中文),当前输入:"to eat"(英语)→ 标准形式应为 "eat"
- 原始输入:"走"(中文),当前输入:"to walk"(英语)→ 标准形式应为 "walk"
` : ''}
规则:
1. 尝试修正明显拼写错误
2. 还原为该语言中**最常见、最自然、最标准**的形式:
* 英语:动词原形、名词单数
* 日语:辞书形
* 意大利语:不定式或最常见规范形式
* 维吾尔语:标准拉丁化或阿拉伯字母形式
* 中文:标准简化字
3. ${originalInput ? '参考原始输入的语义,确保标准形式符合用户的真实查询意图':'若无法确定或输入本身已规范,则保持不变'}
返回 JSON 格式:
{
"standardForm": "标准形式",
"confidence": "high/medium/low",
"reason": "错误原因,成功时为空字符串\"\""
}
成功生成标准形式时reason 为空字符串 ""。
失败时,在 reason 中说明失败原因。
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: "你是一个词典标准形式生成器,只返回 JSON 格式的结果。",
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<any>);
// 代码层面的数据验证
if (!result.standardForm || result.standardForm.trim().length === 0) {
throw new Error(result.reason || "阶段3standardForm 为空");
}
// 处理 confidence 可能是中文或英文的情况
let confidence: "high" | "medium" | "low" = "low";
const confidenceValue = result.confidence?.toLowerCase();
if (confidenceValue === "高" || confidenceValue === "high") {
confidence = "high";
} else if (confidenceValue === "中" || confidenceValue === "medium") {
confidence = "medium";
} else if (confidenceValue === "低" || confidenceValue === "low") {
confidence = "low";
}
// 确保 reason 字段存在
const reason = typeof result.reason === "string" ? result.reason : "";
return {
standardForm: result.standardForm,
confidence,
reason,
};
} catch (error) {
console.error("阶段3失败", error);
// 失败时抛出错误
throw error;
}
}

View File

@@ -0,0 +1,109 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/lib/utils";
import { EntriesGenerationResult } from "./types";
/**
* 阶段 4释义与词条生成
*
* 独立的 LLM 调用,生成词典条目
*/
export async function generateEntries(
standardForm: string,
queryLang: string,
definitionLang: string,
inputType: "word" | "phrase"
): Promise<EntriesGenerationResult> {
const isWord = inputType === "word";
const prompt = `
你是一个词典条目生成器。为标准形式生成词典条目。
标准形式:${standardForm}
查询语言:${queryLang}
释义语言:${definitionLang}
词条类型:${isWord ? "单词" : "短语"}
${isWord ? `
单词条目要求:
- ipa音标如适用
- partOfSpeech词性
- definition释义使用 ${definitionLang}
- example例句使用 ${queryLang}
` : `
短语条目要求:
- definition短语释义使用 ${definitionLang}
- example例句使用 ${queryLang}
`}
生成 1-3 个条目,返回 JSON 格式:
{
"entries": [
${isWord ? `
{
"ipa": "音标",
"partOfSpeech": "词性",
"definition": "释义",
"example": "例句"
}` : `
{
"definition": "释义",
"example": "例句"
}`}
]
}
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: `你是一个词典条目生成器,只返回 JSON 格式的结果。`,
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
// 代码层面的数据验证
if (!result.entries || !Array.isArray(result.entries) || result.entries.length === 0) {
throw new Error("阶段4entries 为空或不是数组");
}
// 处理每个条目,清理 IPA 格式
for (const entry of result.entries) {
// 清理 IPA删除两端可能包含的方括号、斜杠等字符
if (entry.ipa) {
entry.ipa = entry.ipa.trim();
// 删除开头的 [ / /
entry.ipa = entry.ipa.replace(/^[\[\/]/, '');
// 删除结尾的 ] / /
entry.ipa = entry.ipa.replace(/[\]\/]$/, '');
}
if (!entry.definition || entry.definition.trim().length === 0) {
throw new Error("阶段4条目缺少 definition");
}
if (!entry.example || entry.example.trim().length === 0) {
throw new Error("阶段4条目缺少 example");
}
if (isWord && !entry.partOfSpeech) {
throw new Error("阶段4单词条目缺少 partOfSpeech");
}
if (isWord && !entry.ipa) {
throw new Error("阶段4单词条目缺少 ipa");
}
}
return result;
} catch (error) {
console.error("阶段4失败", error);
throw error; // 阶段4失败应该返回错误因为这个阶段是核心
}
}

View File

@@ -0,0 +1,43 @@
/**
* 词典查询的类型定义
*/
export interface DictionaryContext {
queryLang: string;
definitionLang: string;
}
// 阶段1输入分析结果
export interface InputAnalysisResult {
isValid: boolean;
isEmpty: boolean;
isNaturalLanguage: boolean;
inputLanguage?: string;
inputType: "word" | "phrase" | "unknown";
reason: string;
}
// 阶段2语义映射结果
export interface SemanticMappingResult {
shouldMap: boolean;
coreSemantic?: string;
mappedQuery?: string;
reason: string;
}
// 阶段3标准形式结果
export interface StandardFormResult {
standardForm: string;
confidence: "high" | "medium" | "low";
reason: string;
}
// 阶段4词条生成结果
export interface EntriesGenerationResult {
entries: Array<{
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string; // example 必需
}>;
}

View File

@@ -1,100 +1,141 @@
"use server";
import { parseAIGeneratedJSON } from "@/lib/utils";
import { getAnswer } from "./zhipu";
import { executeDictionaryLookup } from "./dictionary";
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
type DictionaryWordEntry = {
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
};
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
if (isDictErrorResponse(res)) return;
else if (isDictPhraseResponse(res)) {
// 先创建 Phrase
const phrase = await createPhrase({
standardForm: res.standardForm,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
});
type DictionaryPhraseEntry = {
definition: string;
example: string;
};
// 创建 Lookup
await createLookUp({
userId: req.userId,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
dictionaryPhraseId: phrase.id,
});
type DictionaryErrorResponse = {
error: string;
};
type DictionarySuccessResponse = {
standardForm: string;
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
};
export const lookUp = async (
text: string,
queryLang: string,
definitionLang: string
): Promise<DictionarySuccessResponse | DictionaryErrorResponse> => {
const response = await getAnswer([
{
role: "system",
content: `
你是一个词典工具,返回单词/短语的JSON解释。
查询语言:${queryLang}
释义语言:${definitionLang}
用户输入在<text>标签内。判断是单词还是短语。
如果输入有效返回JSON对象格式为
{
"standardForm": "字符串,该语言下的正确形式",
"entries": [数组,包含一个或多个条目]
}
如果是单词,条目格式:
{
"ipa": "音标(如适用)",
"definition": "释义",
"partOfSpeech": "词性",
"example": "例句"
}
如果是短语,条目格式:
{
"definition": "短语释义",
"example": "例句"
}
所有释义内容使用${definitionLang}语言。
例句使用${queryLang}语言。
如果输入无效输入为空、包含非法字符、无法识别的语言等返回JSON对象
{
"error": "错误描述信息,使用${definitionLang}语言"
}
提供standardForm时尝试修正笔误或返回原形如英语动词原形、日语基本形等。若无法确定或输入正确则与输入相同。
示例:
英语输入"ran" -> standardForm: "run"
中文输入"跑眬" -> standardForm: "跑"
日语输入"走った" -> standardForm: "走る"
短语同理,尝试返回其标准/常见形式。
现在处理用户输入。
`.trim()
}, {
role: "user",
content: `<text>${text}</text>请处理text标签内的内容后返回给我json`
// 创建 Entries
for (const entry of res.entries) {
await createPhraseEntry({
phraseId: phrase.id,
definition: entry.definition,
example: entry.example,
});
}
]);
} else if (isDictWordResponse(res)) {
// 先创建 Word
const word = await createWord({
standardForm: (res as DictWordResponse).standardForm,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
});
const result = parseAIGeneratedJSON<
DictionaryErrorResponse |
{
standardForm: string,
entries: DictionaryPhraseEntry[];
} |
{
standardForm: string,
entries: DictionaryWordEntry[];
}>(response);
// 创建 Lookup
await createLookUp({
userId: req.userId,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
dictionaryWordId: word.id,
});
return result;
// 创建 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,
});
}
}
};
/**
* 查询单词或短语
*
* 使用模块化的词典查询系统将提示词拆分为6个阶段
* - 阶段0基础系统提示
* - 阶段1输入解析与语言识别
* - 阶段2跨语言语义映射决策
* - 阶段3standardForm 生成与规范化
* - 阶段4释义与词条生成
* - 阶段5错误处理
* - 阶段6最终输出封装
*/
export const lookUp = async ({
text,
queryLang,
definitionLang,
userId,
forceRelook = false
}: DictLookUpRequest): Promise<DictLookUpResponse> => {
try {
const lastLookUp = await selectLastLookUp({
text,
queryLang,
definitionLang
});
if (forceRelook || !lastLookUp) {
// 使用新的模块化查询系统
const response = await executeDictionaryLookup(
text,
queryLang,
definitionLang
);
saveResult({
text,
queryLang,
definitionLang,
userId,
forceRelook
}, response);
return response;
} else {
// 从数据库返回缓存的结果
if (lastLookUp.dictionaryWordId) {
createLookUp({
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryWordId: lastLookUp.dictionaryWordId,
});
return {
standardForm: lastLookUp.dictionaryWord!.standardForm,
entries: lastLookUp.dictionaryWord!.entries
};
} else if (lastLookUp.dictionaryPhraseId) {
createLookUp({
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryPhraseId: lastLookUp.dictionaryPhraseId
});
return {
standardForm: lastLookUp.dictionaryPhrase!.standardForm,
entries: lastLookUp.dictionaryPhrase!.entries
};
} else {
return { error: "Database structure error!" };
}
}
} catch (error) {
console.log(error);
return { error: "LOOK_UP_ERROR" };
}
};

View File

@@ -1,7 +1,13 @@
"use server";
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) => {
return (
"[" +
@@ -24,6 +30,10 @@ export const genIPA = async (text: string) => {
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLocale = async (text: string) => {
return await getAnswer(
`
@@ -38,6 +48,10 @@ export const genLocale = async (text: string) => {
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLanguage = async (text: string) => {
const language = await getAnswer([
{
@@ -79,7 +93,12 @@ export const genLanguage = async (text: string) => {
return language.trim();
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getAnswer(
`
<text>${text}</text>
@@ -91,3 +110,144 @@ export const genTranslation = async (text: string, targetLanguage: string) => {
`.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 响应");
}
}

View File

@@ -0,0 +1,62 @@
"use server";
import {
CreateDictionaryLookUpInput,
DictionaryLookUpQuery,
CreateDictionaryPhraseInput,
CreateDictionaryPhraseEntryInput,
CreateDictionaryWordInput,
CreateDictionaryWordEntryInput
} from "./types";
import prisma from "../../db";
export async function selectLastLookUp(content: DictionaryLookUpQuery) {
return prisma.dictionaryLookUp.findFirst({
where: content,
include: {
dictionaryPhrase: {
include: {
entries: true
}
},
dictionaryWord: {
include: {
entries: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
}
export async function createPhraseEntry(content: CreateDictionaryPhraseEntryInput) {
return prisma.dictionaryPhraseEntry.create({
data: content
});
}
export async function createWordEntry(content: CreateDictionaryWordEntryInput) {
return prisma.dictionaryWordEntry.create({
data: content
});
}
export async function createPhrase(content: CreateDictionaryPhraseInput) {
return prisma.dictionaryPhrase.create({
data: content
});
}
export async function createWord(content: CreateDictionaryWordInput) {
return prisma.dictionaryWord.create({
data: content
});
}
export async function createLookUp(content: CreateDictionaryLookUpInput) {
return prisma.dictionaryLookUp.create({
data: content
});
}

View File

@@ -1,19 +1,18 @@
"use server";
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
import { CreateFolderInput, UpdateFolderInput } from "./types";
import prisma from "../../db";
export async function getFoldersByUserId(userId: string) {
const folders = await prisma.folder.findMany({
return prisma.folder.findMany({
where: {
userId: userId,
},
});
return folders;
}
export async function renameFolderById(id: number, newName: string) {
await prisma.folder.update({
return prisma.folder.update({
where: {
id: id,
},
@@ -32,29 +31,28 @@ export async function getFoldersWithTotalPairsByUserId(userId: string) {
},
},
});
return folders.map(folder => ({
...folder,
total: folder._count?.pairs ?? 0,
}));
}
export async function createFolder(folder: FolderCreateInput) {
await prisma.folder.create({
export async function createFolder(folder: CreateFolderInput) {
return prisma.folder.create({
data: folder,
});
}
export async function deleteFolderById(id: number) {
await prisma.folder.delete({
return prisma.folder.delete({
where: {
id: id,
},
});
}
export async function updateFolderById(id: number, data: FolderUpdateInput) {
await prisma.folder.update({
export async function updateFolderById(id: number, data: UpdateFolderInput) {
return prisma.folder.update({
where: {
id: id,
},

View File

@@ -1,16 +1,16 @@
"use server";
import { PairCreateInput, PairUpdateInput } from "../../../../generated/prisma/models";
import { CreatePairInput, UpdatePairInput } from "./types";
import prisma from "../../db";
export async function createPair(data: PairCreateInput) {
await prisma.pair.create({
export async function createPair(data: CreatePairInput) {
return prisma.pair.create({
data: data,
});
}
export async function deletePairById(id: number) {
await prisma.pair.delete({
return prisma.pair.delete({
where: {
id: id,
},
@@ -19,9 +19,9 @@ export async function deletePairById(id: number) {
export async function updatePairById(
id: number,
data: PairUpdateInput,
data: UpdatePairInput,
) {
await prisma.pair.update({
return prisma.pair.update({
where: {
id: id,
},
@@ -30,19 +30,17 @@ export async function updatePairById(
}
export async function getPairCountByFolderId(folderId: number) {
const count = await prisma.pair.count({
return prisma.pair.count({
where: {
folderId: folderId,
},
});
return count;
}
export async function getPairsByFolderId(folderId: number) {
const textPairs = await prisma.pair.findMany({
return prisma.pair.findMany({
where: {
folderId: folderId,
},
});
return textPairs;
}

View 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',
},
});
}

View 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 决定
}

View File

@@ -1,5 +1,5 @@
import prisma from "@/lib/db";
import { UserCreateInput } from "../../../../generated/prisma/models";
import { randomUUID } from "crypto";
export async function createUserIfNotExists(email: string, name?: string | null) {
const user = await prisma.user.upsert({
@@ -8,9 +8,10 @@ export async function createUserIfNotExists(email: string, name?: string | null)
},
update: {},
create: {
id: randomUUID(),
email: email,
name: name || "New User",
} as UserCreateInput,
},
});
return user;
}

View File

@@ -0,0 +1,63 @@
export type DictLookUpRequest = {
text: string,
queryLang: string,
definitionLang: string,
userId?: string,
forceRelook: boolean;
};
export type DictWordEntry = {
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
};
export type DictPhraseEntry = {
definition: string;
example: string;
};
export type DictErrorResponse = {
error: string;
};
export type DictWordResponse = {
standardForm: string;
entries: DictWordEntry[];
};
export type DictPhraseResponse = {
standardForm: string;
entries: DictPhraseEntry[];
};
export type DictLookUpResponse =
| DictErrorResponse
| DictWordResponse
| DictPhraseResponse;
// 类型守卫:判断是否为错误响应
export function isDictErrorResponse(
response: DictLookUpResponse
): response is DictErrorResponse {
return "error" in response;
}
// 类型守卫:判断是否为单词响应
export function isDictWordResponse(
response: DictLookUpResponse
): response is DictWordResponse {
if (isDictErrorResponse(response)) return false;
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
return entries.length > 0 && "ipa" in entries[0] && "partOfSpeech" in entries[0];
}
// 类型守卫:判断是否为短语响应
export function isDictPhraseResponse(
response: DictLookUpResponse
): response is DictPhraseResponse {
if (isDictErrorResponse(response)) return false;
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
return entries.length > 0 && !("ipa" in entries[0] || "partOfSpeech" in entries[0]);
}

1
src/lib/shared/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./dictionaryTypes";