Compare commits
7 Commits
be3eb17490
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9715844eae | |||
| 504ecd259d | |||
| 06e90687f1 | |||
| b093ed2b4f | |||
| 37e221d8b8 | |||
| f1dcd5afaa | |||
| 66d17df59d |
@@ -42,6 +42,7 @@
|
|||||||
"text2": "Text 2",
|
"text2": "Text 2",
|
||||||
"language1": "Sprache 1",
|
"language1": "Sprache 1",
|
||||||
"language2": "Sprache 2",
|
"language2": "Sprache 2",
|
||||||
|
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"delete": "Löschen"
|
"delete": "Löschen"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
||||||
},
|
},
|
||||||
"autoSave": "Automatisch speichern"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "Text 2",
|
"text2": "Text 2",
|
||||||
"language1": "Locale 1",
|
"language1": "Locale 1",
|
||||||
"language2": "Locale 2",
|
"language2": "Locale 2",
|
||||||
|
"enterLanguageName": "Please enter language name",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "Failed to add text pair to folder"
|
"error": "Failed to add text pair to folder"
|
||||||
},
|
},
|
||||||
"autoSave": "Auto Save"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "Texte 2",
|
"text2": "Texte 2",
|
||||||
"language1": "Langue 1",
|
"language1": "Langue 1",
|
||||||
"language2": "Langue 2",
|
"language2": "Langue 2",
|
||||||
|
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"delete": "Supprimer"
|
"delete": "Supprimer"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "Échec de l'ajout de la paire de textes au dossier"
|
"error": "Échec de l'ajout de la paire de textes au dossier"
|
||||||
},
|
},
|
||||||
"autoSave": "Sauvegarde automatique"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "Testo 2",
|
"text2": "Testo 2",
|
||||||
"language1": "Lingua 1",
|
"language1": "Lingua 1",
|
||||||
"language2": "Lingua 2",
|
"language2": "Lingua 2",
|
||||||
|
"enterLanguageName": "Inserisci il nome della lingua",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina"
|
"delete": "Elimina"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
||||||
},
|
},
|
||||||
"autoSave": "Salvataggio automatico"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "テキスト2",
|
"text2": "テキスト2",
|
||||||
"language1": "言語1",
|
"language1": "言語1",
|
||||||
"language2": "言語2",
|
"language2": "言語2",
|
||||||
|
"enterLanguageName": "言語名を入力してください",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"delete": "削除"
|
"delete": "削除"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "テキストペアの追加に失敗しました"
|
"error": "テキストペアの追加に失敗しました"
|
||||||
},
|
},
|
||||||
"autoSave": "自動保存"
|
"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": "保存に失敗しました。後でもう一度お試しください"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "텍스트 2",
|
"text2": "텍스트 2",
|
||||||
"language1": "언어 1",
|
"language1": "언어 1",
|
||||||
"language2": "언어 2",
|
"language2": "언어 2",
|
||||||
|
"enterLanguageName": "언어 이름을 입력하세요",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"delete": "삭제"
|
"delete": "삭제"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "텍스트 쌍 추가 실패"
|
"error": "텍스트 쌍 추가 실패"
|
||||||
},
|
},
|
||||||
"autoSave": "자동 저장"
|
"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": "저장 실패, 나중에 다시 시도하세요"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "تېكىست 2",
|
"text2": "تېكىست 2",
|
||||||
"language1": "تىل 1",
|
"language1": "تىل 1",
|
||||||
"language2": "تىل 2",
|
"language2": "تىل 2",
|
||||||
|
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
|
||||||
"edit": "تەھرىرلەش",
|
"edit": "تەھرىرلەش",
|
||||||
"delete": "ئۆچۈرۈش"
|
"delete": "ئۆچۈرۈش"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
||||||
},
|
},
|
||||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
"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": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"text2": "文本2",
|
"text2": "文本2",
|
||||||
"language1": "语言1",
|
"language1": "语言1",
|
||||||
"language2": "语言2",
|
"language2": "语言2",
|
||||||
|
"enterLanguageName": "请输入语言名称",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除"
|
"delete": "删除"
|
||||||
},
|
},
|
||||||
@@ -189,5 +190,33 @@
|
|||||||
"error": "添加文本对到文件夹失败"
|
"error": "添加文本对到文件夹失败"
|
||||||
},
|
},
|
||||||
"autoSave": "自动保存"
|
"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": "保存失败,请稍后重试"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -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")
|
||||||
@@ -86,7 +87,7 @@ model Pair {
|
|||||||
|
|
||||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([folderId, language1, language2, text1])
|
@@unique([folderId, language1, language2, text1, text2])
|
||||||
@@index([folderId])
|
@@index([folderId])
|
||||||
@@map("pairs")
|
@@map("pairs")
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,6 @@ model DictionaryWord {
|
|||||||
lookups DictionaryLookUp[]
|
lookups DictionaryLookUp[]
|
||||||
entries DictionaryWordEntry[]
|
entries DictionaryWordEntry[]
|
||||||
|
|
||||||
@@unique([standardForm, queryLang, definitionLang])
|
|
||||||
@@index([standardForm])
|
@@index([standardForm])
|
||||||
@@index([queryLang, definitionLang])
|
@@index([queryLang, definitionLang])
|
||||||
@@map("dictionary_words")
|
@@map("dictionary_words")
|
||||||
@@ -153,7 +153,6 @@ model DictionaryPhrase {
|
|||||||
lookups DictionaryLookUp[]
|
lookups DictionaryLookUp[]
|
||||||
entries DictionaryPhraseEntry[]
|
entries DictionaryPhraseEntry[]
|
||||||
|
|
||||||
@@unique([standardForm, queryLang, definitionLang])
|
|
||||||
@@index([standardForm])
|
@@index([standardForm])
|
||||||
@@index([queryLang, definitionLang])
|
@@index([queryLang, definitionLang])
|
||||||
@@map("dictionary_phrases")
|
@@map("dictionary_phrases")
|
||||||
@@ -190,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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
78
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal file
78
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/(features)/dictionary/DictionaryPage.tsx
Normal file
141
src/app/(features)/dictionary/DictionaryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/(features)/dictionary/SearchForm.tsx
Normal file
129
src/app/(features)/dictionary/SearchForm.tsx
Normal 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>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/app/(features)/dictionary/SearchResult.tsx
Normal file
155
src/app/(features)/dictionary/SearchResult.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/(features)/dictionary/constants.ts
Normal file
8
src/app/(features)/dictionary/constants.ts
Normal 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;
|
||||||
11
src/app/(features)/dictionary/index.ts
Normal file
11
src/app/(features)/dictionary/index.ts
Normal 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";
|
||||||
@@ -1,398 +1 @@
|
|||||||
"use client";
|
export { default } from "./DictionaryPage";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
2
src/app/(features)/dictionary/types.ts
Normal file
2
src/app/(features)/dictionary/types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// 从 shared 文件夹导出所有词典类型和类型守卫
|
||||||
|
export * from "@/lib/shared";
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const COMMON_LANGUAGES = [
|
const COMMON_LANGUAGES = [
|
||||||
{ label: "中文", value: "chinese" },
|
{ label: "chinese", value: "chinese" },
|
||||||
{ label: "英文", value: "english" },
|
{ label: "english", value: "english" },
|
||||||
{ label: "意大利语", value: "italian" },
|
{ label: "italian", value: "italian" },
|
||||||
{ label: "日语", value: "japanese" },
|
{ label: "japanese", value: "japanese" },
|
||||||
{ label: "韩语", value: "korean" },
|
{ label: "korean", value: "korean" },
|
||||||
{ label: "法语", value: "french" },
|
{ label: "french", value: "french" },
|
||||||
{ label: "德语", value: "german" },
|
{ label: "german", value: "german" },
|
||||||
{ label: "西班牙语", value: "spanish" },
|
{ label: "spanish", value: "spanish" },
|
||||||
{ label: "葡萄牙语", value: "portuguese" },
|
{ label: "portuguese", value: "portuguese" },
|
||||||
{ label: "俄语", value: "russian" },
|
{ label: "russian", value: "russian" },
|
||||||
{ label: "其他", value: "other" },
|
{ label: "other", value: "other" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface LocaleSelectorProps {
|
interface LocaleSelectorProps {
|
||||||
@@ -20,6 +21,7 @@ interface LocaleSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const [customInput, setCustomInput] = useState("");
|
const [customInput, setCustomInput] = useState("");
|
||||||
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
|
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
|
||||||
const showCustomInput = value === "other" || !isCommonLanguage;
|
const showCustomInput = value === "other" || !isCommonLanguage;
|
||||||
@@ -52,7 +54,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
|||||||
>
|
>
|
||||||
{COMMON_LANGUAGES.map((lang) => (
|
{COMMON_LANGUAGES.map((lang) => (
|
||||||
<option key={lang.value} value={lang.value}>
|
<option key={lang.value} value={lang.value}>
|
||||||
{lang.label}
|
{t(`translator.${lang.label}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -61,7 +63,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => handleCustomInputChange(e.target.value)}
|
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"
|
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
206
src/lib/server/bigmodel/dictionary/README.md
Normal file
206
src/lib/server/bigmodel/dictionary/README.md
Normal 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 # 阶段3:standardForm 生成与规范化
|
||||||
|
└── 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 使用量,避免成本过高
|
||||||
|
- 错误处理要完善,避免某个阶段卡住整个流程
|
||||||
|
- 日志记录要清晰,方便问题排查
|
||||||
18
src/lib/server/bigmodel/dictionary/index.ts
Normal file
18
src/lib/server/bigmodel/dictionary/index.ts
Normal 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";
|
||||||
106
src/lib/server/bigmodel/dictionary/orchestrator.ts
Normal file
106
src/lib/server/bigmodel/dictionary/orchestrator.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/lib/server/bigmodel/dictionary/stage1-inputAnalysis.ts
Normal file
66
src/lib/server/bigmodel/dictionary/stage1-inputAnalysis.ts
Normal 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 为 true,reason 为空字符串 ""。
|
||||||
|
只返回 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("阶段1:isValid 字段类型错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 reason 字段存在
|
||||||
|
if (typeof result.reason !== "string") {
|
||||||
|
result.reason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("阶段1失败:", error);
|
||||||
|
// 失败时抛出错误,包含 reason
|
||||||
|
throw new Error("输入分析失败:无法识别输入类型或语言");
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/lib/server/bigmodel/dictionary/stage2-semanticMapping.ts
Normal file
106
src/lib/server/bigmodel/dictionary/stage2-semanticMapping.ts
Normal 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=true,reason 为空字符串 ""
|
||||||
|
|
||||||
|
只返回 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("阶段2:shouldMap 字段类型错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/lib/server/bigmodel/dictionary/stage3-standardForm.ts
Normal file
97
src/lib/server/bigmodel/dictionary/stage3-standardForm.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { getAnswer } from "../zhipu";
|
||||||
|
import { parseAIGeneratedJSON } from "@/lib/utils";
|
||||||
|
import { StandardFormResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段 3:standardForm 生成与规范化
|
||||||
|
*
|
||||||
|
* 独立的 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 || "阶段3:standardForm 为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/lib/server/bigmodel/dictionary/stage4-entriesGeneration.ts
Normal file
109
src/lib/server/bigmodel/dictionary/stage4-entriesGeneration.ts
Normal 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("阶段4:entries 为空或不是数组");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每个条目,清理 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失败应该返回错误,因为这个阶段是核心
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/lib/server/bigmodel/dictionary/types.ts
Normal file
43
src/lib/server/bigmodel/dictionary/types.ts
Normal 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 必需
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -1,100 +1,141 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { executeDictionaryLookup } from "./dictionary";
|
||||||
import { getAnswer } from "./zhipu";
|
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
|
||||||
|
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
|
||||||
|
|
||||||
type DictionaryWordEntry = {
|
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
|
||||||
ipa: string;
|
if (isDictErrorResponse(res)) return;
|
||||||
definition: string;
|
else if (isDictPhraseResponse(res)) {
|
||||||
partOfSpeech: string;
|
// 先创建 Phrase
|
||||||
example: string;
|
const phrase = await createPhrase({
|
||||||
};
|
standardForm: res.standardForm,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
});
|
||||||
|
|
||||||
type DictionaryPhraseEntry = {
|
// 创建 Lookup
|
||||||
definition: string;
|
await createLookUp({
|
||||||
example: string;
|
userId: req.userId,
|
||||||
};
|
text: req.text,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
dictionaryPhraseId: phrase.id,
|
||||||
|
});
|
||||||
|
|
||||||
type DictionaryErrorResponse = {
|
// 创建 Entries
|
||||||
error: string;
|
for (const entry of res.entries) {
|
||||||
};
|
await createPhraseEntry({
|
||||||
|
phraseId: phrase.id,
|
||||||
type DictionarySuccessResponse = {
|
definition: entry.definition,
|
||||||
standardForm: string;
|
example: entry.example,
|
||||||
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`
|
|
||||||
}
|
}
|
||||||
]);
|
} else if (isDictWordResponse(res)) {
|
||||||
|
// 先创建 Word
|
||||||
|
const word = await createWord({
|
||||||
|
standardForm: (res as DictWordResponse).standardForm,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
});
|
||||||
|
|
||||||
const result = parseAIGeneratedJSON<
|
// 创建 Lookup
|
||||||
DictionaryErrorResponse |
|
await createLookUp({
|
||||||
{
|
userId: req.userId,
|
||||||
standardForm: string,
|
text: req.text,
|
||||||
entries: DictionaryPhraseEntry[];
|
queryLang: req.queryLang,
|
||||||
} |
|
definitionLang: req.definitionLang,
|
||||||
{
|
dictionaryWordId: word.id,
|
||||||
standardForm: string,
|
});
|
||||||
entries: DictionaryWordEntry[];
|
|
||||||
}>(response);
|
|
||||||
|
|
||||||
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:跨语言语义映射决策
|
||||||
|
* - 阶段3:standardForm 生成与规范化
|
||||||
|
* - 阶段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" };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 响应");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
62
src/lib/server/services/dictionaryService.ts
Normal file
62
src/lib/server/services/dictionaryService.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/lib/shared/dictionaryTypes.ts
Normal file
63
src/lib/shared/dictionaryTypes.ts
Normal 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
1
src/lib/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dictionaryTypes";
|
||||||
Reference in New Issue
Block a user