Compare commits

..

3 Commits

Author SHA1 Message Date
d3e1cd9092 ... 2025-12-29 11:18:36 +08:00
3ac17f66f2 ... 2025-12-29 10:40:59 +08:00
af259d4691 ... 2025-12-29 10:06:16 +08:00
76 changed files with 2389 additions and 5100 deletions

View File

@@ -6,32 +6,3 @@ README.md
.next .next
.git .git
certificates certificates
# testing
/coverage
test.ts
test.js
# build outputs
/out/
/build
*.tsbuildinfo
next-env.d.ts
# debug logs
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# misc
.DS_Store
*.pem
.vercel
build.sh
# prisma
/generated/prisma

View File

@@ -10,6 +10,3 @@ GITHUB_CLIENT_SECRET=
// Database // Database
DATABASE_URL= DATABASE_URL=
// DashScore
DASHSCORE_API_KEY=

1
.gitignore vendored
View File

@@ -46,7 +46,6 @@ next-env.d.ts
build.sh build.sh
test.ts test.ts
test.js
/generated/prisma /generated/prisma
certificates certificates

125
CLAUDE.md
View File

@@ -1,125 +0,0 @@
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
## 项目概述
这是一个基于 Next.js 16 构建的全栈语言学习平台,提供翻译工具、文本转语音、字幕播放、字母学习和记忆功能。平台支持 8 种语言,具有完整的国际化支持。
## 开发命令
```bash
# 启动开发服务器(启用 HTTPS
pnpm run dev
# 构建生产版本standalone 输出模式,用于 Docker
pnpm run build
# 启动生产服务器
pnpm run start
# 代码检查
pnpm run lint
# 数据库操作
pnpm prisma generate # 生成 Prisma client 到 src/generated/prisma
pnpm prisma db push # 推送 schema 变更到数据库
pnpm prisma studio # 打开 Prisma Studio 查看数据库
```
## 技术栈
- **Next.js 16** 使用 App Router 和 standalone 输出模式
- **React 19** 启用 React Compiler 进行优化
- **TypeScript** 严格模式和 ES2023 目标
- **Tailwind CSS v4** 样式框架
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`
- **better-auth** 身份验证(邮箱/密码 + OAuth
- **next-intl** 国际化支持en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
- **edge-tts-universal** 文本转语音
- **pnpm** 包管理器
## 架构设计
### 路由结构
应用使用 Next.js App Router 和基于功能的组织方式:
```
src/app/
├── (features)/ # 功能模块translator, alphabet, memorize, dictionary, srt-player
│ └── [locale]/ # 国际化路由
├── auth/ # 认证页面sign-in, sign-up
├── folders/ # 用户学习文件夹管理
├── api/ # API 路由
└── profile/ # 用户资料页面
```
### 数据库 Schema
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)
- **User**: 用户中心实体,包含认证信息
- **Folder**: 用户拥有的学习资料容器(级联删除 pairs
- **Pair**: 语言对(翻译/词汇),支持 IPA唯一约束为 (folderId, locale1, locale2, text1)
- **Session/Account**: better-auth 追踪
- **Verification**: 邮箱验证系统
### 核心模式
**Server Actions**: 数据库变更使用 `src/lib/actions/` 中的 Server Actions配合类型安全的 Prisma 操作。
**基于功能的组件**: 每个功能在 `(features)/` 下有自己的路由组,带有 locale 前缀。
**国际化**: 所有面向用户的内容通过 next-intl 处理。消息文件在 `messages/` 目录。locale 自动检测并在路由中前缀。
**认证流程**: better-auth 使用客户端适配器 (`authClient`),通过 hooks 管理会话,受保护的路由使用条件渲染。
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY``ZHIPU_MODEL_NAME` 配置。
### 环境变量
需要在 `.env.local` 中配置:
```env
# LLM 集成
ZHIPU_API_KEY=your-api-key
ZHIPU_MODEL_NAME=your-model-name
# 认证
BETTER_AUTH_SECRET=your-secret
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# 数据库
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
// DashScore
DASHSCORE_API_KEY=
```
## 重要配置细节
- **Prisma client 输出**: 自定义目录 `src/generated/prisma`(不是默认的 `node_modules/.prisma`
- **Standalone 输出**: 为 Docker 部署配置
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
- **图片优化**: 通过 remote patterns 允许 GitHub 头像
## 代码组织
- `src/lib/actions/`: 数据库变更的 Server Actions
- `src/lib/server/`: 服务端工具AI 集成、认证、翻译器)
- `src/lib/browser/`: 客户端工具
- `src/hooks/`: 自定义 React hooks认证 hooks、会话管理
- `src/i18n/`: 国际化配置
- `messages/`: 各支持语言的翻译文件
- `src/components/`: 可复用的 UI 组件buttons, cards 等)
## 开发注意事项
- 使用 pnpm而不是 npm 或 yarn
- schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push`
- 应用使用 TypeScript 严格模式 - 确保类型安全
- 所有面向用户的文本都需要国际化
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作

View File

@@ -26,7 +26,7 @@
### 国际化与辅助功能 ### 国际化与辅助功能
- **next-intl** - 国际化解决方案 - **next-intl** - 国际化解决方案
- **qwen3-tts-flash** - 通义千问语音合成 - **edge-tts-universal** - 跨平台文本转语音
### 开发工具 ### 开发工具
- **ESLint** - 代码质量检查 - **ESLint** - 代码质量检查

View File

@@ -1,222 +0,0 @@
{
"alphabet": {
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
"japanese": "Japanische Kana",
"english": "Englisches Alphabet",
"uyghur": "Uigurisches Alphabet",
"esperanto": "Esperanto-Alphabet",
"loading": "Laden...",
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
"hideLetter": "Zeichen ausblenden",
"showLetter": "Zeichen anzeigen",
"hideIPA": "IPA ausblenden",
"showIPA": "IPA anzeigen",
"roman": "Romanisierung",
"letter": "Zeichen",
"random": "Zufälliger Modus",
"randomNext": "Zufällig weiter"
},
"folders": {
"title": "Ordner",
"subtitle": "Verwalten Sie Ihre Sammlungen",
"newFolder": "Neuer Ordner",
"creating": "Erstellen...",
"noFoldersYet": "Noch keine Ordner",
"folderInfo": "ID: {id} • {totalPairs} Paare",
"enterFolderName": "Ordnernamen eingeben:",
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:"
},
"folder_id": {
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners",
"back": "Zurück",
"textPairs": "Textpaare",
"itemsCount": "{count} Elemente",
"memorize": "Einprägen",
"loadingTextPairs": "Textpaare werden geladen...",
"noTextPairs": "Keine Textpaare in diesem Ordner",
"addNewTextPair": "Neues Textpaar hinzufügen",
"add": "Hinzufügen",
"updateTextPair": "Textpaar aktualisieren",
"update": "Aktualisieren",
"text1": "Text 1",
"text2": "Text 2",
"language1": "Sprache 1",
"language2": "Sprache 2",
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
"edit": "Bearbeiten",
"delete": "Löschen"
},
"home": {
"title": "Sprachen lernen",
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
"explore": "Erkunden",
"fortune": {
"quote": "Bleib hungrig, bleiv dumm.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Übersetzer",
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren"
},
"textSpeaker": {
"name": "Text-Sprecher",
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
},
"srtPlayer": {
"name": "SRT-Videoplayer",
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren"
},
"alphabet": {
"name": "Alphabet",
"description": "Beginnen Sie mit dem Erlernen einer neuen Sprache mit dem Alphabet"
},
"memorize": {
"name": "Einprägen",
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
},
"dictionary": {
"name": "Wörterbuch",
"description": "Wörter und Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen"
},
"moreFeatures": {
"name": "Weitere Funktionen",
"description": "In Entwicklung, bleiben Sie dran"
}
},
"auth": {
"title": "Authentifizierung",
"signIn": "Anmelden",
"signUp": "Registrieren",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen",
"name": "Name",
"signInButton": "Anmelden",
"signUpButton": "Registrieren",
"noAccount": "Haben Sie kein Konto?",
"hasAccount": "Haben Sie bereits ein Konto?",
"signInWithGitHub": "Mit GitHub anmelden",
"signUpWithGitHub": "Mit GitHub registrieren",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsNotMatch": "Passwörter stimmen nicht überein",
"nameRequired": "Bitte geben Sie Ihren Namen ein",
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
"loading": "Laden..."
},
"memorize": {
"folder_selector": {
"selectFolder": "Wählen Sie einen Ordner aus",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Antwort",
"next": "Weiter",
"reverse": "Umkehren",
"dictation": "Diktat",
"noTextPairs": "Keine Textpaare verfügbar",
"disorder": "Mischen",
"previous": "Zurück"
},
"page": {
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "Anmelden",
"profile": "Profil",
"folders": "Ordner"
},
"profile": {
"myProfile": "Mein Profil",
"email": "E-Mail: {email}",
"logout": "Abmelden"
},
"srt_player": {
"uploadVideo": "Video hochladen",
"uploadSubtitle": "Untertitel hochladen",
"pause": "Pause",
"play": "Abspielen",
"previous": "Zurück",
"next": "Weiter",
"restart": "Neustart",
"autoPause": "Auto-Pause ({enabled})",
"uploadVideoAndSubtitle": "Bitte laden Sie Video- und Untertiteldateien hoch",
"uploadVideoFile": "Bitte laden Sie eine Videodatei hoch",
"uploadSubtitleFile": "Bitte laden Sie eine Untertiteldatei hoch",
"processingSubtitle": "Untertiteldatei wird verarbeitet...",
"needBothFiles": "Sowohl Video- als auch Untertiteldateien sind erforderlich, um mit dem Lernen zu beginnen",
"videoFile": "Videodatei",
"subtitleFile": "Untertiteldatei",
"uploaded": "Hochgeladen",
"notUploaded": "Nicht hochgeladen",
"upload": "Hochladen",
"autoPauseStatus": "Auto-Pause: {enabled}",
"on": "Ein",
"off": "Aus",
"videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen"
},
"text_speaker": {
"generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Elemente anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
},
"translator": {
"detectLanguage": "Sprache erkennen",
"generateIPA": "IPA generieren",
"translateInto": "Übersetzen in",
"chinese": "Chinesisch",
"english": "Englisch",
"italian": "Italienisch",
"other": "Andere",
"translating": "Übersetzung läuft...",
"translate": "Übersetzen",
"inputLanguage": "Geben Sie eine Sprache ein.",
"history": "Verlauf",
"enterLanguage": "Sprache eingeben",
"add_to_folder": {
"notAuthenticated": "Sie sind nicht authentifiziert",
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen aus",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name}",
"close": "Schließen",
"success": "Textpaar zum Ordner hinzugefügt",
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
},
"autoSave": "Automatisch speichern"
},
"dictionary": {
"title": "Wörterbuch",
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
"searching": "Suche...",
"search": "Suchen",
"languageSettings": "Spracheinstellungen",
"queryLanguage": "Abfragesprache",
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
"definitionLanguage": "Definitionssprache",
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
"relookup": "Neu suchen",
"saveToFolder": "In Ordner speichern",
"loading": "Laden...",
"noResults": "Keine Ergebnisse gefunden",
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
"welcomeTitle": "Willkommen beim Wörterbuch",
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
"relookupSuccess": "Erfolgreich neu gesucht",
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
"savedToFolder": "Im Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
}
}

View File

@@ -22,9 +22,13 @@
"newFolder": "New Folder", "newFolder": "New Folder",
"creating": "Creating...", "creating": "Creating...",
"noFoldersYet": "No folders yet", "noFoldersYet": "No folders yet",
"folderInfo": "ID: {id} • {totalPairs} pairs", "folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "Enter folder name:", "enterFolderName": "Enter folder name:",
"confirmDelete": "Type \"{name}\" to delete:" "confirmDelete": "Type \"{name}\" to delete:",
"createFolderSuccess": "Folder created successfully",
"deleteFolderSuccess": "Folder deleted successfully",
"createFolderError": "Failed to create folder",
"deleteFolderError": "Failed to delete folder"
}, },
"folder_id": { "folder_id": {
"unauthorized": "You are not the owner of this folder", "unauthorized": "You are not the owner of this folder",
@@ -40,9 +44,8 @@
"update": "Update", "update": "Update",
"text1": "Text 1", "text1": "Text 1",
"text2": "Text 2", "text2": "Text 2",
"language1": "Locale 1", "locale1": "Locale 1",
"language2": "Locale 2", "locale2": "Locale 2",
"enterLanguageName": "Please enter language name",
"edit": "Edit", "edit": "Edit",
"delete": "Delete" "delete": "Delete"
}, },
@@ -74,15 +77,15 @@
"name": "Memorize", "name": "Memorize",
"description": "Language A to Language B, Language B to Language A, supports dictation" "description": "Language A to Language B, Language B to Language A, supports dictation"
}, },
"dictionary": {
"name": "Dictionary",
"description": "Look up words and phrases with detailed definitions and examples"
},
"moreFeatures": { "moreFeatures": {
"name": "More Features", "name": "More Features",
"description": "Under development, stay tuned" "description": "Under development, stay tuned"
} }
}, },
"login": {
"loading": "Loading...",
"githubLogin": "GitHub Login"
},
"auth": { "auth": {
"title": "Authentication", "title": "Authentication",
"signIn": "Sign In", "signIn": "Sign In",
@@ -100,6 +103,8 @@
"invalidEmail": "Please enter a valid email address", "invalidEmail": "Please enter a valid email address",
"passwordTooShort": "Password must be at least 8 characters", "passwordTooShort": "Password must be at least 8 characters",
"passwordsNotMatch": "Passwords do not match", "passwordsNotMatch": "Passwords do not match",
"signInFailed": "Sign in failed, please check your email and password",
"signUpFailed": "Sign up failed, please try again later",
"nameRequired": "Please enter your name", "nameRequired": "Please enter your name",
"emailRequired": "Please enter your email", "emailRequired": "Please enter your email",
"passwordRequired": "Please enter your password", "passwordRequired": "Please enter your password",
@@ -146,6 +151,18 @@
"next": "Next", "next": "Next",
"restart": "Restart", "restart": "Restart",
"autoPause": "Auto Pause ({enabled})", "autoPause": "Auto Pause ({enabled})",
"playbackSpeed": "Playback Speed",
"subtitleSettings": "Subtitle Settings",
"fontSize": "Font Size",
"backgroundColor": "Background Color",
"textColor": "Text Color",
"fontFamily": "Font Family",
"opacity": "Opacity",
"position": "Position",
"top": "Top",
"center": "Center",
"bottom": "Bottom",
"keyboardShortcuts": "Keyboard Shortcuts",
"uploadVideoAndSubtitle": "Please upload video and subtitle files", "uploadVideoAndSubtitle": "Please upload video and subtitle files",
"uploadVideoFile": "Please upload video file", "uploadVideoFile": "Please upload video file",
"uploadSubtitleFile": "Please upload subtitle file", "uploadSubtitleFile": "Please upload subtitle file",
@@ -160,7 +177,16 @@
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"videoUploadFailed": "Video upload failed", "videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed" "subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle file loaded successfully",
"subtitleLoadFailed": "Subtitle file loading failed",
"shortcuts": {
"playPause": "Play/Pause",
"next": "Next",
"previous": "Previous",
"restart": "Restart",
"autoPause": "Toggle Auto Pause"
}
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",
@@ -190,33 +216,5 @@
"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"
} }
} }

View File

@@ -1,222 +0,0 @@
{
"alphabet": {
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
"japanese": "Kana japonais",
"english": "Alphabet anglais",
"uyghur": "Alphabet ouïghour",
"esperanto": "Alphabet espéranto",
"loading": "Chargement...",
"loadFailed": "Échec du chargement, veuillez réessayer",
"hideLetter": "Masquer la lettre",
"showLetter": "Afficher la lettre",
"hideIPA": "Masquer l'API",
"showIPA": "Afficher l'API",
"roman": "Romanisation",
"letter": "Lettre",
"random": "Mode aléatoire",
"randomNext": "Suivant aléatoire"
},
"folders": {
"title": "Dossiers",
"subtitle": "Gérez vos collections",
"newFolder": "Nouveau dossier",
"creating": "Création...",
"noFoldersYet": "Aucun dossier pour le moment",
"folderInfo": "ID: {id} • {totalPairs} paires",
"enterFolderName": "Entrez le nom du dossier:",
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
},
"folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
"back": "Retour",
"textPairs": "Paires de textes",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingTextPairs": "Chargement des paires de textes...",
"noTextPairs": "Aucune paire de textes dans ce dossier",
"addNewTextPair": "Ajouter une nouvelle paire de textes",
"add": "Ajouter",
"updateTextPair": "Mettre à jour la paire de textes",
"update": "Mettre à jour",
"text1": "Texte 1",
"text2": "Texte 2",
"language1": "Langue 1",
"language2": "Langue 2",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"edit": "Modifier",
"delete": "Supprimer"
},
"home": {
"title": "Apprendre les langues",
"description": "Voici un site web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
"explore": "Explorer",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Traducteur",
"description": "Traduire dans n'importe quelle langue et annoter avec l'alphabet phonétique international (API)"
},
"textSpeaker": {
"name": "Lecteur de texte",
"description": "Reconnaître et lire le texte à haute voix, prend en charge la lecture en boucle et le réglage de la vitesse"
},
"srtPlayer": {
"name": "Lecteur vidéo SRT",
"description": "Lire des vidéos phrase par phrase basées sur des fichiers de sous-titres SRT pour imiter la prononciation des locuteurs natifs"
},
"alphabet": {
"name": "Alphabet",
"description": "Commencer à apprendre une nouvelle langue par l'alphabet"
},
"memorize": {
"name": "Mémoriser",
"description": "Langue A vers langue B, langue B vers langue A, prend en charge la dictée"
},
"dictionary": {
"name": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples"
},
"moreFeatures": {
"name": "Plus de fonctionnalités",
"description": "En développement, restez à l'écoute"
}
},
"auth": {
"title": "Authentification",
"signIn": "Se connecter",
"signUp": "S'inscrire",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"name": "Nom",
"signInButton": "Se connecter",
"signUpButton": "S'inscrire",
"noAccount": "Vous n'avez pas de compte?",
"hasAccount": "Vous avez déjà un compte?",
"signInWithGitHub": "Se connecter avec GitHub",
"signUpWithGitHub": "S'inscrire avec GitHub",
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
"nameRequired": "Veuillez entrer votre nom",
"emailRequired": "Veuillez entrer votre e-mail",
"passwordRequired": "Veuillez entrer votre mot de passe",
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
"loading": "Chargement..."
},
"memorize": {
"folder_selector": {
"selectFolder": "Sélectionner un dossier",
"noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Réponse",
"next": "Suivant",
"reverse": "Inverser",
"dictation": "Dictée",
"noTextPairs": "Aucune paire de textes disponible",
"disorder": "Désordre",
"previous": "Précédent"
},
"page": {
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "Se connecter",
"profile": "Profil",
"folders": "Dossiers"
},
"profile": {
"myProfile": "Mon profil",
"email": "E-mail: {email}",
"logout": "Se déconnecter"
},
"srt_player": {
"uploadVideo": "Télécharger une vidéo",
"uploadSubtitle": "Télécharger des sous-titres",
"pause": "Pause",
"play": "Lire",
"previous": "Précédent",
"next": "Suivant",
"restart": "Redémarrer",
"autoPause": "Pause automatique ({enabled})",
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
"processingSubtitle": "Traitement du fichier de sous-titres...",
"needBothFiles": "Les fichiers vidéo et de sous-titres sont requis pour commencer l'apprentissage",
"videoFile": "Fichier vidéo",
"subtitleFile": "Fichier de sous-titres",
"uploaded": "Téléchargé",
"notUploaded": "Non téléchargé",
"upload": "Télécharger",
"autoPauseStatus": "Pause automatique: {enabled}",
"on": "Activé",
"off": "Désactivé",
"videoUploadFailed": "Échec du téléchargement de la vidéo",
"subtitleUploadFailed": "Échec du téléchargement des sous-titres"
},
"text_speaker": {
"generateIPA": "Générer l'API",
"viewSavedItems": "Voir les éléments enregistrés",
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer? (O/N)"
},
"translator": {
"detectLanguage": "détecter la langue",
"generateIPA": "générer l'api",
"translateInto": "traduire en",
"chinese": "Chinois",
"english": "Anglais",
"italian": "Italien",
"other": "Autre",
"translating": "traduction...",
"translate": "traduire",
"inputLanguage": "Entrez une langue.",
"history": "Historique",
"enterLanguage": "Entrer la langue",
"add_to_folder": {
"notAuthenticated": "Vous n'êtes pas authentifié",
"chooseFolder": "Choisir un dossier à ajouter",
"noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name}",
"close": "Fermer",
"success": "Paire de textes ajoutée au dossier",
"error": "Échec de l'ajout de la paire de textes au dossier"
},
"autoSave": "Sauvegarde automatique"
},
"dictionary": {
"title": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
"searching": "Recherche...",
"search": "Rechercher",
"languageSettings": "Paramètres linguistiques",
"queryLanguage": "Langue de requête",
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
"definitionLanguage": "Langue de définition",
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
"relookup": "Rechercher à nouveau",
"saveToFolder": "Enregistrer dans le dossier",
"loading": "Chargement...",
"noResults": "Aucun résultat trouvé",
"tryOtherWords": "Essayez d'autres mots ou phrases",
"welcomeTitle": "Bienvenue dans le dictionnaire",
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
"relookupSuccess": "Recherche répétée avec succès",
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
"pleaseLogin": "Veuillez d'abord vous connecter",
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
"savedToFolder": "Enregistré dans le dossier : {folderName}",
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
}
}

View File

@@ -1,222 +0,0 @@
{
"alphabet": {
"chooseCharacters": "Seleziona i caratteri che desideri imparare",
"japanese": "Kana giapponese",
"english": "Alfabeto inglese",
"uyghur": "Alfabeto uiguro",
"esperanto": "Alfabeto esperanto",
"loading": "Caricamento...",
"loadFailed": "Caricamento fallito, riprova",
"hideLetter": "Nascondi lettera",
"showLetter": "Mostra lettera",
"hideIPA": "Nascondi IPA",
"showIPA": "Mostra IPA",
"roman": "Romanizzazione",
"letter": "Lettera",
"random": "Modalità casuale",
"randomNext": "Successivo casuale"
},
"folders": {
"title": "Cartelle",
"subtitle": "Gestisci le tue collezioni",
"newFolder": "Nuova cartella",
"creating": "Creazione...",
"noFoldersYet": "Nessuna cartella ancora",
"folderInfo": "ID: {id} • {totalPairs} coppie",
"enterFolderName": "Inserisci nome cartella:",
"confirmDelete": "Digita \"{name}\" per eliminare:"
},
"folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella",
"back": "Indietro",
"textPairs": "Coppie di testi",
"itemsCount": "{count} elementi",
"memorize": "Memorizza",
"loadingTextPairs": "Caricamento coppie di testi...",
"noTextPairs": "Nessuna coppia di testi in questa cartella",
"addNewTextPair": "Aggiungi nuova coppia di testi",
"add": "Aggiungi",
"updateTextPair": "Aggiorna coppia di testi",
"update": "Aggiorna",
"text1": "Testo 1",
"text2": "Testo 2",
"language1": "Lingua 1",
"language2": "Lingua 2",
"enterLanguageName": "Inserisci il nome della lingua",
"edit": "Modifica",
"delete": "Elimina"
},
"home": {
"title": "Impara le lingue",
"description": "Questo è un sito web molto utile che ti aiuta a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
"explore": "Esplora",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Traduttore",
"description": "Traduci in qualsiasi lingua e annota con l'alfabeto fonetico internazionale (IPA)"
},
"textSpeaker": {
"name": "Lettore di testo",
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
},
"srtPlayer": {
"name": "Lettore video SRT",
"description": "Riproduci video frase per frase basandoti su file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
},
"alphabet": {
"name": "Alfabeto",
"description": "Inizia a imparare una nuova lingua dall'alfabeto"
},
"memorize": {
"name": "Memorizza",
"description": "Lingua A verso lingua B, lingua B verso lingua A, supporta dettatura"
},
"dictionary": {
"name": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
},
"moreFeatures": {
"name": "Altre funzionalità",
"description": "In sviluppo, rimani sintonizzato"
}
},
"auth": {
"title": "Autenticazione",
"signIn": "Accedi",
"signUp": "Registrati",
"email": "Email",
"password": "Password",
"confirmPassword": "Conferma password",
"name": "Nome",
"signInButton": "Accedi",
"signUpButton": "Registrati",
"noAccount": "Non hai un account?",
"hasAccount": "Hai già un account?",
"signInWithGitHub": "Accedi con GitHub",
"signUpWithGitHub": "Registrati con GitHub",
"invalidEmail": "Inserisci un indirizzo email valido",
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
"passwordsNotMatch": "Le password non corrispondono",
"nameRequired": "Inserisci il tuo nome",
"emailRequired": "Inserisci la tua email",
"passwordRequired": "Inserisci la tua password",
"confirmPasswordRequired": "Conferma la tua password",
"loading": "Caricamento..."
},
"memorize": {
"folder_selector": {
"selectFolder": "Seleziona una cartella",
"noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Risposta",
"next": "Successivo",
"reverse": "Inverti",
"dictation": "Dettatura",
"noTextPairs": "Nessuna coppia di testi disponibile",
"disorder": "Disordine",
"previous": "Precedente"
},
"page": {
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "Accedi",
"profile": "Profilo",
"folders": "Cartelle"
},
"profile": {
"myProfile": "Il mio profilo",
"email": "Email: {email}",
"logout": "Esci"
},
"srt_player": {
"uploadVideo": "Carica video",
"uploadSubtitle": "Carica sottotitoli",
"pause": "Pausa",
"play": "Riproduci",
"previous": "Precedente",
"next": "Successivo",
"restart": "Riavvia",
"autoPause": "Pausa automatica ({enabled})",
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli",
"uploadVideoFile": "Carica un file video",
"uploadSubtitleFile": "Carica un file di sottotitoli",
"processingSubtitle": "Elaborazione file sottotitoli...",
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento",
"videoFile": "File video",
"subtitleFile": "File sottotitoli",
"uploaded": "Caricato",
"notUploaded": "Non caricato",
"upload": "Carica",
"autoPauseStatus": "Pausa automatica: {enabled}",
"on": "Attivo",
"off": "Disattivo",
"videoUploadFailed": "Caricamento video fallito",
"subtitleUploadFailed": "Caricamento sottotitoli fallito"
},
"text_speaker": {
"generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza elementi salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
},
"translator": {
"detectLanguage": "rileva lingua",
"generateIPA": "genera ipa",
"translateInto": "traduci in",
"chinese": "Cinese",
"english": "Inglese",
"italian": "Italiano",
"other": "Altro",
"translating": "traduzione...",
"translate": "traduci",
"inputLanguage": "Inserisci una lingua.",
"history": "Cronologia",
"enterLanguage": "Inserisci lingua",
"add_to_folder": {
"notAuthenticated": "Non sei autenticato",
"chooseFolder": "Scegli una cartella a cui aggiungere",
"noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name}",
"close": "Chiudi",
"success": "Coppia di testi aggiunta alla cartella",
"error": "Impossibile aggiungere la coppia di testi alla cartella"
},
"autoSave": "Salvataggio automatico"
},
"dictionary": {
"title": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi",
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
"searching": "Ricerca...",
"search": "Cerca",
"languageSettings": "Impostazioni lingua",
"queryLanguage": "Lingua di interrogazione",
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
"definitionLanguage": "Lingua di definizione",
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
"relookup": "Ricerca di nuovo",
"saveToFolder": "Salva nella cartella",
"loading": "Caricamento...",
"noResults": "Nessun risultato trovato",
"tryOtherWords": "Prova altre parole o frasi",
"welcomeTitle": "Benvenuto nel dizionario",
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
"lookupFailed": "Ricerca fallita, riprova più tardi",
"relookupSuccess": "Ricerca ripetuta con successo",
"relookupFailed": "Nuova ricerca del dizionario fallita",
"pleaseLogin": "Accedi prima",
"pleaseCreateFolder": "Crea prima una cartella",
"savedToFolder": "Salvato nella cartella: {folderName}",
"saveFailed": "Salvataggio fallito, riprova più tardi"
}
}

View File

@@ -1,222 +0,0 @@
{
"alphabet": {
"chooseCharacters": "学習したい文字を選択してください",
"japanese": "日本語仮名",
"english": "英語アルファベット",
"uyghur": "ウイグル文字",
"esperanto": "エスペラント文字",
"loading": "読み込み中...",
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
"hideLetter": "文字を非表示",
"showLetter": "文字を表示",
"hideIPA": "IPAを非表示",
"showIPA": "IPAを表示",
"roman": "ローマ字",
"letter": "文字",
"random": "ランダムモード",
"randomNext": "ランダムで次へ"
},
"folders": {
"title": "フォルダー",
"subtitle": "コレクションを管理",
"newFolder": "新規フォルダー",
"creating": "作成中...",
"noFoldersYet": "フォルダーがありません",
"folderInfo": "ID: {id} • {totalPairs}組",
"enterFolderName": "フォルダー名を入力:",
"confirmDelete": "削除するには「{name}」と入力してください:"
},
"folder_id": {
"unauthorized": "あなたはこのフォルダーの所有者ではありません",
"back": "戻る",
"textPairs": "テキストペア",
"itemsCount": "{count}項目",
"memorize": "暗記",
"loadingTextPairs": "テキストペアを読み込み中...",
"noTextPairs": "このフォルダーにはテキストペアがありません",
"addNewTextPair": "新しいテキストペアを追加",
"add": "追加",
"updateTextPair": "テキストペアを更新",
"update": "更新",
"text1": "テキスト1",
"text2": "テキスト2",
"language1": "言語1",
"language2": "言語2",
"enterLanguageName": "言語名を入力してください",
"edit": "編集",
"delete": "削除"
},
"home": {
"title": "言語を学ぶ",
"description": "これは、人工言語を含む世界中のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。",
"explore": "探索",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— スティーブ・ジョブズ"
},
"translator": {
"name": "翻訳",
"description": "任意の言語に翻訳し、国際音声記号IPAで注釈を付けます"
},
"textSpeaker": {
"name": "テキストスピーカー",
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート"
},
"srtPlayer": {
"name": "SRTビデオプレーヤー",
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します"
},
"alphabet": {
"name": "アルファベット",
"description": "アルファベットから新しい言語の学習を始めましょう"
},
"memorize": {
"name": "暗記",
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート"
},
"dictionary": {
"name": "辞書",
"description": "単語やフレーズを調べ、詳細な定義と例を表示"
},
"moreFeatures": {
"name": "その他の機能",
"description": "開発中です。お楽しみに"
}
},
"auth": {
"title": "認証",
"signIn": "ログイン",
"signUp": "新規登録",
"email": "メールアドレス",
"password": "パスワード",
"confirmPassword": "パスワード(確認)",
"name": "名前",
"signInButton": "ログイン",
"signUpButton": "新規登録",
"noAccount": "アカウントをお持ちでないですか?",
"hasAccount": "すでにアカウントをお持ちですか?",
"signInWithGitHub": "GitHubでログイン",
"signUpWithGitHub": "GitHubで新規登録",
"invalidEmail": "有効なメールアドレスを入力してください",
"passwordTooShort": "パスワードは8文字以上である必要があります",
"passwordsNotMatch": "パスワードが一致しません",
"nameRequired": "名前を入力してください",
"emailRequired": "メールアドレスを入力してください",
"passwordRequired": "パスワードを入力してください",
"confirmPasswordRequired": "パスワード(確認)を入力してください",
"loading": "読み込み中..."
},
"memorize": {
"folder_selector": {
"selectFolder": "フォルダーを選択",
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "回答",
"next": "次へ",
"reverse": "逆順",
"dictation": "ディクテーション",
"noTextPairs": "利用可能なテキストペアがありません",
"disorder": "ランダム",
"previous": "前へ"
},
"page": {
"unauthorized": "このフォルダーにアクセスする権限がありません"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "ログイン",
"profile": "プロフィール",
"folders": "フォルダー"
},
"profile": {
"myProfile": "マイプロフィール",
"email": "メールアドレス: {email}",
"logout": "ログアウト"
},
"srt_player": {
"uploadVideo": "ビデオをアップロード",
"uploadSubtitle": "字幕をアップロード",
"pause": "一時停止",
"play": "再生",
"previous": "前へ",
"next": "次へ",
"restart": "最初から",
"autoPause": "自動一時停止 ({enabled})",
"uploadVideoAndSubtitle": "ビデオと字幕ファイルをアップロードしてください",
"uploadVideoFile": "ビデオファイルをアップロードしてください",
"uploadSubtitleFile": "字幕ファイルをアップロードしてください",
"processingSubtitle": "字幕ファイルを処理中...",
"needBothFiles": "学習を開始するにはビデオと字幕ファイルの両方が必要です",
"videoFile": "ビデオファイル",
"subtitleFile": "字幕ファイル",
"uploaded": "アップロード済み",
"notUploaded": "未アップロード",
"upload": "アップロード",
"autoPauseStatus": "自動一時停止: {enabled}",
"on": "オン",
"off": "オフ",
"videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
},
"text_speaker": {
"generateIPA": "IPAを生成",
"viewSavedItems": "保存済みアイテムを表示",
"confirmDeleteAll": "本当にすべて削除しますか? (Y/N)"
},
"translator": {
"detectLanguage": "言語を検出",
"generateIPA": "IPAを生成",
"translateInto": "翻訳",
"chinese": "中国語",
"english": "英語",
"italian": "イタリア語",
"other": "その他",
"translating": "翻訳中...",
"translate": "翻訳",
"inputLanguage": "言語を入力してください。",
"history": "履歴",
"enterLanguage": "言語を入力",
"add_to_folder": {
"notAuthenticated": "認証されていません",
"chooseFolder": "追加するフォルダーを選択",
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name}",
"close": "閉じる",
"success": "テキストペアをフォルダーに追加しました",
"error": "テキストペアの追加に失敗しました"
},
"autoSave": "自動保存"
},
"dictionary": {
"title": "辞書",
"description": "詳細な定義と例で単語やフレーズを検索",
"searchPlaceholder": "検索する単語やフレーズを入力...",
"searching": "検索中...",
"search": "検索",
"languageSettings": "言語設定",
"queryLanguage": "クエリ言語",
"queryLanguageHint": "検索する単語/フレーズの言語",
"definitionLanguage": "定義言語",
"definitionLanguageHint": "定義を表示する言語",
"otherLanguagePlaceholder": "または他の言語を入力...",
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
"relookup": "再検索",
"saveToFolder": "フォルダに保存",
"loading": "読み込み中...",
"noResults": "結果が見つかりません",
"tryOtherWords": "他の単語やフレーズを試してください",
"welcomeTitle": "辞書へようこそ",
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
"relookupSuccess": "再検索しました",
"relookupFailed": "辞書の再検索に失敗しました",
"pleaseLogin": "まずログインしてください",
"pleaseCreateFolder": "まずフォルダを作成してください",
"savedToFolder": "フォルダに保存しました:{folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
}
}

View File

@@ -1,222 +0,0 @@
{
"alphabet": {
"chooseCharacters": "학습할 문자를 선택하세요",
"japanese": "일본어 가나",
"english": "영문 알파벳",
"uyghur": "위구르 문자",
"esperanto": "에스페란토 문자",
"loading": "로딩 중...",
"loadFailed": "로딩 실패, 다시 시도해 주세요",
"hideLetter": "문자 숨기기",
"showLetter": "문자 표시",
"hideIPA": "IPA 숨기기",
"showIPA": "IPA 표시",
"roman": "로마자 표기",
"letter": "문자",
"random": "무작위 모드",
"randomNext": "무작위 다음"
},
"folders": {
"title": "폴더",
"subtitle": "컬렉션 관리",
"newFolder": "새 폴더",
"creating": "생성 중...",
"noFoldersYet": "폴더가 없습니다",
"folderInfo": "ID: {id} • {totalPairs}쌍",
"enterFolderName": "폴더 이름 입력:",
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
},
"folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다",
"back": "뒤로",
"textPairs": "텍스트 쌍",
"itemsCount": "{count}개 항목",
"memorize": "암기",
"loadingTextPairs": "텍스트 쌍 로딩 중...",
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
"addNewTextPair": "새 텍스트 쌍 추가",
"add": "추가",
"updateTextPair": "텍스트 쌍 업데이트",
"update": "업데이트",
"text1": "텍스트 1",
"text2": "텍스트 2",
"language1": "언어 1",
"language2": "언어 2",
"enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집",
"delete": "삭제"
},
"home": {
"title": "언어 학습",
"description": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
"explore": "탐색",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— 스티브 잡스"
},
"translator": {
"name": "번역기",
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가"
},
"textSpeaker": {
"name": "텍스트 스피커",
"description": "텍스트를 인식하고 읽어줍니다. 반복 재생 및 속도 조정 지원"
},
"srtPlayer": {
"name": "SRT 비디오 플레이어",
"description": "SRT 자막 파일을 기반으로 문장별로 비디오를 재생하여 원어민 발음 모방"
},
"alphabet": {
"name": "알파벳",
"description": "알파벳부터 새로운 언어 학습 시작"
},
"memorize": {
"name": "암기",
"description": "언어 A에서 언어 B로, 언어 B에서 언어 A로, 받아쓰기 지원"
},
"dictionary": {
"name": "사전",
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
},
"moreFeatures": {
"name": "더 많은 기능",
"description": "개발 중, 기대해 주세요"
}
},
"auth": {
"title": "인증",
"signIn": "로그인",
"signUp": "회원가입",
"email": "이메일",
"password": "비밀번호",
"confirmPassword": "비밀번호 확인",
"name": "이름",
"signInButton": "로그인",
"signUpButton": "회원가입",
"noAccount": "계정이 없으신가요?",
"hasAccount": "이미 계정이 있으신가요?",
"signInWithGitHub": "GitHub로 로그인",
"signUpWithGitHub": "GitHub로 회원가입",
"invalidEmail": "유효한 이메일 주소를 입력하세요",
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
"nameRequired": "이름을 입력하세요",
"emailRequired": "이메일을 입력하세요",
"passwordRequired": "비밀번호를 입력하세요",
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
"loading": "로딩 중..."
},
"memorize": {
"folder_selector": {
"selectFolder": "폴더 선택",
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "정답",
"next": "다음",
"reverse": "반대",
"dictation": "받아쓰기",
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
"disorder": "무작위",
"previous": "이전"
},
"page": {
"unauthorized": "이 폴더에 액세스할 권한이 없습니다"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "로그인",
"profile": "프로필",
"folders": "폴더"
},
"profile": {
"myProfile": "내 프로필",
"email": "이메일: {email}",
"logout": "로그아웃"
},
"srt_player": {
"uploadVideo": "비디오 업로드",
"uploadSubtitle": "자막 업로드",
"pause": "일시정지",
"play": "재생",
"previous": "이전",
"next": "다음",
"restart": "처음부터",
"autoPause": "자동 일시정지 ({enabled})",
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
"uploadVideoFile": "비디오 파일을 업로드하세요",
"uploadSubtitleFile": "자막 파일을 업로드하세요",
"processingSubtitle": "자막 파일 처리 중...",
"needBothFiles": "학습을 시작하려면 비디오와 자막 파일이 모두 필요합니다",
"videoFile": "비디오 파일",
"subtitleFile": "자막 파일",
"uploaded": "업로드됨",
"notUploaded": "업로드되지 않음",
"upload": "업로드",
"autoPauseStatus": "자동 일시정지: {enabled}",
"on": "켜기",
"off": "끄기",
"videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패"
},
"text_speaker": {
"generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)"
},
"translator": {
"detectLanguage": "언어 감지",
"generateIPA": "IPA 생성",
"translateInto": "번역",
"chinese": "중국어",
"english": "영어",
"italian": "이탈리아어",
"other": "기타",
"translating": "번역 중...",
"translate": "번역",
"inputLanguage": "언어를 입력하세요.",
"history": "기록",
"enterLanguage": "언어 입력",
"add_to_folder": {
"notAuthenticated": "인증되지 않았습니다",
"chooseFolder": "추가할 폴더 선택",
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name}",
"close": "닫기",
"success": "텍스트 쌍을 폴더에 추가했습니다",
"error": "텍스트 쌍 추가 실패"
},
"autoSave": "자동 저장"
},
"dictionary": {
"title": "사전",
"description": "상세한 정의와 예제로 단어 및 구문 검색",
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
"searching": "검색 중...",
"search": "검색",
"languageSettings": "언어 설정",
"queryLanguage": "쿼리 언어",
"queryLanguageHint": "검색하려는 단어/구문의 언어",
"definitionLanguage": "정의 언어",
"definitionLanguageHint": "정의를 표시할 언어",
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
"relookup": "재검색",
"saveToFolder": "폴더에 저장",
"loading": "로드 중...",
"noResults": "결과를 찾을 수 없습니다",
"tryOtherWords": "다른 단어나 구문을 시도하세요",
"welcomeTitle": "사전에 오신 것을 환영합니다",
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
"relookupSuccess": "재검색했습니다",
"relookupFailed": "사전 재검색 실패",
"pleaseLogin": "먼저 로그인하세요",
"pleaseCreateFolder": "먼저 폴더를 만드세요",
"savedToFolder": "폴더에 저장됨: {folderName}",
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
}
}

View File

@@ -1,222 +0,0 @@
{
"alphabet": {
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
"japanese": "ياپونىيە كانا",
"english": "ئىنگلىز ئېلىپبې",
"uyghur": "ئۇيغۇر ئېلىپبېسى",
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
"hideLetter": "ھەرپنى يوشۇرۇش",
"showLetter": "ھەرپنى كۆرسىتىش",
"hideIPA": "IPA نى يوشۇرۇش",
"showIPA": "IPA نى كۆرسىتىش",
"roman": "روماللاشتۇرۇش",
"letter": "ھەرپ",
"random": "ئىختىيارىي ھالەت",
"randomNext": "ئىختىيارىي كېيىنكى"
},
"folders": {
"title": "قىسقۇچلار",
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
"newFolder": "يېڭى قىسقۇچ",
"creating": "قۇرۇۋاتىدۇ...",
"noFoldersYet": "قىسقۇچ يوق",
"folderInfo": "كود: {id} • {totalPairs} جۈپ",
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
},
"folder_id": {
"unauthorized": "سىز بۇ قىسقۇچنىڭ ئىگىسى ئەمەس",
"back": "كەينىگە",
"textPairs": "تېكىست جۈپلىرى",
"itemsCount": "{count} تۈر",
"memorize": "ئەستە ساقلاش",
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...",
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ",
"add": "قوشۇش",
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ",
"update": "يېڭىلاش",
"text1": "تېكىست 1",
"text2": "تېكىست 2",
"language1": "تىل 1",
"language2": "تىل 2",
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش"
},
"home": {
"title": "تىل ئۆگىنىڭ",
"description": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.",
"explore": "ئىزدىنىش",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— ستىۋ جوۋبس"
},
"translator": {
"name": "تەرجىمە",
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
},
"textSpeaker": {
"name": "تېكىست ئوقۇغۇچى",
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
},
"srtPlayer": {
"name": "SRT سىن ئوپىراتورى",
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
},
"alphabet": {
"name": "ئېلىپبې",
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
},
"memorize": {
"name": "ئەستە ساقلاش",
"description": "تىل A دىن تىل غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
},
"dictionary": {
"name": "لۇغەت",
"description": "سۆز ۋە سۆزنى ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
},
"moreFeatures": {
"name": "تېخىمۇ كۆپ ئىقتىدار",
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
}
},
"auth": {
"title": "دەلىللەش",
"signIn": "كىرىش",
"signUp": "تىزىملىتىش",
"email": "ئېلخەت",
"password": "ئىم",
"confirmPassword": "ئىمنى جەزملەش",
"name": "نام",
"signInButton": "كىرىش",
"signUpButton": "تىزىملىتىش",
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
"invalidEmail": "ئىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
},
"memorize": {
"folder_selector": {
"selectFolder": "قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "جاۋاب",
"next": "كېيىنكى",
"reverse": "تەتۈر",
"dictation": "دىكتات",
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
"disorder": "بەت ئارلاش",
"previous": "ئىلگىرىكى"
},
"page": {
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
}
},
"navbar": {
"title": "تىل ئۆگىنىش",
"sourceCode": "GitHub",
"sign_in": "كىرىش",
"profile": "پروفىل",
"folders": "قىسقۇچلار"
},
"profile": {
"myProfile": "مېنىڭ پروفىلىم",
"email": "ئېلخەت: {email}",
"logout": "چىقىش"
},
"srt_player": {
"uploadVideo": "سىن يۈكلەڭ",
"uploadSubtitle": "خەت ئاستى يۈكلەڭ",
"pause": "ۋاقىتلىق توختىتىش",
"play": "قويۇش",
"previous": "ئىلگىرىكى",
"next": "كېيىنكى",
"restart": "قايتا باشلاش",
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ",
"uploadVideoFile": "سىن فايلى يۈكلەڭ",
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ",
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...",
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم",
"videoFile": "سىن فايلى",
"subtitleFile": "خەت ئاستى فايلى",
"uploaded": "يۈكلەندى",
"notUploaded": "يۈكلەنمىدى",
"upload": "يۈكلەش",
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
"on": "ئوچۇق",
"off": "تاقاق",
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
},
"text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
},
"translator": {
"detectLanguage": "تىل پەرقلەندۈرۈش",
"generateIPA": "IPA ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"italian": "ئىتاليانچە",
"other": "باشقا",
"translating": "تەرجىمە قىلىۋاتىدۇ...",
"translate": "تەرجىمە قىلىش",
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
"history": "تارىخ",
"enterLanguage": "تىل كىرگۈزۈڭ",
"add_to_folder": {
"notAuthenticated": "دەلىتلەنمىدىڭىز",
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name}",
"close": "تاقاش",
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
},
"autoSave": "ئاپتوماتىك ساقلاش"
},
"dictionary": {
"title": "لۇغەت",
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
"searching": "ئىزدەۋاتىدۇ...",
"search": "ئىزدە",
"languageSettings": "تىل تەڭشىكى",
"queryLanguage": "سۈرەشتۈرۈش تىلى",
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
"definitionLanguage": "ئىلمىيى تىلى",
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
"relookup": "قايتا ئىزدە",
"saveToFolder": "قىسقۇچقا ساقلا",
"loading": "يۈكلىۋاتىدۇ...",
"noResults": "نەتىجە تېپىلمىدى",
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
"welcomeTitle": "لۇغەتكە مەرھەمەت",
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
}
}

View File

@@ -22,9 +22,13 @@
"newFolder": "新建文件夹", "newFolder": "新建文件夹",
"creating": "创建中...", "creating": "创建中...",
"noFoldersYet": "还没有文件夹", "noFoldersYet": "还没有文件夹",
"folderInfo": "ID: {id} • {totalPairs} 个文本对", "folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "输入文件夹名称:", "enterFolderName": "输入文件夹名称:",
"confirmDelete": "输入 \"{name}\" 以删除:" "confirmDelete": "输入 \"{name}\" 以删除:",
"createFolderSuccess": "文件夹创建成功",
"deleteFolderSuccess": "文件夹删除成功",
"createFolderError": "创建文件夹失败",
"deleteFolderError": "删除文件夹失败"
}, },
"folder_id": { "folder_id": {
"unauthorized": "您不是此文件夹的所有者", "unauthorized": "您不是此文件夹的所有者",
@@ -40,9 +44,8 @@
"update": "更新", "update": "更新",
"text1": "文本1", "text1": "文本1",
"text2": "文本2", "text2": "文本2",
"language1": "语言1", "locale1": "语言1",
"language2": "语言2", "locale2": "语言2",
"enterLanguageName": "请输入语言名称",
"edit": "编辑", "edit": "编辑",
"delete": "删除" "delete": "删除"
}, },
@@ -74,15 +77,15 @@
"name": "记忆", "name": "记忆",
"description": "语言A到语言B语言B到语言A支持听写" "description": "语言A到语言B语言B到语言A支持听写"
}, },
"dictionary": {
"name": "词典",
"description": "查询单词和短语,提供详细的释义和例句"
},
"moreFeatures": { "moreFeatures": {
"name": "更多功能", "name": "更多功能",
"description": "开发中,敬请期待" "description": "开发中,敬请期待"
} }
}, },
"login": {
"loading": "加载中...",
"githubLogin": "GitHub登录"
},
"auth": { "auth": {
"title": "登录", "title": "登录",
"signIn": "登录", "signIn": "登录",
@@ -100,13 +103,18 @@
"invalidEmail": "请输入有效的邮箱地址", "invalidEmail": "请输入有效的邮箱地址",
"passwordTooShort": "密码至少需要8个字符", "passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配", "passwordsNotMatch": "两次输入的密码不匹配",
"signInFailed": "登录失败,请检查您的邮箱和密码",
"signUpFailed": "注册失败,请稍后再试",
"nameRequired": "请输入用户名", "nameRequired": "请输入用户名",
"emailRequired": "请输入邮箱", "emailRequired": "请输入邮箱",
"passwordRequired": "请输入密码", "passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码", "confirmPasswordRequired": "请确认密码"
"loading": "加载中..."
}, },
"memorize": { "memorize": {
"choose": {
"back": "返回",
"choose": "选择"
},
"folder_selector": { "folder_selector": {
"selectFolder": "选择文件夹", "selectFolder": "选择文件夹",
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
@@ -147,6 +155,18 @@
"next": "下句", "next": "下句",
"restart": "句首", "restart": "句首",
"autoPause": "自动暂停({enabled})", "autoPause": "自动暂停({enabled})",
"playbackSpeed": "播放速度",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"backgroundColor": "背景颜色",
"textColor": "文字颜色",
"fontFamily": "字体",
"opacity": "透明度",
"position": "位置",
"top": "顶部",
"center": "居中",
"bottom": "底部",
"keyboardShortcuts": "键盘快捷键",
"uploadVideoAndSubtitle": "请上传视频和字幕文件", "uploadVideoAndSubtitle": "请上传视频和字幕文件",
"uploadVideoFile": "请上传视频文件", "uploadVideoFile": "请上传视频文件",
"uploadSubtitleFile": "请上传字幕文件", "uploadSubtitleFile": "请上传字幕文件",
@@ -160,7 +180,16 @@
"on": "开", "on": "开",
"off": "关", "off": "关",
"videoUploadFailed": "视频上传失败", "videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败" "subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕文件加载成功",
"subtitleLoadFailed": "字幕文件加载失败",
"shortcuts": {
"playPause": "播放/暂停",
"next": "下一句",
"previous": "上一句",
"restart": "句首",
"autoPause": "切换自动暂停"
}
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",
@@ -190,33 +219,5 @@
"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": "保存失败,请稍后重试"
} }
} }

View File

@@ -11,35 +11,36 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.4.10", "better-auth": "^1.4.6",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"lucide-react": "^0.562.0", "edge-tts-universal": "^1.3.3",
"next": "16.1.1", "lucide-react": "^0.561.0",
"next-intl": "^4.7.0", "next": "16.0.10",
"next-intl": "^4.5.8",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"unstorage": "^1.17.3", "unstorage": "^1.17.3",
"zod": "^4.3.5" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@better-auth/cli": "^1.4.10", "@better-auth/cli": "^1.4.6",
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.3", "@types/node": "^25.0.1",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.51.0", "@typescript-eslint/parser": "^8.49.0",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.2", "eslint": "^9.39.1",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.0.10",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"prisma": "^7.2.0", "prisma": "^7.1.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },

1064
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +0,0 @@
/*
Warnings:
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
*/
-- AlterTable
-- 重命名并修改类型为 TEXT
ALTER TABLE "pairs"
RENAME COLUMN "locale1" TO "language1";
ALTER TABLE "pairs"
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
ALTER TABLE "pairs"
RENAME COLUMN "locale2" TO "language2";
ALTER TABLE "pairs"
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
-- CreateTable
CREATE TABLE "dictionary_lookups" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"text" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dictionary_word_id" INTEGER,
"dictionary_phrase_id" INTEGER,
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_words" (
"id" SERIAL NOT NULL,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_phrases" (
"id" SERIAL NOT NULL,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_word_entries" (
"id" SERIAL NOT NULL,
"word_id" INTEGER NOT NULL,
"ipa" TEXT NOT NULL,
"definition" TEXT NOT NULL,
"part_of_speech" TEXT NOT NULL,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_phrase_entries" (
"id" SERIAL NOT NULL,
"phrase_id" INTEGER NOT NULL,
"definition" TEXT NOT NULL,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
-- CreateIndex
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
-- CreateIndex
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@@ -1,30 +0,0 @@
-- CreateTable
CREATE TABLE "translation_history" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"source_text" TEXT NOT NULL,
"source_language" VARCHAR(20) NOT NULL,
"target_language" VARCHAR(20) NOT NULL,
"translated_text" TEXT NOT NULL,
"source_ipa" TEXT,
"target_ipa" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
-- CreateIndex
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
-- CreateIndex
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
-- CreateIndex
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
-- AddForeignKey
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

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

View File

@@ -1,3 +1,4 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
output = "../generated/prisma" output = "../generated/prisma"
@@ -7,6 +8,39 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
model Pair {
id Int @id @default(autoincrement())
locale1 String @db.VarChar(10)
locale2 String @db.VarChar(10)
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, locale1, locale2, text1])
@@index([folderId])
@@map("pairs")
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model User { model User {
id String @id id String @id
name String name String
@@ -18,8 +52,6 @@ model User {
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
folders Folder[] folders Folder[]
dictionaryLookUps DictionaryLookUp[]
translationHistories TranslationHistory[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -72,141 +104,3 @@ model Verification {
@@index([identifier]) @@index([identifier])
@@map("verification") @@map("verification")
} }
model Pair {
id Int @id @default(autoincrement())
text1 String
text2 String
language1 String @db.VarChar(20)
language2 String @db.VarChar(20)
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, language1, language2, text1, text2])
@@index([folderId])
@@map("pairs")
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
text String
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
dictionaryWordId Int? @map("dictionary_word_id")
dictionaryPhraseId Int? @map("dictionary_phrase_id")
user User? @relation(fields: [userId], references: [id])
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])
@@index([text, queryLang, definitionLang])
@@map("dictionary_lookups")
}
model DictionaryWord {
id Int @id @default(autoincrement())
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lookups DictionaryLookUp[]
entries DictionaryWordEntry[]
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_words")
}
model DictionaryPhrase {
id Int @id @default(autoincrement())
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lookups DictionaryLookUp[]
entries DictionaryPhraseEntry[]
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_phrases")
}
model DictionaryWordEntry {
id Int @id @default(autoincrement())
wordId Int @map("word_id")
ipa String
definition String
partOfSpeech String @map("part_of_speech")
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
@@index([wordId])
@@index([createdAt])
@@map("dictionary_word_entries")
}
model DictionaryPhraseEntry {
id Int @id @default(autoincrement())
phraseId Int @map("phrase_id")
definition String
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
@@index([phraseId])
@@index([createdAt])
@@map("dictionary_phrase_entries")
}
model TranslationHistory {
id Int @id @default(autoincrement())
userId String? @map("user_id")
sourceText String @map("source_text")
sourceLanguage String @map("source_language") @db.VarChar(20)
targetLanguage String @map("target_language") @db.VarChar(20)
translatedText String @map("translated_text")
sourceIpa String? @map("source_ipa")
targetIpa String? @map("target_ipa")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])
@@index([sourceText, targetLanguage])
@@index([translatedText, sourceLanguage, targetLanguage])
@@map("translation_history")
}

View File

@@ -207,15 +207,34 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
<ChevronLeft size={24} /> <ChevronLeft size={24} />
</button> </button>
{/* 中间区域:随机按钮 */} {/* 中间区域:随机按钮或进度条 */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{isRandomMode && ( {isRandomMode ? (
// 随机模式:显示随机切换按钮
<button <button
onClick={goToRandom} onClick={goToRandom}
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors" className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
> >
{t("randomNext")} {t("randomNext")}
</button> </button>
) : (
// 顺序模式:显示进度点
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
{alphabet.slice(0, 20).map((_, index) => (
<div
key={index}
className={`h-2 rounded-full transition-all ${
index === currentIndex
? "w-8 bg-[#35786f]"
: "w-2 bg-gray-300"
}`}
/>
))}
{/* 超过20个字母时显示省略号 */}
{alphabet.length > 20 && (
<div className="text-xs text-gray-500 flex items-center">...</div>
)}
</div>
)} )}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { default } from "./DictionaryPage";

View File

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

View File

@@ -2,7 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils"; import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
@@ -58,33 +59,21 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
if (show === "answer") { if (show === "answer") {
const newIndex = (index + 1) % getTextPairs().length; const newIndex = (index + 1) % getTextPairs().length;
setIndex(newIndex); setIndex(newIndex);
if (dictation) { if (dictation)
const textPair = getTextPairs()[newIndex]; getTTSAudioUrl(
const language = textPair[reverse ? "language2" : "language1"]; getTextPairs()[newIndex][reverse ? "text2" : "text1"],
const text = textPair[reverse ? "text2" : "text1"]; VOICES.find(
(v) =>
// 映射语言到 TTS 支持的格式 v.locale ===
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = { getTextPairs()[newIndex][
"chinese": "Chinese", reverse ? "locale2" : "locale1"
"english": "English", ],
"japanese": "Japanese", )!.short_name,
"korean": "Korean", ).then((url) => {
"french": "French",
"german": "German",
"italian": "Italian",
"portuguese": "Portuguese",
"spanish": "Spanish",
"russian": "Russian",
};
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
getTTSUrl(text, ttsLanguage).then((url) => {
load(url); load(url);
play(); play();
}); });
} }
}
setShow(show === "question" ? "answer" : "question"); setShow(show === "question" ? "answer" : "question");
}; };

View File

@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
getFoldersWithTotalPairsByUserId, getFoldersWithTotalPairsByUserId,
getUserIdByFolderId,
} from "@/lib/server/services/folderService"; } from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
@@ -15,8 +16,18 @@ export default async function MemorizePage({
}: { }: {
searchParams: Promise<{ folder_id?: string; }>; searchParams: Promise<{ folder_id?: string; }>;
}) { }) {
const session = await auth.api.getSession({ headers: await headers() });
const tParam = (await searchParams).folder_id; const tParam = (await searchParams).folder_id;
if (!session) {
redirect(
`/auth?redirect=/memorize${(await searchParams).folder_id
? `?folder_id=${tParam}`
: ""
}`,
);
}
const t = await getTranslations("memorize.page"); const t = await getTranslations("memorize.page");
const folder_id = tParam const folder_id = tParam
@@ -26,8 +37,6 @@ export default async function MemorizePage({
: null; : null;
if (!folder_id) { if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() });
if(!session) redirect("/auth?redirect=/memorize")
return ( return (
<FolderSelector <FolderSelector
folders={await getFoldersWithTotalPairsByUserId(session.user.id)} folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
@@ -35,5 +44,10 @@ export default async function MemorizePage({
); );
} }
const owner = await getUserIdByFolderId(folder_id);
if (owner !== session.user.id) {
return <p>{t("unauthorized")}</p>;
}
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />; return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
} }

View File

@@ -1,5 +1,4 @@
import { SubtitleEntry } from "../types/subtitle"; import { SubtitleEntry } from "../types/subtitle";
import { logger } from "@/lib/logger";
export function parseSrt(data: string): SubtitleEntry[] { export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/); const lines = data.split(/\r?\n/);
@@ -94,7 +93,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
const data = await response.text(); const data = await response.text();
return parseSrt(data); return parseSrt(data);
} catch (error) { } catch (error) {
logger.error('加载字幕失败', error); console.error('Failed to load subtitle:', error);
return []; return [];
} }
} }

View File

@@ -12,12 +12,12 @@ import { ChangeEvent, useEffect, useRef, useState } from "react";
import z from "zod"; import z from "zod";
import SaveList from "./SaveList"; import SaveList from "./SaveList";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { logger } from "@/lib/logger"; import { genIPA, genLocale } from "@/lib/server/translatorActions";
import PageLayout from "@/components/ui/PageLayout"; import PageLayout from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
export default function TextSpeakerPage() { export default function TextSpeakerPage() {
const t = useTranslations("text_speaker"); const t = useTranslations("text_speaker");
@@ -30,7 +30,7 @@ export default function TextSpeakerPage() {
const [pause, setPause] = useState(true); const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true); const [autopause, setAutopause] = useState(true);
const textRef = useRef(""); const textRef = useRef("");
const [language, setLanguage] = useState<string | null>(null); const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>(""); const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null); const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -75,7 +75,7 @@ export default function TextSpeakerPage() {
setIPA(data.ipa); setIPA(data.ipa);
}) })
.catch((e) => { .catch((e) => {
logger.error("生成 IPA 失败", e); console.error(e);
setIPA(""); setIPA("");
}); });
} }
@@ -94,35 +94,40 @@ export default function TextSpeakerPage() {
} else { } else {
// 第一次播放 // 第一次播放
try { try {
let theLanguage = language; let theLocale = locale;
if (!theLanguage) { if (!theLocale) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30)); console.log("downloading text info");
setLanguage(tmp_language); const tmp_locale = await genLocale(textRef.current.slice(0, 30));
theLanguage = tmp_language; setLocale(tmp_locale);
theLocale = tmp_locale;
} }
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase()); const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (!voice) throw "Voice not found.";
// 检查语言是否在 TTS 支持列表中 objurlRef.current = await getTTSAudioUrl(
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
}
objurlRef.current = await getTTSUrl(
textRef.current, textRef.current,
theLanguage as TTS_SUPPORTED_LANGUAGES voice.short_name,
(() => {
if (speed === 1) return {};
else if (speed < 1)
return {
rate: `-${100 - speed * 100}%`,
};
else
return {
rate: `+${speed * 100 - 100}%`,
};
})(),
); );
load(objurlRef.current); load(objurlRef.current);
play(); play();
} catch (e) { } catch (e) {
logger.error("播放音频失败", e); console.error(e);
setPause(true); setPause(true);
setLanguage(null); setLocale(null);
setProcessing(false); setProcessing(false);
} }
} }
@@ -138,7 +143,7 @@ export default function TextSpeakerPage() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim(); textRef.current = e.target.value.trim();
setLanguage(null); setLocale(null);
setIPA(""); setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
@@ -159,7 +164,7 @@ export default function TextSpeakerPage() {
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => { const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
if (textareaRef.current) textareaRef.current.value = item.text; if (textareaRef.current) textareaRef.current.value = item.text;
textRef.current = item.text; textRef.current = item.text;
setLanguage(item.language); setLocale(item.locale);
setIPA(item.ipa || ""); setIPA(item.ipa || "");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
@@ -174,11 +179,12 @@ export default function TextSpeakerPage() {
setSaving(true); setSaving(true);
try { try {
let theLanguage = language; let theLocale = locale;
if (!theLanguage) { if (!theLocale) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30)); console.log("downloading text info");
setLanguage(tmp_language); const tmp_locale = await genLocale(textRef.current.slice(0, 30));
theLanguage = tmp_language; setLocale(tmp_locale);
theLocale = tmp_locale;
} }
let theIPA = ipa; let theIPA = ipa;
@@ -201,19 +207,19 @@ export default function TextSpeakerPage() {
} else if (theIPA.length === 0) { } else if (theIPA.length === 0) {
save.push({ save.push({
text: textRef.current, text: textRef.current,
language: theLanguage as string, locale: theLocale,
}); });
} else { } else {
save.push({ save.push({
text: textRef.current, text: textRef.current,
language: theLanguage as string, locale: theLocale,
ipa: theIPA, ipa: theIPA,
}); });
} }
setIntoLocalStorage(save); setIntoLocalStorage(save);
} catch (e) { } catch (e) {
logger.error("保存到本地存储失败", e); console.error(e);
setLanguage(null); setLocale(null);
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@@ -57,9 +57,13 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
createPair({ createPair({
text1: item.text1, text1: item.text1,
text2: item.text2, text2: item.text2,
language1: item.language1, locale1: item.locale1,
language2: item.language2, locale2: item.locale2,
folderId: folder.id, folder: {
connect: {
id: folder.id,
},
},
}) })
.then(() => { .then(() => {
toast.success(t("success")); toast.success(t("success"));

View File

@@ -3,38 +3,40 @@
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons"; import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces"; import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators"; import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { logger } from "@/lib/logger"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { Plus, Trash } from "lucide-react"; import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import z from "zod"; import z from "zod";
import AddToFolder from "./AddToFolder"; import AddToFolder from "./AddToFolder";
import { translateText } from "@/lib/server/bigmodel/translatorActions"; import {
import type { TranslateTextOutput } from "@/lib/server/services/types"; genIPA,
genLocale,
genTranslation,
} from "@/lib/server/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";
import { shallowEqual } from "@/lib/utils"; import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese"); const [lang, setLang] = useState<string>("chinese");
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | null>(null); const [tresult, setTresult] = useState<string>("");
const [needIpa, setNeedIpa] = useState(true); const [genIpa, setGenIpa] = 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>[]
>([]);
const [showAddToFolder, setShowAddToFolder] = useState(false); const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer< const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema typeof TranslationHistorySchema
@@ -47,96 +49,134 @@ export default function TranslatorPage() {
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null); const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
useEffect(() => {
setHistory(tlso.get());
}, []);
const tts = async (text: string, locale: string) => { const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) { if (lastTTS.current.text !== text) {
try { const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
// Map language name to TTS format if (!shortName) {
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase()); toast.error("Voice not found");
return;
// Check if language is in TTS supported list
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
} }
try {
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES); const url = await getTTSAudioUrl(text, shortName);
await load(url); await load(url);
lastTTS.current.text = text; lastTTS.current.text = text;
lastTTS.current.url = url; lastTTS.current.url = url;
} catch (error) { } catch (error) {
toast.error("Failed to generate audio"); toast.error("Failed to generate audio");
logger.error("生成音频失败", error); console.error(error);
} }
} }
await play(); await play();
}; };
const translate = async () => { const translate = async () => {
if (!taref.current || processing) return; if (!taref.current) return;
if (processing) return;
setProcessing(true); setProcessing(true);
const sourceText = taref.current.value; const text1 = taref.current.value;
// 判断是否需要强制重新翻译 const llmres: {
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译 text1: string | null;
const forceRetranslate = text2: string | null;
lastTranslation?.sourceText === sourceText && locale1: string | null;
lastTranslation?.targetLanguage === targetLanguage; locale2: string | null;
ipa1: string | null;
try { ipa2: string | null;
const result = await translateText({ } = {
sourceText, text1: text1,
targetLanguage, text2: null,
forceRetranslate, locale1: null,
needIpa, locale2: null,
userId: session?.user?.id, ipa1: null,
}); ipa2: null,
setTranslationResult(result);
setLastTranslation({
sourceText,
targetLanguage,
});
// 更新本地历史记录
const historyItem = {
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
}; };
setHistory(tlsoPush(historyItem));
// 自动保存到文件夹 let historyUpdated = false;
// 检查更新历史记录
const checkUpdateLocalStorage = () => {
if (historyUpdated) return;
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
setHistory(
tlsoPush({
text1: llmres.text1,
text2: llmres.text2,
locale1: llmres.locale1,
locale2: llmres.locale2,
}),
);
if (autoSave && autoSaveFolderId) { if (autoSave && autoSaveFolderId) {
createPair({ createPair({
text1: result.sourceText, text1: llmres.text1,
text2: result.translatedText, text2: llmres.text2,
language1: result.sourceLanguage, locale1: llmres.locale1,
language2: result.targetLanguage, locale2: llmres.locale2,
ipa1: result.sourceIpa || undefined, folder: {
ipa2: result.targetIpa || undefined, connect: {
folderId: autoSaveFolderId, id: autoSaveFolderId,
},
},
}) })
.then(() => { .then(() => {
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`); toast.success(
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
);
}) })
.catch((error) => { .catch((error) => {
toast.error(`保存失败: ${error.message}`); toast.error(
llmres.text1 +
"保存到文件夹" +
autoSaveFolderId +
"失败:" +
error.message,
);
}); });
} }
} catch (error) { historyUpdated = true;
toast.error("翻译失败,请重试");
console.error("翻译错误:", error);
} finally {
setProcessing(false);
} }
}; };
// 更新局部翻译状态
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("locale1", locale);
});
genLocale(text2).then((locale) => {
updateState("locale2", 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);
});
};
return ( return (
<> <>
@@ -154,7 +194,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">
{translationResult?.sourceIpa || ""} {ipaTexts[0]}
</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
@@ -172,7 +212,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, translationResult?.sourceLanguage || ""); tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
}} }}
></IconClick> ></IconClick>
</div> </div>
@@ -180,8 +220,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={needIpa} selected={genIpa}
onClick={() => setNeedIpa((prev) => !prev)} onClick={() => setGenIpa((prev) => !prev)}
> >
{t("generateIPA")} {t("generateIPA")}
</LightButton> </LightButton>
@@ -192,26 +232,25 @@ 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">{translationResult?.translatedText || ""}</div> <div className="h-2/3 w-full overflow-y-auto">{tresult}</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">
{translationResult?.targetIpa || ""} {ipaTexts[1]}
</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(translationResult?.translatedText || ""); await navigator.clipboard.writeText(tresult);
}} }}
></IconClick> ></IconClick>
<IconClick <IconClick
src={IMAGES.play_arrow} src={IMAGES.play_arrow}
alt="play" alt="play"
onClick={() => { onClick={() => {
if (!translationResult) return;
tts( tts(
translationResult.translatedText, tresult,
translationResult.targetLanguage, tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
); );
}} }}
></IconClick> ></IconClick>
@@ -220,29 +259,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={targetLanguage === "Chinese"} selected={lang === "chinese"}
onClick={() => setTargetLanguage("Chinese")} onClick={() => setLang("chinese")}
> >
{t("chinese")} {t("chinese")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={targetLanguage === "English"} selected={lang === "english"}
onClick={() => setTargetLanguage("English")} onClick={() => setLang("english")}
> >
{t("english")} {t("english")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={targetLanguage === "Italian"} selected={lang === "italian"}
onClick={() => setTargetLanguage("Italian")} onClick={() => setLang("italian")}
> >
{t("italian")} {t("italian")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)} selected={!["chinese", "english", "italian"].includes(lang)}
onClick={() => { onClick={() => {
const newLang = prompt(t("enterLanguage")); const newLang = prompt(t("enterLanguage"));
if (newLang) { if (newLang) {
setTargetLanguage(newLang); setLang(newLang);
} }
}} }}
> >
@@ -300,10 +339,6 @@ 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);
}} }}

