Compare commits
5 Commits
f3b7f86413
...
be3eb17490
| Author | SHA1 | Date | |
|---|---|---|---|
| be3eb17490 | |||
| bd7eca1bd0 | |||
| 3bc804c5e8 | |||
| 4c64aa0a40 | |||
| 13e8789321 |
@@ -6,3 +6,32 @@ README.md
|
||||
.next
|
||||
.git
|
||||
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
|
||||
|
||||
@@ -10,3 +10,6 @@ GITHUB_CLIENT_SECRET=
|
||||
|
||||
// Database
|
||||
DATABASE_URL=
|
||||
|
||||
// DashScore
|
||||
DASHSCORE_API_KEY=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,6 +46,7 @@ next-env.d.ts
|
||||
build.sh
|
||||
|
||||
test.ts
|
||||
test.js
|
||||
/generated/prisma
|
||||
|
||||
certificates
|
||||
125
CLAUDE.md
Normal file
125
CLAUDE.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 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 适配器进行认证操作
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
### 国际化与辅助功能
|
||||
- **next-intl** - 国际化解决方案
|
||||
- **edge-tts-universal** - 跨平台文本转语音
|
||||
- **qwen3-tts-flash** - 通义千问语音合成
|
||||
|
||||
### 开发工具
|
||||
- **ESLint** - 代码质量检查
|
||||
|
||||
193
messages/de-DE.json
Normal file
193
messages/de-DE.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@
|
||||
"update": "Update",
|
||||
"text1": "Text 1",
|
||||
"text2": "Text 2",
|
||||
"locale1": "Locale 1",
|
||||
"locale2": "Locale 2",
|
||||
"language1": "Locale 1",
|
||||
"language2": "Locale 2",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete"
|
||||
},
|
||||
@@ -73,6 +73,10 @@
|
||||
"name": "Memorize",
|
||||
"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": {
|
||||
"name": "More Features",
|
||||
"description": "Under development, stay tuned"
|
||||
|
||||
193
messages/fr-FR.json
Normal file
193
messages/fr-FR.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
193
messages/it-IT.json
Normal file
193
messages/it-IT.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
193
messages/ja-JP.json
Normal file
193
messages/ja-JP.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"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",
|
||||
"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": "自動保存"
|
||||
}
|
||||
}
|
||||
193
messages/ko-KR.json
Normal file
193
messages/ko-KR.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"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",
|
||||
"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": "자동 저장"
|
||||
}
|
||||
}
|
||||
193
messages/ug-CN.json
Normal file
193
messages/ug-CN.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"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",
|
||||
"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": "ئاپتوماتىك ساقلاش"
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@
|
||||
"update": "更新",
|
||||
"text1": "文本1",
|
||||
"text2": "文本2",
|
||||
"locale1": "语言1",
|
||||
"locale2": "语言2",
|
||||
"language1": "语言1",
|
||||
"language2": "语言2",
|
||||
"edit": "编辑",
|
||||
"delete": "删除"
|
||||
},
|
||||
@@ -73,6 +73,10 @@
|
||||
"name": "记忆",
|
||||
"description": "语言A到语言B,语言B到语言A,支持听写"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "词典",
|
||||
"description": "查询单词和短语,提供详细的释义和例句"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "更多功能",
|
||||
"description": "开发中,敬请期待"
|
||||
|
||||
29
package.json
29
package.json
@@ -11,36 +11,35 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.6",
|
||||
"better-auth": "^1.4.10",
|
||||
"dotenv": "^17.2.3",
|
||||
"edge-tts-universal": "^1.3.3",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "16.0.10",
|
||||
"next-intl": "^4.5.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"unstorage": "^1.17.3",
|
||||
"zod": "^4.1.13"
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.6",
|
||||
"@better-auth/cli": "^1.4.10",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prisma": "^7.1.0",
|
||||
"prisma": "^7.2.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
1064
pnpm-lock.yaml
generated
1064
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
138
prisma/migrations/20260105081337_dictionary_add/migration.sql
Normal file
138
prisma/migrations/20260105081337_dictionary_add/migration.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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;
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
@@ -8,50 +7,18 @@ datasource db {
|
||||
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 {
|
||||
id String @id
|
||||
name String
|
||||
email String
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
folders Folder[]
|
||||
id String @id
|
||||
name String
|
||||
email String
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
folders Folder[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
|
||||
@@unique([email])
|
||||
@@map("user")
|
||||
@@ -104,3 +71,122 @@ model Verification {
|
||||
@@index([identifier])
|
||||
@@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])
|
||||
@@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[]
|
||||
|
||||
@@unique([standardForm, queryLang, definitionLang])
|
||||
@@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[]
|
||||
|
||||
@@unique([standardForm, queryLang, definitionLang])
|
||||
@@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")
|
||||
}
|
||||
|
||||
96
src/app/(features)/dictionary/AddToFolder.tsx
Normal file
96
src/app/(features)/dictionary/AddToFolder.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import Container from "@/components/ui/Container";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface AddToFolderProps {
|
||||
definitionLang: string;
|
||||
queryLang: string;
|
||||
standardForm: string;
|
||||
definition: string;
|
||||
ipa?: string;
|
||||
setShow: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const AddToFolder: React.FC<AddToFolderProps> = ({
|
||||
definitionLang,
|
||||
queryLang,
|
||||
standardForm,
|
||||
definition,
|
||||
ipa,
|
||||
setShow,
|
||||
}) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const userId = session.user.id as string;
|
||||
getFoldersByUserId(userId)
|
||||
.then(setFolders)
|
||||
.then(() => setLoading(false));
|
||||
}, [session]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||
<Container className="p-6">
|
||||
<h1 className="text-xl font-bold mb-4">选择文件夹保存</h1>
|
||||
<div className="border border-gray-200 rounded-2xl">
|
||||
{loading ? (
|
||||
<span>加载中...</span>
|
||||
) : folders.length > 0 ? (
|
||||
folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
||||
onClick={() => {
|
||||
createPair({
|
||||
text1: standardForm,
|
||||
text2: definition,
|
||||
language1: queryLang,
|
||||
language2: definitionLang,
|
||||
ipa1: ipa || undefined,
|
||||
folder: {
|
||||
connect: {
|
||||
id: folder.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`已保存到文件夹:${folder.name}`);
|
||||
setShow(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("保存失败,请稍后重试");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Fd />
|
||||
{folder.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-gray-500">暂无文件夹</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<LightButton onClick={() => setShow(false)}>关闭</LightButton>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddToFolder;
|
||||
398
src/app/(features)/dictionary/page.tsx
Normal file
398
src/app/(features)/dictionary/page.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Container from "@/components/ui/Container";
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
||||
import { toast } from "sonner";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
|
||||
// 主流语言列表
|
||||
const POPULAR_LANGUAGES = [
|
||||
{ code: "english", name: "英语" },
|
||||
{ code: "chinese", name: "中文" },
|
||||
{ code: "japanese", name: "日语" },
|
||||
{ code: "korean", name: "韩语" },
|
||||
{ code: "french", name: "法语" },
|
||||
{ code: "german", name: "德语" },
|
||||
{ code: "italian", name: "意大利语" },
|
||||
{ code: "spanish", name: "西班牙语" },
|
||||
];
|
||||
|
||||
type DictionaryWordEntry = {
|
||||
ipa: string;
|
||||
definition: string;
|
||||
partOfSpeech: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
type DictionaryPhraseEntry = {
|
||||
definition: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
type DictionaryErrorResponse = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
type DictionarySuccessResponse = {
|
||||
standardForm: string;
|
||||
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
|
||||
};
|
||||
|
||||
type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse;
|
||||
|
||||
// 类型守卫:判断是否为单词条目
|
||||
function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry {
|
||||
return "ipa" in entry && "partOfSpeech" in entry;
|
||||
}
|
||||
|
||||
// 类型守卫:判断是否为错误响应
|
||||
function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse {
|
||||
return "error" in response;
|
||||
}
|
||||
|
||||
export default function Dictionary() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResult, setSearchResult] = useState<DictionaryResponse | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [queryLang, setQueryLang] = useState("english");
|
||||
const [definitionLang, setDefinitionLang] = useState("chinese");
|
||||
const [showLangSettings, setShowLangSettings] = useState(false);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
// 加载用户的文件夹列表
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
getFoldersByUserId(session.user.id as string)
|
||||
.then((loadedFolders) => {
|
||||
setFolders(loadedFolders);
|
||||
// 如果有文件夹且未选择,默认选择第一个
|
||||
if (loadedFolders.length > 0 && !selectedFolderId) {
|
||||
setSelectedFolderId(loadedFolders[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [session, selectedFolderId]);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setHasSearched(true);
|
||||
setSearchResult(null);
|
||||
|
||||
try {
|
||||
// 使用查询语言和释义语言
|
||||
const result = await lookUp(searchQuery, queryLang, definitionLang);
|
||||
|
||||
// 检查是否为错误响应
|
||||
if (isErrorResponse(result)) {
|
||||
toast.error(result.error);
|
||||
setSearchResult(null);
|
||||
} else {
|
||||
setSearchResult(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("词典查询失败:", error);
|
||||
toast.error("查询失败,请稍后重试");
|
||||
setSearchResult(null);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
|
||||
{/* 搜索区域 */}
|
||||
<div className="flex items-center justify-center px-4 py-12">
|
||||
<Container className="max-w-3xl w-full p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
词典
|
||||
</h1>
|
||||
<p className="text-gray-700 text-lg">
|
||||
查询单词和短语,提供详细的释义和例句
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
placeholder="输入要查询的单词或短语..."
|
||||
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
{isSearching ? "查询中..." : "查询"}
|
||||
</LightButton>
|
||||
</form>
|
||||
|
||||
{/* 语言设置 */}
|
||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-gray-800 font-semibold">语言设置</span>
|
||||
<LightButton
|
||||
onClick={() => setShowLangSettings(!showLangSettings)}
|
||||
className="text-sm px-4 py-2"
|
||||
>
|
||||
{showLangSettings ? "收起" : "展开"}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
{showLangSettings && (
|
||||
<div className="space-y-4">
|
||||
{/* 查询语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
查询语言 (你要查询的单词/短语是什么语言)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
selected={queryLang === lang.code}
|
||||
onClick={() => setQueryLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.name}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={queryLang}
|
||||
onChange={(e) => setQueryLang(e.target.value)}
|
||||
placeholder="或输入其他语言..."
|
||||
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 释义语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
释义语言 (你希望用什么语言查看释义)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
selected={definitionLang === lang.code}
|
||||
onClick={() => setDefinitionLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.name}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={definitionLang}
|
||||
onChange={(e) => setDefinitionLang(e.target.value)}
|
||||
placeholder="或输入其他语言..."
|
||||
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 当前设置显示 */}
|
||||
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
|
||||
当前设置:查询 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang}</span>
|
||||
,释义 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索提示 */}
|
||||
<div className="mt-4 text-center text-gray-700 text-sm">
|
||||
<p>试试搜索:hello, look up, dictionary</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果区域 */}
|
||||
<div className="flex-1 px-4 pb-12">
|
||||
<Container className="max-w-3xl w-full p-4">
|
||||
{isSearching && (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
<p className="mt-4 text-white">加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && hasSearched && !searchResult && (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<p className="text-gray-800 text-xl">未找到结果</p>
|
||||
<p className="text-gray-600 mt-2">尝试其他单词或短语</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
{/* 标题和保存按钮 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
{searchResult.standardForm !== searchQuery && (
|
||||
<p className="text-gray-500 text-sm">
|
||||
原始输入: {searchQuery}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
<select
|
||||
value={selectedFolderId || ""}
|
||||
onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||
>
|
||||
{folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!session) {
|
||||
toast.error("请先登录");
|
||||
return;
|
||||
}
|
||||
if (!selectedFolderId) {
|
||||
toast.error("请先创建文件夹");
|
||||
return;
|
||||
}
|
||||
if (!searchResult || isErrorResponse(searchResult)) return;
|
||||
|
||||
const entry = searchResult.entries[0];
|
||||
createPair({
|
||||
text1: searchResult.standardForm,
|
||||
text2: entry.definition,
|
||||
language1: queryLang,
|
||||
language2: definitionLang,
|
||||
ipa1: isWordEntry(entry) ? entry.ipa : undefined,
|
||||
folder: {
|
||||
connect: {
|
||||
id: selectedFolderId,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const folderName = folders.find(f => f.id === selectedFolderId)?.name;
|
||||
toast.success(`已保存到文件夹:${folderName}`);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("保存失败,请稍后重试");
|
||||
});
|
||||
}}
|
||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center flex-shrink-0"
|
||||
title="保存到文件夹"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="space-y-6">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
{isWordEntry(entry) ? (
|
||||
// 单词条目
|
||||
<div>
|
||||
{/* 音标和词性 */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{entry.ipa && (
|
||||
<span className="text-gray-600 text-lg">
|
||||
{entry.ipa}
|
||||
</span>
|
||||
)}
|
||||
{entry.partOfSpeech && (
|
||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||
{entry.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 短语条目
|
||||
<div>
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSearched && (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<div className="text-6xl mb-4">📚</div>
|
||||
<p className="text-gray-800 text-xl mb-2">欢迎使用词典</p>
|
||||
<p className="text-gray-600">在上方搜索框中输入单词或短语开始查询</p>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
||||
import { useTranslations } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
||||
@@ -59,20 +58,32 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||
if (show === "answer") {
|
||||
const newIndex = (index + 1) % getTextPairs().length;
|
||||
setIndex(newIndex);
|
||||
if (dictation)
|
||||
getTTSAudioUrl(
|
||||
getTextPairs()[newIndex][reverse ? "text2" : "text1"],
|
||||
VOICES.find(
|
||||
(v) =>
|
||||
v.locale ===
|
||||
getTextPairs()[newIndex][
|
||||
reverse ? "locale2" : "locale1"
|
||||
],
|
||||
)!.short_name,
|
||||
).then((url) => {
|
||||
if (dictation) {
|
||||
const textPair = getTextPairs()[newIndex];
|
||||
const language = textPair[reverse ? "language2" : "language1"];
|
||||
const text = textPair[reverse ? "text2" : "text1"];
|
||||
|
||||
// 映射语言到 TTS 支持的格式
|
||||
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"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);
|
||||
play();
|
||||
});
|
||||
}
|
||||
}
|
||||
setShow(show === "question" ? "answer" : "question");
|
||||
};
|
||||
|
||||
@@ -12,13 +12,12 @@ import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import SaveList from "./SaveList";
|
||||
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { genIPA, genLocale } from "@/lib/server/translatorActions";
|
||||
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
|
||||
import { logger } from "@/lib/logger";
|
||||
import PageLayout from "@/components/ui/PageLayout";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
||||
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text_speaker");
|
||||
@@ -31,7 +30,7 @@ export default function TextSpeakerPage() {
|
||||
const [pause, setPause] = useState(true);
|
||||
const [autopause, setAutopause] = useState(true);
|
||||
const textRef = useRef("");
|
||||
const [locale, setLocale] = useState<string | null>(null);
|
||||
const [language, setLanguage] = useState<string | null>(null);
|
||||
const [ipa, setIPA] = useState<string>("");
|
||||
const objurlRef = useRef<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@@ -95,38 +94,35 @@ export default function TextSpeakerPage() {
|
||||
} else {
|
||||
// 第一次播放
|
||||
try {
|
||||
let theLocale = locale;
|
||||
if (!theLocale) {
|
||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||
setLocale(tmp_locale);
|
||||
theLocale = tmp_locale;
|
||||
let theLanguage = language;
|
||||
if (!theLanguage) {
|
||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||
setLanguage(tmp_language);
|
||||
theLanguage = tmp_language;
|
||||
}
|
||||
|
||||
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
||||
if (!voice) throw "Voice not found.";
|
||||
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
|
||||
objurlRef.current = await getTTSAudioUrl(
|
||||
// 检查语言是否在 TTS 支持列表中
|
||||
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,
|
||||
voice.short_name,
|
||||
(() => {
|
||||
if (speed === 1) return {};
|
||||
else if (speed < 1)
|
||||
return {
|
||||
rate: `-${100 - speed * 100}%`,
|
||||
};
|
||||
else
|
||||
return {
|
||||
rate: `+${speed * 100 - 100}%`,
|
||||
};
|
||||
})(),
|
||||
theLanguage as TTS_SUPPORTED_LANGUAGES
|
||||
);
|
||||
load(objurlRef.current);
|
||||
play();
|
||||
} catch (e) {
|
||||
logger.error("播放音频失败", e);
|
||||
setPause(true);
|
||||
setLocale(null);
|
||||
|
||||
setLanguage(null);
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
@@ -142,7 +138,7 @@ export default function TextSpeakerPage() {
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
textRef.current = e.target.value.trim();
|
||||
setLocale(null);
|
||||
setLanguage(null);
|
||||
setIPA("");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
@@ -163,7 +159,7 @@ export default function TextSpeakerPage() {
|
||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||
textRef.current = item.text;
|
||||
setLocale(item.locale);
|
||||
setLanguage(item.language);
|
||||
setIPA(item.ipa || "");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
@@ -178,11 +174,11 @@ export default function TextSpeakerPage() {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
let theLocale = locale;
|
||||
if (!theLocale) {
|
||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||
setLocale(tmp_locale);
|
||||
theLocale = tmp_locale;
|
||||
let theLanguage = language;
|
||||
if (!theLanguage) {
|
||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||
setLanguage(tmp_language);
|
||||
theLanguage = tmp_language;
|
||||
}
|
||||
|
||||
let theIPA = ipa;
|
||||
@@ -205,19 +201,19 @@ export default function TextSpeakerPage() {
|
||||
} else if (theIPA.length === 0) {
|
||||
save.push({
|
||||
text: textRef.current,
|
||||
locale: theLocale,
|
||||
language: theLanguage as string,
|
||||
});
|
||||
} else {
|
||||
save.push({
|
||||
text: textRef.current,
|
||||
locale: theLocale,
|
||||
language: theLanguage as string,
|
||||
ipa: theIPA,
|
||||
});
|
||||
}
|
||||
setIntoLocalStorage(save);
|
||||
} catch (e) {
|
||||
logger.error("保存到本地存储失败", e);
|
||||
setLocale(null);
|
||||
setLanguage(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
||||
createPair({
|
||||
text1: item.text1,
|
||||
text2: item.text2,
|
||||
locale1: item.locale1,
|
||||
locale2: item.locale2,
|
||||
language1: item.language1,
|
||||
language2: item.language2,
|
||||
folder: {
|
||||
connect: {
|
||||
id: folder.id,
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { LightButton } from "@/components/ui/buttons";
|
||||
import { IconClick } from "@/components/ui/buttons";
|
||||
import IMAGES from "@/config/images";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -18,12 +16,13 @@ import {
|
||||
genIPA,
|
||||
genLocale,
|
||||
genTranslation,
|
||||
} from "@/lib/server/translatorActions";
|
||||
} from "@/lib/server/bigmodel/translatorActions";
|
||||
import { toast } from "sonner";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
import { shallowEqual } from "@/lib/utils";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
@@ -50,13 +49,21 @@ export default function TranslatorPage() {
|
||||
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
|
||||
if (!shortName) {
|
||||
toast.error("Voice not found");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = await getTTSAudioUrl(text, shortName);
|
||||
// Map language name to TTS format
|
||||
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||
await load(url);
|
||||
lastTTS.current.text = text;
|
||||
lastTTS.current.url = url;
|
||||
@@ -79,15 +86,15 @@ export default function TranslatorPage() {
|
||||
const llmres: {
|
||||
text1: string | null;
|
||||
text2: string | null;
|
||||
locale1: string | null;
|
||||
locale2: string | null;
|
||||
language1: string | null;
|
||||
language2: string | null;
|
||||
ipa1: string | null;
|
||||
ipa2: string | null;
|
||||
} = {
|
||||
text1: text1,
|
||||
text2: null,
|
||||
locale1: null,
|
||||
locale2: null,
|
||||
language1: null,
|
||||
language2: null,
|
||||
ipa1: null,
|
||||
ipa2: null,
|
||||
};
|
||||
@@ -97,21 +104,21 @@ export default function TranslatorPage() {
|
||||
// 检查更新历史记录
|
||||
const checkUpdateLocalStorage = () => {
|
||||
if (historyUpdated) return;
|
||||
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
|
||||
if (llmres.text1 && llmres.text2 && llmres.language1 && llmres.language2) {
|
||||
setHistory(
|
||||
tlsoPush({
|
||||
text1: llmres.text1,
|
||||
text2: llmres.text2,
|
||||
locale1: llmres.locale1,
|
||||
locale2: llmres.locale2,
|
||||
language1: llmres.language1,
|
||||
language2: llmres.language2,
|
||||
}),
|
||||
);
|
||||
if (autoSave && autoSaveFolderId) {
|
||||
createPair({
|
||||
text1: llmres.text1,
|
||||
text2: llmres.text2,
|
||||
locale1: llmres.locale1,
|
||||
locale2: llmres.locale2,
|
||||
language1: llmres.language1,
|
||||
language2: llmres.language2,
|
||||
folder: {
|
||||
connect: {
|
||||
id: autoSaveFolderId,
|
||||
@@ -148,10 +155,10 @@ export default function TranslatorPage() {
|
||||
setTresult(text2);
|
||||
// 生成两个locale
|
||||
genLocale(text1).then((locale) => {
|
||||
updateState("locale1", locale);
|
||||
updateState("language1", locale);
|
||||
});
|
||||
genLocale(text2).then((locale) => {
|
||||
updateState("locale2", locale);
|
||||
updateState("language2", locale);
|
||||
});
|
||||
// 生成俩IPA
|
||||
if (genIpa) {
|
||||
@@ -207,7 +214,7 @@ export default function TranslatorPage() {
|
||||
onClick={() => {
|
||||
const t = taref.current?.value;
|
||||
if (!t) return;
|
||||
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
|
||||
tts(t, tlso.get().find((v) => v.text1 === t)?.language1 || "");
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
@@ -245,7 +252,7 @@ export default function TranslatorPage() {
|
||||
onClick={() => {
|
||||
tts(
|
||||
tresult,
|
||||
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
|
||||
tlso.get().find((v) => v.text2 === tresult)?.language2 || "",
|
||||
);
|
||||
}}
|
||||
></IconClick>
|
||||
|
||||
@@ -5,7 +5,7 @@ import AuthForm from "./AuthForm";
|
||||
|
||||
export default async function AuthPage(
|
||||
props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
|
||||
}
|
||||
) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
@@ -11,8 +11,8 @@ interface AddTextPairModalProps {
|
||||
onAdd: (
|
||||
text1: string,
|
||||
text2: string,
|
||||
locale1: string,
|
||||
locale2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export default function AddTextPairModal({
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const [locale1, setLocale1] = useState("en-US");
|
||||
const [locale2, setLocale2] = useState("zh-CN");
|
||||
const [language1, setLanguage1] = useState("english");
|
||||
const [language2, setLanguage2] = useState("chinese");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -33,8 +33,8 @@ export default function AddTextPairModal({
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!locale1 ||
|
||||
!locale2
|
||||
!language1 ||
|
||||
!language2
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -44,14 +44,14 @@ export default function AddTextPairModal({
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
typeof locale1 === "string" &&
|
||||
typeof locale2 === "string" &&
|
||||
typeof language1 === "string" &&
|
||||
typeof language2 === "string" &&
|
||||
text1.trim() !== "" &&
|
||||
text2.trim() !== "" &&
|
||||
locale1.trim() !== "" &&
|
||||
locale2.trim() !== ""
|
||||
language1.trim() !== "" &&
|
||||
language2.trim() !== ""
|
||||
) {
|
||||
onAdd(text1, text2, locale1, locale2);
|
||||
onAdd(text1, text2, language1, language2);
|
||||
input1Ref.current.value = "";
|
||||
input2Ref.current.value = "";
|
||||
}
|
||||
@@ -84,12 +84,12 @@ export default function AddTextPairModal({
|
||||
<Input ref={input2Ref} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("locale1")}
|
||||
<LocaleSelector value={locale1} onChange={setLocale1} />
|
||||
{t("language1")}
|
||||
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("locale2")}
|
||||
<LocaleSelector value={locale2} onChange={setLocale2} />
|
||||
{t("language2")}
|
||||
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||
|
||||
@@ -21,8 +21,8 @@ export interface TextPair {
|
||||
id: number;
|
||||
text1: string;
|
||||
text2: string;
|
||||
locale1: string;
|
||||
locale2: string;
|
||||
language1: string;
|
||||
language2: string;
|
||||
}
|
||||
|
||||
export default function InFolder({ folderId }: { folderId: number }) {
|
||||
@@ -140,14 +140,14 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
||||
onAdd={async (
|
||||
text1: string,
|
||||
text2: string,
|
||||
locale1: string,
|
||||
locale2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => {
|
||||
await createPair({
|
||||
text1: text1,
|
||||
text2: text2,
|
||||
locale1: locale1,
|
||||
locale2: locale2,
|
||||
language1: language1,
|
||||
language2: language2,
|
||||
folder: {
|
||||
connect: {
|
||||
id: folderId,
|
||||
|
||||
@@ -25,11 +25,11 @@ export default function TextPairCard({
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{textPair.locale1.toUpperCase()}
|
||||
{textPair.language1.toUpperCase()}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{textPair.locale2.toUpperCase()}
|
||||
{textPair.language2.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ export default function UpdateTextPairModal({
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const [locale1, setLocale1] = useState(textPair.locale1);
|
||||
const [locale2, setLocale2] = useState(textPair.locale2);
|
||||
const [language1, setLanguage1] = useState(textPair.language1);
|
||||
const [language2, setLanguage2] = useState(textPair.language2);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -32,8 +32,8 @@ export default function UpdateTextPairModal({
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!locale1 ||
|
||||
!locale2
|
||||
!language1 ||
|
||||
!language2
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -43,14 +43,14 @@ export default function UpdateTextPairModal({
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
typeof locale1 === "string" &&
|
||||
typeof locale2 === "string" &&
|
||||
typeof language1 === "string" &&
|
||||
typeof language2 === "string" &&
|
||||
text1.trim() !== "" &&
|
||||
text2.trim() !== "" &&
|
||||
locale1.trim() !== "" &&
|
||||
locale2.trim() !== ""
|
||||
language1.trim() !== "" &&
|
||||
language2.trim() !== ""
|
||||
) {
|
||||
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
|
||||
onUpdate(textPair.id, { text1, text2, language1, language2 });
|
||||
}
|
||||
};
|
||||
return (
|
||||
@@ -88,12 +88,12 @@ export default function UpdateTextPairModal({
|
||||
></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("locale1")}
|
||||
<LocaleSelector value={locale1} onChange={setLocale1} />
|
||||
{t("language1")}
|
||||
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("locale2")}
|
||||
<LocaleSelector value={locale2} onChange={setLocale2} />
|
||||
{t("language2")}
|
||||
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
||||
|
||||
@@ -60,6 +60,12 @@ export default async function HomePage() {
|
||||
description={t("srtPlayer.description")}
|
||||
color="#3c988d"
|
||||
></LinkArea>
|
||||
<LinkArea
|
||||
href="/dictionary"
|
||||
name={t("dictionary.name")}
|
||||
description={t("dictionary.description")}
|
||||
color="#6a9c89"
|
||||
></LinkArea>
|
||||
<LinkArea
|
||||
href="/alphabet"
|
||||
name={t("alphabet.name")}
|
||||
|
||||
@@ -38,6 +38,42 @@ export default function LanguageSettings() {
|
||||
>
|
||||
中文
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { LOCALES } from "@/config/locales";
|
||||
import { useState } from "react";
|
||||
|
||||
const COMMON_LOCALES = [
|
||||
{ label: "中文", value: "zh-CN" },
|
||||
{ label: "英文", value: "en-US" },
|
||||
{ label: "意大利语", value: "it-IT" },
|
||||
{ label: "日语", value: "ja-JP" },
|
||||
const COMMON_LANGUAGES = [
|
||||
{ label: "中文", value: "chinese" },
|
||||
{ label: "英文", value: "english" },
|
||||
{ label: "意大利语", value: "italian" },
|
||||
{ label: "日语", value: "japanese" },
|
||||
{ label: "韩语", value: "korean" },
|
||||
{ label: "法语", value: "french" },
|
||||
{ label: "德语", value: "german" },
|
||||
{ label: "西班牙语", value: "spanish" },
|
||||
{ label: "葡萄牙语", value: "portuguese" },
|
||||
{ label: "俄语", value: "russian" },
|
||||
{ label: "其他", value: "other" },
|
||||
];
|
||||
|
||||
@@ -14,34 +20,50 @@ interface LocaleSelectorProps {
|
||||
}
|
||||
|
||||
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
||||
const isCommonLocale = COMMON_LOCALES.some((l) => l.value === value && l.value !== "other");
|
||||
const showFullList = value === "other" || !isCommonLocale;
|
||||
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={isCommonLocale ? value : "other"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
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_LOCALES.map((locale) => (
|
||||
<option key={locale.value} value={locale.value}>
|
||||
{locale.label}
|
||||
{COMMON_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showFullList && (
|
||||
<select
|
||||
value={value === "other" ? LOCALES[0] : value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{showCustomInput && (
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleCustomInputChange(e.target.value)}
|
||||
placeholder="请输入语言名称"
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
export const SUPPORTED_LOCALES = ["en-US", "zh-CN"];
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en-US",
|
||||
"zh-CN",
|
||||
"ja-JP",
|
||||
"ko-KR",
|
||||
"de-DE",
|
||||
"fr-FR",
|
||||
"it-IT",
|
||||
"ug-CN",
|
||||
];
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,19 @@ import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface SignUpFormData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignUpState {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
errors?: {
|
||||
username?: string[];
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
};
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
errors?: {
|
||||
username?: string[];
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function signUpAction(prevState: SignUpState, formData: FormData) {
|
||||
@@ -111,6 +111,9 @@ export async function signInAction(prevState: SignUpState, formData: FormData) {
|
||||
|
||||
redirect(redirectTo || "/");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "登录失败,请检查您的邮箱和密码"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,15 @@ export type SupportedAlphabets =
|
||||
export const TextSpeakerItemSchema = z.object({
|
||||
text: z.string(),
|
||||
ipa: z.string().optional(),
|
||||
locale: z.string(),
|
||||
language: z.string(),
|
||||
});
|
||||
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
||||
|
||||
export const WordDataSchema = z.object({
|
||||
locales: z
|
||||
languages: z
|
||||
.tuple([z.string(), z.string()])
|
||||
.refine(([first, second]) => first !== second, {
|
||||
message: "Locales must be different",
|
||||
message: "Languages must be different",
|
||||
}),
|
||||
wordPairs: z
|
||||
.array(z.tuple([z.string(), z.string()]))
|
||||
@@ -47,8 +47,8 @@ export const WordDataSchema = z.object({
|
||||
export const TranslationHistorySchema = z.object({
|
||||
text1: z.string(),
|
||||
text2: z.string(),
|
||||
locale1: z.string(),
|
||||
locale2: z.string(),
|
||||
language1: z.string(),
|
||||
language2: z.string(),
|
||||
});
|
||||
|
||||
export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"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))),
|
||||
),
|
||||
});
|
||||
}
|
||||
100
src/lib/server/bigmodel/dictionaryActions.ts
Normal file
100
src/lib/server/bigmodel/dictionaryActions.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
"use server";
|
||||
|
||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
||||
import { getAnswer } from "./zhipu";
|
||||
|
||||
type DictionaryWordEntry = {
|
||||
ipa: string;
|
||||
definition: string;
|
||||
partOfSpeech: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
type DictionaryPhraseEntry = {
|
||||
definition: string;
|
||||
example: string;
|
||||
};
|
||||
|
||||
type DictionaryErrorResponse = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
type DictionarySuccessResponse = {
|
||||
standardForm: string;
|
||||
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
|
||||
};
|
||||
|
||||
export const lookUp = async (
|
||||
text: string,
|
||||
queryLang: string,
|
||||
definitionLang: string
|
||||
): Promise<DictionarySuccessResponse | DictionaryErrorResponse> => {
|
||||
const response = await getAnswer([
|
||||
{
|
||||
role: "system",
|
||||
content: `
|
||||
你是一个词典工具,返回单词/短语的JSON解释。
|
||||
|
||||
查询语言:${queryLang}
|
||||
释义语言:${definitionLang}
|
||||
|
||||
用户输入在<text>标签内。判断是单词还是短语。
|
||||
|
||||
如果输入有效,返回JSON对象,格式为:
|
||||
{
|
||||
"standardForm": "字符串,该语言下的正确形式",
|
||||
"entries": [数组,包含一个或多个条目]
|
||||
}
|
||||
|
||||
如果是单词,条目格式:
|
||||
{
|
||||
"ipa": "音标(如适用)",
|
||||
"definition": "释义",
|
||||
"partOfSpeech": "词性",
|
||||
"example": "例句"
|
||||
}
|
||||
|
||||
如果是短语,条目格式:
|
||||
{
|
||||
"definition": "短语释义",
|
||||
"example": "例句"
|
||||
}
|
||||
|
||||
所有释义内容使用${definitionLang}语言。
|
||||
例句使用${queryLang}语言。
|
||||
|
||||
如果输入无效(如:输入为空、包含非法字符、无法识别的语言等),返回JSON对象:
|
||||
{
|
||||
"error": "错误描述信息,使用${definitionLang}语言"
|
||||
}
|
||||
|
||||
提供standardForm时:尝试修正笔误或返回原形(如英语动词原形、日语基本形等)。若无法确定或输入正确,则与输入相同。
|
||||
|
||||
示例:
|
||||
英语输入"ran" -> standardForm: "run"
|
||||
中文输入"跑眬" -> standardForm: "跑"
|
||||
日语输入"走った" -> standardForm: "走る"
|
||||
|
||||
短语同理,尝试返回其标准/常见形式。
|
||||
|
||||
现在处理用户输入。
|
||||
`.trim()
|
||||
}, {
|
||||
role: "user",
|
||||
content: `<text>${text}</text>请处理text标签内的内容后返回给我json`
|
||||
}
|
||||
]);
|
||||
|
||||
const result = parseAIGeneratedJSON<
|
||||
DictionaryErrorResponse |
|
||||
{
|
||||
standardForm: string,
|
||||
entries: DictionaryPhraseEntry[];
|
||||
} |
|
||||
{
|
||||
standardForm: string,
|
||||
entries: DictionaryWordEntry[];
|
||||
}>(response);
|
||||
|
||||
return result;
|
||||
};
|
||||
93
src/lib/server/bigmodel/translatorActions.ts
Normal file
93
src/lib/server/bigmodel/translatorActions.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
"use server";
|
||||
|
||||
import { getAnswer } from "./zhipu";
|
||||
|
||||
export const genIPA = async (text: string) => {
|
||||
return (
|
||||
"[" +
|
||||
(
|
||||
await getAnswer(
|
||||
`
|
||||
<text>${text}</text>
|
||||
|
||||
请生成以上文本的严式国际音标
|
||||
然后直接发给我
|
||||
不要附带任何说明
|
||||
不要擅自增减符号
|
||||
不许用"/"或者"[]"包裹
|
||||
`.trim(),
|
||||
)
|
||||
)
|
||||
.replaceAll("[", "")
|
||||
.replaceAll("]", "") +
|
||||
"]"
|
||||
);
|
||||
};
|
||||
|
||||
export const genLocale = async (text: string) => {
|
||||
return await getAnswer(
|
||||
`
|
||||
<text>${text}</text>
|
||||
|
||||
推断以上文本的地区(locale)
|
||||
然后直接发给我
|
||||
形如如zh-CN
|
||||
不要附带任何说明
|
||||
不要擅自增减符号
|
||||
`.trim(),
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
export const genTranslation = async (text: string, targetLanguage: string) => {
|
||||
return await getAnswer(
|
||||
`
|
||||
<text>${text}</text>
|
||||
|
||||
请将以上文本翻译到 <target_language>${targetLanguage}</target_language>
|
||||
然后直接发给我
|
||||
不要附带任何说明
|
||||
不要擅自增减符号
|
||||
`.trim(),
|
||||
);
|
||||
};
|
||||
182
src/lib/server/bigmodel/tts.ts
Normal file
182
src/lib/server/bigmodel/tts.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
"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";
|
||||
}
|
||||
}
|
||||
45
src/lib/server/bigmodel/zhipu.ts
Normal file
45
src/lib/server/bigmodel/zhipu.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
"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 };
|
||||
@@ -1,29 +0,0 @@
|
||||
"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},然后直接发给我,不要附带任何说明,不要擅自增减符号。`,
|
||||
);
|
||||
};
|
||||
@@ -147,3 +147,29 @@ export class SeededRandom {
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user