View File

@@ -5,7 +5,7 @@ import AuthForm from "./AuthForm";
export default async function AuthPage( export default async function AuthPage(
props: { props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>; searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
} }
) { ) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;

View File

@@ -8,7 +8,6 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { logger } from "@/lib/logger";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser"; import { Folder } from "../../../generated/prisma/browser";
import { import {
@@ -102,7 +101,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
setLoading(false); setLoading(false);
}) })
.catch((error) => { .catch((error) => {
logger.error("加载文件夹失败", error); console.error(error);
toast.error("加载出错,请重试。"); toast.error("加载出错,请重试。");
}); });
}, [userId]); }, [userId]);
@@ -112,7 +111,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId); const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
setFolders(updatedFolders); setFolders(updatedFolders);
} catch (error) { } catch (error) {
logger.error("更新文件夹失败", error); console.error(error);
} }
}; };
@@ -129,7 +128,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
try { try {
await createFolder({ await createFolder({
name: folderName, name: folderName,
userId: userId, user: { connect: { id: userId } },
}); });
await updateFolders(); await updateFolders();
} finally { } finally {

View File

@@ -1,9 +1,9 @@
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
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 { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { LOCALES } from "@/config/locales";
interface AddTextPairModalProps { interface AddTextPairModalProps {
isOpen: boolean; isOpen: boolean;
@@ -11,11 +11,58 @@ interface AddTextPairModalProps {
onAdd: ( onAdd: (
text1: string, text1: string,
text2: string, text2: string,
language1: string, locale1: string,
language2: string, locale2: string,
) => void; ) => void;
} }
const COMMON_LOCALES = [
{ label: "中文", value: "zh-CN" },
{ label: "英文", value: "en-US" },
{ label: "意大利语", value: "it-IT" },
{ label: "日语", value: "ja-JP" },
{ label: "其他", value: "other" },
];
interface LocaleSelectorProps {
value: string;
onChange: (val: string) => void;
}
function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
const isCommonLocale = COMMON_LOCALES.some((l) => l.value === value && l.value !== "other");
const showFullList = value === "other" || !isCommonLocale;
return (
<div>
<select
value={isCommonLocale ? value : "other"}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{COMMON_LOCALES.map((locale) => (
<option key={locale.value} value={locale.value}>
{locale.label}
</option>
))}
</select>
{showFullList && (
<select
value={value === "other" ? LOCALES[0] : value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
>
{LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
)}
</div>
);
}
export default function AddTextPairModal({ export default function AddTextPairModal({
isOpen, isOpen,
onClose, onClose,
@@ -24,8 +71,8 @@ export default function AddTextPairModal({
const t = useTranslations("folder_id"); const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null); const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null); const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState("english"); const [locale1, setLocale1] = useState("en-US");
const [language2, setLanguage2] = useState("chinese"); const [locale2, setLocale2] = useState("zh-CN");
if (!isOpen) return null; if (!isOpen) return null;
@@ -33,8 +80,8 @@ export default function AddTextPairModal({
if ( if (
!input1Ref.current?.value || !input1Ref.current?.value ||
!input2Ref.current?.value || !input2Ref.current?.value ||
!language1 || !locale1 ||
!language2 !locale2
) )
return; return;
@@ -44,14 +91,14 @@ export default function AddTextPairModal({
if ( if (
typeof text1 === "string" && typeof text1 === "string" &&
typeof text2 === "string" && typeof text2 === "string" &&
typeof language1 === "string" && typeof locale1 === "string" &&
typeof language2 === "string" && typeof locale2 === "string" &&
text1.trim() !== "" && text1.trim() !== "" &&
text2.trim() !== "" && text2.trim() !== "" &&
language1.trim() !== "" && locale1.trim() !== "" &&
language2.trim() !== "" locale2.trim() !== ""
) { ) {
onAdd(text1, text2, language1, language2); onAdd(text1, text2, locale1, locale2);
input1Ref.current.value = ""; input1Ref.current.value = "";
input2Ref.current.value = ""; input2Ref.current.value = "";
} }
@@ -84,12 +131,12 @@ export default function AddTextPairModal({
<Input ref={input2Ref} className="w-full"></Input> <Input ref={input2Ref} className="w-full"></Input>
</div> </div>
<div> <div>
{t("language1")} {t("locale1")}
<LocaleSelector value={language1} onChange={setLanguage1} /> <LocaleSelector value={locale1} onChange={setLocale1} />
</div> </div>
<div> <div>
{t("language2")} {t("locale2")}
<LocaleSelector value={language2} onChange={setLanguage2} /> <LocaleSelector value={locale2} onChange={setLocale2} />
</div> </div>
</div> </div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton> <LightButton onClick={handleAdd}>{t("add")}</LightButton>

View File

@@ -13,7 +13,6 @@ import TextPairCard from "./TextPairCard";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import PageLayout from "@/components/ui/PageLayout"; import PageLayout from "@/components/ui/PageLayout";
import { GreenButton } from "@/components/ui/buttons"; import { GreenButton } from "@/components/ui/buttons";
import { logger } from "@/lib/logger";
import { IconButton } from "@/components/ui/buttons"; import { IconButton } from "@/components/ui/buttons";
import CardList from "@/components/ui/CardList"; import CardList from "@/components/ui/CardList";
@@ -21,8 +20,8 @@ export interface TextPair {
id: number; id: number;
text1: string; text1: string;
text2: string; text2: string;
language1: string; locale1: string;
language2: string; locale2: string;
} }
export default function InFolder({ folderId }: { folderId: number }) { export default function InFolder({ folderId }: { folderId: number }) {
@@ -39,7 +38,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
const data = await getPairsByFolderId(folderId); const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]); setTextPairs(data as TextPair[]);
} catch (error) { } catch (error) {
logger.error("获取文本对失败", error); console.error("Failed to fetch text pairs:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -52,7 +51,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
const data = await getPairsByFolderId(folderId); const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]); setTextPairs(data as TextPair[]);
} catch (error) { } catch (error) {
logger.error("获取文本对失败", error); console.error("Failed to fetch text pairs:", error);
} }
}; };
@@ -140,15 +139,19 @@ export default function InFolder({ folderId }: { folderId: number }) {
onAdd={async ( onAdd={async (
text1: string, text1: string,
text2: string, text2: string,
language1: string, locale1: string,
language2: string, locale2: string,
) => { ) => {
await createPair({ await createPair({
text1: text1, text1: text1,
text2: text2, text2: text2,
language1: language1, locale1: locale1,
language2: language2, locale2: locale2,
folderId: folderId, folder: {
connect: {
id: folderId,
},
},
}); });
refreshTextPairs(); refreshTextPairs();
}} }}

View File

@@ -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 { UpdatePairInput } from "@/lib/server/services/types"; import { PairUpdateInput } from "../../../../generated/prisma/models";
interface TextPairCardProps { interface TextPairCardProps {
textPair: TextPair; textPair: TextPair;
@@ -25,11 +25,11 @@ export default function TextPairCard({
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500"> <div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md"> <span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language1.toUpperCase()} {textPair.locale1.toUpperCase()}
</span> </span>
<span></span> <span></span>
<span className="px-2 py-1 bg-gray-100 rounded-md"> <span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language2.toUpperCase()} {textPair.locale2.toUpperCase()}
</span> </span>
</div> </div>
@@ -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: UpdatePairInput) => { onUpdate={async (id: number, data: PairUpdateInput) => {
await updatePairById(id, data); await updatePairById(id, data);
setOpenUpdateModal(false); setOpenUpdateModal(false);
refreshTextPairs(); refreshTextPairs();

View File

@@ -1,9 +1,8 @@
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef, useState } from "react"; import { useRef } from "react";
import { UpdatePairInput } from "@/lib/server/services/types"; import { PairUpdateInput } from "../../../../generated/prisma/models";
import { TextPair } from "./InFolder"; import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -11,7 +10,7 @@ interface UpdateTextPairModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
textPair: TextPair; textPair: TextPair;
onUpdate: (id: number, tp: UpdatePairInput) => void; onUpdate: (id: number, tp: PairUpdateInput) => void;
} }
export default function UpdateTextPairModal({ export default function UpdateTextPairModal({
@@ -23,34 +22,36 @@ export default function UpdateTextPairModal({
const t = useTranslations("folder_id"); const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null); const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null); const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState(textPair.language1); const input3Ref = useRef<HTMLInputElement>(null);
const [language2, setLanguage2] = useState(textPair.language2); const input4Ref = useRef<HTMLInputElement>(null);
if (!isOpen) return null; if (!isOpen) return null;
const handleUpdate = () => { const handleUpdate = () => {
if ( if (
!input1Ref.current?.value || !input1Ref.current?.value ||
!input2Ref.current?.value || !input2Ref.current?.value ||
!language1 || !input3Ref.current?.value ||
!language2 !input4Ref.current?.value
) )
return; return;
const text1 = input1Ref.current.value; const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value; const text2 = input2Ref.current.value;
const locale1 = input3Ref.current.value;
const locale2 = input4Ref.current.value;
if ( if (
typeof text1 === "string" && typeof text1 === "string" &&
typeof text2 === "string" && typeof text2 === "string" &&
typeof language1 === "string" && typeof locale1 === "string" &&
typeof language2 === "string" && typeof locale2 === "string" &&
text1.trim() !== "" && text1.trim() !== "" &&
text2.trim() !== "" && text2.trim() !== "" &&
language1.trim() !== "" && locale1.trim() !== "" &&
language2.trim() !== "" locale2.trim() !== ""
) { ) {
onUpdate(textPair.id, { text1, text2, language1, language2 }); onUpdate(textPair.id, { text1, text2, locale1, locale2 });
input1Ref.current.value = "";
input2Ref.current.value = "";
} }
}; };
return ( return (
@@ -88,12 +89,20 @@ export default function UpdateTextPairModal({
></Input> ></Input>
</div> </div>
<div> <div>
{t("language1")} {t("locale1")}
<LocaleSelector value={language1} onChange={setLanguage1} /> <Input
defaultValue={textPair.locale1}
ref={input3Ref}
className="w-full"
></Input>
</div> </div>
<div> <div>
{t("language2")} {t("locale2")}
<LocaleSelector value={language2} onChange={setLanguage2} /> <Input
defaultValue={textPair.locale2}
ref={input4Ref}
className="w-full"
></Input>
</div> </div>
</div> </div>
<LightButton onClick={handleUpdate}>{t("update")}</LightButton> <LightButton onClick={handleUpdate}>{t("update")}</LightButton>

View File

@@ -60,12 +60,6 @@ export default async function HomePage() {
description={t("srtPlayer.description")} description={t("srtPlayer.description")}
color="#3c988d" color="#3c988d"
></LinkArea> ></LinkArea>
<LinkArea
href="/dictionary"
name={t("dictionary.name")}
description={t("dictionary.description")}
color="#6a9c89"
></LinkArea>
<LinkArea <LinkArea
href="/alphabet" href="/alphabet"
name={t("alphabet.name")} name={t("alphabet.name")}

View File

@@ -38,42 +38,6 @@ export default function LanguageSettings() {
> >
</GhostButton> </GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
onClick={() => setLocale("ja-JP")}
>
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
onClick={() => setLocale("ko-KR")}
>
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
onClick={() => setLocale("de-DE")}
>
Deutsch
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
onClick={() => setLocale("fr-FR")}
>
Français
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
onClick={() => setLocale("it-IT")}
>
Italiano
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
onClick={() => setLocale("ug-CN")}
>
ئۇيغۇرچە
</GhostButton>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,72 +0,0 @@
import { useTranslations } from "next-intl";
import { useState } from "react";
const COMMON_LANGUAGES = [
{ label: "chinese", value: "chinese" },
{ label: "english", value: "english" },
{ label: "italian", value: "italian" },
{ label: "japanese", value: "japanese" },
{ label: "korean", value: "korean" },
{ label: "french", value: "french" },
{ label: "german", value: "german" },
{ label: "spanish", value: "spanish" },
{ label: "portuguese", value: "portuguese" },
{ label: "russian", value: "russian" },
{ label: "other", value: "other" },
];
interface LocaleSelectorProps {
value: string;
onChange: (val: string) => void;
}
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
const t = useTranslations();
const [customInput, setCustomInput] = useState("");
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
const showCustomInput = value === "other" || !isCommonLanguage;
// 计算输入框的值:如果是"other"使用自定义输入,否则使用外部传入的值
const inputValue = value === "other" ? customInput : value;
// 处理自定义输入
const handleCustomInputChange = (inputValue: string) => {
setCustomInput(inputValue);
onChange(inputValue);
};
// 当选择常见语言或"其他"时
const handleSelectChange = (selectedValue: string) => {
if (selectedValue === "other") {
setCustomInput("");
onChange("other");
} else {
onChange(selectedValue);
}
};
return (
<div>
<select
value={isCommonLanguage ? value : "other"}
onChange={(e) => handleSelectChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{COMMON_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{t(`translator.${lang.label}`)}
</option>
))}
</select>
{showCustomInput && (
<input
type="text"
value={inputValue}
onChange={(e) => handleCustomInputChange(e.target.value)}
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"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
interface ACardProps {
children?: React.ReactNode;
className?: string;
}
export default function ACard({ children, className }: ACardProps) {
return (
<div
className={`${className} w-[95dvw] md:w-[61vw] h-96 p-2 md:shadow-2xl rounded-xl`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
interface BCardProps {
children?: React.ReactNode;
className?: string;
}
export default function BCard({ children, className }: BCardProps) {
return (
<div className={`${className} rounded-xl p-2 shadow-xl`}>{children}</div>
);
}

View File

@@ -1,11 +1,2 @@
export const SUPPORTED_LOCALES = [ export const SUPPORTED_LOCALES = ["en-US", "zh-CN"];
"en-US",
"zh-CN",
"ja-JP",
"ko-KR",
"de-DE",
"fr-FR",
"it-IT",
"ug-CN",
];
export const DEFAULT_LOCALE = "en-US"; export const DEFAULT_LOCALE = "en-US";

1220
src/config/locales.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -111,9 +111,6 @@ export async function signInAction(prevState: SignUpState, formData: FormData) {
redirect(redirectTo || "/"); redirect(redirectTo || "/");
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
throw error;
}
return { return {
success: false, success: false,
message: "登录失败,请检查您的邮箱和密码" message: "登录失败,请检查您的邮箱和密码"

View File

@@ -6,7 +6,6 @@ import {
} from "@/lib/interfaces"; } from "@/lib/interfaces";
import z from "zod"; import z from "zod";
import { shallowEqual } from "../utils"; import { shallowEqual } from "../utils";
import { logger } from "@/lib/logger";
export const getLocalStorageOperator = <T extends z.ZodTypeAny>( export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
key: string, key: string,
@@ -15,7 +14,6 @@ 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>;
@@ -26,14 +24,14 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
if (result.success) { if (result.success) {
return result.data; return result.data;
} else { } else {
logger.error( console.error(
"Invalid data structure in localStorage:", "Invalid data structure in localStorage:",
result.error, result.error,
); );
return [] as z.infer<T>; return [] as z.infer<T>;
} }
} catch (e) { } catch (e) {
logger.error(`Failed to parse ${key} data:`, e); console.error(`Failed to parse ${key} data:`, e);
return [] as z.infer<T>; return [] as z.infer<T>;
} }
}, },

15
src/lib/browser/tts.ts Normal file
View File

@@ -0,0 +1,15 @@
import { ProsodyOptions, EdgeTTS } from "edge-tts-universal/browser";
export async function getTTSAudioUrl(
text: string,
short_name: string,
options: ProsodyOptions | undefined = undefined,
) {
const tts = new EdgeTTS(text, short_name, options);
try {
const result = await tts.synthesize();
return URL.createObjectURL(result.audio);
} catch (e) {
throw e;
}
}

View File

@@ -19,15 +19,15 @@ export type SupportedAlphabets =
export const TextSpeakerItemSchema = z.object({ export const TextSpeakerItemSchema = z.object({
text: z.string(), text: z.string(),
ipa: z.string().optional(), ipa: z.string().optional(),
language: z.string(), locale: z.string(),
}); });
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema); export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
export const WordDataSchema = z.object({ export const WordDataSchema = z.object({
languages: z locales: z
.tuple([z.string(), z.string()]) .tuple([z.string(), z.string()])
.refine(([first, second]) => first !== second, { .refine(([first, second]) => first !== second, {
message: "Languages must be different", message: "Locales must be different",
}), }),
wordPairs: z wordPairs: z
.array(z.tuple([z.string(), z.string()])) .array(z.tuple([z.string(), z.string()]))
@@ -47,8 +47,8 @@ export const WordDataSchema = z.object({
export const TranslationHistorySchema = z.object({ export const TranslationHistorySchema = z.object({
text1: z.string(), text1: z.string(),
text2: z.string(), text2: z.string(),
language1: z.string(), locale1: z.string(),
language2: z.string(), locale2: z.string(),
}); });
export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema); export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema);

View File

@@ -1,29 +0,0 @@
/**
* 统一的日志工具
* 在生产环境中可以通过环境变量控制日志级别
*/
type LogLevel = 'info' | 'warn' | 'error';
const isDevelopment = process.env.NODE_ENV === 'development';
export const logger = {
error: (message: string, error?: unknown) => {
if (isDevelopment) {
console.error(message, error);
}
// 在生产环境中,这里可以发送到错误追踪服务(如 Sentry
},
warn: (message: string, data?: unknown) => {
if (isDevelopment) {
console.warn(message, data);
}
},
info: (message: string, data?: unknown) => {
if (isDevelopment) {
console.info(message, data);
}
},
};

62
src/lib/server/ai.ts Normal file
View File

@@ -0,0 +1,62 @@
"use server";
import { format } from "util";
async function callZhipuAPI(
messages: { role: string; content: string }[],
model = process.env.ZHIPU_MODEL_NAME,
) {
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ZHIPU_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.2,
thinking: {
type: "disabled",
},
}),
});
if (!response.ok) {
throw new Error(`API 调用失败: ${response.status} ${response.statusText}`);
}
return await response.json();
}
export async function getLLMAnswer(prompt: string) {
return (
await callZhipuAPI([
{
role: "user",
content: prompt,
},
])
).choices[0].message.content.trim() as string;
}
export async function simpleGetLLMAnswer(
prompt: string,
searchParams: URLSearchParams,
args: string[],
) {
if (args.some((arg) => typeof searchParams.get(arg) !== "string")) {
return Response.json({
status: "error",
message: "Missing required parameters",
});
}
return Response.json({
status: "success",
message: await getLLMAnswer(
format(prompt, ...args.map((v) => searchParams.get(v))),
),
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,141 +0,0 @@
"use server";
import { executeDictionaryLookup } from "./dictionary";
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
if (isDictErrorResponse(res)) return;
else if (isDictPhraseResponse(res)) {
// 先创建 Phrase
const phrase = await createPhrase({
standardForm: res.standardForm,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
});
// 创建 Lookup
await createLookUp({
userId: req.userId,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
dictionaryPhraseId: phrase.id,
});
// 创建 Entries
for (const entry of res.entries) {
await createPhraseEntry({
phraseId: phrase.id,
definition: entry.definition,
example: entry.example,
});
}
} else if (isDictWordResponse(res)) {
// 先创建 Word
const word = await createWord({
standardForm: (res as DictWordResponse).standardForm,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
});
// 创建 Lookup
await createLookUp({
userId: req.userId,
text: req.text,
queryLang: req.queryLang,
definitionLang: req.definitionLang,
dictionaryWordId: word.id,
});
// 创建 Entries
for (const entry of (res as DictWordResponse).entries) {
await createWordEntry({
wordId: word.id,
ipa: entry.ipa,
definition: entry.definition,
partOfSpeech: entry.partOfSpeech,
example: entry.example,
});
}
}
};
/**
* 查询单词或短语
*
* 使用模块化的词典查询系统将提示词拆分为6个阶段
* - 阶段0基础系统提示
* - 阶段1输入解析与语言识别
* - 阶段2跨语言语义映射决策
* - 阶段3standardForm 生成与规范化
* - 阶段4释义与词条生成
* - 阶段5错误处理
* - 阶段6最终输出封装
*/
export const lookUp = async ({
text,
queryLang,
definitionLang,
userId,
forceRelook = false
}: DictLookUpRequest): Promise<DictLookUpResponse> => {
try {
const lastLookUp = await selectLastLookUp({
text,
queryLang,
definitionLang
});
if (forceRelook || !lastLookUp) {
// 使用新的模块化查询系统
const response = await executeDictionaryLookup(
text,
queryLang,
definitionLang
);
saveResult({
text,
queryLang,
definitionLang,
userId,
forceRelook
}, response);
return response;
} else {
// 从数据库返回缓存的结果
if (lastLookUp.dictionaryWordId) {
createLookUp({
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryWordId: lastLookUp.dictionaryWordId,
});
return {
standardForm: lastLookUp.dictionaryWord!.standardForm,
entries: lastLookUp.dictionaryWord!.entries
};
} else if (lastLookUp.dictionaryPhraseId) {
createLookUp({
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryPhraseId: lastLookUp.dictionaryPhraseId
});
return {
standardForm: lastLookUp.dictionaryPhrase!.standardForm,
entries: lastLookUp.dictionaryPhrase!.entries
};
} else {
return { error: "Database structure error!" };
}
}
} catch (error) {
console.log(error);
return { error: "LOOK_UP_ERROR" };
}
};

View File

@@ -1,253 +0,0 @@
"use server";
import { getAnswer } from "./zhipu";
import { selectLatestTranslation, createTranslationHistory } from "../services/translatorService";
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "../services/types";
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genIPA = async (text: string) => {
return (
"[" +
(
await getAnswer(
`
<text>${text}</text>
请生成以上文本的严式国际音标
然后直接发给我
不要附带任何说明
不要擅自增减符号
不许用"/"或者"[]"包裹
`.trim(),
)
)
.replaceAll("[", "")
.replaceAll("]", "") +
"]"
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLocale = async (text: string) => {
return await getAnswer(
`
<text>${text}</text>
推断以上文本的地区locale
然后直接发给我
形如如zh-CN
不要附带任何说明
不要擅自增减符号
`.trim(),
);
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLanguage = async (text: string) => {
const language = await getAnswer([
{
role: "system",
content: `
你是一个语言检测工具。请识别文本的语言并返回语言名称。
返回语言的标准英文名称,例如:
- 中文: Chinese
- 英语: English
- 日语: Japanese
- 韩语: Korean
- 法语: French
- 德语: German
- 意大利语: Italian
- 葡萄牙语: Portuguese
- 西班牙语: Spanish
- 俄语: Russian
- 阿拉伯语: Arabic
- 印地语: Hindi
- 泰语: Thai
- 越南语: Vietnamese
- 等等...
如果无法识别语言,返回 "Unknown"
规则:
1. 只返回语言的标准英文名称
2. 首字母大写,其余小写
3. 不要附带任何说明
4. 不要擅自增减符号
`.trim()
},
{
role: "user",
content: `<text>${text}</text>`
}
]);
return language.trim();
};
/**
* @deprecated 请使用 translateText 函数代替
* 保留此函数以支持旧代码text-speaker 功能)
*/
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getAnswer(
`
<text>${text}</text>
请将以上文本翻译到 <target_language>${targetLanguage}</target_language>
然后直接发给我
不要附带任何说明
不要擅自增减符号
`.trim(),
);
};
/**
* 统一的翻译函数
* 一次调用生成所有信息,支持缓存查询
*/
export async function translateText(options: TranslateTextInput): Promise<TranslateTextOutput> {
const {
sourceText,
targetLanguage,
forceRetranslate = false,
needIpa = true,
userId,
} = options;
// 1. 检查缓存(如果未强制重新翻译)并获取翻译数据
let translatedData: TranslationLLMResponse | null = null;
let fromCache = false;
if (!forceRetranslate) {
const cached = await selectLatestTranslation({
sourceText,
targetLanguage,
});
if (cached && cached.translatedText && cached.sourceLanguage) {
// 如果不需要 IPA或缓存已有 IPA使用缓存
if (!needIpa || (cached.sourceIpa && cached.targetIpa)) {
console.log("✅ 翻译缓存命中");
translatedData = {
translatedText: cached.translatedText,
sourceLanguage: cached.sourceLanguage,
targetLanguage: cached.targetLanguage,
sourceIpa: cached.sourceIpa || undefined,
targetIpa: cached.targetIpa || undefined,
};
fromCache = true;
}
}
}
// 2. 如果缓存未命中,调用 LLM 生成翻译
if (!fromCache) {
translatedData = await callTranslationLLM({
sourceText,
targetLanguage,
needIpa,
});
}
// 3. 保存到数据库(不管缓存是否命中都保存)
if (translatedData) {
try {
await createTranslationHistory({
userId,
sourceText,
sourceLanguage: translatedData.sourceLanguage,
targetLanguage: translatedData.targetLanguage,
translatedText: translatedData.translatedText,
sourceIpa: needIpa ? translatedData.sourceIpa : undefined,
targetIpa: needIpa ? translatedData.targetIpa : undefined,
});
} catch (error) {
console.error("保存翻译历史失败:", error);
}
}
return {
sourceText,
translatedText: translatedData!.translatedText,
sourceLanguage: translatedData!.sourceLanguage,
targetLanguage: translatedData!.targetLanguage,
sourceIpa: needIpa ? (translatedData!.sourceIpa || "") : "",
targetIpa: needIpa ? (translatedData!.targetIpa || "") : "",
};
}
/**
* 调用 LLM 生成翻译和相关数据
*/
async function callTranslationLLM(params: {
sourceText: string;
targetLanguage: string;
needIpa: boolean;
}): Promise<TranslationLLMResponse> {
const { sourceText, targetLanguage, needIpa } = params;
console.log("🤖 调用 LLM 翻译");
let systemPrompt = "你是一个专业的翻译助手。请根据用户的要求翻译文本,并返回 JSON 格式的结果。\n\n返回的 JSON 必须严格符合以下格式:\n{\n \"translatedText\": \"翻译后的文本\",\n \"sourceLanguage\": \"源语言的标准英文名称(如 Chinese, English, Japanese\",\n \"targetLanguage\": \"目标语言的标准英文名称\"";
if (needIpa) {
systemPrompt += ",\n \"sourceIpa\": \"源文本的严式国际音标(用方括号包裹,如 [tɕɪn˥˩]\",\n \"targetIpa\": \"译文的严式国际音标(用方括号包裹)\"";
}
systemPrompt += "}\n\n规则\n1. 只返回 JSON不要包含任何其他文字说明\n2. 语言名称必须是标准英文名称,首字母大写\n";
if (needIpa) {
systemPrompt += "3. 国际音标必须用方括号 [] 包裹,使用严式音标\n";
} else {
systemPrompt += "3. 本次请求不需要生成国际音标\n";
}
systemPrompt += needIpa ? "4. 确保翻译准确、自然" : "4. 确保翻译准确、自然";
const userPrompt = `请将以下文本翻译成 ${targetLanguage}\n\n<text>${sourceText}</text>\n\n返回 JSON 格式的翻译结果。`;
const response = await getAnswer([
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userPrompt,
},
]);
// 解析 LLM 返回的 JSON
try {
// 清理响应:移除 markdown 代码块标记和多余空白
let cleanedResponse = response
.replace(/```json\s*\n/g, "") // 移除 ```json 开头
.replace(/```\s*\n/g, "") // 移除 ``` 结尾
.replace(/```\s*$/g, "") // 移除末尾的 ```
.replace(/```json\s*$/g, "") // 移除末尾的 ```json
.trim();
const parsed = JSON.parse(cleanedResponse) as TranslationLLMResponse;
// 验证必需字段
if (!parsed.translatedText || !parsed.sourceLanguage || !parsed.targetLanguage) {
throw new Error("LLM 返回的数据缺少必需字段");
}
console.log("LLM 翻译成功");
return parsed;
} catch (error) {
console.error("LLM 翻译失败:", error);
console.error("原始响应:", response);
throw new Error("翻译失败:无法解析 LLM 响应");
}
}

View File

@@ -1,182 +0,0 @@
"use server";
// ==================== 类型定义 ====================
/**
* 支持的语音合成模型
*/
type TTSModel = 'qwen3-tts-flash' | string; // 主要模型为 'qwen3-tts-flash'
/**
* API 支持的语言类型(必须严格按文档使用)
*/
type SupportedLanguage =
| 'Auto' // 自动检测(混合语言场景)
| 'Chinese' // 中文
| 'English' // 英文
| 'German' // 德文
| 'Italian' | 'Portuguese' | 'Spanish'
| 'Japanese' | 'Korean' | 'French'
| 'Russian';
/**
* API 请求参数接口
*/
interface TTSRequest {
model: TTSModel;
input: {
text: string; // 要合成的文本qwen3-tts-flash最长600字符
voice: string; // 音色名称,如 'Cherry'
language_type?: SupportedLanguage; // 可选,默认为 'Auto'
};
parameters?: {
stream?: boolean; // 是否流式输出需配合特定Header
};
}
/**
* API 响应接口(通用结构)
*/
interface TTSResponse {
status_code: number; // HTTP状态码200表示成功
request_id: string; // 请求唯一标识,用于排查问题
code: string; // 错误码,成功时为 ''
message: string; // 错误信息,成功时为 ''
output: {
audio: {
data: string; // Base64编码的音频数据流式输出时有效
url: string; // 音频文件下载URL非流式输出时有效
id: string; // 音频ID
expires_at: number; // URL过期时间戳
};
text: null; // 文档注明始终为null
choices: null; // 文档注明始终为null
finish_reason: string; // 生成状态
};
usage: {
characters: number; // 计费字符数qwen3-tts-flash
input_tokens?: number;
output_tokens?: number;
};
}
// ==================== TTS 服务类 ====================
class QwenTTSService {
private baseUrl: string;
private apiKey: string;
private region: 'cn-beijing' | 'intl-singapore'; // 地域
/**
* 构造函数
* @param apiKey - DashScope API Key从环境变量获取更安全
* @param region - 服务地域,默认北京
*/
constructor(
apiKey: string,
region: 'cn-beijing' | 'intl-singapore' = 'cn-beijing'
) {
this.apiKey = apiKey;
this.region = region;
// 根据地域设置API端点文档中特别强调
this.baseUrl = region === 'cn-beijing'
? 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'
: 'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
}
/**
* 验证文本长度qwen3-tts-flash模型限制600字符
*/
private validateTextLength(text: string, model: TTSModel): void {
const maxLength = model.includes('qwen3-tts-flash') ? 600 : 512;
if (text.length > maxLength) {
throw new Error(
`文本长度 ${text.length} 字符超过模型限制(最大 ${maxLength} 字符)`
);
}
}
/**
* 合成语音非流式输出返回音频URL
*/
async synthesize(
text: string,
options: {
voice?: string; // 音色,默认 'Cherry'
language?: SupportedLanguage; // 语种,默认 'Auto'
model?: TTSModel; // 模型,默认 'qwen3-tts-flash'
} = {}
): Promise<TTSResponse> {
const {
voice = 'Cherry',
language = 'Auto',
model = 'qwen3-tts-flash'
} = options;
// 1. 文本长度验证
this.validateTextLength(text, model);
// 2. 构建请求体
const requestBody: TTSRequest = {
model,
input: {
text,
voice,
language_type: language
}
// 非流式输出不需要 stream 参数
};
try {
// 3. 调用API
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
// 4. 错误处理
if (response.status !== 200) {
throw new Error(`TTS API错误: [${response.status}] ${response.statusText}}`);
}
const data: TTSResponse = await response.json();
return data;
} catch (error) {
console.error('语音合成请求失败:', error);
throw error;
}
}
}
export type TTS_SUPPORTED_LANGUAGES = 'Auto' | 'Chinese' | 'English' | 'German' | 'Italian' | 'Portuguese' | 'Spanish' | 'Japanese' | 'Korean' | 'French' | 'Russian';
export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
try {
if (!process.env.DASHSCORE_API_KEY) {
console.warn(
`⚠️ 环境变量 DASHSCORE_API_KEY 未设置\n` +
` 请在 .env 文件中设置或直接传入API Key\n` +
` 获取API Key: https://help.aliyun.com/zh/model-studio/get-api-key`
);
throw "API Key设置错误";
}
const ttsService = new QwenTTSService(
process.env.DASHSCORE_API_KEY
);
const result = await ttsService.synthesize(
text,
{
voice: 'Cherry',
language: lang
}
);
return result.output.audio.url;
} catch (error) {
console.error('TTS合成失败:', error instanceof Error ? error.message : error);
return "error";
}
}

View File

@@ -1,45 +0,0 @@
"use server";
type Messages = { role: string; content: string; }[];
async function callZhipuAPI(
messages: Messages,
model = process.env.ZHIPU_MODEL_NAME,
) {
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ZHIPU_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.2,
thinking: {
type: "disabled",
},
}),
});
if (!response.ok) {
throw new Error(`API 调用失败: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function getAnswer(prompt: string): Promise<string>;
async function getAnswer(prompt: Messages): Promise<string>;
async function getAnswer(prompt: string | Messages): Promise<string> {
const messages = typeof prompt === "string"
? [{ role: "user", content: prompt }]
: prompt;
const response = await callZhipuAPI(messages);
return response.choices[0].message.content.trim() as string;
}
export { getAnswer };

View File

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

View File

@@ -1,18 +1,19 @@
"use server"; "use server";
import { CreateFolderInput, UpdateFolderInput } from "./types"; import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
import prisma from "../../db"; import prisma from "../../db";
export async function getFoldersByUserId(userId: string) { export async function getFoldersByUserId(userId: string) {
return prisma.folder.findMany({ const folders = await 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) {
return prisma.folder.update({ await prisma.folder.update({
where: { where: {
id: id, id: id,
}, },
@@ -31,28 +32,29 @@ 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: CreateFolderInput) { export async function createFolder(folder: FolderCreateInput) {
return prisma.folder.create({ await prisma.folder.create({
data: folder, data: folder,
}); });
} }
export async function deleteFolderById(id: number) { export async function deleteFolderById(id: number) {
return prisma.folder.delete({ await prisma.folder.delete({
where: { where: {
id: id, id: id,
}, },
}); });
} }
export async function updateFolderById(id: number, data: UpdateFolderInput) { export async function updateFolderById(id: number, data: FolderUpdateInput) {
return prisma.folder.update({ await prisma.folder.update({
where: { where: {
id: id, id: id,
}, },

View File

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

View File

@@ -1,31 +0,0 @@
"use server";
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./types";
import prisma from "../../db";
/**
* 创建翻译历史记录
*/
export async function createTranslationHistory(data: CreateTranslationHistoryInput) {
return prisma.translationHistory.create({
data: data,
});
}
/**
* 查询最新的翻译记录
* @param sourceText 源文本
* @param targetLanguage 目标语言
* @returns 最新的翻译记录,如果不存在则返回 null
*/
export async function selectLatestTranslation(query: TranslationHistoryQuery) {
return prisma.translationHistory.findFirst({
where: {
sourceText: query.sourceText,
targetLanguage: query.targetLanguage,
},
orderBy: {
createdAt: 'desc',
},
});
}

View File

@@ -1,122 +0,0 @@
/**
* Service 层的自定义业务类型
*
* 这些类型用于替换 Prisma 生成的类型,提高代码的可维护性和抽象层次
*/
// Folder 相关
export interface CreateFolderInput {
name: string;
userId: string;
}
export interface UpdateFolderInput {
name?: string;
}
// Pair 相关
export interface CreatePairInput {
text1: string;
text2: string;
language1: string;
language2: string;
ipa1?: string;
ipa2?: string;
folderId: number;
}
export interface UpdatePairInput {
text1?: string;
text2?: string;
language1?: string;
language2?: string;
ipa1?: string;
ipa2?: string;
}
// Translation 相关
export interface CreateTranslationHistoryInput {
userId?: string;
sourceText: string;
sourceLanguage: string;
targetLanguage: string;
translatedText: string;
sourceIpa?: string;
targetIpa?: string;
}
export interface TranslationHistoryQuery {
sourceText: string;
targetLanguage: string;
}
// Dictionary 相关
export interface CreateDictionaryLookUpInput {
userId?: string;
text: string;
queryLang: string;
definitionLang: string;
dictionaryWordId?: number;
dictionaryPhraseId?: number;
}
export interface DictionaryLookUpQuery {
userId?: string;
text?: string;
queryLang?: string;
definitionLang?: string;
dictionaryWordId?: number;
dictionaryPhraseId?: number;
}
export interface CreateDictionaryWordInput {
standardForm: string;
queryLang: string;
definitionLang: string;
}
export interface CreateDictionaryPhraseInput {
standardForm: string;
queryLang: string;
definitionLang: string;
}
export interface CreateDictionaryWordEntryInput {
wordId: number;
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
}
export interface CreateDictionaryPhraseEntryInput {
phraseId: number;
definition: string;
example: string;
}
// 翻译相关 - 统一翻译函数
export interface TranslateTextInput {
sourceText: string;
targetLanguage: string;
forceRetranslate?: boolean; // 默认 false
needIpa?: boolean; // 默认 true
userId?: string; // 可选用户 ID
}
export interface TranslateTextOutput {
sourceText: string;
translatedText: string;
sourceLanguage: string;
targetLanguage: string;
sourceIpa: string; // 如果 needIpa=false返回空字符串
targetIpa: string; // 如果 needIpa=false返回空字符串
}
export interface TranslationLLMResponse {
translatedText: string;
sourceLanguage: string;
targetLanguage: string;
sourceIpa?: string; // 可选,根据 needIpa 决定
targetIpa?: string; // 可选,根据 needIpa 决定
}

View File

@@ -1,5 +1,5 @@
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import { randomUUID } from "crypto"; import { UserCreateInput } from "../../../../generated/prisma/models";
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,10 +8,9 @@ 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;
} }

View File

@@ -0,0 +1,29 @@
"use server";
import { getLLMAnswer } from "./ai";
export const genIPA = async (text: string) => {
return (
"[" +
(
await getLLMAnswer(
`${text}\n请生成以上文本的严式国际音标然后直接发给我不要附带任何说明不要擅自增减符号。`,
)
)
.replaceAll("[", "")
.replaceAll("]", "") +
"]"
);
};
export const genLocale = async (text: string) => {
return await getLLMAnswer(
`${text}\n推断以上文本的地区locale然后直接发给我形如如zh-CN不要附带任何说明不要擅自增减符号。`,
);
};
export const genTranslation = async (text: string, targetLanguage: string) => {
return await getLLMAnswer(
`${text}\n请将以上文本翻译到${targetLanguage},然后直接发给我,不要附带任何说明,不要擅自增减符号。`,
);
};

View File

@@ -1,63 +0,0 @@
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]);
}

View File

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

View File

@@ -147,29 +147,3 @@ export class SeededRandom {
return shuffled; return shuffled;
} }
} }
export function parseAIGeneratedJSON<T>(aiResponse: string): T {
// 匹配 ```json ... ``` 包裹的内容
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
} else if (typeof error === 'string') {
throw new Error(`Failed to parse JSON: ${error}`);
} else {
throw new Error('Failed to parse JSON: Unknown error');
}
}
}
// 如果没有找到json代码块尝试直接解析整个字符串
try {
return JSON.parse(aiResponse.trim());
} catch (error) {
throw new Error('No valid JSON found in the response');
}
}