Compare commits
16 Commits
d3e1cd9092
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9715844eae | |||
| 504ecd259d | |||
| 06e90687f1 | |||
| b093ed2b4f | |||
| 37e221d8b8 | |||
| f1dcd5afaa | |||
| 66d17df59d | |||
| be3eb17490 | |||
| bd7eca1bd0 | |||
| 3bc804c5e8 | |||
| 4c64aa0a40 | |||
| 13e8789321 | |||
| f3b7f86413 | |||
| 6c4a73d857 | |||
| 7c70ec1028 | |||
| 5f24929116 |
@@ -6,3 +6,32 @@ README.md
|
|||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
test.ts
|
||||||
|
test.js
|
||||||
|
|
||||||
|
# build outputs
|
||||||
|
/out/
|
||||||
|
/build
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# debug logs
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
.vercel
|
||||||
|
build.sh
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
/generated/prisma
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ GITHUB_CLIENT_SECRET=
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
|
||||||
|
// DashScore
|
||||||
|
DASHSCORE_API_KEY=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,6 +46,7 @@ next-env.d.ts
|
|||||||
build.sh
|
build.sh
|
||||||
|
|
||||||
test.ts
|
test.ts
|
||||||
|
test.js
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
certificates
|
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** - 国际化解决方案
|
- **next-intl** - 国际化解决方案
|
||||||
- **edge-tts-universal** - 跨平台文本转语音
|
- **qwen3-tts-flash** - 通义千问语音合成
|
||||||
|
|
||||||
### 开发工具
|
### 开发工具
|
||||||
- **ESLint** - 代码质量检查
|
- **ESLint** - 代码质量检查
|
||||||
|
|||||||
222
messages/de-DE.json
Normal file
222
messages/de-DE.json
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
|
||||||
|
"japanese": "Japanische Kana",
|
||||||
|
"english": "Englisches Alphabet",
|
||||||
|
"uyghur": "Uigurisches Alphabet",
|
||||||
|
"esperanto": "Esperanto-Alphabet",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||||
|
"hideLetter": "Zeichen ausblenden",
|
||||||
|
"showLetter": "Zeichen anzeigen",
|
||||||
|
"hideIPA": "IPA ausblenden",
|
||||||
|
"showIPA": "IPA anzeigen",
|
||||||
|
"roman": "Romanisierung",
|
||||||
|
"letter": "Zeichen",
|
||||||
|
"random": "Zufälliger Modus",
|
||||||
|
"randomNext": "Zufällig weiter"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Ordner",
|
||||||
|
"subtitle": "Verwalten Sie Ihre Sammlungen",
|
||||||
|
"newFolder": "Neuer Ordner",
|
||||||
|
"creating": "Erstellen...",
|
||||||
|
"noFoldersYet": "Noch keine Ordner",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
||||||
|
"enterFolderName": "Ordnernamen eingeben:",
|
||||||
|
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners",
|
||||||
|
"back": "Zurück",
|
||||||
|
"textPairs": "Textpaare",
|
||||||
|
"itemsCount": "{count} Elemente",
|
||||||
|
"memorize": "Einprägen",
|
||||||
|
"loadingTextPairs": "Textpaare werden geladen...",
|
||||||
|
"noTextPairs": "Keine Textpaare in diesem Ordner",
|
||||||
|
"addNewTextPair": "Neues Textpaar hinzufügen",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"updateTextPair": "Textpaar aktualisieren",
|
||||||
|
"update": "Aktualisieren",
|
||||||
|
"text1": "Text 1",
|
||||||
|
"text2": "Text 2",
|
||||||
|
"language1": "Sprache 1",
|
||||||
|
"language2": "Sprache 2",
|
||||||
|
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Sprachen lernen",
|
||||||
|
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
|
||||||
|
"explore": "Erkunden",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Bleib hungrig, bleiv dumm.",
|
||||||
|
"author": "— Steve Jobs"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "Übersetzer",
|
||||||
|
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "Text-Sprecher",
|
||||||
|
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRT-Videoplayer",
|
||||||
|
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "Alphabet",
|
||||||
|
"description": "Beginnen Sie mit dem Erlernen einer neuen Sprache mit dem Alphabet"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "Einprägen",
|
||||||
|
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "Wörterbuch",
|
||||||
|
"description": "Wörter und Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "Weitere Funktionen",
|
||||||
|
"description": "In Entwicklung, bleiben Sie dran"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentifizierung",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signUp": "Registrieren",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Passwort",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"name": "Name",
|
||||||
|
"signInButton": "Anmelden",
|
||||||
|
"signUpButton": "Registrieren",
|
||||||
|
"noAccount": "Haben Sie kein Konto?",
|
||||||
|
"hasAccount": "Haben Sie bereits ein Konto?",
|
||||||
|
"signInWithGitHub": "Mit GitHub anmelden",
|
||||||
|
"signUpWithGitHub": "Mit GitHub registrieren",
|
||||||
|
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||||
|
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||||
|
"passwordsNotMatch": "Passwörter stimmen nicht überein",
|
||||||
|
"nameRequired": "Bitte geben Sie Ihren Namen ein",
|
||||||
|
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
|
||||||
|
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
|
||||||
|
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
||||||
|
"loading": "Laden..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "Wählen Sie einen Ordner aus",
|
||||||
|
"noFolders": "Keine Ordner gefunden",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "Antwort",
|
||||||
|
"next": "Weiter",
|
||||||
|
"reverse": "Umkehren",
|
||||||
|
"dictation": "Diktat",
|
||||||
|
"noTextPairs": "Keine Textpaare verfügbar",
|
||||||
|
"disorder": "Mischen",
|
||||||
|
"previous": "Zurück"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Anmelden",
|
||||||
|
"profile": "Profil",
|
||||||
|
"folders": "Ordner"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "Mein Profil",
|
||||||
|
"email": "E-Mail: {email}",
|
||||||
|
"logout": "Abmelden"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "Video hochladen",
|
||||||
|
"uploadSubtitle": "Untertitel hochladen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"play": "Abspielen",
|
||||||
|
"previous": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"restart": "Neustart",
|
||||||
|
"autoPause": "Auto-Pause ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "Bitte laden Sie Video- und Untertiteldateien hoch",
|
||||||
|
"uploadVideoFile": "Bitte laden Sie eine Videodatei hoch",
|
||||||
|
"uploadSubtitleFile": "Bitte laden Sie eine Untertiteldatei hoch",
|
||||||
|
"processingSubtitle": "Untertiteldatei wird verarbeitet...",
|
||||||
|
"needBothFiles": "Sowohl Video- als auch Untertiteldateien sind erforderlich, um mit dem Lernen zu beginnen",
|
||||||
|
"videoFile": "Videodatei",
|
||||||
|
"subtitleFile": "Untertiteldatei",
|
||||||
|
"uploaded": "Hochgeladen",
|
||||||
|
"notUploaded": "Nicht hochgeladen",
|
||||||
|
"upload": "Hochladen",
|
||||||
|
"autoPauseStatus": "Auto-Pause: {enabled}",
|
||||||
|
"on": "Ein",
|
||||||
|
"off": "Aus",
|
||||||
|
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||||
|
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "IPA generieren",
|
||||||
|
"viewSavedItems": "Gespeicherte Elemente anzeigen",
|
||||||
|
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "Sprache erkennen",
|
||||||
|
"generateIPA": "IPA generieren",
|
||||||
|
"translateInto": "Übersetzen in",
|
||||||
|
"chinese": "Chinesisch",
|
||||||
|
"english": "Englisch",
|
||||||
|
"italian": "Italienisch",
|
||||||
|
"other": "Andere",
|
||||||
|
"translating": "Übersetzung läuft...",
|
||||||
|
"translate": "Übersetzen",
|
||||||
|
"inputLanguage": "Geben Sie eine Sprache ein.",
|
||||||
|
"history": "Verlauf",
|
||||||
|
"enterLanguage": "Sprache eingeben",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "Sie sind nicht authentifiziert",
|
||||||
|
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen aus",
|
||||||
|
"noFolders": "Keine Ordner gefunden",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "Schließen",
|
||||||
|
"success": "Textpaar zum Ordner hinzugefügt",
|
||||||
|
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
||||||
|
},
|
||||||
|
"autoSave": "Automatisch speichern"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Wörterbuch",
|
||||||
|
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
||||||
|
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
|
||||||
|
"searching": "Suche...",
|
||||||
|
"search": "Suchen",
|
||||||
|
"languageSettings": "Spracheinstellungen",
|
||||||
|
"queryLanguage": "Abfragesprache",
|
||||||
|
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
|
||||||
|
"definitionLanguage": "Definitionssprache",
|
||||||
|
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
|
||||||
|
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
|
||||||
|
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
||||||
|
"relookup": "Neu suchen",
|
||||||
|
"saveToFolder": "In Ordner speichern",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"noResults": "Keine Ergebnisse gefunden",
|
||||||
|
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||||
|
"welcomeTitle": "Willkommen beim Wörterbuch",
|
||||||
|
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
|
||||||
|
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
|
||||||
|
"relookupSuccess": "Erfolgreich neu gesucht",
|
||||||
|
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
|
||||||
|
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||||
|
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
||||||
|
"savedToFolder": "Im Ordner gespeichert: {folderName}",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,13 +22,9 @@
|
|||||||
"newFolder": "New Folder",
|
"newFolder": "New Folder",
|
||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
"noFoldersYet": "No folders yet",
|
"noFoldersYet": "No folders yet",
|
||||||
"folderInfo": "{id}. {name} ({totalPairs})",
|
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
||||||
"enterFolderName": "Enter folder name:",
|
"enterFolderName": "Enter folder name:",
|
||||||
"confirmDelete": "Type \"{name}\" to delete:",
|
"confirmDelete": "Type \"{name}\" to delete:"
|
||||||
"createFolderSuccess": "Folder created successfully",
|
|
||||||
"deleteFolderSuccess": "Folder deleted successfully",
|
|
||||||
"createFolderError": "Failed to create folder",
|
|
||||||
"deleteFolderError": "Failed to delete folder"
|
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "You are not the owner of this folder",
|
"unauthorized": "You are not the owner of this folder",
|
||||||
@@ -44,8 +40,9 @@
|
|||||||
"update": "Update",
|
"update": "Update",
|
||||||
"text1": "Text 1",
|
"text1": "Text 1",
|
||||||
"text2": "Text 2",
|
"text2": "Text 2",
|
||||||
"locale1": "Locale 1",
|
"language1": "Locale 1",
|
||||||
"locale2": "Locale 2",
|
"language2": "Locale 2",
|
||||||
|
"enterLanguageName": "Please enter language name",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
@@ -77,15 +74,15 @@
|
|||||||
"name": "Memorize",
|
"name": "Memorize",
|
||||||
"description": "Language A to Language B, Language B to Language A, supports dictation"
|
"description": "Language A to Language B, Language B to Language A, supports dictation"
|
||||||
},
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "Dictionary",
|
||||||
|
"description": "Look up words and phrases with detailed definitions and examples"
|
||||||
|
},
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "More Features",
|
"name": "More Features",
|
||||||
"description": "Under development, stay tuned"
|
"description": "Under development, stay tuned"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
|
||||||
"loading": "Loading...",
|
|
||||||
"githubLogin": "GitHub Login"
|
|
||||||
},
|
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Authentication",
|
"title": "Authentication",
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
@@ -103,8 +100,6 @@
|
|||||||
"invalidEmail": "Please enter a valid email address",
|
"invalidEmail": "Please enter a valid email address",
|
||||||
"passwordTooShort": "Password must be at least 8 characters",
|
"passwordTooShort": "Password must be at least 8 characters",
|
||||||
"passwordsNotMatch": "Passwords do not match",
|
"passwordsNotMatch": "Passwords do not match",
|
||||||
"signInFailed": "Sign in failed, please check your email and password",
|
|
||||||
"signUpFailed": "Sign up failed, please try again later",
|
|
||||||
"nameRequired": "Please enter your name",
|
"nameRequired": "Please enter your name",
|
||||||
"emailRequired": "Please enter your email",
|
"emailRequired": "Please enter your email",
|
||||||
"passwordRequired": "Please enter your password",
|
"passwordRequired": "Please enter your password",
|
||||||
@@ -151,18 +146,6 @@
|
|||||||
"next": "Next",
|
"next": "Next",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"autoPause": "Auto Pause ({enabled})",
|
"autoPause": "Auto Pause ({enabled})",
|
||||||
"playbackSpeed": "Playback Speed",
|
|
||||||
"subtitleSettings": "Subtitle Settings",
|
|
||||||
"fontSize": "Font Size",
|
|
||||||
"backgroundColor": "Background Color",
|
|
||||||
"textColor": "Text Color",
|
|
||||||
"fontFamily": "Font Family",
|
|
||||||
"opacity": "Opacity",
|
|
||||||
"position": "Position",
|
|
||||||
"top": "Top",
|
|
||||||
"center": "Center",
|
|
||||||
"bottom": "Bottom",
|
|
||||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
|
||||||
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||||
"uploadVideoFile": "Please upload video file",
|
"uploadVideoFile": "Please upload video file",
|
||||||
"uploadSubtitleFile": "Please upload subtitle file",
|
"uploadSubtitleFile": "Please upload subtitle file",
|
||||||
@@ -177,16 +160,7 @@
|
|||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"videoUploadFailed": "Video upload failed",
|
"videoUploadFailed": "Video upload failed",
|
||||||
"subtitleUploadFailed": "Subtitle upload failed",
|
"subtitleUploadFailed": "Subtitle upload failed"
|
||||||
"subtitleLoadSuccess": "Subtitle file loaded successfully",
|
|
||||||
"subtitleLoadFailed": "Subtitle file loading failed",
|
|
||||||
"shortcuts": {
|
|
||||||
"playPause": "Play/Pause",
|
|
||||||
"next": "Next",
|
|
||||||
"previous": "Previous",
|
|
||||||
"restart": "Restart",
|
|
||||||
"autoPause": "Toggle Auto Pause"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "Generate IPA",
|
"generateIPA": "Generate IPA",
|
||||||
@@ -216,5 +190,33 @@
|
|||||||
"error": "Failed to add text pair to folder"
|
"error": "Failed to add text pair to folder"
|
||||||
},
|
},
|
||||||
"autoSave": "Auto Save"
|
"autoSave": "Auto Save"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dictionary",
|
||||||
|
"description": "Look up words and phrases with detailed definitions and examples",
|
||||||
|
"searchPlaceholder": "Enter a word or phrase to look up...",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search": "Search",
|
||||||
|
"languageSettings": "Language Settings",
|
||||||
|
"queryLanguage": "Query Language",
|
||||||
|
"queryLanguageHint": "What language is the word/phrase you want to look up",
|
||||||
|
"definitionLanguage": "Definition Language",
|
||||||
|
"definitionLanguageHint": "What language do you want the definitions in",
|
||||||
|
"otherLanguagePlaceholder": "Or enter another language...",
|
||||||
|
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
||||||
|
"relookup": "Re-search",
|
||||||
|
"saveToFolder": "Save to folder",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"tryOtherWords": "Try other words or phrases",
|
||||||
|
"welcomeTitle": "Welcome to Dictionary",
|
||||||
|
"welcomeHint": "Enter a word or phrase in the search box above to start looking up",
|
||||||
|
"lookupFailed": "Search failed, please try again later",
|
||||||
|
"relookupSuccess": "Re-searched successfully",
|
||||||
|
"relookupFailed": "Dictionary re-search failed",
|
||||||
|
"pleaseLogin": "Please log in first",
|
||||||
|
"pleaseCreateFolder": "Please create a folder first",
|
||||||
|
"savedToFolder": "Saved to folder: {folderName}",
|
||||||
|
"saveFailed": "Save failed, please try again later"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
222
messages/fr-FR.json
Normal file
222
messages/fr-FR.json
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
|
||||||
|
"japanese": "Kana japonais",
|
||||||
|
"english": "Alphabet anglais",
|
||||||
|
"uyghur": "Alphabet ouïghour",
|
||||||
|
"esperanto": "Alphabet espéranto",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"loadFailed": "Échec du chargement, veuillez réessayer",
|
||||||
|
"hideLetter": "Masquer la lettre",
|
||||||
|
"showLetter": "Afficher la lettre",
|
||||||
|
"hideIPA": "Masquer l'API",
|
||||||
|
"showIPA": "Afficher l'API",
|
||||||
|
"roman": "Romanisation",
|
||||||
|
"letter": "Lettre",
|
||||||
|
"random": "Mode aléatoire",
|
||||||
|
"randomNext": "Suivant aléatoire"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Dossiers",
|
||||||
|
"subtitle": "Gérez vos collections",
|
||||||
|
"newFolder": "Nouveau dossier",
|
||||||
|
"creating": "Création...",
|
||||||
|
"noFoldersYet": "Aucun dossier pour le moment",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} paires",
|
||||||
|
"enterFolderName": "Entrez le nom du dossier:",
|
||||||
|
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||||
|
"back": "Retour",
|
||||||
|
"textPairs": "Paires de textes",
|
||||||
|
"itemsCount": "{count} éléments",
|
||||||
|
"memorize": "Mémoriser",
|
||||||
|
"loadingTextPairs": "Chargement des paires de textes...",
|
||||||
|
"noTextPairs": "Aucune paire de textes dans ce dossier",
|
||||||
|
"addNewTextPair": "Ajouter une nouvelle paire de textes",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"updateTextPair": "Mettre à jour la paire de textes",
|
||||||
|
"update": "Mettre à jour",
|
||||||
|
"text1": "Texte 1",
|
||||||
|
"text2": "Texte 2",
|
||||||
|
"language1": "Langue 1",
|
||||||
|
"language2": "Langue 2",
|
||||||
|
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Apprendre les langues",
|
||||||
|
"description": "Voici un site web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
|
||||||
|
"explore": "Explorer",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— Steve Jobs"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "Traducteur",
|
||||||
|
"description": "Traduire dans n'importe quelle langue et annoter avec l'alphabet phonétique international (API)"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "Lecteur de texte",
|
||||||
|
"description": "Reconnaître et lire le texte à haute voix, prend en charge la lecture en boucle et le réglage de la vitesse"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "Lecteur vidéo SRT",
|
||||||
|
"description": "Lire des vidéos phrase par phrase basées sur des fichiers de sous-titres SRT pour imiter la prononciation des locuteurs natifs"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "Alphabet",
|
||||||
|
"description": "Commencer à apprendre une nouvelle langue par l'alphabet"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "Mémoriser",
|
||||||
|
"description": "Langue A vers langue B, langue B vers langue A, prend en charge la dictée"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "Dictionnaire",
|
||||||
|
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "Plus de fonctionnalités",
|
||||||
|
"description": "En développement, restez à l'écoute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Authentification",
|
||||||
|
"signIn": "Se connecter",
|
||||||
|
"signUp": "S'inscrire",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"confirmPassword": "Confirmer le mot de passe",
|
||||||
|
"name": "Nom",
|
||||||
|
"signInButton": "Se connecter",
|
||||||
|
"signUpButton": "S'inscrire",
|
||||||
|
"noAccount": "Vous n'avez pas de compte?",
|
||||||
|
"hasAccount": "Vous avez déjà un compte?",
|
||||||
|
"signInWithGitHub": "Se connecter avec GitHub",
|
||||||
|
"signUpWithGitHub": "S'inscrire avec GitHub",
|
||||||
|
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
|
||||||
|
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
||||||
|
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
|
||||||
|
"nameRequired": "Veuillez entrer votre nom",
|
||||||
|
"emailRequired": "Veuillez entrer votre e-mail",
|
||||||
|
"passwordRequired": "Veuillez entrer votre mot de passe",
|
||||||
|
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
|
||||||
|
"loading": "Chargement..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "Sélectionner un dossier",
|
||||||
|
"noFolders": "Aucun dossier trouvé",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "Réponse",
|
||||||
|
"next": "Suivant",
|
||||||
|
"reverse": "Inverser",
|
||||||
|
"dictation": "Dictée",
|
||||||
|
"noTextPairs": "Aucune paire de textes disponible",
|
||||||
|
"disorder": "Désordre",
|
||||||
|
"previous": "Précédent"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Se connecter",
|
||||||
|
"profile": "Profil",
|
||||||
|
"folders": "Dossiers"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "Mon profil",
|
||||||
|
"email": "E-mail: {email}",
|
||||||
|
"logout": "Se déconnecter"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "Télécharger une vidéo",
|
||||||
|
"uploadSubtitle": "Télécharger des sous-titres",
|
||||||
|
"pause": "Pause",
|
||||||
|
"play": "Lire",
|
||||||
|
"previous": "Précédent",
|
||||||
|
"next": "Suivant",
|
||||||
|
"restart": "Redémarrer",
|
||||||
|
"autoPause": "Pause automatique ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
|
||||||
|
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
|
||||||
|
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
|
||||||
|
"processingSubtitle": "Traitement du fichier de sous-titres...",
|
||||||
|
"needBothFiles": "Les fichiers vidéo et de sous-titres sont requis pour commencer l'apprentissage",
|
||||||
|
"videoFile": "Fichier vidéo",
|
||||||
|
"subtitleFile": "Fichier de sous-titres",
|
||||||
|
"uploaded": "Téléchargé",
|
||||||
|
"notUploaded": "Non téléchargé",
|
||||||
|
"upload": "Télécharger",
|
||||||
|
"autoPauseStatus": "Pause automatique: {enabled}",
|
||||||
|
"on": "Activé",
|
||||||
|
"off": "Désactivé",
|
||||||
|
"videoUploadFailed": "Échec du téléchargement de la vidéo",
|
||||||
|
"subtitleUploadFailed": "Échec du téléchargement des sous-titres"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "Générer l'API",
|
||||||
|
"viewSavedItems": "Voir les éléments enregistrés",
|
||||||
|
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer? (O/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "détecter la langue",
|
||||||
|
"generateIPA": "générer l'api",
|
||||||
|
"translateInto": "traduire en",
|
||||||
|
"chinese": "Chinois",
|
||||||
|
"english": "Anglais",
|
||||||
|
"italian": "Italien",
|
||||||
|
"other": "Autre",
|
||||||
|
"translating": "traduction...",
|
||||||
|
"translate": "traduire",
|
||||||
|
"inputLanguage": "Entrez une langue.",
|
||||||
|
"history": "Historique",
|
||||||
|
"enterLanguage": "Entrer la langue",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "Vous n'êtes pas authentifié",
|
||||||
|
"chooseFolder": "Choisir un dossier à ajouter",
|
||||||
|
"noFolders": "Aucun dossier trouvé",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "Fermer",
|
||||||
|
"success": "Paire de textes ajoutée au dossier",
|
||||||
|
"error": "Échec de l'ajout de la paire de textes au dossier"
|
||||||
|
},
|
||||||
|
"autoSave": "Sauvegarde automatique"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dictionnaire",
|
||||||
|
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
|
||||||
|
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
|
||||||
|
"searching": "Recherche...",
|
||||||
|
"search": "Rechercher",
|
||||||
|
"languageSettings": "Paramètres linguistiques",
|
||||||
|
"queryLanguage": "Langue de requête",
|
||||||
|
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
|
||||||
|
"definitionLanguage": "Langue de définition",
|
||||||
|
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
|
||||||
|
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
|
||||||
|
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
|
||||||
|
"relookup": "Rechercher à nouveau",
|
||||||
|
"saveToFolder": "Enregistrer dans le dossier",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"noResults": "Aucun résultat trouvé",
|
||||||
|
"tryOtherWords": "Essayez d'autres mots ou phrases",
|
||||||
|
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||||
|
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
|
||||||
|
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
|
||||||
|
"relookupSuccess": "Recherche répétée avec succès",
|
||||||
|
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
|
||||||
|
"pleaseLogin": "Veuillez d'abord vous connecter",
|
||||||
|
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
|
||||||
|
"savedToFolder": "Enregistré dans le dossier : {folderName}",
|
||||||
|
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
messages/it-IT.json
Normal file
222
messages/it-IT.json
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "Seleziona i caratteri che desideri imparare",
|
||||||
|
"japanese": "Kana giapponese",
|
||||||
|
"english": "Alfabeto inglese",
|
||||||
|
"uyghur": "Alfabeto uiguro",
|
||||||
|
"esperanto": "Alfabeto esperanto",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"loadFailed": "Caricamento fallito, riprova",
|
||||||
|
"hideLetter": "Nascondi lettera",
|
||||||
|
"showLetter": "Mostra lettera",
|
||||||
|
"hideIPA": "Nascondi IPA",
|
||||||
|
"showIPA": "Mostra IPA",
|
||||||
|
"roman": "Romanizzazione",
|
||||||
|
"letter": "Lettera",
|
||||||
|
"random": "Modalità casuale",
|
||||||
|
"randomNext": "Successivo casuale"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "Cartelle",
|
||||||
|
"subtitle": "Gestisci le tue collezioni",
|
||||||
|
"newFolder": "Nuova cartella",
|
||||||
|
"creating": "Creazione...",
|
||||||
|
"noFoldersYet": "Nessuna cartella ancora",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
||||||
|
"enterFolderName": "Inserisci nome cartella:",
|
||||||
|
"confirmDelete": "Digita \"{name}\" per eliminare:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||||
|
"back": "Indietro",
|
||||||
|
"textPairs": "Coppie di testi",
|
||||||
|
"itemsCount": "{count} elementi",
|
||||||
|
"memorize": "Memorizza",
|
||||||
|
"loadingTextPairs": "Caricamento coppie di testi...",
|
||||||
|
"noTextPairs": "Nessuna coppia di testi in questa cartella",
|
||||||
|
"addNewTextPair": "Aggiungi nuova coppia di testi",
|
||||||
|
"add": "Aggiungi",
|
||||||
|
"updateTextPair": "Aggiorna coppia di testi",
|
||||||
|
"update": "Aggiorna",
|
||||||
|
"text1": "Testo 1",
|
||||||
|
"text2": "Testo 2",
|
||||||
|
"language1": "Lingua 1",
|
||||||
|
"language2": "Lingua 2",
|
||||||
|
"enterLanguageName": "Inserisci il nome della lingua",
|
||||||
|
"edit": "Modifica",
|
||||||
|
"delete": "Elimina"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Impara le lingue",
|
||||||
|
"description": "Questo è un sito web molto utile che ti aiuta a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||||
|
"explore": "Esplora",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— Steve Jobs"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "Traduttore",
|
||||||
|
"description": "Traduci in qualsiasi lingua e annota con l'alfabeto fonetico internazionale (IPA)"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "Lettore di testo",
|
||||||
|
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "Lettore video SRT",
|
||||||
|
"description": "Riproduci video frase per frase basandoti su file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "Alfabeto",
|
||||||
|
"description": "Inizia a imparare una nuova lingua dall'alfabeto"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "Memorizza",
|
||||||
|
"description": "Lingua A verso lingua B, lingua B verso lingua A, supporta dettatura"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "Dizionario",
|
||||||
|
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "Altre funzionalità",
|
||||||
|
"description": "In sviluppo, rimani sintonizzato"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "Autenticazione",
|
||||||
|
"signIn": "Accedi",
|
||||||
|
"signUp": "Registrati",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Conferma password",
|
||||||
|
"name": "Nome",
|
||||||
|
"signInButton": "Accedi",
|
||||||
|
"signUpButton": "Registrati",
|
||||||
|
"noAccount": "Non hai un account?",
|
||||||
|
"hasAccount": "Hai già un account?",
|
||||||
|
"signInWithGitHub": "Accedi con GitHub",
|
||||||
|
"signUpWithGitHub": "Registrati con GitHub",
|
||||||
|
"invalidEmail": "Inserisci un indirizzo email valido",
|
||||||
|
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||||
|
"passwordsNotMatch": "Le password non corrispondono",
|
||||||
|
"nameRequired": "Inserisci il tuo nome",
|
||||||
|
"emailRequired": "Inserisci la tua email",
|
||||||
|
"passwordRequired": "Inserisci la tua password",
|
||||||
|
"confirmPasswordRequired": "Conferma la tua password",
|
||||||
|
"loading": "Caricamento..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "Seleziona una cartella",
|
||||||
|
"noFolders": "Nessuna cartella trovata",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "Risposta",
|
||||||
|
"next": "Successivo",
|
||||||
|
"reverse": "Inverti",
|
||||||
|
"dictation": "Dettatura",
|
||||||
|
"noTextPairs": "Nessuna coppia di testi disponibile",
|
||||||
|
"disorder": "Disordine",
|
||||||
|
"previous": "Precedente"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "Accedi",
|
||||||
|
"profile": "Profilo",
|
||||||
|
"folders": "Cartelle"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "Il mio profilo",
|
||||||
|
"email": "Email: {email}",
|
||||||
|
"logout": "Esci"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "Carica video",
|
||||||
|
"uploadSubtitle": "Carica sottotitoli",
|
||||||
|
"pause": "Pausa",
|
||||||
|
"play": "Riproduci",
|
||||||
|
"previous": "Precedente",
|
||||||
|
"next": "Successivo",
|
||||||
|
"restart": "Riavvia",
|
||||||
|
"autoPause": "Pausa automatica ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli",
|
||||||
|
"uploadVideoFile": "Carica un file video",
|
||||||
|
"uploadSubtitleFile": "Carica un file di sottotitoli",
|
||||||
|
"processingSubtitle": "Elaborazione file sottotitoli...",
|
||||||
|
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento",
|
||||||
|
"videoFile": "File video",
|
||||||
|
"subtitleFile": "File sottotitoli",
|
||||||
|
"uploaded": "Caricato",
|
||||||
|
"notUploaded": "Non caricato",
|
||||||
|
"upload": "Carica",
|
||||||
|
"autoPauseStatus": "Pausa automatica: {enabled}",
|
||||||
|
"on": "Attivo",
|
||||||
|
"off": "Disattivo",
|
||||||
|
"videoUploadFailed": "Caricamento video fallito",
|
||||||
|
"subtitleUploadFailed": "Caricamento sottotitoli fallito"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "Genera IPA",
|
||||||
|
"viewSavedItems": "Visualizza elementi salvati",
|
||||||
|
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "rileva lingua",
|
||||||
|
"generateIPA": "genera ipa",
|
||||||
|
"translateInto": "traduci in",
|
||||||
|
"chinese": "Cinese",
|
||||||
|
"english": "Inglese",
|
||||||
|
"italian": "Italiano",
|
||||||
|
"other": "Altro",
|
||||||
|
"translating": "traduzione...",
|
||||||
|
"translate": "traduci",
|
||||||
|
"inputLanguage": "Inserisci una lingua.",
|
||||||
|
"history": "Cronologia",
|
||||||
|
"enterLanguage": "Inserisci lingua",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "Non sei autenticato",
|
||||||
|
"chooseFolder": "Scegli una cartella a cui aggiungere",
|
||||||
|
"noFolders": "Nessuna cartella trovata",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "Chiudi",
|
||||||
|
"success": "Coppia di testi aggiunta alla cartella",
|
||||||
|
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
||||||
|
},
|
||||||
|
"autoSave": "Salvataggio automatico"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dizionario",
|
||||||
|
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi",
|
||||||
|
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
||||||
|
"searching": "Ricerca...",
|
||||||
|
"search": "Cerca",
|
||||||
|
"languageSettings": "Impostazioni lingua",
|
||||||
|
"queryLanguage": "Lingua di interrogazione",
|
||||||
|
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
|
||||||
|
"definitionLanguage": "Lingua di definizione",
|
||||||
|
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
|
||||||
|
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
|
||||||
|
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
|
||||||
|
"relookup": "Ricerca di nuovo",
|
||||||
|
"saveToFolder": "Salva nella cartella",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"noResults": "Nessun risultato trovato",
|
||||||
|
"tryOtherWords": "Prova altre parole o frasi",
|
||||||
|
"welcomeTitle": "Benvenuto nel dizionario",
|
||||||
|
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
|
||||||
|
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
||||||
|
"relookupSuccess": "Ricerca ripetuta con successo",
|
||||||
|
"relookupFailed": "Nuova ricerca del dizionario fallita",
|
||||||
|
"pleaseLogin": "Accedi prima",
|
||||||
|
"pleaseCreateFolder": "Crea prima una cartella",
|
||||||
|
"savedToFolder": "Salvato nella cartella: {folderName}",
|
||||||
|
"saveFailed": "Salvataggio fallito, riprova più tardi"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
messages/ja-JP.json
Normal file
222
messages/ja-JP.json
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "学習したい文字を選択してください",
|
||||||
|
"japanese": "日本語仮名",
|
||||||
|
"english": "英語アルファベット",
|
||||||
|
"uyghur": "ウイグル文字",
|
||||||
|
"esperanto": "エスペラント文字",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
||||||
|
"hideLetter": "文字を非表示",
|
||||||
|
"showLetter": "文字を表示",
|
||||||
|
"hideIPA": "IPAを非表示",
|
||||||
|
"showIPA": "IPAを表示",
|
||||||
|
"roman": "ローマ字",
|
||||||
|
"letter": "文字",
|
||||||
|
"random": "ランダムモード",
|
||||||
|
"randomNext": "ランダムで次へ"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "フォルダー",
|
||||||
|
"subtitle": "コレクションを管理",
|
||||||
|
"newFolder": "新規フォルダー",
|
||||||
|
"creating": "作成中...",
|
||||||
|
"noFoldersYet": "フォルダーがありません",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs}組",
|
||||||
|
"enterFolderName": "フォルダー名を入力:",
|
||||||
|
"confirmDelete": "削除するには「{name}」と入力してください:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "あなたはこのフォルダーの所有者ではありません",
|
||||||
|
"back": "戻る",
|
||||||
|
"textPairs": "テキストペア",
|
||||||
|
"itemsCount": "{count}項目",
|
||||||
|
"memorize": "暗記",
|
||||||
|
"loadingTextPairs": "テキストペアを読み込み中...",
|
||||||
|
"noTextPairs": "このフォルダーにはテキストペアがありません",
|
||||||
|
"addNewTextPair": "新しいテキストペアを追加",
|
||||||
|
"add": "追加",
|
||||||
|
"updateTextPair": "テキストペアを更新",
|
||||||
|
"update": "更新",
|
||||||
|
"text1": "テキスト1",
|
||||||
|
"text2": "テキスト2",
|
||||||
|
"language1": "言語1",
|
||||||
|
"language2": "言語2",
|
||||||
|
"enterLanguageName": "言語名を入力してください",
|
||||||
|
"edit": "編集",
|
||||||
|
"delete": "削除"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "言語を学ぶ",
|
||||||
|
"description": "これは、人工言語を含む世界中のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||||
|
"explore": "探索",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— スティーブ・ジョブズ"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "翻訳",
|
||||||
|
"description": "任意の言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "テキストスピーカー",
|
||||||
|
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRTビデオプレーヤー",
|
||||||
|
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "アルファベット",
|
||||||
|
"description": "アルファベットから新しい言語の学習を始めましょう"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "暗記",
|
||||||
|
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "辞書",
|
||||||
|
"description": "単語やフレーズを調べ、詳細な定義と例を表示"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "その他の機能",
|
||||||
|
"description": "開発中です。お楽しみに"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "認証",
|
||||||
|
"signIn": "ログイン",
|
||||||
|
"signUp": "新規登録",
|
||||||
|
"email": "メールアドレス",
|
||||||
|
"password": "パスワード",
|
||||||
|
"confirmPassword": "パスワード(確認)",
|
||||||
|
"name": "名前",
|
||||||
|
"signInButton": "ログイン",
|
||||||
|
"signUpButton": "新規登録",
|
||||||
|
"noAccount": "アカウントをお持ちでないですか?",
|
||||||
|
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||||
|
"signInWithGitHub": "GitHubでログイン",
|
||||||
|
"signUpWithGitHub": "GitHubで新規登録",
|
||||||
|
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||||
|
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||||
|
"passwordsNotMatch": "パスワードが一致しません",
|
||||||
|
"nameRequired": "名前を入力してください",
|
||||||
|
"emailRequired": "メールアドレスを入力してください",
|
||||||
|
"passwordRequired": "パスワードを入力してください",
|
||||||
|
"confirmPasswordRequired": "パスワード(確認)を入力してください",
|
||||||
|
"loading": "読み込み中..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "フォルダーを選択",
|
||||||
|
"noFolders": "フォルダーが見つかりません",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "回答",
|
||||||
|
"next": "次へ",
|
||||||
|
"reverse": "逆順",
|
||||||
|
"dictation": "ディクテーション",
|
||||||
|
"noTextPairs": "利用可能なテキストペアがありません",
|
||||||
|
"disorder": "ランダム",
|
||||||
|
"previous": "前へ"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "このフォルダーにアクセスする権限がありません"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "ログイン",
|
||||||
|
"profile": "プロフィール",
|
||||||
|
"folders": "フォルダー"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "マイプロフィール",
|
||||||
|
"email": "メールアドレス: {email}",
|
||||||
|
"logout": "ログアウト"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "ビデオをアップロード",
|
||||||
|
"uploadSubtitle": "字幕をアップロード",
|
||||||
|
"pause": "一時停止",
|
||||||
|
"play": "再生",
|
||||||
|
"previous": "前へ",
|
||||||
|
"next": "次へ",
|
||||||
|
"restart": "最初から",
|
||||||
|
"autoPause": "自動一時停止 ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "ビデオと字幕ファイルをアップロードしてください",
|
||||||
|
"uploadVideoFile": "ビデオファイルをアップロードしてください",
|
||||||
|
"uploadSubtitleFile": "字幕ファイルをアップロードしてください",
|
||||||
|
"processingSubtitle": "字幕ファイルを処理中...",
|
||||||
|
"needBothFiles": "学習を開始するにはビデオと字幕ファイルの両方が必要です",
|
||||||
|
"videoFile": "ビデオファイル",
|
||||||
|
"subtitleFile": "字幕ファイル",
|
||||||
|
"uploaded": "アップロード済み",
|
||||||
|
"notUploaded": "未アップロード",
|
||||||
|
"upload": "アップロード",
|
||||||
|
"autoPauseStatus": "自動一時停止: {enabled}",
|
||||||
|
"on": "オン",
|
||||||
|
"off": "オフ",
|
||||||
|
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||||
|
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "IPAを生成",
|
||||||
|
"viewSavedItems": "保存済みアイテムを表示",
|
||||||
|
"confirmDeleteAll": "本当にすべて削除しますか? (Y/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "言語を検出",
|
||||||
|
"generateIPA": "IPAを生成",
|
||||||
|
"translateInto": "翻訳",
|
||||||
|
"chinese": "中国語",
|
||||||
|
"english": "英語",
|
||||||
|
"italian": "イタリア語",
|
||||||
|
"other": "その他",
|
||||||
|
"translating": "翻訳中...",
|
||||||
|
"translate": "翻訳",
|
||||||
|
"inputLanguage": "言語を入力してください。",
|
||||||
|
"history": "履歴",
|
||||||
|
"enterLanguage": "言語を入力",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "認証されていません",
|
||||||
|
"chooseFolder": "追加するフォルダーを選択",
|
||||||
|
"noFolders": "フォルダーが見つかりません",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "閉じる",
|
||||||
|
"success": "テキストペアをフォルダーに追加しました",
|
||||||
|
"error": "テキストペアの追加に失敗しました"
|
||||||
|
},
|
||||||
|
"autoSave": "自動保存"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "辞書",
|
||||||
|
"description": "詳細な定義と例で単語やフレーズを検索",
|
||||||
|
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
||||||
|
"searching": "検索中...",
|
||||||
|
"search": "検索",
|
||||||
|
"languageSettings": "言語設定",
|
||||||
|
"queryLanguage": "クエリ言語",
|
||||||
|
"queryLanguageHint": "検索する単語/フレーズの言語",
|
||||||
|
"definitionLanguage": "定義言語",
|
||||||
|
"definitionLanguageHint": "定義を表示する言語",
|
||||||
|
"otherLanguagePlaceholder": "または他の言語を入力...",
|
||||||
|
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
|
||||||
|
"relookup": "再検索",
|
||||||
|
"saveToFolder": "フォルダに保存",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"noResults": "結果が見つかりません",
|
||||||
|
"tryOtherWords": "他の単語やフレーズを試してください",
|
||||||
|
"welcomeTitle": "辞書へようこそ",
|
||||||
|
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
|
||||||
|
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||||
|
"relookupSuccess": "再検索しました",
|
||||||
|
"relookupFailed": "辞書の再検索に失敗しました",
|
||||||
|
"pleaseLogin": "まずログインしてください",
|
||||||
|
"pleaseCreateFolder": "まずフォルダを作成してください",
|
||||||
|
"savedToFolder": "フォルダに保存しました:{folderName}",
|
||||||
|
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
messages/ko-KR.json
Normal file
222
messages/ko-KR.json
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "학습할 문자를 선택하세요",
|
||||||
|
"japanese": "일본어 가나",
|
||||||
|
"english": "영문 알파벳",
|
||||||
|
"uyghur": "위구르 문자",
|
||||||
|
"esperanto": "에스페란토 문자",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"loadFailed": "로딩 실패, 다시 시도해 주세요",
|
||||||
|
"hideLetter": "문자 숨기기",
|
||||||
|
"showLetter": "문자 표시",
|
||||||
|
"hideIPA": "IPA 숨기기",
|
||||||
|
"showIPA": "IPA 표시",
|
||||||
|
"roman": "로마자 표기",
|
||||||
|
"letter": "문자",
|
||||||
|
"random": "무작위 모드",
|
||||||
|
"randomNext": "무작위 다음"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "폴더",
|
||||||
|
"subtitle": "컬렉션 관리",
|
||||||
|
"newFolder": "새 폴더",
|
||||||
|
"creating": "생성 중...",
|
||||||
|
"noFoldersYet": "폴더가 없습니다",
|
||||||
|
"folderInfo": "ID: {id} • {totalPairs}쌍",
|
||||||
|
"enterFolderName": "폴더 이름 입력:",
|
||||||
|
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||||
|
"back": "뒤로",
|
||||||
|
"textPairs": "텍스트 쌍",
|
||||||
|
"itemsCount": "{count}개 항목",
|
||||||
|
"memorize": "암기",
|
||||||
|
"loadingTextPairs": "텍스트 쌍 로딩 중...",
|
||||||
|
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
|
||||||
|
"addNewTextPair": "새 텍스트 쌍 추가",
|
||||||
|
"add": "추가",
|
||||||
|
"updateTextPair": "텍스트 쌍 업데이트",
|
||||||
|
"update": "업데이트",
|
||||||
|
"text1": "텍스트 1",
|
||||||
|
"text2": "텍스트 2",
|
||||||
|
"language1": "언어 1",
|
||||||
|
"language2": "언어 2",
|
||||||
|
"enterLanguageName": "언어 이름을 입력하세요",
|
||||||
|
"edit": "편집",
|
||||||
|
"delete": "삭제"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "언어 학습",
|
||||||
|
"description": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||||
|
"explore": "탐색",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— 스티브 잡스"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "번역기",
|
||||||
|
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "텍스트 스피커",
|
||||||
|
"description": "텍스트를 인식하고 읽어줍니다. 반복 재생 및 속도 조정 지원"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRT 비디오 플레이어",
|
||||||
|
"description": "SRT 자막 파일을 기반으로 문장별로 비디오를 재생하여 원어민 발음 모방"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "알파벳",
|
||||||
|
"description": "알파벳부터 새로운 언어 학습 시작"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "암기",
|
||||||
|
"description": "언어 A에서 언어 B로, 언어 B에서 언어 A로, 받아쓰기 지원"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "사전",
|
||||||
|
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "더 많은 기능",
|
||||||
|
"description": "개발 중, 기대해 주세요"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "인증",
|
||||||
|
"signIn": "로그인",
|
||||||
|
"signUp": "회원가입",
|
||||||
|
"email": "이메일",
|
||||||
|
"password": "비밀번호",
|
||||||
|
"confirmPassword": "비밀번호 확인",
|
||||||
|
"name": "이름",
|
||||||
|
"signInButton": "로그인",
|
||||||
|
"signUpButton": "회원가입",
|
||||||
|
"noAccount": "계정이 없으신가요?",
|
||||||
|
"hasAccount": "이미 계정이 있으신가요?",
|
||||||
|
"signInWithGitHub": "GitHub로 로그인",
|
||||||
|
"signUpWithGitHub": "GitHub로 회원가입",
|
||||||
|
"invalidEmail": "유효한 이메일 주소를 입력하세요",
|
||||||
|
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||||
|
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
||||||
|
"nameRequired": "이름을 입력하세요",
|
||||||
|
"emailRequired": "이메일을 입력하세요",
|
||||||
|
"passwordRequired": "비밀번호를 입력하세요",
|
||||||
|
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
|
||||||
|
"loading": "로딩 중..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "폴더 선택",
|
||||||
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "정답",
|
||||||
|
"next": "다음",
|
||||||
|
"reverse": "반대",
|
||||||
|
"dictation": "받아쓰기",
|
||||||
|
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
|
||||||
|
"disorder": "무작위",
|
||||||
|
"previous": "이전"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "이 폴더에 액세스할 권한이 없습니다"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "learn-languages",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "로그인",
|
||||||
|
"profile": "프로필",
|
||||||
|
"folders": "폴더"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "내 프로필",
|
||||||
|
"email": "이메일: {email}",
|
||||||
|
"logout": "로그아웃"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "비디오 업로드",
|
||||||
|
"uploadSubtitle": "자막 업로드",
|
||||||
|
"pause": "일시정지",
|
||||||
|
"play": "재생",
|
||||||
|
"previous": "이전",
|
||||||
|
"next": "다음",
|
||||||
|
"restart": "처음부터",
|
||||||
|
"autoPause": "자동 일시정지 ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
|
||||||
|
"uploadVideoFile": "비디오 파일을 업로드하세요",
|
||||||
|
"uploadSubtitleFile": "자막 파일을 업로드하세요",
|
||||||
|
"processingSubtitle": "자막 파일 처리 중...",
|
||||||
|
"needBothFiles": "학습을 시작하려면 비디오와 자막 파일이 모두 필요합니다",
|
||||||
|
"videoFile": "비디오 파일",
|
||||||
|
"subtitleFile": "자막 파일",
|
||||||
|
"uploaded": "업로드됨",
|
||||||
|
"notUploaded": "업로드되지 않음",
|
||||||
|
"upload": "업로드",
|
||||||
|
"autoPauseStatus": "자동 일시정지: {enabled}",
|
||||||
|
"on": "켜기",
|
||||||
|
"off": "끄기",
|
||||||
|
"videoUploadFailed": "비디오 업로드 실패",
|
||||||
|
"subtitleUploadFailed": "자막 업로드 실패"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "IPA 생성",
|
||||||
|
"viewSavedItems": "저장된 항목 보기",
|
||||||
|
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "언어 감지",
|
||||||
|
"generateIPA": "IPA 생성",
|
||||||
|
"translateInto": "번역",
|
||||||
|
"chinese": "중국어",
|
||||||
|
"english": "영어",
|
||||||
|
"italian": "이탈리아어",
|
||||||
|
"other": "기타",
|
||||||
|
"translating": "번역 중...",
|
||||||
|
"translate": "번역",
|
||||||
|
"inputLanguage": "언어를 입력하세요.",
|
||||||
|
"history": "기록",
|
||||||
|
"enterLanguage": "언어 입력",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "인증되지 않았습니다",
|
||||||
|
"chooseFolder": "추가할 폴더 선택",
|
||||||
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "닫기",
|
||||||
|
"success": "텍스트 쌍을 폴더에 추가했습니다",
|
||||||
|
"error": "텍스트 쌍 추가 실패"
|
||||||
|
},
|
||||||
|
"autoSave": "자동 저장"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "사전",
|
||||||
|
"description": "상세한 정의와 예제로 단어 및 구문 검색",
|
||||||
|
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
||||||
|
"searching": "검색 중...",
|
||||||
|
"search": "검색",
|
||||||
|
"languageSettings": "언어 설정",
|
||||||
|
"queryLanguage": "쿼리 언어",
|
||||||
|
"queryLanguageHint": "검색하려는 단어/구문의 언어",
|
||||||
|
"definitionLanguage": "정의 언어",
|
||||||
|
"definitionLanguageHint": "정의를 표시할 언어",
|
||||||
|
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
|
||||||
|
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
|
||||||
|
"relookup": "재검색",
|
||||||
|
"saveToFolder": "폴더에 저장",
|
||||||
|
"loading": "로드 중...",
|
||||||
|
"noResults": "결과를 찾을 수 없습니다",
|
||||||
|
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||||
|
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||||
|
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||||
|
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||||
|
"relookupSuccess": "재검색했습니다",
|
||||||
|
"relookupFailed": "사전 재검색 실패",
|
||||||
|
"pleaseLogin": "먼저 로그인하세요",
|
||||||
|
"pleaseCreateFolder": "먼저 폴더를 만드세요",
|
||||||
|
"savedToFolder": "폴더에 저장됨: {folderName}",
|
||||||
|
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
|
||||||
|
}
|
||||||
|
}
|
||||||
222
messages/ug-CN.json
Normal file
222
messages/ug-CN.json
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"alphabet": {
|
||||||
|
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
|
||||||
|
"japanese": "ياپونىيە كانا",
|
||||||
|
"english": "ئىنگلىز ئېلىپبې",
|
||||||
|
"uyghur": "ئۇيغۇر ئېلىپبېسى",
|
||||||
|
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
|
||||||
|
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
|
||||||
|
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
|
||||||
|
"hideLetter": "ھەرپنى يوشۇرۇش",
|
||||||
|
"showLetter": "ھەرپنى كۆرسىتىش",
|
||||||
|
"hideIPA": "IPA نى يوشۇرۇش",
|
||||||
|
"showIPA": "IPA نى كۆرسىتىش",
|
||||||
|
"roman": "روماللاشتۇرۇش",
|
||||||
|
"letter": "ھەرپ",
|
||||||
|
"random": "ئىختىيارىي ھالەت",
|
||||||
|
"randomNext": "ئىختىيارىي كېيىنكى"
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"title": "قىسقۇچلار",
|
||||||
|
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
|
||||||
|
"newFolder": "يېڭى قىسقۇچ",
|
||||||
|
"creating": "قۇرۇۋاتىدۇ...",
|
||||||
|
"noFoldersYet": "قىسقۇچ يوق",
|
||||||
|
"folderInfo": "كود: {id} • {totalPairs} جۈپ",
|
||||||
|
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
|
||||||
|
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
|
||||||
|
},
|
||||||
|
"folder_id": {
|
||||||
|
"unauthorized": "سىز بۇ قىسقۇچنىڭ ئىگىسى ئەمەس",
|
||||||
|
"back": "كەينىگە",
|
||||||
|
"textPairs": "تېكىست جۈپلىرى",
|
||||||
|
"itemsCount": "{count} تۈر",
|
||||||
|
"memorize": "ئەستە ساقلاش",
|
||||||
|
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...",
|
||||||
|
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
|
||||||
|
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ",
|
||||||
|
"add": "قوشۇش",
|
||||||
|
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ",
|
||||||
|
"update": "يېڭىلاش",
|
||||||
|
"text1": "تېكىست 1",
|
||||||
|
"text2": "تېكىست 2",
|
||||||
|
"language1": "تىل 1",
|
||||||
|
"language2": "تىل 2",
|
||||||
|
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
|
||||||
|
"edit": "تەھرىرلەش",
|
||||||
|
"delete": "ئۆچۈرۈش"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "تىل ئۆگىنىڭ",
|
||||||
|
"description": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.",
|
||||||
|
"explore": "ئىزدىنىش",
|
||||||
|
"fortune": {
|
||||||
|
"quote": "Stay hungry, stay foolish.",
|
||||||
|
"author": "— ستىۋ جوۋبس"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"name": "تەرجىمە",
|
||||||
|
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
|
||||||
|
},
|
||||||
|
"textSpeaker": {
|
||||||
|
"name": "تېكىست ئوقۇغۇچى",
|
||||||
|
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||||
|
},
|
||||||
|
"srtPlayer": {
|
||||||
|
"name": "SRT سىن ئوپىراتورى",
|
||||||
|
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
|
||||||
|
},
|
||||||
|
"alphabet": {
|
||||||
|
"name": "ئېلىپبې",
|
||||||
|
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"name": "ئەستە ساقلاش",
|
||||||
|
"description": "تىل A دىن تىل غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "لۇغەت",
|
||||||
|
"description": "سۆز ۋە سۆزنى ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
|
||||||
|
},
|
||||||
|
"moreFeatures": {
|
||||||
|
"name": "تېخىمۇ كۆپ ئىقتىدار",
|
||||||
|
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"title": "دەلىللەش",
|
||||||
|
"signIn": "كىرىش",
|
||||||
|
"signUp": "تىزىملىتىش",
|
||||||
|
"email": "ئېلخەت",
|
||||||
|
"password": "ئىم",
|
||||||
|
"confirmPassword": "ئىمنى جەزملەش",
|
||||||
|
"name": "نام",
|
||||||
|
"signInButton": "كىرىش",
|
||||||
|
"signUpButton": "تىزىملىتىش",
|
||||||
|
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
||||||
|
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
||||||
|
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
|
||||||
|
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
|
||||||
|
"invalidEmail": "ئىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||||
|
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
|
||||||
|
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
|
||||||
|
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
|
||||||
|
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
|
||||||
|
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
|
||||||
|
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
|
||||||
|
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"folder_selector": {
|
||||||
|
"selectFolder": "قىسقۇچ تاللاڭ",
|
||||||
|
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||||
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
|
},
|
||||||
|
"memorize": {
|
||||||
|
"answer": "جاۋاب",
|
||||||
|
"next": "كېيىنكى",
|
||||||
|
"reverse": "تەتۈر",
|
||||||
|
"dictation": "دىكتات",
|
||||||
|
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
|
||||||
|
"disorder": "بەت ئارلاش",
|
||||||
|
"previous": "ئىلگىرىكى"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"title": "تىل ئۆگىنىش",
|
||||||
|
"sourceCode": "GitHub",
|
||||||
|
"sign_in": "كىرىش",
|
||||||
|
"profile": "پروفىل",
|
||||||
|
"folders": "قىسقۇچلار"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"myProfile": "مېنىڭ پروفىلىم",
|
||||||
|
"email": "ئېلخەت: {email}",
|
||||||
|
"logout": "چىقىش"
|
||||||
|
},
|
||||||
|
"srt_player": {
|
||||||
|
"uploadVideo": "سىن يۈكلەڭ",
|
||||||
|
"uploadSubtitle": "خەت ئاستى يۈكلەڭ",
|
||||||
|
"pause": "ۋاقىتلىق توختىتىش",
|
||||||
|
"play": "قويۇش",
|
||||||
|
"previous": "ئىلگىرىكى",
|
||||||
|
"next": "كېيىنكى",
|
||||||
|
"restart": "قايتا باشلاش",
|
||||||
|
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
|
||||||
|
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ",
|
||||||
|
"uploadVideoFile": "سىن فايلى يۈكلەڭ",
|
||||||
|
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ",
|
||||||
|
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...",
|
||||||
|
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم",
|
||||||
|
"videoFile": "سىن فايلى",
|
||||||
|
"subtitleFile": "خەت ئاستى فايلى",
|
||||||
|
"uploaded": "يۈكلەندى",
|
||||||
|
"notUploaded": "يۈكلەنمىدى",
|
||||||
|
"upload": "يۈكلەش",
|
||||||
|
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
||||||
|
"on": "ئوچۇق",
|
||||||
|
"off": "تاقاق",
|
||||||
|
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
|
||||||
|
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
|
||||||
|
},
|
||||||
|
"text_speaker": {
|
||||||
|
"generateIPA": "IPA ھاسىل قىلىش",
|
||||||
|
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||||
|
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
|
||||||
|
},
|
||||||
|
"translator": {
|
||||||
|
"detectLanguage": "تىل پەرقلەندۈرۈش",
|
||||||
|
"generateIPA": "IPA ھاسىل قىلىش",
|
||||||
|
"translateInto": "تەرجىمە قىلىش",
|
||||||
|
"chinese": "خەنزۇچە",
|
||||||
|
"english": "ئىنگلىزچە",
|
||||||
|
"italian": "ئىتاليانچە",
|
||||||
|
"other": "باشقا",
|
||||||
|
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
||||||
|
"translate": "تەرجىمە قىلىش",
|
||||||
|
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
|
||||||
|
"history": "تارىخ",
|
||||||
|
"enterLanguage": "تىل كىرگۈزۈڭ",
|
||||||
|
"add_to_folder": {
|
||||||
|
"notAuthenticated": "دەلىتلەنمىدىڭىز",
|
||||||
|
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ",
|
||||||
|
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||||
|
"folderInfo": "{id}. {name}",
|
||||||
|
"close": "تاقاش",
|
||||||
|
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||||
|
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
||||||
|
},
|
||||||
|
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "لۇغەت",
|
||||||
|
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
|
||||||
|
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||||
|
"searching": "ئىزدەۋاتىدۇ...",
|
||||||
|
"search": "ئىزدە",
|
||||||
|
"languageSettings": "تىل تەڭشىكى",
|
||||||
|
"queryLanguage": "سۈرەشتۈرۈش تىلى",
|
||||||
|
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||||
|
"definitionLanguage": "ئىلمىيى تىلى",
|
||||||
|
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
|
||||||
|
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||||
|
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
|
||||||
|
"relookup": "قايتا ئىزدە",
|
||||||
|
"saveToFolder": "قىسقۇچقا ساقلا",
|
||||||
|
"loading": "يۈكلىۋاتىدۇ...",
|
||||||
|
"noResults": "نەتىجە تېپىلمىدى",
|
||||||
|
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
|
||||||
|
"welcomeTitle": "لۇغەتكە مەرھەمەت",
|
||||||
|
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||||
|
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
|
||||||
|
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
|
||||||
|
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
|
||||||
|
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
|
||||||
|
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
|
||||||
|
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
||||||
|
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,13 +22,9 @@
|
|||||||
"newFolder": "新建文件夹",
|
"newFolder": "新建文件夹",
|
||||||
"creating": "创建中...",
|
"creating": "创建中...",
|
||||||
"noFoldersYet": "还没有文件夹",
|
"noFoldersYet": "还没有文件夹",
|
||||||
"folderInfo": "{id}. {name} ({totalPairs})",
|
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
||||||
"enterFolderName": "输入文件夹名称:",
|
"enterFolderName": "输入文件夹名称:",
|
||||||
"confirmDelete": "输入 \"{name}\" 以删除:",
|
"confirmDelete": "输入 \"{name}\" 以删除:"
|
||||||
"createFolderSuccess": "文件夹创建成功",
|
|
||||||
"deleteFolderSuccess": "文件夹删除成功",
|
|
||||||
"createFolderError": "创建文件夹失败",
|
|
||||||
"deleteFolderError": "删除文件夹失败"
|
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "您不是此文件夹的所有者",
|
"unauthorized": "您不是此文件夹的所有者",
|
||||||
@@ -44,8 +40,9 @@
|
|||||||
"update": "更新",
|
"update": "更新",
|
||||||
"text1": "文本1",
|
"text1": "文本1",
|
||||||
"text2": "文本2",
|
"text2": "文本2",
|
||||||
"locale1": "语言1",
|
"language1": "语言1",
|
||||||
"locale2": "语言2",
|
"language2": "语言2",
|
||||||
|
"enterLanguageName": "请输入语言名称",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除"
|
"delete": "删除"
|
||||||
},
|
},
|
||||||
@@ -77,15 +74,15 @@
|
|||||||
"name": "记忆",
|
"name": "记忆",
|
||||||
"description": "语言A到语言B,语言B到语言A,支持听写"
|
"description": "语言A到语言B,语言B到语言A,支持听写"
|
||||||
},
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"name": "词典",
|
||||||
|
"description": "查询单词和短语,提供详细的释义和例句"
|
||||||
|
},
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "更多功能",
|
"name": "更多功能",
|
||||||
"description": "开发中,敬请期待"
|
"description": "开发中,敬请期待"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
|
||||||
"loading": "加载中...",
|
|
||||||
"githubLogin": "GitHub登录"
|
|
||||||
},
|
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
@@ -98,23 +95,18 @@
|
|||||||
"signUpButton": "注册",
|
"signUpButton": "注册",
|
||||||
"noAccount": "还没有账户?",
|
"noAccount": "还没有账户?",
|
||||||
"hasAccount": "已有账户?",
|
"hasAccount": "已有账户?",
|
||||||
"signInWithGitHub": "使用GitHub登录",
|
"signInWithGitHub": "使用 GitHub 登录",
|
||||||
"signUpWithGitHub": "使用GitHub注册",
|
"signUpWithGitHub": "使用 GitHub 注册",
|
||||||
"invalidEmail": "请输入有效的邮箱地址",
|
"invalidEmail": "请输入有效的邮箱地址",
|
||||||
"passwordTooShort": "密码至少需要8个字符",
|
"passwordTooShort": "密码至少需要8个字符",
|
||||||
"passwordsNotMatch": "两次输入的密码不匹配",
|
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||||
"signInFailed": "登录失败,请检查您的邮箱和密码",
|
|
||||||
"signUpFailed": "注册失败,请稍后再试",
|
|
||||||
"nameRequired": "请输入用户名",
|
"nameRequired": "请输入用户名",
|
||||||
"emailRequired": "请输入邮箱",
|
"emailRequired": "请输入邮箱",
|
||||||
"passwordRequired": "请输入密码",
|
"passwordRequired": "请输入密码",
|
||||||
"confirmPasswordRequired": "请确认密码"
|
"confirmPasswordRequired": "请确认密码",
|
||||||
|
"loading": "加载中..."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"choose": {
|
|
||||||
"back": "返回",
|
|
||||||
"choose": "选择"
|
|
||||||
},
|
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
"selectFolder": "选择文件夹",
|
"selectFolder": "选择文件夹",
|
||||||
"noFolders": "未找到文件夹",
|
"noFolders": "未找到文件夹",
|
||||||
@@ -155,18 +147,6 @@
|
|||||||
"next": "下句",
|
"next": "下句",
|
||||||
"restart": "句首",
|
"restart": "句首",
|
||||||
"autoPause": "自动暂停({enabled})",
|
"autoPause": "自动暂停({enabled})",
|
||||||
"playbackSpeed": "播放速度",
|
|
||||||
"subtitleSettings": "字幕设置",
|
|
||||||
"fontSize": "字体大小",
|
|
||||||
"backgroundColor": "背景颜色",
|
|
||||||
"textColor": "文字颜色",
|
|
||||||
"fontFamily": "字体",
|
|
||||||
"opacity": "透明度",
|
|
||||||
"position": "位置",
|
|
||||||
"top": "顶部",
|
|
||||||
"center": "居中",
|
|
||||||
"bottom": "底部",
|
|
||||||
"keyboardShortcuts": "键盘快捷键",
|
|
||||||
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||||
"uploadVideoFile": "请上传视频文件",
|
"uploadVideoFile": "请上传视频文件",
|
||||||
"uploadSubtitleFile": "请上传字幕文件",
|
"uploadSubtitleFile": "请上传字幕文件",
|
||||||
@@ -180,16 +160,7 @@
|
|||||||
"on": "开",
|
"on": "开",
|
||||||
"off": "关",
|
"off": "关",
|
||||||
"videoUploadFailed": "视频上传失败",
|
"videoUploadFailed": "视频上传失败",
|
||||||
"subtitleUploadFailed": "字幕上传失败",
|
"subtitleUploadFailed": "字幕上传失败"
|
||||||
"subtitleLoadSuccess": "字幕文件加载成功",
|
|
||||||
"subtitleLoadFailed": "字幕文件加载失败",
|
|
||||||
"shortcuts": {
|
|
||||||
"playPause": "播放/暂停",
|
|
||||||
"next": "下一句",
|
|
||||||
"previous": "上一句",
|
|
||||||
"restart": "句首",
|
|
||||||
"autoPause": "切换自动暂停"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "生成IPA",
|
"generateIPA": "生成IPA",
|
||||||
@@ -219,5 +190,33 @@
|
|||||||
"error": "添加文本对到文件夹失败"
|
"error": "添加文本对到文件夹失败"
|
||||||
},
|
},
|
||||||
"autoSave": "自动保存"
|
"autoSave": "自动保存"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "词典",
|
||||||
|
"description": "查询单词和短语,提供详细的释义和例句",
|
||||||
|
"searchPlaceholder": "输入要查询的单词或短语...",
|
||||||
|
"searching": "查询中...",
|
||||||
|
"search": "查询",
|
||||||
|
"languageSettings": "语言设置",
|
||||||
|
"queryLanguage": "查询语言",
|
||||||
|
"queryLanguageHint": "你要查询的单词/短语是什么语言",
|
||||||
|
"definitionLanguage": "释义语言",
|
||||||
|
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||||
|
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||||
|
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||||
|
"relookup": "重新查询",
|
||||||
|
"saveToFolder": "保存到文件夹",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noResults": "未找到结果",
|
||||||
|
"tryOtherWords": "尝试其他单词或短语",
|
||||||
|
"welcomeTitle": "欢迎使用词典",
|
||||||
|
"welcomeHint": "在上方搜索框中输入单词或短语开始查询",
|
||||||
|
"lookupFailed": "查询失败,请稍后重试",
|
||||||
|
"relookupSuccess": "已重新查询",
|
||||||
|
"relookupFailed": "词典重新查询失败",
|
||||||
|
"pleaseLogin": "请先登录",
|
||||||
|
"pleaseCreateFolder": "请先创建文件夹",
|
||||||
|
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||||
|
"saveFailed": "保存失败,请稍后重试"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
package.json
29
package.json
@@ -11,36 +11,35 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.1.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.1.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.6",
|
"better-auth": "^1.4.10",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"edge-tts-universal": "^1.3.3",
|
"lucide-react": "^0.562.0",
|
||||||
"lucide-react": "^0.561.0",
|
"next": "16.1.1",
|
||||||
"next": "16.0.10",
|
"next-intl": "^4.7.0",
|
||||||
"next-intl": "^4.5.8",
|
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "^1.4.6",
|
"@better-auth/cli": "^1.4.10",
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^25.0.1",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||||
"@typescript-eslint/parser": "^8.49.0",
|
"@typescript-eslint/parser": "^8.51.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.1.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"prisma": "^7.1.0",
|
"prisma": "^7.2.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3"
|
"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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "translation_history" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"source_text" TEXT NOT NULL,
|
||||||
|
"source_language" VARCHAR(20) NOT NULL,
|
||||||
|
"target_language" VARCHAR(20) NOT NULL,
|
||||||
|
"translated_text" TEXT NOT NULL,
|
||||||
|
"source_ipa" TEXT,
|
||||||
|
"target_ipa" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../generated/prisma"
|
output = "../generated/prisma"
|
||||||
@@ -8,50 +7,19 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
model Pair {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
locale1 String @db.VarChar(10)
|
|
||||||
locale2 String @db.VarChar(10)
|
|
||||||
text1 String
|
|
||||||
text2 String
|
|
||||||
ipa1 String?
|
|
||||||
ipa2 String?
|
|
||||||
folderId Int @map("folder_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([folderId, locale1, locale2, text1])
|
|
||||||
@@index([folderId])
|
|
||||||
@@map("pairs")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Folder {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String
|
|
||||||
userId String @map("user_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
pairs Pair[]
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@map("folders")
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
|
translationHistories TranslationHistory[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -104,3 +72,141 @@ model Verification {
|
|||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
@@map("verification")
|
@@map("verification")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Pair {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
text1 String
|
||||||
|
text2 String
|
||||||
|
language1 String @db.VarChar(20)
|
||||||
|
language2 String @db.VarChar(20)
|
||||||
|
ipa1 String?
|
||||||
|
ipa2 String?
|
||||||
|
folderId Int @map("folder_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([folderId, language1, language2, text1, text2])
|
||||||
|
@@index([folderId])
|
||||||
|
@@map("pairs")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Folder {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
userId String @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
pairs Pair[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("folders")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DictionaryLookUp {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId String? @map("user_id")
|
||||||
|
text String
|
||||||
|
queryLang String @map("query_lang")
|
||||||
|
definitionLang String @map("definition_lang")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
dictionaryWordId Int? @map("dictionary_word_id")
|
||||||
|
dictionaryPhraseId Int? @map("dictionary_phrase_id")
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
|
||||||
|
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([text, queryLang, definitionLang])
|
||||||
|
@@map("dictionary_lookups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DictionaryWord {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
standardForm String @map("standard_form")
|
||||||
|
queryLang String @map("query_lang")
|
||||||
|
definitionLang String @map("definition_lang")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
lookups DictionaryLookUp[]
|
||||||
|
entries DictionaryWordEntry[]
|
||||||
|
|
||||||
|
@@index([standardForm])
|
||||||
|
@@index([queryLang, definitionLang])
|
||||||
|
@@map("dictionary_words")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DictionaryPhrase {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
standardForm String @map("standard_form")
|
||||||
|
queryLang String @map("query_lang")
|
||||||
|
definitionLang String @map("definition_lang")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
lookups DictionaryLookUp[]
|
||||||
|
entries DictionaryPhraseEntry[]
|
||||||
|
|
||||||
|
@@index([standardForm])
|
||||||
|
@@index([queryLang, definitionLang])
|
||||||
|
@@map("dictionary_phrases")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DictionaryWordEntry {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
wordId Int @map("word_id")
|
||||||
|
ipa String
|
||||||
|
definition String
|
||||||
|
partOfSpeech String @map("part_of_speech")
|
||||||
|
example String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([wordId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("dictionary_word_entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DictionaryPhraseEntry {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
phraseId Int @map("phrase_id")
|
||||||
|
definition String
|
||||||
|
example String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([phraseId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("dictionary_phrase_entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TranslationHistory {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId String? @map("user_id")
|
||||||
|
sourceText String @map("source_text")
|
||||||
|
sourceLanguage String @map("source_language") @db.VarChar(20)
|
||||||
|
targetLanguage String @map("target_language") @db.VarChar(20)
|
||||||
|
translatedText String @map("translated_text")
|
||||||
|
sourceIpa String? @map("source_ipa")
|
||||||
|
targetIpa String? @map("target_ipa")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([sourceText, targetLanguage])
|
||||||
|
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||||
|
@@map("translation_history")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import IconClick from "@/components/ui/buttons/IconClick";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
{/* 返回按钮 */}
|
{/* 右上角返回按钮 */}
|
||||||
<div className="flex justify-end mb-4">
|
<div className="flex justify-end mb-4">
|
||||||
<IconClick
|
<IconClick
|
||||||
size={32}
|
size={32}
|
||||||
@@ -110,13 +110,15 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主卡片 */}
|
{/* 白色主卡片容器 */}
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
|
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
|
||||||
{/* 进度指示器 */}
|
{/* 顶部进度指示器和显示选项按钮 */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
{/* 当前字母进度 */}
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{currentIndex + 1} / {alphabet.length}
|
{currentIndex + 1} / {alphabet.length}
|
||||||
</span>
|
</span>
|
||||||
|
{/* 显示选项切换按钮组 */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLetter(!showLetter)}
|
onClick={() => setShowLetter(!showLetter)}
|
||||||
@@ -128,6 +130,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
>
|
>
|
||||||
{t("letter")}
|
{t("letter")}
|
||||||
</button>
|
</button>
|
||||||
|
{/* IPA 音标显示切换 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowIPA(!showIPA)}
|
onClick={() => setShowIPA(!showIPA)}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
@@ -138,6 +141,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
>
|
>
|
||||||
IPA
|
IPA
|
||||||
</button>
|
</button>
|
||||||
|
{/* 罗马音显示切换(仅日语显示) */}
|
||||||
{hasRomanization && (
|
{hasRomanization && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRoman(!showRoman)}
|
onClick={() => setShowRoman(!showRoman)}
|
||||||
@@ -150,6 +154,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
{t("roman")}
|
{t("roman")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* 随机模式切换 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsRandomMode(!isRandomMode)}
|
onClick={() => setIsRandomMode(!isRandomMode)}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
@@ -163,8 +168,9 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 字母显示区域 */}
|
{/* 字母主要内容显示区域 */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
|
{/* 字母本身(可隐藏) */}
|
||||||
{showLetter ? (
|
{showLetter ? (
|
||||||
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
|
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
|
||||||
{currentLetter.letter}
|
{currentLetter.letter}
|
||||||
@@ -175,12 +181,14 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* IPA 音标显示 */}
|
||||||
{showIPA && (
|
{showIPA && (
|
||||||
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
|
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
|
||||||
{currentLetter.letter_sound_ipa}
|
{currentLetter.letter_sound_ipa}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 罗马音显示(日语) */}
|
||||||
{showRoman && hasRomanization && currentLetter.roman_letter && (
|
{showRoman && hasRomanization && currentLetter.roman_letter && (
|
||||||
<div className="text-lg md:text-xl text-gray-500">
|
<div className="text-lg md:text-xl text-gray-500">
|
||||||
{currentLetter.roman_letter}
|
{currentLetter.roman_letter}
|
||||||
@@ -188,8 +196,9 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航控制 */}
|
{/* 底部导航控制区域 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
|
{/* 上一个按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={goToPrevious}
|
onClick={goToPrevious}
|
||||||
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
@@ -198,33 +207,19 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
<ChevronLeft size={24} />
|
<ChevronLeft size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 中间区域:随机按钮 */}
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{isRandomMode ? (
|
{isRandomMode && (
|
||||||
<button
|
<button
|
||||||
onClick={goToRandom}
|
onClick={goToRandom}
|
||||||
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
|
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
|
||||||
>
|
>
|
||||||
{t("randomNext")}
|
{t("randomNext")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
|
|
||||||
{alphabet.slice(0, 20).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`h-2 rounded-full transition-all ${
|
|
||||||
index === currentIndex
|
|
||||||
? "w-8 bg-[#35786f]"
|
|
||||||
: "w-2 bg-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{alphabet.length > 20 && (
|
|
||||||
<div className="text-xs text-gray-500 flex items-center">...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 下一个按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={goToNext}
|
onClick={goToNext}
|
||||||
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
@@ -235,7 +230,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作提示 */}
|
{/* 底部操作提示文字 */}
|
||||||
<div className="text-center mt-6 text-white text-sm">
|
<div className="text-center mt-6 text-white text-sm">
|
||||||
<p>
|
<p>
|
||||||
{isRandomMode
|
{isRandomMode
|
||||||
@@ -246,7 +241,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 触摸事件处理 */}
|
{/* 全屏触摸事件监听层(用于滑动切换) */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 pointer-events-none"
|
className="absolute inset-0 pointer-events-none"
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import IconClick from "@/components/ui/buttons/IconClick";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import Container from "@/components/ui/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import AlphabetCard from "./AlphabetCard";
|
import AlphabetCard from "./AlphabetCard";
|
||||||
|
|
||||||
export default function Alphabet() {
|
export default function Alphabet() {
|
||||||
@@ -50,14 +50,18 @@ export default function Alphabet() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
|
||||||
<Container className="p-8 max-w-2xl w-full text-center">
|
<Container className="p-8 max-w-2xl w-full text-center">
|
||||||
|
{/* 页面标题 */}
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
{t("chooseCharacters")}
|
{t("chooseCharacters")}
|
||||||
</h1>
|
</h1>
|
||||||
|
{/* 副标题说明 */}
|
||||||
<p className="text-gray-600 mb-8 text-lg">
|
<p className="text-gray-600 mb-8 text-lg">
|
||||||
选择一种语言的字母表开始学习
|
选择一种语言的字母表开始学习
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* 语言选择按钮网格 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 日语假名选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("japanese")}
|
onClick={() => setChosenAlphabet("japanese")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
@@ -68,6 +72,7 @@ export default function Alphabet() {
|
|||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|
||||||
|
{/* 英语字母选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("english")}
|
onClick={() => setChosenAlphabet("english")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
@@ -78,6 +83,7 @@ export default function Alphabet() {
|
|||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|
||||||
|
{/* 维吾尔语字母选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("uyghur")}
|
onClick={() => setChosenAlphabet("uyghur")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
@@ -88,6 +94,7 @@ export default function Alphabet() {
|
|||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|
||||||
|
{/* 世界语字母选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("esperanto")}
|
onClick={() => setChosenAlphabet("esperanto")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
|
|||||||
78
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal file
78
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { DictWordEntry, DictPhraseEntry } from "./types";
|
||||||
|
|
||||||
|
interface DictionaryEntryProps {
|
||||||
|
entry: DictWordEntry | DictPhraseEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||||
|
// 检查是否有 ipa 字段来判断是否为单词条目
|
||||||
|
const isWordEntry = "ipa" in entry && "partOfSpeech" in entry;
|
||||||
|
|
||||||
|
if (isWordEntry) {
|
||||||
|
// 单词条目
|
||||||
|
const wordEntry = entry as DictWordEntry;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 音标和词性 */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
{wordEntry.ipa && (
|
||||||
|
<span className="text-gray-600 text-lg">
|
||||||
|
[{wordEntry.ipa}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{wordEntry.partOfSpeech && (
|
||||||
|
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||||
|
{wordEntry.partOfSpeech}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 释义 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
释义
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-800">{wordEntry.definition}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 例句 */}
|
||||||
|
{wordEntry.example && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
例句
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
|
{wordEntry.example}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短语条目
|
||||||
|
const phraseEntry = entry as DictPhraseEntry;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 释义 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
释义
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-800">{phraseEntry.definition}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 例句 */}
|
||||||
|
{phraseEntry.example && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
例句
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
|
{phraseEntry.example}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/(features)/dictionary/DictionaryPage.tsx
Normal file
141
src/app/(features)/dictionary/DictionaryPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Container from "@/components/ui/Container";
|
||||||
|
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
|
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||||
|
import { DictLookUpResponse, isDictErrorResponse } from "./types";
|
||||||
|
import { SearchForm } from "./SearchForm";
|
||||||
|
import { SearchResult } from "./SearchResult";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { POPULAR_LANGUAGES } from "./constants";
|
||||||
|
|
||||||
|
export default function Dictionary() {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResult, setSearchResult] = useState<DictLookUpResponse | null>(null);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const [queryLang, setQueryLang] = useState("english");
|
||||||
|
const [definitionLang, setDefinitionLang] = useState("chinese");
|
||||||
|
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
// 加载用户的文件夹列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
getFoldersByUserId(session.user.id as string)
|
||||||
|
.then((loadedFolders) => {
|
||||||
|
setFolders(loadedFolders);
|
||||||
|
// 如果有文件夹且未选择,默认选择第一个
|
||||||
|
if (loadedFolders.length > 0 && !selectedFolderId) {
|
||||||
|
setSelectedFolderId(loadedFolders[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [session, selectedFolderId]);
|
||||||
|
|
||||||
|
// 将 code 转换为 nativeName
|
||||||
|
const getNativeName = (code: string) => {
|
||||||
|
return POPULAR_LANGUAGES.find(l => l.code === code)?.nativeName || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setHasSearched(true);
|
||||||
|
setSearchResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用查询语言和释义语言的 nativeName
|
||||||
|
const result = await lookUp({
|
||||||
|
text: searchQuery,
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
forceRelook: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否为错误响应
|
||||||
|
if (isDictErrorResponse(result)) {
|
||||||
|
toast.error(result.error);
|
||||||
|
setSearchResult(null);
|
||||||
|
} else {
|
||||||
|
setSearchResult(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("词典查询失败:", error);
|
||||||
|
toast.error(t("lookupFailed"));
|
||||||
|
setSearchResult(null);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
|
||||||
|
{/* 搜索区域 */}
|
||||||
|
<div className="flex items-center justify-center px-4 py-12">
|
||||||
|
<Container className="max-w-3xl w-full p-4">
|
||||||
|
<SearchForm
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchQueryChange={setSearchQuery}
|
||||||
|
isSearching={isSearching}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
queryLang={queryLang}
|
||||||
|
onQueryLangChange={setQueryLang}
|
||||||
|
definitionLang={definitionLang}
|
||||||
|
onDefinitionLangChange={setDefinitionLang}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果区域 */}
|
||||||
|
<div className="flex-1 px-4 pb-12">
|
||||||
|
<Container className="max-w-3xl w-full p-4">
|
||||||
|
{isSearching && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||||
|
<p className="mt-4 text-white">{t("loading")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && hasSearched && !searchResult && (
|
||||||
|
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||||
|
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
||||||
|
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && searchResult && !isDictErrorResponse(searchResult) && (
|
||||||
|
<SearchResult
|
||||||
|
searchResult={searchResult}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
queryLang={queryLang}
|
||||||
|
definitionLang={definitionLang}
|
||||||
|
folders={folders}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
onFolderSelect={setSelectedFolderId}
|
||||||
|
onResultUpdate={setSearchResult}
|
||||||
|
onSearchingChange={setIsSearching}
|
||||||
|
getNativeName={getNativeName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasSearched && (
|
||||||
|
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||||
|
<div className="text-6xl mb-4">📚</div>
|
||||||
|
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
|
||||||
|
<p className="text-gray-600">{t("welcomeHint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/(features)/dictionary/SearchForm.tsx
Normal file
129
src/app/(features)/dictionary/SearchForm.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
|
import { POPULAR_LANGUAGES } from "./constants";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface SearchFormProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (query: string) => void;
|
||||||
|
isSearching: boolean;
|
||||||
|
onSearch: (e: React.FormEvent) => void;
|
||||||
|
queryLang: string;
|
||||||
|
onQueryLangChange: (lang: string) => void;
|
||||||
|
definitionLang: string;
|
||||||
|
onDefinitionLangChange: (lang: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchForm({
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
|
isSearching,
|
||||||
|
onSearch,
|
||||||
|
queryLang,
|
||||||
|
onQueryLangChange,
|
||||||
|
definitionLang,
|
||||||
|
onDefinitionLangChange,
|
||||||
|
}: SearchFormProps) {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-700 text-lg">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索表单 */}
|
||||||
|
<form onSubmit={onSearch} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)}
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||||
|
/>
|
||||||
|
<LightButton
|
||||||
|
type="submit"
|
||||||
|
disabled={isSearching || !searchQuery.trim()}
|
||||||
|
className="px-6 py-3"
|
||||||
|
>
|
||||||
|
{isSearching ? t("searching") : t("search")}
|
||||||
|
</LightButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 语言设置 */}
|
||||||
|
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 查询语言 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-700 text-sm mb-2">
|
||||||
|
{t("queryLanguage")} ({t("queryLanguageHint")})
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{POPULAR_LANGUAGES.map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.code}
|
||||||
|
selected={queryLang === lang.code}
|
||||||
|
onClick={() => onQueryLangChange(lang.code)}
|
||||||
|
className="text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
{lang.nativeName}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={queryLang}
|
||||||
|
onChange={(e) => onQueryLangChange(e.target.value)}
|
||||||
|
placeholder={t("otherLanguagePlaceholder")}
|
||||||
|
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 释义语言 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-700 text-sm mb-2">
|
||||||
|
{t("definitionLanguage")} ({t("definitionLanguageHint")})
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{POPULAR_LANGUAGES.map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.code}
|
||||||
|
selected={definitionLang === lang.code}
|
||||||
|
onClick={() => onDefinitionLangChange(lang.code)}
|
||||||
|
className="text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
{lang.nativeName}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={definitionLang}
|
||||||
|
onChange={(e) => onDefinitionLangChange(e.target.value)}
|
||||||
|
placeholder={t("otherLanguagePlaceholder")}
|
||||||
|
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前设置显示 */}
|
||||||
|
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
|
||||||
|
{t("currentSettings", {
|
||||||
|
queryLang: POPULAR_LANGUAGES.find(l => l.code === queryLang)?.nativeName || queryLang,
|
||||||
|
definitionLang: POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.nativeName || definitionLang
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/app/(features)/dictionary/SearchResult.tsx
Normal file
155
src/app/(features)/dictionary/SearchResult.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
|
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
||||||
|
import {
|
||||||
|
DictWordResponse,
|
||||||
|
DictPhraseResponse,
|
||||||
|
isDictWordResponse,
|
||||||
|
DictWordEntry,
|
||||||
|
isDictErrorResponse,
|
||||||
|
} from "./types";
|
||||||
|
import { DictionaryEntry } from "./DictionaryEntry";
|
||||||
|
import { POPULAR_LANGUAGES } from "./constants";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface SearchResultProps {
|
||||||
|
searchResult: DictWordResponse | DictPhraseResponse;
|
||||||
|
searchQuery: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
folders: Folder[];
|
||||||
|
selectedFolderId: number | null;
|
||||||
|
onFolderSelect: (folderId: number | null) => void;
|
||||||
|
onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void;
|
||||||
|
onSearchingChange: (isSearching: boolean) => void;
|
||||||
|
getNativeName: (code: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchResult({
|
||||||
|
searchResult,
|
||||||
|
searchQuery,
|
||||||
|
queryLang,
|
||||||
|
definitionLang,
|
||||||
|
folders,
|
||||||
|
selectedFolderId,
|
||||||
|
onFolderSelect,
|
||||||
|
onResultUpdate,
|
||||||
|
onSearchingChange,
|
||||||
|
getNativeName,
|
||||||
|
}: SearchResultProps) {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
const handleRelookup = async () => {
|
||||||
|
onSearchingChange(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await lookUp({
|
||||||
|
text: searchQuery,
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
forceRelook: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDictErrorResponse(result)) {
|
||||||
|
toast.error(result.error);
|
||||||
|
} else {
|
||||||
|
onResultUpdate(result);
|
||||||
|
toast.success(t("relookupSuccess"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("词典重新查询失败:", error);
|
||||||
|
toast.error(t("lookupFailed"));
|
||||||
|
} finally {
|
||||||
|
onSearchingChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!session) {
|
||||||
|
toast.error(t("pleaseLogin"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedFolderId) {
|
||||||
|
toast.error(t("pleaseCreateFolder"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = searchResult.entries[0];
|
||||||
|
createPair({
|
||||||
|
text1: searchResult.standardForm,
|
||||||
|
text2: entry.definition,
|
||||||
|
language1: queryLang,
|
||||||
|
language2: definitionLang,
|
||||||
|
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
|
||||||
|
folderId: selectedFolderId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
|
||||||
|
toast.success(t("savedToFolder", { folderName }));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t("saveFailed"));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||||
|
{/* 标题和保存按钮 */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
{searchResult.standardForm}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{session && folders.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedFolderId || ""}
|
||||||
|
onChange={(e) => onFolderSelect(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||||
|
>
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center shrink-0"
|
||||||
|
title={t("saveToFolder")}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 条目列表 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchResult.entries.map((entry, index) => (
|
||||||
|
<div key={index} className="border-t border-gray-200 pt-4">
|
||||||
|
<DictionaryEntry entry={entry} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 重新查询按钮 */}
|
||||||
|
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleRelookup}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
{t("relookup")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/(features)/dictionary/constants.ts
Normal file
8
src/app/(features)/dictionary/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const POPULAR_LANGUAGES = [
|
||||||
|
{ code: "english", name: "英语", nativeName: "English" },
|
||||||
|
{ code: "chinese", name: "中文", nativeName: "中文" },
|
||||||
|
{ code: "japanese", name: "日语", nativeName: "日本語" },
|
||||||
|
{ code: "korean", name: "韩语", nativeName: "한국어" },
|
||||||
|
{ code: "italian", name: "意大利语", nativeName: "Italiano" },
|
||||||
|
{ code: "uyghur", name: "维吾尔语", nativeName: "ئۇيغۇرچە" },
|
||||||
|
] as const;
|
||||||
11
src/app/(features)/dictionary/index.ts
Normal file
11
src/app/(features)/dictionary/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// 类型定义
|
||||||
|
export * from "./types";
|
||||||
|
|
||||||
|
// 常量
|
||||||
|
export * from "./constants";
|
||||||
|
|
||||||
|
// 组件
|
||||||
|
export { default as DictionaryPage } from "./DictionaryPage";
|
||||||
|
export { SearchForm } from "./SearchForm";
|
||||||
|
export { SearchResult } from "./SearchResult";
|
||||||
|
export { DictionaryEntry } from "./DictionaryEntry";
|
||||||
1
src/app/(features)/dictionary/page.tsx
Normal file
1
src/app/(features)/dictionary/page.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./DictionaryPage";
|
||||||
2
src/app/(features)/dictionary/types.ts
Normal file
2
src/app/(features)/dictionary/types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// 从 shared 文件夹导出所有词典类型和类型守卫
|
||||||
|
export * from "@/lib/shared";
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Center } from "@/components/common/Center";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
@@ -15,49 +13,83 @@ interface FolderSelectorProps {
|
|||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
const t = useTranslations("memorize.folder_selector");
|
const t = useTranslations("memorize.folder_selector");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
<Container className="p-6 gap-4 flex flex-col">
|
<div className="w-full max-w-2xl">
|
||||||
{(folders.length === 0 && (
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
<h1 className="text-2xl text-gray-900 font-light">
|
{folders.length === 0 ? (
|
||||||
{t("noFolders")}
|
// 空状态 - 显示提示和跳转按钮
|
||||||
<Link className="text-blue-900 border-b" href={"/folders"}>
|
<div className="text-center">
|
||||||
folders
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||||
</Link>
|
{t("noFolders")}
|
||||||
</h1>
|
</h1>
|
||||||
)) || (
|
<Link
|
||||||
<>
|
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors"
|
||||||
<h1 className="text-2xl text-gray-900 font-light">
|
href="/folders"
|
||||||
{t("selectFolder")}
|
>
|
||||||
</h1>
|
Go to Folders
|
||||||
<div className="text-gray-900 border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
</Link>
|
||||||
{folders
|
|
||||||
.toSorted((a, b) => a.id - b.id)
|
|
||||||
.map((folder) => (
|
|
||||||
<div
|
|
||||||
key={folder.id}
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/memorize?folder_id=${folder.id}`)
|
|
||||||
}
|
|
||||||
className="flex flex-row justify-center items-center group p-2 gap-2 hover:cursor-pointer hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
<div className="flex-1 flex gap-2">
|
|
||||||
<span className="group-hover:text-blue-500">
|
|
||||||
{t("folderInfo", {
|
|
||||||
id: folder.id,
|
|
||||||
name: folder.name,
|
|
||||||
count: folder.total,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
</Container>
|
{/* 页面标题 */}
|
||||||
</Center>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("selectFolder")}
|
||||||
|
</h1>
|
||||||
|
{/* 文件夹列表 */}
|
||||||
|
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
||||||
|
{folders
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/memorize?folder_id=${folder.id}`)
|
||||||
|
}
|
||||||
|
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||||
|
>
|
||||||
|
{/* 文件夹图标 */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Fd className="text-gray-600" size={24} />
|
||||||
|
</div>
|
||||||
|
{/* 文件夹信息 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{t("folderInfo", {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
count: folder.total,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 右箭头 */}
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
||||||
import { VOICES } from "@/config/locales";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
||||||
@@ -28,7 +26,13 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
|
|
||||||
if (textPairs.length === 0) {
|
if (textPairs.length === 0) {
|
||||||
return <p>{t("noTextPairs")}</p>;
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||||
|
<p className="text-gray-700">{t("noTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rng = new SeededRandom(textPairs[0].folderId);
|
const rng = new SeededRandom(textPairs[0].folderId);
|
||||||
@@ -38,135 +42,172 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
|
|
||||||
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||||
|
|
||||||
|
const handleIndexClick = () => {
|
||||||
|
const newIndex = prompt("Input a index number.")?.trim();
|
||||||
|
if (
|
||||||
|
newIndex &&
|
||||||
|
isNonNegativeInteger(newIndex) &&
|
||||||
|
parseInt(newIndex) <= textPairs.length &&
|
||||||
|
parseInt(newIndex) > 0
|
||||||
|
) {
|
||||||
|
setIndex(parseInt(newIndex) - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (show === "answer") {
|
||||||
|
const newIndex = (index + 1) % getTextPairs().length;
|
||||||
|
setIndex(newIndex);
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setIndex(
|
||||||
|
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||||
|
);
|
||||||
|
setShow("question");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReverse = () => setReverse(!reverse);
|
||||||
|
const toggleDictation = () => setDictation(!dictation);
|
||||||
|
const toggleDisorder = () => setDisorder(!disorder);
|
||||||
|
|
||||||
|
const createText = (text: string) => {
|
||||||
|
return (
|
||||||
|
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [text1, text2] = reverse
|
||||||
|
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||||
|
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
{(getTextPairs().length > 0 && (
|
<div className="w-full max-w-2xl">
|
||||||
<>
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
<div className="text-center">
|
{/* 进度指示器 */}
|
||||||
<div
|
<div className="flex justify-center mb-4">
|
||||||
className="text-sm text-gray-500"
|
<button
|
||||||
onClick={() => {
|
onClick={handleIndexClick}
|
||||||
const newIndex = prompt("Input a index number.")?.trim();
|
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
if (
|
|
||||||
newIndex &&
|
|
||||||
isNonNegativeInteger(newIndex) &&
|
|
||||||
parseInt(newIndex) <= textPairs.length &&
|
|
||||||
parseInt(newIndex) > 0
|
|
||||||
) {
|
|
||||||
setIndex(parseInt(newIndex) - 1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1} / {getTextPairs().length}
|
||||||
{"/" + getTextPairs().length}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-[40dvh] md:px-16 px-4 ${myFont.className}`}>
|
|
||||||
{(() => {
|
{/* 文本显示区域 */}
|
||||||
const createText = (text: string) => {
|
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
||||||
|
{(() => {
|
||||||
|
if (dictation) {
|
||||||
|
if (show === "question") {
|
||||||
return (
|
return (
|
||||||
<div className="text-gray-900 text-xl border-y border-y-gray-200 p-4 md:text-3xl h-[20dvh] overflow-y-auto">
|
<div className="h-full flex items-center justify-center">
|
||||||
{text}
|
<div className="text-gray-400 text-4xl">?</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const [text1, text2] = reverse
|
|
||||||
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
|
||||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
|
||||||
|
|
||||||
if (dictation) {
|
|
||||||
// dictation
|
|
||||||
if (show === "question") {
|
|
||||||
return createText("");
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{createText(text1)}
|
|
||||||
{createText(text2)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// non-dictation
|
return (
|
||||||
if (show === "question") {
|
<div className="space-y-2">
|
||||||
return createText(text1);
|
{createText(text1)}
|
||||||
} else {
|
<div className="border-t border-gray-200"></div>
|
||||||
return (
|
{createText(text2)}
|
||||||
<>
|
</div>
|
||||||
{createText(text1)}
|
);
|
||||||
{createText(text2)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})()}
|
} else {
|
||||||
</div>
|
if (show === "question") {
|
||||||
|
return createText(text1);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{createText(text1)}
|
||||||
|
<div className="border-t border-gray-200"></div>
|
||||||
|
{createText(text2)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||||
<LightButton
|
<button
|
||||||
className="w-20"
|
onClick={handleNext}
|
||||||
onClick={async () => {
|
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
||||||
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) => {
|
|
||||||
load(url);
|
|
||||||
play();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShow(show === "question" ? "answer" : "question");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{show === "question" ? t("answer") : t("next")}
|
{show === "question" ? t("answer") : t("next")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={handlePrevious}
|
||||||
setIndex(
|
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
||||||
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
|
||||||
);
|
|
||||||
setShow("question");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("previous")}
|
{t("previous")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={toggleReverse}
|
||||||
setReverse(!reverse);
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
}}
|
reverse
|
||||||
selected={reverse}
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("reverse")}
|
{t("reverse")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={toggleDictation}
|
||||||
setDictation(!dictation);
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
}}
|
dictation
|
||||||
selected={dictation}
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("dictation")}
|
{t("dictation")}
|
||||||
</LightButton>
|
</button>
|
||||||
<LightButton
|
<button
|
||||||
onClick={() => {
|
onClick={toggleDisorder}
|
||||||
setDisorder(!disorder);
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
||||||
}}
|
disorder
|
||||||
selected={disorder}
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("disorder")}
|
{t("disorder")}
|
||||||
</LightButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)) || <p>{t("noTextPairs")}</p>}
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
getFoldersWithTotalPairsByUserId,
|
getFoldersWithTotalPairsByUserId,
|
||||||
getUserIdByFolderId,
|
|
||||||
} from "@/lib/server/services/folderService";
|
} from "@/lib/server/services/folderService";
|
||||||
import { isNonNegativeInteger } from "@/lib/utils";
|
import { isNonNegativeInteger } from "@/lib/utils";
|
||||||
import FolderSelector from "./FolderSelector";
|
import FolderSelector from "./FolderSelector";
|
||||||
@@ -16,18 +15,8 @@ export default async function MemorizePage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ folder_id?: string; }>;
|
searchParams: Promise<{ folder_id?: string; }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
const tParam = (await searchParams).folder_id;
|
const tParam = (await searchParams).folder_id;
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect(
|
|
||||||
`/auth?redirect=/memorize${(await searchParams).folder_id
|
|
||||||
? `?folder_id=${tParam}`
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getTranslations("memorize.page");
|
const t = await getTranslations("memorize.page");
|
||||||
|
|
||||||
const folder_id = tParam
|
const folder_id = tParam
|
||||||
@@ -37,6 +26,8 @@ export default async function MemorizePage({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!folder_id) {
|
if (!folder_id) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if(!session) redirect("/auth?redirect=/memorize")
|
||||||
return (
|
return (
|
||||||
<FolderSelector
|
<FolderSelector
|
||||||
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
||||||
@@ -44,10 +35,5 @@ export default async function MemorizePage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = await getUserIdByFolderId(folder_id);
|
|
||||||
if (owner !== session.user.id) {
|
|
||||||
return <p>{t("unauthorized")}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||||
import SubtitleDisplay from "./SubtitleDisplay";
|
import SubtitleDisplay from "./SubtitleDisplay";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { PlayButtonProps } from "../../types/player";
|
import { PlayButtonProps } from "../../types/player";
|
||||||
|
|
||||||
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { SpeedControlProps } from "../../types/player";
|
import { SpeedControlProps } from "../../types/player";
|
||||||
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { ControlBarProps } from "../../types/controls";
|
import { ControlBarProps } from "../../types/controls";
|
||||||
import PlayButton from "../atoms/PlayButton";
|
import PlayButton from "../atoms/PlayButton";
|
||||||
import SpeedControl from "../atoms/SpeedControl";
|
import SpeedControl from "../atoms/SpeedControl";
|
||||||
@@ -31,32 +31,32 @@ export default function ControlBar({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={disabled ? undefined : onPrevious}
|
onClick={disabled ? undefined : onPrevious}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex items-center px-3 py-2"
|
className="flex items-center px-3 py-2"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
{t("previous")}
|
{t("previous")}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
|
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={disabled ? undefined : onNext}
|
onClick={disabled ? undefined : onNext}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex items-center px-3 py-2"
|
className="flex items-center px-3 py-2"
|
||||||
>
|
>
|
||||||
{t("next")}
|
{t("next")}
|
||||||
<ChevronRight className="w-4 h-4 ml-2" />
|
<ChevronRight className="w-4 h-4 ml-2" />
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
|
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={disabled ? undefined : onRestart}
|
onClick={disabled ? undefined : onRestart}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex items-center px-3 py-2"
|
className="flex items-center px-3 py-2"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
{t("restart")}
|
{t("restart")}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
|
|
||||||
<SpeedControl
|
<SpeedControl
|
||||||
playbackRate={playbackRate}
|
playbackRate={playbackRate}
|
||||||
@@ -64,14 +64,14 @@ export default function ControlBar({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={disabled ? undefined : onAutoPauseToggle}
|
onClick={disabled ? undefined : onAutoPauseToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex items-center px-3 py-2"
|
className="flex items-center px-3 py-2"
|
||||||
>
|
>
|
||||||
<Pause className="w-4 h-4 mr-2" />
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import React from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Video, FileText } from "lucide-react";
|
import { Video, FileText } from "lucide-react";
|
||||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { FileUploadProps } from "../../types/controls";
|
import { FileUploadProps } from "../../types/controls";
|
||||||
import { useFileUpload } from "../../hooks/useFileUpload";
|
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||||
|
|
||||||
@@ -26,21 +26,21 @@ export default function UploadZone({ onVideoUpload, onSubtitleUpload, className
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex gap-3 ${className || ''}`}>
|
<div className={`flex gap-3 ${className || ''}`}>
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={handleVideoUpload}
|
onClick={handleVideoUpload}
|
||||||
className="flex-1 py-2 px-3 text-sm"
|
className="flex-1 py-2 px-3 text-sm"
|
||||||
>
|
>
|
||||||
<Video className="w-4 h-4 mr-2" />
|
<Video className="w-4 h-4 mr-2" />
|
||||||
{t("uploadVideo")}
|
{t("uploadVideo")}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
|
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={handleSubtitleUpload}
|
onClick={handleSubtitleUpload}
|
||||||
className="flex-1 py-2 px-3 text-sm"
|
className="flex-1 py-2 px-3 text-sm"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
{t("uploadSubtitle")}
|
{t("uploadSubtitle")}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import SubtitleArea from "./components/compounds/SubtitleArea";
|
|||||||
import ControlBar from "./components/compounds/ControlBar";
|
import ControlBar from "./components/compounds/ControlBar";
|
||||||
import UploadZone from "./components/compounds/UploadZone";
|
import UploadZone from "./components/compounds/UploadZone";
|
||||||
import SeekBar from "./components/atoms/SeekBar";
|
import SeekBar from "./components/atoms/SeekBar";
|
||||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
|
|
||||||
export default function SrtPlayerPage() {
|
export default function SrtPlayerPage() {
|
||||||
const t = useTranslations("home");
|
const t = useTranslations("home");
|
||||||
@@ -182,13 +182,13 @@ export default function SrtPlayerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={state.video.url ? undefined : handleVideoUpload}
|
onClick={state.video.url ? undefined : handleVideoUpload}
|
||||||
disabled={!!state.video.url}
|
disabled={!!state.video.url}
|
||||||
className="px-2 py-1 text-xs"
|
className="px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,13 +206,13 @@ export default function SrtPlayerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DarkButton
|
<LightButton
|
||||||
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
||||||
disabled={!!state.subtitle.url}
|
disabled={!!state.subtitle.url}
|
||||||
className="px-2 py-1 text-xs"
|
className="px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SubtitleEntry } from "../types/subtitle";
|
import { SubtitleEntry } from "../types/subtitle";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export function parseSrt(data: string): SubtitleEntry[] {
|
export function parseSrt(data: string): SubtitleEntry[] {
|
||||||
const lines = data.split(/\r?\n/);
|
const lines = data.split(/\r?\n/);
|
||||||
@@ -93,7 +94,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
|||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
return parseSrt(data);
|
return parseSrt(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load subtitle:', error);
|
logger.error('加载字幕失败', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
TextSpeakerItemSchema,
|
TextSpeakerItemSchema,
|
||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import IconClick from "@/components/ui/buttons/IconClick";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import IconClick from "@/components/ui/buttons/IconClick";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import {
|
import {
|
||||||
@@ -12,11 +12,12 @@ import { ChangeEvent, useEffect, useRef, useState } from "react";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import SaveList from "./SaveList";
|
import SaveList from "./SaveList";
|
||||||
|
|
||||||
import { VOICES } from "@/config/locales";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
|
||||||
import { genIPA, genLocale } from "@/lib/server/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() {
|
export default function TextSpeakerPage() {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
@@ -29,7 +30,7 @@ export default function TextSpeakerPage() {
|
|||||||
const [pause, setPause] = useState(true);
|
const [pause, setPause] = useState(true);
|
||||||
const [autopause, setAutopause] = useState(true);
|
const [autopause, setAutopause] = useState(true);
|
||||||
const textRef = useRef("");
|
const textRef = useRef("");
|
||||||
const [locale, setLocale] = useState<string | null>(null);
|
const [language, setLanguage] = useState<string | null>(null);
|
||||||
const [ipa, setIPA] = useState<string>("");
|
const [ipa, setIPA] = useState<string>("");
|
||||||
const objurlRef = useRef<string | null>(null);
|
const objurlRef = useRef<string | null>(null);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -74,7 +75,7 @@ export default function TextSpeakerPage() {
|
|||||||
setIPA(data.ipa);
|
setIPA(data.ipa);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
logger.error("生成 IPA 失败", e);
|
||||||
setIPA("");
|
setIPA("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -93,40 +94,35 @@ export default function TextSpeakerPage() {
|
|||||||
} else {
|
} else {
|
||||||
// 第一次播放
|
// 第一次播放
|
||||||
try {
|
try {
|
||||||
let theLocale = locale;
|
let theLanguage = language;
|
||||||
if (!theLocale) {
|
if (!theLanguage) {
|
||||||
console.log("downloading text info");
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
setLanguage(tmp_language);
|
||||||
setLocale(tmp_locale);
|
theLanguage = tmp_language;
|
||||||
theLocale = tmp_locale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
|
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||||
if (!voice) throw "Voice not found.";
|
|
||||||
|
|
||||||
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,
|
textRef.current,
|
||||||
voice.short_name,
|
theLanguage as TTS_SUPPORTED_LANGUAGES
|
||||||
(() => {
|
|
||||||
if (speed === 1) return {};
|
|
||||||
else if (speed < 1)
|
|
||||||
return {
|
|
||||||
rate: `-${100 - speed * 100}%`,
|
|
||||||
};
|
|
||||||
else
|
|
||||||
return {
|
|
||||||
rate: `+${speed * 100 - 100}%`,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
);
|
);
|
||||||
load(objurlRef.current);
|
load(objurlRef.current);
|
||||||
play();
|
play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
logger.error("播放音频失败", e);
|
||||||
|
|
||||||
setPause(true);
|
setPause(true);
|
||||||
setLocale(null);
|
setLanguage(null);
|
||||||
|
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +138,7 @@ export default function TextSpeakerPage() {
|
|||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
textRef.current = e.target.value.trim();
|
textRef.current = e.target.value.trim();
|
||||||
setLocale(null);
|
setLanguage(null);
|
||||||
setIPA("");
|
setIPA("");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -163,7 +159,7 @@ export default function TextSpeakerPage() {
|
|||||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||||
textRef.current = item.text;
|
textRef.current = item.text;
|
||||||
setLocale(item.locale);
|
setLanguage(item.language);
|
||||||
setIPA(item.ipa || "");
|
setIPA(item.ipa || "");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -178,12 +174,11 @@ export default function TextSpeakerPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let theLocale = locale;
|
let theLanguage = language;
|
||||||
if (!theLocale) {
|
if (!theLanguage) {
|
||||||
console.log("downloading text info");
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
setLanguage(tmp_language);
|
||||||
setLocale(tmp_locale);
|
theLanguage = tmp_language;
|
||||||
theLocale = tmp_locale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let theIPA = ipa;
|
let theIPA = ipa;
|
||||||
@@ -206,43 +201,49 @@ export default function TextSpeakerPage() {
|
|||||||
} else if (theIPA.length === 0) {
|
} else if (theIPA.length === 0) {
|
||||||
save.push({
|
save.push({
|
||||||
text: textRef.current,
|
text: textRef.current,
|
||||||
locale: theLocale,
|
language: theLanguage as string,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
save.push({
|
save.push({
|
||||||
text: textRef.current,
|
text: textRef.current,
|
||||||
locale: theLocale,
|
language: theLanguage as string,
|
||||||
ipa: theIPA,
|
ipa: theIPA,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIntoLocalStorage(save);
|
setIntoLocalStorage(save);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
logger.error("保存到本地存储失败", e);
|
||||||
setLocale(null);
|
setLanguage(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PageLayout className="items-start py-4">
|
||||||
|
{/* 文本输入区域 */}
|
||||||
<div
|
<div
|
||||||
className="my-4 p-4 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
|
className="border border-gray-200 rounded-2xl"
|
||||||
style={{ fontFamily: "Times New Roman, serif" }}
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
>
|
>
|
||||||
|
{/* 文本输入框 */}
|
||||||
<textarea
|
<textarea
|
||||||
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b"
|
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
{/* IPA 显示区域 */}
|
||||||
{(ipa.length !== 0 && (
|
{(ipa.length !== 0 && (
|
||||||
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b">
|
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
||||||
{ipa}
|
{ipa}
|
||||||
</div>
|
</div>
|
||||||
)) || <div className="h-18"></div>}
|
)) || <div className="h-18"></div>}
|
||||||
<div className="mt-8 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
|
||||||
|
{/* 控制按钮区域 */}
|
||||||
|
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
{/* 速度调节面板 */}
|
||||||
{showSpeedAdjust && (
|
{showSpeedAdjust && (
|
||||||
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center">
|
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size={45}
|
||||||
onClick={letMeSetSpeed(0.5)}
|
onClick={letMeSetSpeed(0.5)}
|
||||||
@@ -280,6 +281,7 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 播放/暂停按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size={45}
|
||||||
onClick={speak}
|
onClick={speak}
|
||||||
@@ -287,6 +289,7 @@ export default function TextSpeakerPage() {
|
|||||||
alt="playorpause"
|
alt="playorpause"
|
||||||
className={`${processing ? "bg-gray-200" : ""}`}
|
className={`${processing ? "bg-gray-200" : ""}`}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
|
{/* 自动暂停按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size={45}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -299,6 +302,7 @@ export default function TextSpeakerPage() {
|
|||||||
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
|
src={autopause ? IMAGES.autoplay : IMAGES.autopause}
|
||||||
alt="autoplayorpause"
|
alt="autoplayorpause"
|
||||||
></IconClick>
|
></IconClick>
|
||||||
|
{/* 速度调节按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size={45}
|
||||||
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||||
@@ -306,6 +310,7 @@ export default function TextSpeakerPage() {
|
|||||||
alt="speed"
|
alt="speed"
|
||||||
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
|
className={`${showSpeedAdjust ? "bg-gray-200" : ""}`}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
|
{/* 保存按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size={45}
|
||||||
onClick={save}
|
onClick={save}
|
||||||
@@ -313,6 +318,7 @@ export default function TextSpeakerPage() {
|
|||||||
alt="save"
|
alt="save"
|
||||||
className={`${saving ? "bg-gray-200" : ""}`}
|
className={`${saving ? "bg-gray-200" : ""}`}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
|
{/* 功能开关按钮 */}
|
||||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={ipaEnabled}
|
selected={ipaEnabled}
|
||||||
@@ -331,7 +337,12 @@ export default function TextSpeakerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
{/* 保存列表 */}
|
||||||
</>
|
{showSaveList && (
|
||||||
|
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden">
|
||||||
|
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import Container from "@/components/ui/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||||
import { Dispatch, useEffect, useState } from "react";
|
import { Dispatch, useEffect, useState } from "react";
|
||||||
@@ -57,13 +57,9 @@ const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
|||||||
createPair({
|
createPair({
|
||||||
text1: item.text1,
|
text1: item.text1,
|
||||||
text2: item.text2,
|
text2: item.text2,
|
||||||
locale1: item.locale1,
|
language1: item.language1,
|
||||||
locale2: item.locale2,
|
language2: item.language2,
|
||||||
folder: {
|
folderId: folder.id,
|
||||||
connect: {
|
|
||||||
id: folder.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(t("success"));
|
toast.success(t("success"));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Container from "@/components/ui/Container";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
import { Folder } from "../../../../generated/prisma/browser";
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { Folder as Fd } from "lucide-react";
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
interface FolderSelectorProps {
|
||||||
|
|||||||
@@ -1,42 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import IconClick from "@/components/ui/buttons/IconClick";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { VOICES } from "@/config/locales";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||||
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
import { logger } from "@/lib/logger";
|
||||||
import { Plus, Trash } from "lucide-react";
|
import { Plus, Trash } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import AddToFolder from "./AddToFolder";
|
import AddToFolder from "./AddToFolder";
|
||||||
import {
|
import { translateText } from "@/lib/server/bigmodel/translatorActions";
|
||||||
genIPA,
|
import type { TranslateTextOutput } from "@/lib/server/services/types";
|
||||||
genLocale,
|
|
||||||
genTranslation,
|
|
||||||
} from "@/lib/server/translatorActions";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import FolderSelector from "./FolderSelector";
|
import FolderSelector from "./FolderSelector";
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
import { shallowEqual } from "@/lib/utils";
|
import { shallowEqual } from "@/lib/utils";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
const [lang, setLang] = useState<string>("chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
const [tresult, setTresult] = useState<string>("");
|
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | null>(null);
|
||||||
const [genIpa, setGenIpa] = useState(true);
|
const [needIpa, setNeedIpa] = useState(true);
|
||||||
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [lastTranslation, setLastTranslation] = useState<{
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
} | null>(null);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
const [history, setHistory] = useState<
|
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
|
||||||
z.infer<typeof TranslationHistorySchema>[]
|
|
||||||
>([]);
|
|
||||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||||
typeof TranslationHistorySchema
|
typeof TranslationHistorySchema
|
||||||
@@ -49,133 +47,95 @@ export default function TranslatorPage() {
|
|||||||
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHistory(tlso.get());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tts = async (text: string, locale: string) => {
|
const tts = async (text: string, locale: string) => {
|
||||||
if (lastTTS.current.text !== text) {
|
if (lastTTS.current.text !== text) {
|
||||||
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
|
|
||||||
if (!shortName) {
|
|
||||||
toast.error("Voice not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
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);
|
await load(url);
|
||||||
lastTTS.current.text = text;
|
lastTTS.current.text = text;
|
||||||
lastTTS.current.url = url;
|
lastTTS.current.url = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to generate audio");
|
toast.error("Failed to generate audio");
|
||||||
console.error(error);
|
logger.error("生成音频失败", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await play();
|
await play();
|
||||||
};
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
if (!taref.current) return;
|
if (!taref.current || processing) return;
|
||||||
if (processing) return;
|
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
const text1 = taref.current.value;
|
const sourceText = taref.current.value;
|
||||||
|
|
||||||
const llmres: {
|
// 判断是否需要强制重新翻译
|
||||||
text1: string | null;
|
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
||||||
text2: string | null;
|
const forceRetranslate =
|
||||||
locale1: string | null;
|
lastTranslation?.sourceText === sourceText &&
|
||||||
locale2: string | null;
|
lastTranslation?.targetLanguage === targetLanguage;
|
||||||
ipa1: string | null;
|
|
||||||
ipa2: string | null;
|
|
||||||
} = {
|
|
||||||
text1: text1,
|
|
||||||
text2: null,
|
|
||||||
locale1: null,
|
|
||||||
locale2: null,
|
|
||||||
ipa1: null,
|
|
||||||
ipa2: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let historyUpdated = false;
|
try {
|
||||||
|
const result = await translateText({
|
||||||
// 检查更新历史记录
|
sourceText,
|
||||||
const checkUpdateLocalStorage = () => {
|
targetLanguage,
|
||||||
if (historyUpdated) return;
|
forceRetranslate,
|
||||||
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
|
needIpa,
|
||||||
setHistory(
|
userId: session?.user?.id,
|
||||||
tlsoPush({
|
|
||||||
text1: llmres.text1,
|
|
||||||
text2: llmres.text2,
|
|
||||||
locale1: llmres.locale1,
|
|
||||||
locale2: llmres.locale2,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (autoSave && autoSaveFolderId) {
|
|
||||||
createPair({
|
|
||||||
text1: llmres.text1,
|
|
||||||
text2: llmres.text2,
|
|
||||||
locale1: llmres.locale1,
|
|
||||||
locale2: llmres.locale2,
|
|
||||||
folder: {
|
|
||||||
connect: {
|
|
||||||
id: autoSaveFolderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
llmres.text1 +
|
|
||||||
"保存到文件夹" +
|
|
||||||
autoSaveFolderId +
|
|
||||||
"失败:" +
|
|
||||||
error.message,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
historyUpdated = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// 更新局部翻译状态
|
|
||||||
const updateState = (stateName: keyof typeof llmres, value: string) => {
|
|
||||||
llmres[stateName] = value;
|
|
||||||
checkUpdateLocalStorage();
|
|
||||||
};
|
|
||||||
|
|
||||||
genTranslation(text1, lang)
|
|
||||||
.then(async (text2) => {
|
|
||||||
updateState("text2", text2);
|
|
||||||
setTresult(text2);
|
|
||||||
// 生成两个locale
|
|
||||||
genLocale(text1).then((locale) => {
|
|
||||||
updateState("locale1", locale);
|
|
||||||
});
|
|
||||||
genLocale(text2).then((locale) => {
|
|
||||||
updateState("locale2", locale);
|
|
||||||
});
|
|
||||||
// 生成俩IPA
|
|
||||||
if (genIpa) {
|
|
||||||
genIPA(text1).then((ipa1) => {
|
|
||||||
setIpaTexts((prev) => [ipa1, prev[1]]);
|
|
||||||
updateState("ipa1", ipa1);
|
|
||||||
});
|
|
||||||
genIPA(text2).then((ipa2) => {
|
|
||||||
setIpaTexts((prev) => [prev[0], ipa2]);
|
|
||||||
updateState("ipa2", ipa2);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Translation failed");
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setProcessing(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTranslationResult(result);
|
||||||
|
setLastTranslation({
|
||||||
|
sourceText,
|
||||||
|
targetLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新本地历史记录
|
||||||
|
const historyItem = {
|
||||||
|
text1: result.sourceText,
|
||||||
|
text2: result.translatedText,
|
||||||
|
language1: result.sourceLanguage,
|
||||||
|
language2: result.targetLanguage,
|
||||||
|
};
|
||||||
|
setHistory(tlsoPush(historyItem));
|
||||||
|
|
||||||
|
// 自动保存到文件夹
|
||||||
|
if (autoSave && autoSaveFolderId) {
|
||||||
|
createPair({
|
||||||
|
text1: result.sourceText,
|
||||||
|
text2: result.translatedText,
|
||||||
|
language1: result.sourceLanguage,
|
||||||
|
language2: result.targetLanguage,
|
||||||
|
ipa1: result.sourceIpa || undefined,
|
||||||
|
ipa2: result.targetIpa || undefined,
|
||||||
|
folderId: autoSaveFolderId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(`保存失败: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("翻译失败,请重试");
|
||||||
|
console.error("翻译错误:", error);
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -194,7 +154,7 @@ export default function TranslatorPage() {
|
|||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{ipaTexts[0]}
|
{translationResult?.sourceIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
@@ -212,7 +172,7 @@ export default function TranslatorPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const t = taref.current?.value;
|
const t = taref.current?.value;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
|
tts(t, translationResult?.sourceLanguage || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,8 +180,8 @@ export default function TranslatorPage() {
|
|||||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||||
<span>{t("detectLanguage")}</span>
|
<span>{t("detectLanguage")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={genIpa}
|
selected={needIpa}
|
||||||
onClick={() => setGenIpa((prev) => !prev)}
|
onClick={() => setNeedIpa((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{t("generateIPA")}
|
{t("generateIPA")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
@@ -232,25 +192,26 @@ export default function TranslatorPage() {
|
|||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard2 Component */}
|
{/* ICard2 Component */}
|
||||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||||
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
|
||||||
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||||
{ipaTexts[1]}
|
{translationResult?.targetIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1/6 w-full flex justify-end items-center">
|
<div className="h-1/6 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.copy_all}
|
src={IMAGES.copy_all}
|
||||||
alt="copy"
|
alt="copy"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(tresult);
|
await navigator.clipboard.writeText(translationResult?.translatedText || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.play_arrow}
|
src={IMAGES.play_arrow}
|
||||||
alt="play"
|
alt="play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!translationResult) return;
|
||||||
tts(
|
tts(
|
||||||
tresult,
|
translationResult.translatedText,
|
||||||
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
|
translationResult.targetLanguage,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
@@ -259,29 +220,29 @@ export default function TranslatorPage() {
|
|||||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||||
<span>{t("translateInto")}</span>
|
<span>{t("translateInto")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "chinese"}
|
selected={targetLanguage === "Chinese"}
|
||||||
onClick={() => setLang("chinese")}
|
onClick={() => setTargetLanguage("Chinese")}
|
||||||
>
|
>
|
||||||
{t("chinese")}
|
{t("chinese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "english"}
|
selected={targetLanguage === "English"}
|
||||||
onClick={() => setLang("english")}
|
onClick={() => setTargetLanguage("English")}
|
||||||
>
|
>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "italian"}
|
selected={targetLanguage === "Italian"}
|
||||||
onClick={() => setLang("italian")}
|
onClick={() => setTargetLanguage("Italian")}
|
||||||
>
|
>
|
||||||
{t("italian")}
|
{t("italian")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={!["chinese", "english", "italian"].includes(lang)}
|
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newLang = prompt(t("enterLanguage"));
|
const newLang = prompt(t("enterLanguage"));
|
||||||
if (newLang) {
|
if (newLang) {
|
||||||
setLang(newLang);
|
setTargetLanguage(newLang);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -339,6 +300,10 @@ export default function TranslatorPage() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!session?.user) {
|
||||||
|
toast.info("请先登录后再保存到文件夹");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowAddToFolder(true);
|
setShowAddToFolder(true);
|
||||||
setAddToFolderItem(item);
|
setAddToFolderItem(item);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
|
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
|
||||||
import Container from "@/components/ui/Container";
|
import Container from "@/components/ui/Container";
|
||||||
import Input from "@/components/ui/Input";
|
import Input from "@/components/ui/Input";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
interface AuthFormProps {
|
interface AuthFormProps {
|
||||||
@@ -115,17 +114,21 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
||||||
<Container className="p-8 max-w-md w-full">
|
<Container className="p-8 max-w-md w-full">
|
||||||
|
{/* 页面标题 */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 服务器端错误提示 */}
|
||||||
{currentError?.message && (
|
{currentError?.message && (
|
||||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||||
{currentError.message}
|
{currentError.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 登录/注册表单 */}
|
||||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
|
{/* 用户名输入(仅注册模式显示) */}
|
||||||
{mode === 'signup' && (
|
{mode === 'signup' && (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
@@ -134,15 +137,18 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
placeholder={t("name")}
|
placeholder={t("name")}
|
||||||
className="w-full px-3 py-2"
|
className="w-full px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
{/* 客户端验证错误 */}
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
||||||
)}
|
)}
|
||||||
|
{/* 服务器端验证错误 */}
|
||||||
{currentError?.errors?.username && (
|
{currentError?.errors?.username && (
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 邮箱输入 */}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -158,6 +164,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 密码输入 */}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -173,6 +180,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 确认密码输入(仅注册模式显示) */}
|
||||||
{mode === 'signup' && (
|
{mode === 'signup' && (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
@@ -187,7 +195,8 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DarkButton
|
{/* 提交按钮 */}
|
||||||
|
<LightButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -195,10 +204,12 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
? t("loading")
|
? t("loading")
|
||||||
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
||||||
}
|
}
|
||||||
</DarkButton>
|
</LightButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* 第三方登录区域 */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
{/* 分隔线 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
@@ -208,6 +219,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub 登录按钮 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={handleGitHubSignIn}
|
onClick={handleGitHubSignIn}
|
||||||
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
||||||
@@ -219,6 +231,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
</LightButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 模式切换链接 */}
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import AuthForm from "./AuthForm";
|
|||||||
|
|
||||||
export default async function AuthPage(
|
export default async function AuthPage(
|
||||||
props: {
|
props: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
|
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Center } from "@/components/common/Center";
|
import { logger } from "@/lib/logger";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Folder } from "../../../generated/prisma/browser";
|
import { Folder } from "../../../generated/prisma/browser";
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
} from "@/lib/server/services/folderService";
|
} from "@/lib/server/services/folderService";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
|
import CardList from "@/components/ui/CardList";
|
||||||
|
|
||||||
interface FolderProps {
|
interface FolderProps {
|
||||||
folder: Folder & { total: number };
|
folder: Folder & { total: number };
|
||||||
@@ -27,8 +30,8 @@ interface FolderProps {
|
|||||||
|
|
||||||
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("folders");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
@@ -37,24 +40,23 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<div className="w-10 h-10 rounded-lg bg-linear-to-br from-blue-50 to-blue-100 flex items-center justify-center group-hover:from-blue-100 group-hover:to-blue-200 transition-colors">
|
<div className="shrink-0">
|
||||||
<Fd></Fd>
|
<Fd className="text-gray-600" size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-medium text-gray-900">
|
<h3 className="font-medium text-gray-900">{folder.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
{t("folderInfo", {
|
{t("folderInfo", {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
totalPairs: folder.total,
|
totalPairs: folder.total,
|
||||||
})}
|
})}
|
||||||
</h3>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-400">#{folder.id}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 opacity-50 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -63,7 +65,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
renameFolderById(folder.id, newName).then(refresh);
|
renameFolderById(folder.id, newName).then(refresh);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<FolderPen size={16} />
|
<FolderPen size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -100,7 +102,7 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
logger.error("加载文件夹失败", error);
|
||||||
toast.error("加载出错,请重试。");
|
toast.error("加载出错,请重试。");
|
||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
@@ -110,48 +112,50 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
||||||
setFolders(updatedFolders);
|
setFolders(updatedFolders);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error("更新文件夹失败", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<PageLayout>
|
||||||
<div className="w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl p-6">
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-light text-gray-900">{t("title")}</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{t("subtitle")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{/* 新建文件夹按钮 */}
|
||||||
onClick={async () => {
|
<button
|
||||||
const folderName = prompt(t("enterFolderName"));
|
onClick={async () => {
|
||||||
if (!folderName) return;
|
const folderName = prompt(t("enterFolderName"));
|
||||||
setLoading(true);
|
if (!folderName) return;
|
||||||
try {
|
setLoading(true);
|
||||||
await createFolder({
|
try {
|
||||||
name: folderName,
|
await createFolder({
|
||||||
user: { connect: { id: userId } },
|
name: folderName,
|
||||||
});
|
userId: userId,
|
||||||
await updateFolders();
|
});
|
||||||
} finally {
|
await updateFolders();
|
||||||
setLoading(false);
|
} finally {
|
||||||
}
|
setLoading(false);
|
||||||
}}
|
}
|
||||||
disabled={loading}
|
}}
|
||||||
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
disabled={loading}
|
||||||
>
|
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
||||||
<FolderPlus size={18} />
|
>
|
||||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
<FolderPlus size={18} />
|
||||||
</button>
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="mt-4 max-h-96 overflow-y-auto">
|
{/* 文件夹列表 */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<CardList>
|
||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
|
// 空状态
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="text-center py-12 text-gray-400">
|
||||||
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<FolderPlus size={24} className="text-gray-400" />
|
<FolderPlus size={24} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
// 文件夹卡片列表
|
||||||
<div className="rounded-xl border border-gray-200 overflow-hidden">
|
<div className="rounded-xl border border-gray-200 overflow-hidden">
|
||||||
{folders
|
{folders
|
||||||
.toSorted((a, b) => a.id - b.id)
|
.toSorted((a, b) => a.id - b.id)
|
||||||
@@ -164,8 +168,8 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardList>
|
||||||
</div>
|
</div>
|
||||||
</Center>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import Input from "@/components/ui/Input";
|
import Input from "@/components/ui/Input";
|
||||||
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface AddTextPairModalProps {
|
interface AddTextPairModalProps {
|
||||||
@@ -10,8 +11,8 @@ interface AddTextPairModalProps {
|
|||||||
onAdd: (
|
onAdd: (
|
||||||
text1: string,
|
text1: string,
|
||||||
text2: string,
|
text2: string,
|
||||||
locale1: string,
|
language1: string,
|
||||||
locale2: string,
|
language2: string,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,38 +24,39 @@ export default function AddTextPairModal({
|
|||||||
const t = useTranslations("folder_id");
|
const t = useTranslations("folder_id");
|
||||||
const input1Ref = useRef<HTMLInputElement>(null);
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
const input2Ref = useRef<HTMLInputElement>(null);
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
const input3Ref = useRef<HTMLInputElement>(null);
|
const [language1, setLanguage1] = useState("english");
|
||||||
const input4Ref = useRef<HTMLInputElement>(null);
|
const [language2, setLanguage2] = useState("chinese");
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (
|
if (
|
||||||
!input1Ref.current?.value ||
|
!input1Ref.current?.value ||
|
||||||
!input2Ref.current?.value ||
|
!input2Ref.current?.value ||
|
||||||
!input3Ref.current?.value ||
|
!language1 ||
|
||||||
!input4Ref.current?.value
|
!language2
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const text1 = input1Ref.current.value;
|
const text1 = input1Ref.current.value;
|
||||||
const text2 = input2Ref.current.value;
|
const text2 = input2Ref.current.value;
|
||||||
const locale1 = input3Ref.current.value;
|
|
||||||
const locale2 = input4Ref.current.value;
|
|
||||||
if (
|
if (
|
||||||
typeof text1 === "string" &&
|
typeof text1 === "string" &&
|
||||||
typeof text2 === "string" &&
|
typeof text2 === "string" &&
|
||||||
typeof locale1 === "string" &&
|
typeof language1 === "string" &&
|
||||||
typeof locale2 === "string" &&
|
typeof language2 === "string" &&
|
||||||
text1.trim() !== "" &&
|
text1.trim() !== "" &&
|
||||||
text2.trim() !== "" &&
|
text2.trim() !== "" &&
|
||||||
locale1.trim() !== "" &&
|
language1.trim() !== "" &&
|
||||||
locale2.trim() !== ""
|
language2.trim() !== ""
|
||||||
) {
|
) {
|
||||||
onAdd(text1, text2, locale1, locale2);
|
onAdd(text1, text2, language1, language2);
|
||||||
input1Ref.current.value = "";
|
input1Ref.current.value = "";
|
||||||
input2Ref.current.value = "";
|
input2Ref.current.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
@@ -82,20 +84,12 @@ export default function AddTextPairModal({
|
|||||||
<Input ref={input2Ref} className="w-full"></Input>
|
<Input ref={input2Ref} className="w-full"></Input>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t("locale1")}
|
{t("language1")}
|
||||||
<Input
|
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||||
ref={input3Ref}
|
|
||||||
className="w-full"
|
|
||||||
placeholder="en-US"
|
|
||||||
></Input>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t("locale2")}
|
{t("language2")}
|
||||||
<Input
|
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||||
ref={input4Ref}
|
|
||||||
className="w-full"
|
|
||||||
placeholder="zh-CN"
|
|
||||||
></Input>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { Center } from "@/components/common/Center";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import {
|
import {
|
||||||
createPair,
|
createPair,
|
||||||
deletePairById,
|
deletePairById,
|
||||||
@@ -12,15 +10,19 @@ import {
|
|||||||
} from "@/lib/server/services/pairService";
|
} from "@/lib/server/services/pairService";
|
||||||
import AddTextPairModal from "./AddTextPairModal";
|
import AddTextPairModal from "./AddTextPairModal";
|
||||||
import TextPairCard from "./TextPairCard";
|
import TextPairCard from "./TextPairCard";
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
import { GreenButton } from "@/components/ui/buttons";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { IconButton } from "@/components/ui/buttons";
|
||||||
|
import CardList from "@/components/ui/CardList";
|
||||||
|
|
||||||
export interface TextPair {
|
export interface TextPair {
|
||||||
id: number;
|
id: number;
|
||||||
text1: string;
|
text1: string;
|
||||||
text2: string;
|
text2: string;
|
||||||
locale1: string;
|
language1: string;
|
||||||
locale2: string;
|
language2: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InFolder({ folderId }: { folderId: number }) {
|
export default function InFolder({ folderId }: { folderId: number }) {
|
||||||
@@ -37,7 +39,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
const data = await getPairsByFolderId(folderId);
|
const data = await getPairsByFolderId(folderId);
|
||||||
setTextPairs(data as TextPair[]);
|
setTextPairs(data as TextPair[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch text pairs:", error);
|
logger.error("获取文本对失败", error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -50,107 +52,107 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
const data = await getPairsByFolderId(folderId);
|
const data = await getPairsByFolderId(folderId);
|
||||||
setTextPairs(data as TextPair[]);
|
setTextPairs(data as TextPair[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch text pairs:", error);
|
logger.error("获取文本对失败", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<PageLayout>
|
||||||
<Container className="p-6">
|
{/* 顶部导航和标题栏 */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<button
|
{/* 返回按钮 */}
|
||||||
onClick={router.back}
|
<button
|
||||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
onClick={router.back}
|
||||||
>
|
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||||
<ArrowLeft size={16} />
|
>
|
||||||
<span className="text-sm">{t("back")}</span>
|
<ArrowLeft size={16} />
|
||||||
</button>
|
<span className="text-sm">{t("back")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* 页面标题和操作按钮 */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-light text-gray-900">
|
{/* 标题区域 */}
|
||||||
{t("textPairs")}
|
<div>
|
||||||
</h1>
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
{t("textPairs")}
|
||||||
{t("itemsCount", { count: textPairs.length })}
|
</h1>
|
||||||
</p>
|
<p className="text-sm text-gray-500">
|
||||||
</div>
|
{t("itemsCount", { count: textPairs.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* 操作按钮区域 */}
|
||||||
<LightButton
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => {
|
<GreenButton
|
||||||
redirect(`/memorize?folder_id=${folderId}`);
|
onClick={() => {
|
||||||
}}
|
redirect(`/memorize?folder_id=${folderId}`);
|
||||||
>
|
}}
|
||||||
{t("memorize")}
|
>
|
||||||
</LightButton>
|
{t("memorize")}
|
||||||
<button
|
</GreenButton>
|
||||||
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddModal(true);
|
setAddModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
icon={<Plus size={18} className="text-gray-700" />}
|
||||||
<Plus
|
/>
|
||||||
size={18}
|
|
||||||
className="text-gray-600 hover:cursor-pointer"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden">
|
{/* 文本对列表 */}
|
||||||
{loading ? (
|
<CardList>
|
||||||
<div className="p-8 text-center">
|
{loading ? (
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
// 加载状态
|
||||||
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
<div className="p-8 text-center">
|
||||||
</div>
|
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||||
) : textPairs.length === 0 ? (
|
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||||
<div className="p-12 text-center">
|
</div>
|
||||||
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
) : textPairs.length === 0 ? (
|
||||||
</div>
|
// 空状态
|
||||||
) : (
|
<div className="p-12 text-center">
|
||||||
<div className="divide-y divide-gray-100">
|
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||||
{textPairs
|
</div>
|
||||||
.toSorted((a, b) => a.id - b.id)
|
) : (
|
||||||
.map((textPair) => (
|
// 文本对卡片列表
|
||||||
<TextPairCard
|
<div className="divide-y divide-gray-100">
|
||||||
key={textPair.id}
|
{textPairs
|
||||||
textPair={textPair}
|
.toSorted((a, b) => a.id - b.id)
|
||||||
onDel={() => {
|
.map((textPair) => (
|
||||||
deletePairById(textPair.id);
|
<TextPairCard
|
||||||
refreshTextPairs();
|
key={textPair.id}
|
||||||
}}
|
textPair={textPair}
|
||||||
refreshTextPairs={refreshTextPairs}
|
onDel={() => {
|
||||||
/>
|
deletePairById(textPair.id);
|
||||||
))}
|
refreshTextPairs();
|
||||||
</div>
|
}}
|
||||||
)}
|
refreshTextPairs={refreshTextPairs}
|
||||||
</div>
|
/>
|
||||||
</Container>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardList>
|
||||||
|
|
||||||
|
{/* 添加文本对模态框 */}
|
||||||
<AddTextPairModal
|
<AddTextPairModal
|
||||||
isOpen={openAddModal}
|
isOpen={openAddModal}
|
||||||
onClose={() => setAddModal(false)}
|
onClose={() => setAddModal(false)}
|
||||||
onAdd={async (
|
onAdd={async (
|
||||||
text1: string,
|
text1: string,
|
||||||
text2: string,
|
text2: string,
|
||||||
locale1: string,
|
language1: string,
|
||||||
locale2: string,
|
language2: string,
|
||||||
) => {
|
) => {
|
||||||
await createPair({
|
await createPair({
|
||||||
text1: text1,
|
text1: text1,
|
||||||
text2: text2,
|
text2: text2,
|
||||||
locale1: locale1,
|
language1: language1,
|
||||||
locale2: locale2,
|
language2: language2,
|
||||||
folder: {
|
folderId: folderId,
|
||||||
connect: {
|
|
||||||
id: folderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { updatePairById } from "@/lib/server/services/pairService";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
import { UpdatePairInput } from "@/lib/server/services/types";
|
||||||
|
|
||||||
interface TextPairCardProps {
|
interface TextPairCardProps {
|
||||||
textPair: TextPair;
|
textPair: TextPair;
|
||||||
@@ -25,11 +25,11 @@ export default function TextPairCard({
|
|||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
{textPair.locale1.toUpperCase()}
|
{textPair.language1.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span>→</span>
|
<span>→</span>
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
{textPair.locale2.toUpperCase()}
|
{textPair.language2.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export default function TextPairCard({
|
|||||||
<UpdateTextPairModal
|
<UpdateTextPairModal
|
||||||
isOpen={openUpdateModal}
|
isOpen={openUpdateModal}
|
||||||
onClose={() => setOpenUpdateModal(false)}
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
onUpdate={async (id: number, data: PairUpdateInput) => {
|
onUpdate={async (id: number, data: UpdatePairInput) => {
|
||||||
await updatePairById(id, data);
|
await updatePairById(id, data);
|
||||||
setOpenUpdateModal(false);
|
setOpenUpdateModal(false);
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import Input from "@/components/ui/Input";
|
import Input from "@/components/ui/Input";
|
||||||
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
import { UpdatePairInput } from "@/lib/server/services/types";
|
||||||
import { TextPair } from "./InFolder";
|
import { TextPair } from "./InFolder";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ interface UpdateTextPairModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
textPair: TextPair;
|
textPair: TextPair;
|
||||||
onUpdate: (id: number, tp: PairUpdateInput) => void;
|
onUpdate: (id: number, tp: UpdatePairInput) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdateTextPairModal({
|
export default function UpdateTextPairModal({
|
||||||
@@ -22,36 +23,34 @@ export default function UpdateTextPairModal({
|
|||||||
const t = useTranslations("folder_id");
|
const t = useTranslations("folder_id");
|
||||||
const input1Ref = useRef<HTMLInputElement>(null);
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
const input2Ref = useRef<HTMLInputElement>(null);
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
const input3Ref = useRef<HTMLInputElement>(null);
|
const [language1, setLanguage1] = useState(textPair.language1);
|
||||||
const input4Ref = useRef<HTMLInputElement>(null);
|
const [language2, setLanguage2] = useState(textPair.language2);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
if (
|
if (
|
||||||
!input1Ref.current?.value ||
|
!input1Ref.current?.value ||
|
||||||
!input2Ref.current?.value ||
|
!input2Ref.current?.value ||
|
||||||
!input3Ref.current?.value ||
|
!language1 ||
|
||||||
!input4Ref.current?.value
|
!language2
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const text1 = input1Ref.current.value;
|
const text1 = input1Ref.current.value;
|
||||||
const text2 = input2Ref.current.value;
|
const text2 = input2Ref.current.value;
|
||||||
const locale1 = input3Ref.current.value;
|
|
||||||
const locale2 = input4Ref.current.value;
|
|
||||||
if (
|
if (
|
||||||
typeof text1 === "string" &&
|
typeof text1 === "string" &&
|
||||||
typeof text2 === "string" &&
|
typeof text2 === "string" &&
|
||||||
typeof locale1 === "string" &&
|
typeof language1 === "string" &&
|
||||||
typeof locale2 === "string" &&
|
typeof language2 === "string" &&
|
||||||
text1.trim() !== "" &&
|
text1.trim() !== "" &&
|
||||||
text2.trim() !== "" &&
|
text2.trim() !== "" &&
|
||||||
locale1.trim() !== "" &&
|
language1.trim() !== "" &&
|
||||||
locale2.trim() !== ""
|
language2.trim() !== ""
|
||||||
) {
|
) {
|
||||||
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
|
onUpdate(textPair.id, { text1, text2, language1, language2 });
|
||||||
input1Ref.current.value = "";
|
|
||||||
input2Ref.current.value = "";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -89,20 +88,12 @@ export default function UpdateTextPairModal({
|
|||||||
></Input>
|
></Input>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t("locale1")}
|
{t("language1")}
|
||||||
<Input
|
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||||
defaultValue={textPair.locale1}
|
|
||||||
ref={input3Ref}
|
|
||||||
className="w-full"
|
|
||||||
></Input>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{t("locale2")}
|
{t("language2")}
|
||||||
<Input
|
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||||
defaultValue={textPair.locale2}
|
|
||||||
ref={input4Ref}
|
|
||||||
className="w-full"
|
|
||||||
></Input>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export default async function HomePage() {
|
|||||||
description={t("srtPlayer.description")}
|
description={t("srtPlayer.description")}
|
||||||
color="#3c988d"
|
color="#3c988d"
|
||||||
></LinkArea>
|
></LinkArea>
|
||||||
|
<LinkArea
|
||||||
|
href="/dictionary"
|
||||||
|
name={t("dictionary.name")}
|
||||||
|
description={t("dictionary.description")}
|
||||||
|
color="#6a9c89"
|
||||||
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="/alphabet"
|
href="/alphabet"
|
||||||
name={t("alphabet.name")}
|
name={t("alphabet.name")}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import LightButton from "@/components/ui/buttons/LightButton";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Center } from "@/components/common/Center";
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
import Container from "@/components/ui/Container";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -16,25 +16,34 @@ export default async function ProfilePage() {
|
|||||||
redirect("/auth?redirect=/profile");
|
redirect("/auth?redirect=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(JSON.stringify(session, null, 2));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center>
|
<PageLayout>
|
||||||
<Container className="p-6">
|
<PageHeader title={t("myProfile")} />
|
||||||
<h1>{t("myProfile")}</h1>
|
|
||||||
|
{/* 用户信息区域 */}
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{/* 用户头像 */}
|
||||||
{session.user.image && (
|
{session.user.image && (
|
||||||
<Image
|
<Image
|
||||||
width={64}
|
width={80}
|
||||||
height={64}
|
height={80}
|
||||||
alt="User Avatar"
|
alt="User Avatar"
|
||||||
src={session.user.image as string}
|
src={session.user.image as string}
|
||||||
className="rounded-4xl"
|
className="rounded-full"
|
||||||
></Image>
|
/>
|
||||||
)}
|
)}
|
||||||
<p>{session.user.name}</p>
|
|
||||||
<p>{t("email", { email: session.user.email })}</p>
|
{/* 用户名和邮箱 */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
{session.user.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">{t("email", { email: session.user.email })}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登出按钮 */}
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</Container>
|
</div>
|
||||||
</Center>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import IconClick from "./ui/buttons/IconClick";
|
import { IconClick, GhostButton } from "./ui/buttons";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import GhostButton from "./ui/buttons/GhostButton";
|
|
||||||
|
|
||||||
export default function LanguageSettings() {
|
export default function LanguageSettings() {
|
||||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||||
@@ -21,6 +20,7 @@ export default function LanguageSettings() {
|
|||||||
alt="language"
|
alt="language"
|
||||||
disableOnHoverBgChange={true}
|
disableOnHoverBgChange={true}
|
||||||
onClick={handleLanguageClick}
|
onClick={handleLanguageClick}
|
||||||
|
size={40}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{showLanguageMenu && (
|
{showLanguageMenu && (
|
||||||
@@ -38,6 +38,42 @@ export default function LanguageSettings() {
|
|||||||
>
|
>
|
||||||
中文
|
中文
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("ja-JP")}
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("ko-KR")}
|
||||||
|
>
|
||||||
|
한국어
|
||||||
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("de-DE")}
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("fr-FR")}
|
||||||
|
>
|
||||||
|
Français
|
||||||
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("it-IT")}
|
||||||
|
>
|
||||||
|
Italiano
|
||||||
|
</GhostButton>
|
||||||
|
<GhostButton
|
||||||
|
className="w-full bg-[#35786f]"
|
||||||
|
onClick={() => setLocale("ug-CN")}
|
||||||
|
>
|
||||||
|
ئۇيغۇرچە
|
||||||
|
</GhostButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import IMAGES from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { Folder, Home } from "lucide-react";
|
import { Folder, Home, User } from "lucide-react";
|
||||||
import LanguageSettings from "../LanguageSettings";
|
import LanguageSettings from "../LanguageSettings";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import GhostButton from "../ui/buttons/GhostButton";
|
import { GhostButton } from "../ui/buttons";
|
||||||
|
|
||||||
export async function Navbar() {
|
export async function Navbar() {
|
||||||
const t = await getTranslations("navbar");
|
const t = await getTranslations("navbar");
|
||||||
@@ -14,46 +14,56 @@ export async function Navbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
|
||||||
<GhostButton href="/" className="text-xl border-b hidden md:block">
|
<GhostButton href="/" className="text-lg md:text-xl border-b hidden! md:block!">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
<GhostButton className="block md:hidden" href={"/"}>
|
<GhostButton className="block! md:hidden!" href={"/"}>
|
||||||
<Home />
|
<Home size={20} />
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
<div className="flex text-xl gap-0.5 justify-center items-center flex-wrap">
|
<div className="flex text-base md:text-xl gap-0.5 justify-center items-center flex-wrap">
|
||||||
|
<LanguageSettings />
|
||||||
<GhostButton
|
<GhostButton
|
||||||
className="md:hidden block"
|
className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2"
|
||||||
href="https://github.com/GoddoNebianU/learn-languages"
|
href="https://github.com/GoddoNebianU/learn-languages"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={IMAGES.github_mark_white}
|
src={IMAGES.github_mark_white}
|
||||||
alt="GitHub"
|
alt="GitHub"
|
||||||
width={24}
|
width={20}
|
||||||
height={24}
|
height={20}
|
||||||
/>
|
/>
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
<LanguageSettings />
|
<GhostButton href="/folders" className="md:block! hidden! border-0 bg-transparent hover:bg-black/30 shadow-none">
|
||||||
<GhostButton href="/folders" className="md:block hidden">
|
|
||||||
{t("folders")}
|
{t("folders")}
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
<GhostButton href="/folders" className="md:hidden block">
|
<GhostButton href="/folders" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
||||||
<Folder />
|
<Folder size={20} />
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
{
|
|
||||||
(() => {
|
|
||||||
return session &&
|
|
||||||
<GhostButton href="/profile">{t("profile")}</GhostButton>
|
|
||||||
|| <GhostButton href="/auth">{t("sign_in")}</GhostButton>;
|
|
||||||
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
<GhostButton
|
<GhostButton
|
||||||
className="hidden md:block"
|
className="hidden! md:block! border-0 bg-transparent hover:bg-black/30 shadow-none"
|
||||||
href="https://github.com/GoddoNebianU/learn-languages"
|
href="https://github.com/GoddoNebianU/learn-languages"
|
||||||
>
|
>
|
||||||
{t("sourceCode")}
|
{t("sourceCode")}
|
||||||
</GhostButton>
|
</GhostButton>
|
||||||
|
{
|
||||||
|
(() => {
|
||||||
|
return session &&
|
||||||
|
<>
|
||||||
|
<GhostButton href="/profile" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("profile")}</GhostButton>
|
||||||
|
<GhostButton href="/profile" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
||||||
|
<User size={20} />
|
||||||
|
</GhostButton>
|
||||||
|
</>
|
||||||
|
|| <>
|
||||||
|
<GhostButton href="/auth" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("sign_in")}</GhostButton>
|
||||||
|
<GhostButton href="/auth" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
||||||
|
<User size={20} />
|
||||||
|
</GhostButton>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
})()
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
163
src/components/ui/Button.tsx
Normal file
163
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { COLORS } from "@/lib/theme/colors";
|
||||||
|
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon";
|
||||||
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
// Content
|
||||||
|
children?: React.ReactNode;
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
|
||||||
|
// Styling
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
className?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
iconSrc?: string; // For Next.js Image icons
|
||||||
|
iconAlt?: string;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = "secondary",
|
||||||
|
size = "md",
|
||||||
|
selected = false,
|
||||||
|
href,
|
||||||
|
iconSrc,
|
||||||
|
iconAlt,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
// Base classes
|
||||||
|
const baseClasses = "inline-flex items-center justify-center gap-2 rounded font-bold shadow hover:cursor-pointer transition-colors";
|
||||||
|
|
||||||
|
// Variant-specific classes
|
||||||
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
|
primary: `
|
||||||
|
text-white
|
||||||
|
hover:opacity-90
|
||||||
|
`,
|
||||||
|
secondary: `
|
||||||
|
text-black
|
||||||
|
hover:bg-gray-100
|
||||||
|
`,
|
||||||
|
ghost: `
|
||||||
|
hover:bg-black/30
|
||||||
|
p-2
|
||||||
|
`,
|
||||||
|
icon: `
|
||||||
|
p-2 bg-gray-200 rounded-full
|
||||||
|
hover:bg-gray-300
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Size-specific classes
|
||||||
|
const sizeStyles: Record<ButtonSize, string> = {
|
||||||
|
sm: "px-3 py-1 text-sm",
|
||||||
|
md: "px-4 py-2",
|
||||||
|
lg: "px-6 py-3 text-lg"
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClass = variantStyles[variant];
|
||||||
|
const sizeClass = sizeStyles[size];
|
||||||
|
|
||||||
|
// Selected state for secondary variant
|
||||||
|
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
|
||||||
|
|
||||||
|
// Background color for primary variant
|
||||||
|
const backgroundColor = variant === "primary" ? COLORS.primary : undefined;
|
||||||
|
|
||||||
|
// Combine all classes
|
||||||
|
const combinedClasses = `
|
||||||
|
${baseClasses}
|
||||||
|
${variantClass}
|
||||||
|
${sizeClass}
|
||||||
|
${selectedClass}
|
||||||
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
|
${className}
|
||||||
|
`.trim().replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
// Icon rendering helper for SVG icons
|
||||||
|
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
|
||||||
|
if (!icon) return null;
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image icon rendering for Next.js Image
|
||||||
|
const renderImageIcon = () => {
|
||||||
|
if (!iconSrc) return null;
|
||||||
|
const sizeMap = { sm: 16, md: 20, lg: 24 };
|
||||||
|
const imgSize = sizeMap[size] || 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={iconSrc}
|
||||||
|
width={imgSize}
|
||||||
|
height={imgSize}
|
||||||
|
alt={iconAlt || "icon"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Content assembly
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{renderImageIcon()}
|
||||||
|
{renderSvgIcon(leftIcon, "left")}
|
||||||
|
{children}
|
||||||
|
{renderSvgIcon(rightIcon, "right")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If href is provided, render as Link
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={combinedClasses}
|
||||||
|
style={{ ...style, backgroundColor }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise render as button
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
className={combinedClasses}
|
||||||
|
style={{ ...style, backgroundColor }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/ui/CardList.tsx
Normal file
30
src/components/ui/CardList.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* CardList - 可滚动的卡片列表容器
|
||||||
|
*
|
||||||
|
* 用于显示可滚动的列表内容,如文件夹列表、文本对列表等
|
||||||
|
* - 最大高度 96 (24rem)
|
||||||
|
* - 垂直滚动
|
||||||
|
* - 圆角边框
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <CardList>
|
||||||
|
* {items.map(item => (
|
||||||
|
* <div key={item.id}>{item.name}</div>
|
||||||
|
* ))}
|
||||||
|
* </CardList>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface CardListProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 额外的 CSS 类名 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CardList({ children, className = "" }: CardListProps) {
|
||||||
|
return (
|
||||||
|
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/ui/LocaleSelector.tsx
Normal file
72
src/components/ui/LocaleSelector.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const COMMON_LANGUAGES = [
|
||||||
|
{ label: "chinese", value: "chinese" },
|
||||||
|
{ label: "english", value: "english" },
|
||||||
|
{ label: "italian", value: "italian" },
|
||||||
|
{ label: "japanese", value: "japanese" },
|
||||||
|
{ label: "korean", value: "korean" },
|
||||||
|
{ label: "french", value: "french" },
|
||||||
|
{ label: "german", value: "german" },
|
||||||
|
{ label: "spanish", value: "spanish" },
|
||||||
|
{ label: "portuguese", value: "portuguese" },
|
||||||
|
{ label: "russian", value: "russian" },
|
||||||
|
{ label: "other", value: "other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface LocaleSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [customInput, setCustomInput] = useState("");
|
||||||
|
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
|
||||||
|
const showCustomInput = value === "other" || !isCommonLanguage;
|
||||||
|
|
||||||
|
// 计算输入框的值:如果是"other"使用自定义输入,否则使用外部传入的值
|
||||||
|
const inputValue = value === "other" ? customInput : value;
|
||||||
|
|
||||||
|
// 处理自定义输入
|
||||||
|
const handleCustomInputChange = (inputValue: string) => {
|
||||||
|
setCustomInput(inputValue);
|
||||||
|
onChange(inputValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当选择常见语言或"其他"时
|
||||||
|
const handleSelectChange = (selectedValue: string) => {
|
||||||
|
if (selectedValue === "other") {
|
||||||
|
setCustomInput("");
|
||||||
|
onChange("other");
|
||||||
|
} else {
|
||||||
|
onChange(selectedValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={isCommonLanguage ? value : "other"}
|
||||||
|
onChange={(e) => handleSelectChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||||
|
>
|
||||||
|
{COMMON_LANGUAGES.map((lang) => (
|
||||||
|
<option key={lang.value} value={lang.value}>
|
||||||
|
{t(`translator.${lang.label}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{showCustomInput && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => handleCustomInputChange(e.target.value)}
|
||||||
|
placeholder={t("folder_id.enterLanguageName")}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/ui/PageHeader.tsx
Normal file
29
src/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* PageHeader - 页面标题组件
|
||||||
|
*
|
||||||
|
* 用于 PageLayout 内的页面标题,支持主标题和可选的副标题
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PageHeader title="我的文件夹" subtitle="管理和组织你的学习内容" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PageHeaderProps {
|
||||||
|
/** 页面主标题 */
|
||||||
|
title: string;
|
||||||
|
/** 可选的副标题/描述 */
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-gray-500">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ui/PageLayout.tsx
Normal file
33
src/components/ui/PageLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* PageLayout - 统一的页面布局组件
|
||||||
|
*
|
||||||
|
* 提供应用统一的标准页面布局:
|
||||||
|
* - 绿色背景 (#35786f)
|
||||||
|
* - 居中的白色圆角卡片
|
||||||
|
* - 响应式内边距
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PageLayout>
|
||||||
|
* <PageHeader title="标题" subtitle="副标题" />
|
||||||
|
* <div>页面内容</div>
|
||||||
|
* </PageLayout>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PageLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 额外的 CSS 类名,用于自定义布局行为 */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageLayout({ children, className = "" }: PageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8 ${className}`}>
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import PlainButton, { ButtonType } from "./PlainButton";
|
|
||||||
|
|
||||||
export default function DarkButton({
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
selected,
|
|
||||||
children,
|
|
||||||
type = "button",
|
|
||||||
disabled
|
|
||||||
}: {
|
|
||||||
onClick?: (() => void) | undefined;
|
|
||||||
className?: string;
|
|
||||||
selected?: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
type?: ButtonType;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<PlainButton
|
|
||||||
onClick={onClick}
|
|
||||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
|
||||||
type={type}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PlainButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
|
||||||
|
|
||||||
export default function GhostButton({
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
type = "button",
|
|
||||||
href
|
|
||||||
}: {
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
type?: ButtonType;
|
|
||||||
href?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`rounded hover:bg-black/30 p-2 ${className}`}
|
|
||||||
type={type}
|
|
||||||
>
|
|
||||||
{href ? <Link href={href}>{children}</Link> : children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
interface IconClickProps {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
size?: number;
|
|
||||||
disableOnHoverBgChange?: boolean;
|
|
||||||
}
|
|
||||||
export default function IconClick({
|
|
||||||
src,
|
|
||||||
alt,
|
|
||||||
onClick = () => {},
|
|
||||||
className = "",
|
|
||||||
size = 32,
|
|
||||||
disableOnHoverBgChange = false,
|
|
||||||
}: IconClickProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
onClick={onClick}
|
|
||||||
className={`${disableOnHoverBgChange ? "" : "hover:bg-gray-200"} hover:cursor-pointer rounded-3xl w-[${size}px] h-[${size}px] flex justify-center items-center ${className}`}
|
|
||||||
>
|
|
||||||
<Image src={src} width={size - 5} height={size - 5} alt={alt}></Image>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import PlainButton, { ButtonType } from "../buttons/PlainButton";
|
|
||||||
|
|
||||||
export default function LightButton({
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
selected,
|
|
||||||
children,
|
|
||||||
type = "button",
|
|
||||||
disabled
|
|
||||||
}: {
|
|
||||||
onClick?: (() => void) | undefined;
|
|
||||||
className?: string;
|
|
||||||
selected?: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
type?: ButtonType;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<PlainButton
|
|
||||||
onClick={onClick}
|
|
||||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
|
||||||
type={type}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PlainButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
|
||||||
|
|
||||||
export default function PlainButton({
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
type = "button",
|
|
||||||
disabled
|
|
||||||
}: {
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
type?: ButtonType;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
|
|
||||||
type={type}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
56
src/components/ui/buttons/index.tsx
Normal file
56
src/components/ui/buttons/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// 向后兼容的按钮组件包装器
|
||||||
|
// 这些组件将新 Button 组件包装,以保持向后兼容
|
||||||
|
|
||||||
|
import Button from "../Button";
|
||||||
|
|
||||||
|
// LightButton: 次要按钮,支持 selected 状态
|
||||||
|
export const LightButton = (props: any) => <Button variant="secondary" {...props} />;
|
||||||
|
|
||||||
|
// GreenButton: 主题色主要按钮
|
||||||
|
export const GreenButton = (props: any) => <Button variant="primary" {...props} />;
|
||||||
|
|
||||||
|
// IconButton: SVG 图标按钮
|
||||||
|
export const IconButton = (props: any) => {
|
||||||
|
const { icon, ...rest } = props;
|
||||||
|
return <Button variant="icon" leftIcon={icon} {...rest} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GhostButton: 透明导航按钮
|
||||||
|
export const GhostButton = (props: any) => {
|
||||||
|
const { className, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" className={className} {...rest}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// IconClick: 图片图标按钮
|
||||||
|
export const IconClick = (props: any) => {
|
||||||
|
// IconClick 使用 src/alt 属性,需要映射到 Button 的 iconSrc/iconAlt
|
||||||
|
const { src, alt, size, disableOnHoverBgChange, className, ...rest } = props;
|
||||||
|
let buttonSize: "sm" | "md" | "lg" = "md";
|
||||||
|
if (typeof size === "number") {
|
||||||
|
if (size <= 20) buttonSize = "sm";
|
||||||
|
else if (size >= 32) buttonSize = "lg";
|
||||||
|
} else if (typeof size === "string") {
|
||||||
|
buttonSize = (size === "sm" || size === "md" || size === "lg") ? size : "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果禁用悬停背景变化,通过 className 覆盖
|
||||||
|
const hoverClass = disableOnHoverBgChange ? "hover:bg-black/30 hover:cursor-pointer border-0 bg-transparent shadow-none" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
iconSrc={src}
|
||||||
|
iconAlt={alt}
|
||||||
|
size={buttonSize}
|
||||||
|
className={`${hoverClass} ${className || ""}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// PlainButton: 基础小按钮
|
||||||
|
export const PlainButton = (props: any) => <Button variant="secondary" size="sm" {...props} />;
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface ACardProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ACard({ children, className }: ACardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${className} w-[95dvw] md:w-[61vw] h-96 p-2 md:shadow-2xl rounded-xl`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface BCardProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BCard({ children, className }: BCardProps) {
|
|
||||||
return (
|
|
||||||
<div className={`${className} rounded-xl p-2 shadow-xl`}>{children}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
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";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export interface SignUpFormData {
|
export interface SignUpFormData {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignUpState {
|
export interface SignUpState {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
errors?: {
|
errors?: {
|
||||||
username?: string[];
|
username?: string[];
|
||||||
email?: string[];
|
email?: string[];
|
||||||
password?: string[];
|
password?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signUpAction(prevState: SignUpState, formData: FormData) {
|
export async function signUpAction(prevState: SignUpState, formData: FormData) {
|
||||||
@@ -111,6 +111,9 @@ export async function signInAction(prevState: SignUpState, formData: FormData) {
|
|||||||
|
|
||||||
redirect(redirectTo || "/");
|
redirect(redirectTo || "/");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "登录失败,请检查您的邮箱和密码"
|
message: "登录失败,请检查您的邮箱和密码"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { shallowEqual } from "../utils";
|
import { shallowEqual } from "../utils";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -14,6 +15,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
|||||||
return {
|
return {
|
||||||
get: (): z.infer<T> => {
|
get: (): z.infer<T> => {
|
||||||
try {
|
try {
|
||||||
|
if (!globalThis.localStorage) return [] as z.infer<T>;
|
||||||
const item = globalThis.localStorage.getItem(key);
|
const item = globalThis.localStorage.getItem(key);
|
||||||
|
|
||||||
if (!item) return [] as z.infer<T>;
|
if (!item) return [] as z.infer<T>;
|
||||||
@@ -24,14 +26,14 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
return result.data;
|
return result.data;
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
logger.error(
|
||||||
"Invalid data structure in localStorage:",
|
"Invalid data structure in localStorage:",
|
||||||
result.error,
|
result.error,
|
||||||
);
|
);
|
||||||
return [] as z.infer<T>;
|
return [] as z.infer<T>;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to parse ${key} data:`, e);
|
logger.error(`Failed to parse ${key} data:`, e);
|
||||||
return [] as z.infer<T>;
|
return [] as z.infer<T>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
export const TextSpeakerItemSchema = z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
ipa: z.string().optional(),
|
ipa: z.string().optional(),
|
||||||
locale: z.string(),
|
language: z.string(),
|
||||||
});
|
});
|
||||||
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
||||||
|
|
||||||
export const WordDataSchema = z.object({
|
export const WordDataSchema = z.object({
|
||||||
locales: z
|
languages: z
|
||||||
.tuple([z.string(), z.string()])
|
.tuple([z.string(), z.string()])
|
||||||
.refine(([first, second]) => first !== second, {
|
.refine(([first, second]) => first !== second, {
|
||||||
message: "Locales must be different",
|
message: "Languages must be different",
|
||||||
}),
|
}),
|
||||||
wordPairs: z
|
wordPairs: z
|
||||||
.array(z.tuple([z.string(), z.string()]))
|
.array(z.tuple([z.string(), z.string()]))
|
||||||
@@ -47,8 +47,8 @@ export const WordDataSchema = z.object({
|
|||||||
export const TranslationHistorySchema = z.object({
|
export const TranslationHistorySchema = z.object({
|
||||||
text1: z.string(),
|
text1: z.string(),
|
||||||
text2: z.string(),
|
text2: z.string(),
|
||||||
locale1: z.string(),
|
language1: z.string(),
|
||||||
locale2: z.string(),
|
language2: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema);
|
export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema);
|
||||||
|
|||||||
29
src/lib/logger.ts
Normal file
29
src/lib/logger.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 统一的日志工具
|
||||||
|
* 在生产环境中可以通过环境变量控制日志级别
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LogLevel = 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
error: (message: string, error?: unknown) => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.error(message, error);
|
||||||
|
}
|
||||||
|
// 在生产环境中,这里可以发送到错误追踪服务(如 Sentry)
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (message: string, data?: unknown) => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.warn(message, data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (message: string, data?: unknown) => {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.info(message, data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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))),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
206
src/lib/server/bigmodel/dictionary/README.md
Normal file
206
src/lib/server/bigmodel/dictionary/README.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# 词典查询模块化架构
|
||||||
|
|
||||||
|
本目录包含词典查询系统的**多阶段 LLM 调用**实现,将查询过程拆分为 4 个独立的 LLM 调用,每个阶段之间有代码层面的数据验证,只要有一环失败,直接返回错误。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
dictionary/
|
||||||
|
├── index.ts # 主导出文件
|
||||||
|
├── orchestrator.ts # 主编排器,串联所有阶段
|
||||||
|
├── types.ts # 类型定义
|
||||||
|
├── stage1-inputAnalysis.ts # 阶段1:输入解析与语言识别
|
||||||
|
├── stage2-semanticMapping.ts # 阶段2:跨语言语义映射决策
|
||||||
|
├── stage3-standardForm.ts # 阶段3:standardForm 生成与规范化
|
||||||
|
└── stage4-entriesGeneration.ts # 阶段4:释义与词条生成
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入
|
||||||
|
↓
|
||||||
|
[阶段1] 输入分析 → 代码验证 → 失败则返回错误
|
||||||
|
↓
|
||||||
|
[阶段2] 语义映射 → 代码验证 → 失败则保守处理(不映射)
|
||||||
|
↓
|
||||||
|
[阶段3] 标准形式 → 代码验证 → 失败则返回错误
|
||||||
|
↓
|
||||||
|
[阶段4] 词条生成 → 代码验证 → 失败则返回错误
|
||||||
|
↓
|
||||||
|
最终结果
|
||||||
|
```
|
||||||
|
|
||||||
|
## 各阶段详细说明
|
||||||
|
|
||||||
|
### 阶段 1:输入分析
|
||||||
|
|
||||||
|
**文件**: `stage1-inputAnalysis.ts`
|
||||||
|
|
||||||
|
**目的**:
|
||||||
|
- 判断输入是否有效
|
||||||
|
- 判断是「单词」还是「短语」
|
||||||
|
- 识别输入语言
|
||||||
|
|
||||||
|
**返回**: `InputAnalysisResult`
|
||||||
|
|
||||||
|
**代码验证**:
|
||||||
|
- `isValid` 必须是 boolean
|
||||||
|
- 输入为空或无效时立即返回错误
|
||||||
|
|
||||||
|
### 阶段 2:语义映射
|
||||||
|
|
||||||
|
**文件**: `stage2-semanticMapping.ts`
|
||||||
|
|
||||||
|
**目的**:
|
||||||
|
- 决定是否启用"语义级查询"
|
||||||
|
- **严格条件**:只有输入符合"明确、基础、可词典化的语义概念"且语言不一致时才映射
|
||||||
|
- 不符合条件则**直接失败**(快速失败)
|
||||||
|
|
||||||
|
**返回**: `SemanticMappingResult`
|
||||||
|
|
||||||
|
**代码验证**:
|
||||||
|
- `shouldMap` 必须是 boolean
|
||||||
|
- 如果 `shouldMap=true`,必须有 `mappedQuery`
|
||||||
|
- 如果不应该映射,**抛出异常**(不符合条件直接失败)
|
||||||
|
- **失败则直接返回错误响应**,不继续后续阶段
|
||||||
|
|
||||||
|
**映射条件**(必须同时满足):
|
||||||
|
a) 输入语言 ≠ 查询语言
|
||||||
|
b) 输入是明确、基础、可词典化的语义概念(如常见动词、名词、形容词)
|
||||||
|
|
||||||
|
**不符合条件的例子**:
|
||||||
|
- 复杂句子:"我喜欢吃苹果"
|
||||||
|
- 专业术语
|
||||||
|
- 无法确定语义的词汇
|
||||||
|
|
||||||
|
### 阶段 3:标准形式生成
|
||||||
|
|
||||||
|
**文件**: `stage3-standardForm.ts`
|
||||||
|
|
||||||
|
**目的**:
|
||||||
|
- 确定最终词条的"标准形"(整个系统的锚点)
|
||||||
|
- 修正拼写错误
|
||||||
|
- 还原为词典形式(动词原形、辞书形等)
|
||||||
|
- **如果进行了语义映射**:基于映射结果生成标准形式,同时参考原始输入的语义上下文
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `inputText`: 用于生成标准形式的文本(可能是映射后的结果)
|
||||||
|
- `queryLang`: 查询语言
|
||||||
|
- `originalInput`: (可选)原始用户输入,用于语义参考
|
||||||
|
|
||||||
|
**返回**: `StandardFormResult`
|
||||||
|
|
||||||
|
**代码验证**:
|
||||||
|
- `standardForm` 不能为空
|
||||||
|
- `confidence` 必须是 "high" | "medium" | "low"
|
||||||
|
- 失败时使用原输入作为标准形式
|
||||||
|
|
||||||
|
**特殊逻辑**:
|
||||||
|
- 当进行了语义映射时(即提供了 `originalInput`),阶段 3 会:
|
||||||
|
1. 基于 `inputText`(映射结果)生成标准形式
|
||||||
|
2. 参考 `originalInput` 的语义上下文,确保标准形式符合用户的真实查询意图
|
||||||
|
3. 例如:原始输入 "吃"(中文)→ 映射为 "to eat"(英语)→ 标准形式 "eat"
|
||||||
|
|
||||||
|
### 阶段 4:词条生成
|
||||||
|
|
||||||
|
**文件**: `stage4-entriesGeneration.ts`
|
||||||
|
|
||||||
|
**目的**:
|
||||||
|
- 生成真正的词典内容
|
||||||
|
- 根据类型生成单词或短语条目
|
||||||
|
|
||||||
|
**返回**: `EntriesGenerationResult`
|
||||||
|
|
||||||
|
**代码验证**:
|
||||||
|
- `entries` 必须是非空数组
|
||||||
|
- 每个条目必须有 `definition` 和 `example`
|
||||||
|
- 单词条目必须有 `partOfSpeech`
|
||||||
|
- **失败则抛出异常**(核心阶段)
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
### 基本使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
||||||
|
|
||||||
|
const result = await lookUp({
|
||||||
|
text: "hello",
|
||||||
|
queryLang: "English",
|
||||||
|
definitionLang: "中文"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 高级使用(直接调用编排器)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { executeDictionaryLookup } from "@/lib/server/bigmodel/dictionary";
|
||||||
|
|
||||||
|
const result = await executeDictionaryLookup(
|
||||||
|
"hello",
|
||||||
|
"English",
|
||||||
|
"中文"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单独测试某个阶段
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { analyzeInput } from "@/lib/server/bigmodel/dictionary";
|
||||||
|
|
||||||
|
const analysis = await analyzeInput("hello");
|
||||||
|
console.log(analysis);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计优势
|
||||||
|
|
||||||
|
### 1. 代码层面的数据验证
|
||||||
|
每个阶段完成后都有严格的类型检查和数据验证,确保数据质量。
|
||||||
|
|
||||||
|
### 2. 快速失败
|
||||||
|
只要有一个阶段失败,立即返回错误,不浪费后续的 LLM 调用。
|
||||||
|
|
||||||
|
### 3. 可观测性
|
||||||
|
每个阶段都有 console.log 输出,方便调试和追踪问题。
|
||||||
|
|
||||||
|
### 4. 模块化
|
||||||
|
每个阶段独立文件,可以单独测试、修改或替换。
|
||||||
|
|
||||||
|
### 5. 容错性
|
||||||
|
非核心阶段(阶段2、3)失败时有降级策略,不会导致整个查询失败。
|
||||||
|
|
||||||
|
## 日志示例
|
||||||
|
|
||||||
|
```
|
||||||
|
[阶段1] 开始输入分析...
|
||||||
|
[阶段1] 输入分析完成: { isValid: true, inputType: 'word', inputLanguage: 'English' }
|
||||||
|
[阶段2] 开始语义映射...
|
||||||
|
[阶段2] 语义映射完成: { shouldMap: false }
|
||||||
|
[阶段3] 开始生成标准形式...
|
||||||
|
[阶段3] 标准形式生成完成: { standardForm: 'hello', confidence: 'high' }
|
||||||
|
[阶段4] 开始生成词条...
|
||||||
|
[阶段4] 词条生成完成: { entries: [...] }
|
||||||
|
[完成] 词典查询成功
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展建议
|
||||||
|
|
||||||
|
### 添加缓存
|
||||||
|
对阶段1、3的结果进行缓存,避免重复调用 LLM。
|
||||||
|
|
||||||
|
### 添加指标
|
||||||
|
记录每个阶段的耗时和成功率,用于性能优化。
|
||||||
|
|
||||||
|
### 并行化
|
||||||
|
某些阶段可以并行执行(如果有依赖关系允许的话)。
|
||||||
|
|
||||||
|
### A/B 测试
|
||||||
|
为某个阶段创建不同版本的实现,进行效果对比。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 每个阶段都是独立的 LLM 调用,会增加总耗时
|
||||||
|
- 需要控制 token 使用量,避免成本过高
|
||||||
|
- 错误处理要完善,避免某个阶段卡住整个流程
|
||||||
|
- 日志记录要清晰,方便问题排查
|
||||||
18
src/lib/server/bigmodel/dictionary/index.ts
Normal file
18
src/lib/server/bigmodel/dictionary/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 词典查询模块 - 多阶段 LLM 调用架构
|
||||||
|
*
|
||||||
|
* 将词典查询拆分为 4 个独立的 LLM 调用阶段,每个阶段都有代码层面的数据验证
|
||||||
|
* 只要有一环失败,直接返回错误
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 导出主编排器
|
||||||
|
export { executeDictionaryLookup } from "./orchestrator";
|
||||||
|
|
||||||
|
// 导出各阶段的独立函数(可选,用于调试或单独使用)
|
||||||
|
export { analyzeInput } from "./stage1-inputAnalysis";
|
||||||
|
export { determineSemanticMapping } from "./stage2-semanticMapping";
|
||||||
|
export { generateStandardForm } from "./stage3-standardForm";
|
||||||
|
export { generateEntries } from "./stage4-entriesGeneration";
|
||||||
|
|
||||||
|
// 导出类型定义
|
||||||
|
export * from "./types";
|
||||||
106
src/lib/server/bigmodel/dictionary/orchestrator.ts
Normal file
106
src/lib/server/bigmodel/dictionary/orchestrator.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { DictLookUpResponse } from "@/lib/shared";
|
||||||
|
|
||||||
|
import { analyzeInput } from "./stage1-inputAnalysis";
|
||||||
|
import { determineSemanticMapping } from "./stage2-semanticMapping";
|
||||||
|
import { generateStandardForm } from "./stage3-standardForm";
|
||||||
|
import { generateEntries } from "./stage4-entriesGeneration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 词典查询主编排器
|
||||||
|
*
|
||||||
|
* 将多个独立的 LLM 调用串联起来,每个阶段都有代码层面的数据验证
|
||||||
|
* 只要有一环失败,直接返回错误
|
||||||
|
*/
|
||||||
|
export async function executeDictionaryLookup(
|
||||||
|
text: string,
|
||||||
|
queryLang: string,
|
||||||
|
definitionLang: string
|
||||||
|
): Promise<DictLookUpResponse> {
|
||||||
|
try {
|
||||||
|
// ========== 阶段 1:输入分析 ==========
|
||||||
|
console.log("[阶段1] 开始输入分析...");
|
||||||
|
const analysis = await analyzeInput(text);
|
||||||
|
|
||||||
|
// 代码层面验证:输入是否有效
|
||||||
|
if (!analysis.isValid) {
|
||||||
|
console.log("[阶段1] 输入无效:", analysis.reason);
|
||||||
|
return {
|
||||||
|
error: analysis.reason || "无效输入",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.isEmpty) {
|
||||||
|
console.log("[阶段1] 输入为空");
|
||||||
|
return {
|
||||||
|
error: "输入为空",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[阶段1] 输入分析完成:", analysis);
|
||||||
|
|
||||||
|
// ========== 阶段 2:语义映射 ==========
|
||||||
|
console.log("[阶段2] 开始语义映射...");
|
||||||
|
const semanticMapping = await determineSemanticMapping(
|
||||||
|
text,
|
||||||
|
queryLang,
|
||||||
|
analysis.inputLanguage || text
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[阶段2] 语义映射完成:", semanticMapping);
|
||||||
|
|
||||||
|
// ========== 阶段 3:生成标准形式 ==========
|
||||||
|
console.log("[阶段3] 开始生成标准形式...");
|
||||||
|
|
||||||
|
// 如果进行了语义映射,标准形式要基于映射后的结果
|
||||||
|
// 同时传递原始输入作为语义参考
|
||||||
|
const shouldUseMapping = semanticMapping.shouldMap && semanticMapping.mappedQuery;
|
||||||
|
const inputForStandardForm = shouldUseMapping ? semanticMapping.mappedQuery! : text;
|
||||||
|
|
||||||
|
const standardFormResult = await generateStandardForm(
|
||||||
|
inputForStandardForm,
|
||||||
|
queryLang,
|
||||||
|
shouldUseMapping ? text : undefined // 如果进行了映射,传递原始输入作为语义参考
|
||||||
|
);
|
||||||
|
|
||||||
|
// 代码层面验证:标准形式不能为空
|
||||||
|
if (!standardFormResult.standardForm) {
|
||||||
|
console.error("[阶段3] 标准形式为空");
|
||||||
|
return {
|
||||||
|
error: "无法生成标准形式",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
|
||||||
|
|
||||||
|
// ========== 阶段 4:生成词条 ==========
|
||||||
|
console.log("[阶段4] 开始生成词条...");
|
||||||
|
const entriesResult = await generateEntries(
|
||||||
|
standardFormResult.standardForm,
|
||||||
|
queryLang,
|
||||||
|
definitionLang,
|
||||||
|
analysis.inputType === "unknown"
|
||||||
|
? (standardFormResult.standardForm.includes(" ") ? "phrase" : "word")
|
||||||
|
: analysis.inputType
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[阶段4] 词条生成完成:", entriesResult);
|
||||||
|
|
||||||
|
// ========== 组装最终结果 ==========
|
||||||
|
const finalResult: DictLookUpResponse = {
|
||||||
|
standardForm: standardFormResult.standardForm,
|
||||||
|
entries: entriesResult.entries,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[完成] 词典查询成功");
|
||||||
|
return finalResult;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[错误] 词典查询失败:", error);
|
||||||
|
|
||||||
|
// 任何阶段失败都返回错误(包含 reason)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||||
|
return {
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/lib/server/bigmodel/dictionary/stage1-inputAnalysis.ts
Normal file
66
src/lib/server/bigmodel/dictionary/stage1-inputAnalysis.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { getAnswer } from "../zhipu";
|
||||||
|
import { parseAIGeneratedJSON } from "@/lib/utils";
|
||||||
|
import { InputAnalysisResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段 1:输入解析与语言识别
|
||||||
|
*
|
||||||
|
* 独立的 LLM 调用,分析输入文本
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function analyzeInput(text: string): Promise<InputAnalysisResult> {
|
||||||
|
const prompt = `
|
||||||
|
你是一个输入分析器。分析用户输入并返回 JSON 结果。
|
||||||
|
|
||||||
|
用户输入位于 <text> 标签内:
|
||||||
|
<text>${text}</text>
|
||||||
|
|
||||||
|
你的任务是:
|
||||||
|
1. 判断输入是否为空或明显非法
|
||||||
|
2. 判断输入是「单词」还是「短语」
|
||||||
|
3. 识别输入所属语言
|
||||||
|
|
||||||
|
返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"isValid": true/false,
|
||||||
|
"isEmpty": true/false,
|
||||||
|
"isNaturalLanguage": true/false,
|
||||||
|
"inputLanguage": "检测到的语言名称(如 English、中文、日本語等)",
|
||||||
|
"inputType": "word/phrase/unknown",
|
||||||
|
"reason": "错误原因,成功时为空字符串\"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
若输入为空、非自然语言或无法识别语言,设置 isValid 为 false,并在 reason 中说明原因。
|
||||||
|
若输入有效,设置 isValid 为 true,reason 为空字符串 ""。
|
||||||
|
只返回 JSON,不要任何其他文字。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "你是一个输入分析器,只返回 JSON 格式的分析结果。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]).then(parseAIGeneratedJSON<InputAnalysisResult>);
|
||||||
|
|
||||||
|
// 代码层面的数据验证
|
||||||
|
if (typeof result.isValid !== "boolean") {
|
||||||
|
throw new Error("阶段1:isValid 字段类型错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 reason 字段存在
|
||||||
|
if (typeof result.reason !== "string") {
|
||||||
|
result.reason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("阶段1失败:", error);
|
||||||
|
// 失败时抛出错误,包含 reason
|
||||||
|
throw new Error("输入分析失败:无法识别输入类型或语言");
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/lib/server/bigmodel/dictionary/stage2-semanticMapping.ts
Normal file
106
src/lib/server/bigmodel/dictionary/stage2-semanticMapping.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { getAnswer } from "../zhipu";
|
||||||
|
import { parseAIGeneratedJSON } from "@/lib/utils";
|
||||||
|
import { SemanticMappingResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段 2:跨语言语义映射决策
|
||||||
|
*
|
||||||
|
* 独立的 LLM 调用,决定是否需要语义映射
|
||||||
|
* 如果输入不符合"明确、基础、可词典化的语义概念"且语言不一致,直接返回失败
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function determineSemanticMapping(
|
||||||
|
text: string,
|
||||||
|
queryLang: string,
|
||||||
|
inputLanguage: string
|
||||||
|
): Promise<SemanticMappingResult> {
|
||||||
|
// 如果输入语言就是查询语言,不需要映射
|
||||||
|
if (inputLanguage.toLowerCase() === queryLang.toLowerCase()) {
|
||||||
|
return {
|
||||||
|
shouldMap: false,
|
||||||
|
reason: "输入语言与查询语言一致",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
你是一个语义映射决策器。判断是否需要对输入进行跨语言语义映射。
|
||||||
|
|
||||||
|
查询语言:${queryLang}
|
||||||
|
输入语言:${inputLanguage}
|
||||||
|
用户输入:${text}
|
||||||
|
|
||||||
|
判断规则:
|
||||||
|
1. 若输入表达一个**明确、基础、可词典化的语义概念**(如常见动词、名词、形容词),则应该映射
|
||||||
|
2. 若输入不符合上述条件(如复杂句子、专业术语、无法确定语义的词汇),则不应该映射
|
||||||
|
|
||||||
|
映射条件必须同时满足:
|
||||||
|
a) 输入语言 ≠ 查询语言
|
||||||
|
b) 输入是明确、基础、可词典化的语义概念
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- 查询语言=English,输入="吃"(中文)→ 应该映射 → coreSemantic="to eat"
|
||||||
|
- 查询语言=Italiano,输入="run"(English)→ 应该映射 → coreSemantic="correre"
|
||||||
|
- 查询语言=中文,输入="hello"(English)→ 应该映射 → coreSemantic="你好"
|
||||||
|
- 查询语言=English,输入="我喜欢吃苹果"(中文,复杂句子)→ 不应该映射 → canMap=false
|
||||||
|
|
||||||
|
返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"shouldMap": true/false,
|
||||||
|
"canMap": true/false,
|
||||||
|
"coreSemantic": "提取的核心语义(用${queryLang}表达)",
|
||||||
|
"mappedQuery": "映射到${queryLang}的标准表达",
|
||||||
|
"reason": "错误原因,成功时为空字符串\"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
- canMap=true 表示输入符合"明确、基础、可词典化的语义概念"
|
||||||
|
- shouldMap=true 表示需要进行映射
|
||||||
|
- 只有 canMap=true 且语言不一致时,shouldMap 才为 true
|
||||||
|
- 如果 shouldMap=false,在 reason 中说明原因
|
||||||
|
- 如果 shouldMap=true,reason 为空字符串 ""
|
||||||
|
|
||||||
|
只返回 JSON,不要任何其他文字。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `你是一个语义映射决策器,只返回 JSON 格式的结果。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]).then(parseAIGeneratedJSON<any>);
|
||||||
|
|
||||||
|
// 代码层面的数据验证
|
||||||
|
if (typeof result.shouldMap !== "boolean") {
|
||||||
|
throw new Error("阶段2:shouldMap 字段类型错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 reason 字段存在
|
||||||
|
if (typeof result.reason !== "string") {
|
||||||
|
result.reason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不应该映射,返回错误
|
||||||
|
if (!result.shouldMap) {
|
||||||
|
throw new Error(result.reason || "输入不符合可词典化的语义概念,无法进行跨语言查询");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.mappedQuery || result.mappedQuery.trim().length === 0) {
|
||||||
|
throw new Error("语义映射失败:映射结果为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldMap: result.shouldMap,
|
||||||
|
coreSemantic: result.coreSemantic,
|
||||||
|
mappedQuery: result.mappedQuery,
|
||||||
|
reason: result.reason,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("阶段2失败:", error);
|
||||||
|
// 失败时直接抛出错误,让编排器返回错误响应
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/lib/server/bigmodel/dictionary/stage3-standardForm.ts
Normal file
97
src/lib/server/bigmodel/dictionary/stage3-standardForm.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { getAnswer } from "../zhipu";
|
||||||
|
import { parseAIGeneratedJSON } from "@/lib/utils";
|
||||||
|
import { StandardFormResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段 3:standardForm 生成与规范化
|
||||||
|
*
|
||||||
|
* 独立的 LLM 调用,生成标准形式
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function generateStandardForm(
|
||||||
|
inputText: string,
|
||||||
|
queryLang: string,
|
||||||
|
originalInput?: string
|
||||||
|
): Promise<StandardFormResult> {
|
||||||
|
const prompt = `
|
||||||
|
你是一个词典标准形式生成器。为输入生成该语言下的标准形式。
|
||||||
|
|
||||||
|
查询语言:${queryLang}
|
||||||
|
当前输入:${inputText}
|
||||||
|
${originalInput ? `原始输入(语义参考):${originalInput}` : ''}
|
||||||
|
|
||||||
|
${originalInput ? `
|
||||||
|
**重要说明**:
|
||||||
|
- 当前输入是经过语义映射后的结果(从原始语言映射到查询语言)
|
||||||
|
- 原始输入提供了语义上下文,帮助你理解用户的真实查询意图
|
||||||
|
- 你需要基于**当前输入**生成标准形式,但要参考**原始输入的语义**以确保准确性
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- 原始输入:"吃"(中文),当前输入:"to eat"(英语)→ 标准形式应为 "eat"
|
||||||
|
- 原始输入:"走"(中文),当前输入:"to walk"(英语)→ 标准形式应为 "walk"
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 尝试修正明显拼写错误
|
||||||
|
2. 还原为该语言中**最常见、最自然、最标准**的形式:
|
||||||
|
* 英语:动词原形、名词单数
|
||||||
|
* 日语:辞书形
|
||||||
|
* 意大利语:不定式或最常见规范形式
|
||||||
|
* 维吾尔语:标准拉丁化或阿拉伯字母形式
|
||||||
|
* 中文:标准简化字
|
||||||
|
3. ${originalInput ? '参考原始输入的语义,确保标准形式符合用户的真实查询意图':'若无法确定或输入本身已规范,则保持不变'}
|
||||||
|
|
||||||
|
返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"standardForm": "标准形式",
|
||||||
|
"confidence": "high/medium/low",
|
||||||
|
"reason": "错误原因,成功时为空字符串\"\""
|
||||||
|
}
|
||||||
|
|
||||||
|
成功生成标准形式时,reason 为空字符串 ""。
|
||||||
|
失败时,在 reason 中说明失败原因。
|
||||||
|
只返回 JSON,不要任何其他文字。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "你是一个词典标准形式生成器,只返回 JSON 格式的结果。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]).then(parseAIGeneratedJSON<any>);
|
||||||
|
|
||||||
|
// 代码层面的数据验证
|
||||||
|
if (!result.standardForm || result.standardForm.trim().length === 0) {
|
||||||
|
throw new Error(result.reason || "阶段3:standardForm 为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 confidence 可能是中文或英文的情况
|
||||||
|
let confidence: "high" | "medium" | "low" = "low";
|
||||||
|
const confidenceValue = result.confidence?.toLowerCase();
|
||||||
|
if (confidenceValue === "高" || confidenceValue === "high") {
|
||||||
|
confidence = "high";
|
||||||
|
} else if (confidenceValue === "中" || confidenceValue === "medium") {
|
||||||
|
confidence = "medium";
|
||||||
|
} else if (confidenceValue === "低" || confidenceValue === "low") {
|
||||||
|
confidence = "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 reason 字段存在
|
||||||
|
const reason = typeof result.reason === "string" ? result.reason : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
standardForm: result.standardForm,
|
||||||
|
confidence,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("阶段3失败:", error);
|
||||||
|
// 失败时抛出错误
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/lib/server/bigmodel/dictionary/stage4-entriesGeneration.ts
Normal file
109
src/lib/server/bigmodel/dictionary/stage4-entriesGeneration.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { getAnswer } from "../zhipu";
|
||||||
|
import { parseAIGeneratedJSON } from "@/lib/utils";
|
||||||
|
import { EntriesGenerationResult } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阶段 4:释义与词条生成
|
||||||
|
*
|
||||||
|
* 独立的 LLM 调用,生成词典条目
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function generateEntries(
|
||||||
|
standardForm: string,
|
||||||
|
queryLang: string,
|
||||||
|
definitionLang: string,
|
||||||
|
inputType: "word" | "phrase"
|
||||||
|
): Promise<EntriesGenerationResult> {
|
||||||
|
const isWord = inputType === "word";
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
你是一个词典条目生成器。为标准形式生成词典条目。
|
||||||
|
|
||||||
|
标准形式:${standardForm}
|
||||||
|
查询语言:${queryLang}
|
||||||
|
释义语言:${definitionLang}
|
||||||
|
词条类型:${isWord ? "单词" : "短语"}
|
||||||
|
|
||||||
|
${isWord ? `
|
||||||
|
单词条目要求:
|
||||||
|
- ipa:音标(如适用)
|
||||||
|
- partOfSpeech:词性
|
||||||
|
- definition:释义(使用 ${definitionLang})
|
||||||
|
- example:例句(使用 ${queryLang})
|
||||||
|
` : `
|
||||||
|
短语条目要求:
|
||||||
|
- definition:短语释义(使用 ${definitionLang})
|
||||||
|
- example:例句(使用 ${queryLang})
|
||||||
|
`}
|
||||||
|
|
||||||
|
生成 1-3 个条目,返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
${isWord ? `
|
||||||
|
{
|
||||||
|
"ipa": "音标",
|
||||||
|
"partOfSpeech": "词性",
|
||||||
|
"definition": "释义",
|
||||||
|
"example": "例句"
|
||||||
|
}` : `
|
||||||
|
{
|
||||||
|
"definition": "释义",
|
||||||
|
"example": "例句"
|
||||||
|
}`}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
只返回 JSON,不要任何其他文字。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `你是一个词典条目生成器,只返回 JSON 格式的结果。`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
|
||||||
|
|
||||||
|
// 代码层面的数据验证
|
||||||
|
if (!result.entries || !Array.isArray(result.entries) || result.entries.length === 0) {
|
||||||
|
throw new Error("阶段4:entries 为空或不是数组");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每个条目,清理 IPA 格式
|
||||||
|
for (const entry of result.entries) {
|
||||||
|
// 清理 IPA:删除两端可能包含的方括号、斜杠等字符
|
||||||
|
if (entry.ipa) {
|
||||||
|
entry.ipa = entry.ipa.trim();
|
||||||
|
// 删除开头的 [ / /
|
||||||
|
entry.ipa = entry.ipa.replace(/^[\[\/]/, '');
|
||||||
|
// 删除结尾的 ] / /
|
||||||
|
entry.ipa = entry.ipa.replace(/[\]\/]$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.definition || entry.definition.trim().length === 0) {
|
||||||
|
throw new Error("阶段4:条目缺少 definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.example || entry.example.trim().length === 0) {
|
||||||
|
throw new Error("阶段4:条目缺少 example");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWord && !entry.partOfSpeech) {
|
||||||
|
throw new Error("阶段4:单词条目缺少 partOfSpeech");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWord && !entry.ipa) {
|
||||||
|
throw new Error("阶段4:单词条目缺少 ipa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("阶段4失败:", error);
|
||||||
|
throw error; // 阶段4失败应该返回错误,因为这个阶段是核心
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/lib/server/bigmodel/dictionary/types.ts
Normal file
43
src/lib/server/bigmodel/dictionary/types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 词典查询的类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DictionaryContext {
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段1:输入分析结果
|
||||||
|
export interface InputAnalysisResult {
|
||||||
|
isValid: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
isNaturalLanguage: boolean;
|
||||||
|
inputLanguage?: string;
|
||||||
|
inputType: "word" | "phrase" | "unknown";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段2:语义映射结果
|
||||||
|
export interface SemanticMappingResult {
|
||||||
|
shouldMap: boolean;
|
||||||
|
coreSemantic?: string;
|
||||||
|
mappedQuery?: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段3:标准形式结果
|
||||||
|
export interface StandardFormResult {
|
||||||
|
standardForm: string;
|
||||||
|
confidence: "high" | "medium" | "low";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阶段4:词条生成结果
|
||||||
|
export interface EntriesGenerationResult {
|
||||||
|
entries: Array<{
|
||||||
|
ipa?: string;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech?: string;
|
||||||
|
example: string; // example 必需
|
||||||
|
}>;
|
||||||
|
}
|
||||||
141
src/lib/server/bigmodel/dictionaryActions.ts
Normal file
141
src/lib/server/bigmodel/dictionaryActions.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { executeDictionaryLookup } from "./dictionary";
|
||||||
|
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
|
||||||
|
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
|
||||||
|
|
||||||
|
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
|
||||||
|
if (isDictErrorResponse(res)) return;
|
||||||
|
else if (isDictPhraseResponse(res)) {
|
||||||
|
// 先创建 Phrase
|
||||||
|
const phrase = await createPhrase({
|
||||||
|
standardForm: res.standardForm,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Lookup
|
||||||
|
await createLookUp({
|
||||||
|
userId: req.userId,
|
||||||
|
text: req.text,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
dictionaryPhraseId: phrase.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Entries
|
||||||
|
for (const entry of res.entries) {
|
||||||
|
await createPhraseEntry({
|
||||||
|
phraseId: phrase.id,
|
||||||
|
definition: entry.definition,
|
||||||
|
example: entry.example,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (isDictWordResponse(res)) {
|
||||||
|
// 先创建 Word
|
||||||
|
const word = await createWord({
|
||||||
|
standardForm: (res as DictWordResponse).standardForm,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Lookup
|
||||||
|
await createLookUp({
|
||||||
|
userId: req.userId,
|
||||||
|
text: req.text,
|
||||||
|
queryLang: req.queryLang,
|
||||||
|
definitionLang: req.definitionLang,
|
||||||
|
dictionaryWordId: word.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Entries
|
||||||
|
for (const entry of (res as DictWordResponse).entries) {
|
||||||
|
await createWordEntry({
|
||||||
|
wordId: word.id,
|
||||||
|
ipa: entry.ipa,
|
||||||
|
definition: entry.definition,
|
||||||
|
partOfSpeech: entry.partOfSpeech,
|
||||||
|
example: entry.example,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单词或短语
|
||||||
|
*
|
||||||
|
* 使用模块化的词典查询系统,将提示词拆分为6个阶段:
|
||||||
|
* - 阶段0:基础系统提示
|
||||||
|
* - 阶段1:输入解析与语言识别
|
||||||
|
* - 阶段2:跨语言语义映射决策
|
||||||
|
* - 阶段3:standardForm 生成与规范化
|
||||||
|
* - 阶段4:释义与词条生成
|
||||||
|
* - 阶段5:错误处理
|
||||||
|
* - 阶段6:最终输出封装
|
||||||
|
*/
|
||||||
|
export const lookUp = async ({
|
||||||
|
text,
|
||||||
|
queryLang,
|
||||||
|
definitionLang,
|
||||||
|
userId,
|
||||||
|
forceRelook = false
|
||||||
|
}: DictLookUpRequest): Promise<DictLookUpResponse> => {
|
||||||
|
try {
|
||||||
|
const lastLookUp = await selectLastLookUp({
|
||||||
|
text,
|
||||||
|
queryLang,
|
||||||
|
definitionLang
|
||||||
|
});
|
||||||
|
|
||||||
|
if (forceRelook || !lastLookUp) {
|
||||||
|
// 使用新的模块化查询系统
|
||||||
|
const response = await executeDictionaryLookup(
|
||||||
|
text,
|
||||||
|
queryLang,
|
||||||
|
definitionLang
|
||||||
|
);
|
||||||
|
|
||||||
|
saveResult({
|
||||||
|
text,
|
||||||
|
queryLang,
|
||||||
|
definitionLang,
|
||||||
|
userId,
|
||||||
|
forceRelook
|
||||||
|
}, response);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
// 从数据库返回缓存的结果
|
||||||
|
if (lastLookUp.dictionaryWordId) {
|
||||||
|
createLookUp({
|
||||||
|
userId: userId,
|
||||||
|
text: text,
|
||||||
|
queryLang: queryLang,
|
||||||
|
definitionLang: definitionLang,
|
||||||
|
dictionaryWordId: lastLookUp.dictionaryWordId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
standardForm: lastLookUp.dictionaryWord!.standardForm,
|
||||||
|
entries: lastLookUp.dictionaryWord!.entries
|
||||||
|
};
|
||||||
|
} else if (lastLookUp.dictionaryPhraseId) {
|
||||||
|
createLookUp({
|
||||||
|
userId: userId,
|
||||||
|
text: text,
|
||||||
|
queryLang: queryLang,
|
||||||
|
definitionLang: definitionLang,
|
||||||
|
dictionaryPhraseId: lastLookUp.dictionaryPhraseId
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
standardForm: lastLookUp.dictionaryPhrase!.standardForm,
|
||||||
|
entries: lastLookUp.dictionaryPhrase!.entries
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return { error: "Database structure error!" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return { error: "LOOK_UP_ERROR" };
|
||||||
|
}
|
||||||
|
};
|
||||||
253
src/lib/server/bigmodel/translatorActions.ts
Normal file
253
src/lib/server/bigmodel/translatorActions.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getAnswer } from "./zhipu";
|
||||||
|
import { selectLatestTranslation, createTranslationHistory } from "../services/translatorService";
|
||||||
|
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "../services/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
|
export const genIPA = async (text: string) => {
|
||||||
|
return (
|
||||||
|
"[" +
|
||||||
|
(
|
||||||
|
await getAnswer(
|
||||||
|
`
|
||||||
|
<text>${text}</text>
|
||||||
|
|
||||||
|
请生成以上文本的严式国际音标
|
||||||
|
然后直接发给我
|
||||||
|
不要附带任何说明
|
||||||
|
不要擅自增减符号
|
||||||
|
不许用"/"或者"[]"包裹
|
||||||
|
`.trim(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.replaceAll("[", "")
|
||||||
|
.replaceAll("]", "") +
|
||||||
|
"]"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
|
export const genLocale = async (text: string) => {
|
||||||
|
return await getAnswer(
|
||||||
|
`
|
||||||
|
<text>${text}</text>
|
||||||
|
|
||||||
|
推断以上文本的地区(locale)
|
||||||
|
然后直接发给我
|
||||||
|
形如如zh-CN
|
||||||
|
不要附带任何说明
|
||||||
|
不要擅自增减符号
|
||||||
|
`.trim(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
|
export const genLanguage = async (text: string) => {
|
||||||
|
const language = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `
|
||||||
|
你是一个语言检测工具。请识别文本的语言并返回语言名称。
|
||||||
|
|
||||||
|
返回语言的标准英文名称,例如:
|
||||||
|
- 中文: Chinese
|
||||||
|
- 英语: English
|
||||||
|
- 日语: Japanese
|
||||||
|
- 韩语: Korean
|
||||||
|
- 法语: French
|
||||||
|
- 德语: German
|
||||||
|
- 意大利语: Italian
|
||||||
|
- 葡萄牙语: Portuguese
|
||||||
|
- 西班牙语: Spanish
|
||||||
|
- 俄语: Russian
|
||||||
|
- 阿拉伯语: Arabic
|
||||||
|
- 印地语: Hindi
|
||||||
|
- 泰语: Thai
|
||||||
|
- 越南语: Vietnamese
|
||||||
|
- 等等...
|
||||||
|
|
||||||
|
如果无法识别语言,返回 "Unknown"
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 只返回语言的标准英文名称
|
||||||
|
2. 首字母大写,其余小写
|
||||||
|
3. 不要附带任何说明
|
||||||
|
4. 不要擅自增减符号
|
||||||
|
`.trim()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `<text>${text}</text>`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
return language.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 请使用 translateText 函数代替
|
||||||
|
* 保留此函数以支持旧代码(text-speaker 功能)
|
||||||
|
*/
|
||||||
|
export const genTranslation = async (text: string, targetLanguage: string) => {
|
||||||
|
|
||||||
|
return await getAnswer(
|
||||||
|
`
|
||||||
|
<text>${text}</text>
|
||||||
|
|
||||||
|
请将以上文本翻译到 <target_language>${targetLanguage}</target_language>
|
||||||
|
然后直接发给我
|
||||||
|
不要附带任何说明
|
||||||
|
不要擅自增减符号
|
||||||
|
`.trim(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的翻译函数
|
||||||
|
* 一次调用生成所有信息,支持缓存查询
|
||||||
|
*/
|
||||||
|
export async function translateText(options: TranslateTextInput): Promise<TranslateTextOutput> {
|
||||||
|
const {
|
||||||
|
sourceText,
|
||||||
|
targetLanguage,
|
||||||
|
forceRetranslate = false,
|
||||||
|
needIpa = true,
|
||||||
|
userId,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 1. 检查缓存(如果未强制重新翻译)并获取翻译数据
|
||||||
|
let translatedData: TranslationLLMResponse | null = null;
|
||||||
|
let fromCache = false;
|
||||||
|
|
||||||
|
if (!forceRetranslate) {
|
||||||
|
const cached = await selectLatestTranslation({
|
||||||
|
sourceText,
|
||||||
|
targetLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached && cached.translatedText && cached.sourceLanguage) {
|
||||||
|
// 如果不需要 IPA,或缓存已有 IPA,使用缓存
|
||||||
|
if (!needIpa || (cached.sourceIpa && cached.targetIpa)) {
|
||||||
|
console.log("✅ 翻译缓存命中");
|
||||||
|
translatedData = {
|
||||||
|
translatedText: cached.translatedText,
|
||||||
|
sourceLanguage: cached.sourceLanguage,
|
||||||
|
targetLanguage: cached.targetLanguage,
|
||||||
|
sourceIpa: cached.sourceIpa || undefined,
|
||||||
|
targetIpa: cached.targetIpa || undefined,
|
||||||
|
};
|
||||||
|
fromCache = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果缓存未命中,调用 LLM 生成翻译
|
||||||
|
if (!fromCache) {
|
||||||
|
translatedData = await callTranslationLLM({
|
||||||
|
sourceText,
|
||||||
|
targetLanguage,
|
||||||
|
needIpa,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 保存到数据库(不管缓存是否命中都保存)
|
||||||
|
if (translatedData) {
|
||||||
|
try {
|
||||||
|
await createTranslationHistory({
|
||||||
|
userId,
|
||||||
|
sourceText,
|
||||||
|
sourceLanguage: translatedData.sourceLanguage,
|
||||||
|
targetLanguage: translatedData.targetLanguage,
|
||||||
|
translatedText: translatedData.translatedText,
|
||||||
|
sourceIpa: needIpa ? translatedData.sourceIpa : undefined,
|
||||||
|
targetIpa: needIpa ? translatedData.targetIpa : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存翻译历史失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceText,
|
||||||
|
translatedText: translatedData!.translatedText,
|
||||||
|
sourceLanguage: translatedData!.sourceLanguage,
|
||||||
|
targetLanguage: translatedData!.targetLanguage,
|
||||||
|
sourceIpa: needIpa ? (translatedData!.sourceIpa || "") : "",
|
||||||
|
targetIpa: needIpa ? (translatedData!.targetIpa || "") : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 LLM 生成翻译和相关数据
|
||||||
|
*/
|
||||||
|
async function callTranslationLLM(params: {
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
needIpa: boolean;
|
||||||
|
}): Promise<TranslationLLMResponse> {
|
||||||
|
const { sourceText, targetLanguage, needIpa } = params;
|
||||||
|
|
||||||
|
console.log("🤖 调用 LLM 翻译");
|
||||||
|
|
||||||
|
let systemPrompt = "你是一个专业的翻译助手。请根据用户的要求翻译文本,并返回 JSON 格式的结果。\n\n返回的 JSON 必须严格符合以下格式:\n{\n \"translatedText\": \"翻译后的文本\",\n \"sourceLanguage\": \"源语言的标准英文名称(如 Chinese, English, Japanese)\",\n \"targetLanguage\": \"目标语言的标准英文名称\"";
|
||||||
|
|
||||||
|
if (needIpa) {
|
||||||
|
systemPrompt += ",\n \"sourceIpa\": \"源文本的严式国际音标(用方括号包裹,如 [tɕɪn˥˩]\",\n \"targetIpa\": \"译文的严式国际音标(用方括号包裹)\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt += "}\n\n规则:\n1. 只返回 JSON,不要包含任何其他文字说明\n2. 语言名称必须是标准英文名称,首字母大写\n";
|
||||||
|
|
||||||
|
if (needIpa) {
|
||||||
|
systemPrompt += "3. 国际音标必须用方括号 [] 包裹,使用严式音标\n";
|
||||||
|
} else {
|
||||||
|
systemPrompt += "3. 本次请求不需要生成国际音标\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt += needIpa ? "4. 确保翻译准确、自然" : "4. 确保翻译准确、自然";
|
||||||
|
|
||||||
|
const userPrompt = `请将以下文本翻译成 ${targetLanguage}:\n\n<text>${sourceText}</text>\n\n返回 JSON 格式的翻译结果。`;
|
||||||
|
|
||||||
|
const response = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: userPrompt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 解析 LLM 返回的 JSON
|
||||||
|
try {
|
||||||
|
// 清理响应:移除 markdown 代码块标记和多余空白
|
||||||
|
let cleanedResponse = response
|
||||||
|
.replace(/```json\s*\n/g, "") // 移除 ```json 开头
|
||||||
|
.replace(/```\s*\n/g, "") // 移除 ``` 结尾
|
||||||
|
.replace(/```\s*$/g, "") // 移除末尾的 ```
|
||||||
|
.replace(/```json\s*$/g, "") // 移除末尾的 ```json
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleanedResponse) as TranslationLLMResponse;
|
||||||
|
|
||||||
|
// 验证必需字段
|
||||||
|
if (!parsed.translatedText || !parsed.sourceLanguage || !parsed.targetLanguage) {
|
||||||
|
throw new Error("LLM 返回的数据缺少必需字段");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("LLM 翻译成功");
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("LLM 翻译失败:", error);
|
||||||
|
console.error("原始响应:", response);
|
||||||
|
throw new Error("翻译失败:无法解析 LLM 响应");
|
||||||
|
}
|
||||||
|
}
|
||||||
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 };
|
||||||
62
src/lib/server/services/dictionaryService.ts
Normal file
62
src/lib/server/services/dictionaryService.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateDictionaryLookUpInput,
|
||||||
|
DictionaryLookUpQuery,
|
||||||
|
CreateDictionaryPhraseInput,
|
||||||
|
CreateDictionaryPhraseEntryInput,
|
||||||
|
CreateDictionaryWordInput,
|
||||||
|
CreateDictionaryWordEntryInput
|
||||||
|
} from "./types";
|
||||||
|
import prisma from "../../db";
|
||||||
|
|
||||||
|
export async function selectLastLookUp(content: DictionaryLookUpQuery) {
|
||||||
|
return prisma.dictionaryLookUp.findFirst({
|
||||||
|
where: content,
|
||||||
|
include: {
|
||||||
|
dictionaryPhrase: {
|
||||||
|
include: {
|
||||||
|
entries: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dictionaryWord: {
|
||||||
|
include: {
|
||||||
|
entries: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPhraseEntry(content: CreateDictionaryPhraseEntryInput) {
|
||||||
|
return prisma.dictionaryPhraseEntry.create({
|
||||||
|
data: content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWordEntry(content: CreateDictionaryWordEntryInput) {
|
||||||
|
return prisma.dictionaryWordEntry.create({
|
||||||
|
data: content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPhrase(content: CreateDictionaryPhraseInput) {
|
||||||
|
return prisma.dictionaryPhrase.create({
|
||||||
|
data: content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWord(content: CreateDictionaryWordInput) {
|
||||||
|
return prisma.dictionaryWord.create({
|
||||||
|
data: content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLookUp(content: CreateDictionaryLookUpInput) {
|
||||||
|
return prisma.dictionaryLookUp.create({
|
||||||
|
data: content
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
|
import { CreateFolderInput, UpdateFolderInput } from "./types";
|
||||||
import prisma from "../../db";
|
import prisma from "../../db";
|
||||||
|
|
||||||
export async function getFoldersByUserId(userId: string) {
|
export async function getFoldersByUserId(userId: string) {
|
||||||
const folders = await prisma.folder.findMany({
|
return prisma.folder.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return folders;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameFolderById(id: number, newName: string) {
|
export async function renameFolderById(id: number, newName: string) {
|
||||||
await prisma.folder.update({
|
return prisma.folder.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
@@ -32,29 +31,28 @@ export async function getFoldersWithTotalPairsByUserId(userId: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return folders.map(folder => ({
|
return folders.map(folder => ({
|
||||||
...folder,
|
...folder,
|
||||||
total: folder._count?.pairs ?? 0,
|
total: folder._count?.pairs ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFolder(folder: FolderCreateInput) {
|
export async function createFolder(folder: CreateFolderInput) {
|
||||||
await prisma.folder.create({
|
return prisma.folder.create({
|
||||||
data: folder,
|
data: folder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFolderById(id: number) {
|
export async function deleteFolderById(id: number) {
|
||||||
await prisma.folder.delete({
|
return prisma.folder.delete({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateFolderById(id: number, data: FolderUpdateInput) {
|
export async function updateFolderById(id: number, data: UpdateFolderInput) {
|
||||||
await prisma.folder.update({
|
return prisma.folder.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { PairCreateInput, PairUpdateInput } from "../../../../generated/prisma/models";
|
import { CreatePairInput, UpdatePairInput } from "./types";
|
||||||
import prisma from "../../db";
|
import prisma from "../../db";
|
||||||
|
|
||||||
export async function createPair(data: PairCreateInput) {
|
export async function createPair(data: CreatePairInput) {
|
||||||
await prisma.pair.create({
|
return prisma.pair.create({
|
||||||
data: data,
|
data: data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePairById(id: number) {
|
export async function deletePairById(id: number) {
|
||||||
await prisma.pair.delete({
|
return prisma.pair.delete({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
@@ -19,9 +19,9 @@ export async function deletePairById(id: number) {
|
|||||||
|
|
||||||
export async function updatePairById(
|
export async function updatePairById(
|
||||||
id: number,
|
id: number,
|
||||||
data: PairUpdateInput,
|
data: UpdatePairInput,
|
||||||
) {
|
) {
|
||||||
await prisma.pair.update({
|
return prisma.pair.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
@@ -30,19 +30,17 @@ export async function updatePairById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPairCountByFolderId(folderId: number) {
|
export async function getPairCountByFolderId(folderId: number) {
|
||||||
const count = await prisma.pair.count({
|
return prisma.pair.count({
|
||||||
where: {
|
where: {
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPairsByFolderId(folderId: number) {
|
export async function getPairsByFolderId(folderId: number) {
|
||||||
const textPairs = await prisma.pair.findMany({
|
return prisma.pair.findMany({
|
||||||
where: {
|
where: {
|
||||||
folderId: folderId,
|
folderId: folderId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return textPairs;
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/lib/server/services/translatorService.ts
Normal file
31
src/lib/server/services/translatorService.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./types";
|
||||||
|
import prisma from "../../db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建翻译历史记录
|
||||||
|
*/
|
||||||
|
export async function createTranslationHistory(data: CreateTranslationHistoryInput) {
|
||||||
|
return prisma.translationHistory.create({
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询最新的翻译记录
|
||||||
|
* @param sourceText 源文本
|
||||||
|
* @param targetLanguage 目标语言
|
||||||
|
* @returns 最新的翻译记录,如果不存在则返回 null
|
||||||
|
*/
|
||||||
|
export async function selectLatestTranslation(query: TranslationHistoryQuery) {
|
||||||
|
return prisma.translationHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
sourceText: query.sourceText,
|
||||||
|
targetLanguage: query.targetLanguage,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
122
src/lib/server/services/types.ts
Normal file
122
src/lib/server/services/types.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Service 层的自定义业务类型
|
||||||
|
*
|
||||||
|
* 这些类型用于替换 Prisma 生成的类型,提高代码的可维护性和抽象层次
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Folder 相关
|
||||||
|
export interface CreateFolderInput {
|
||||||
|
name: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFolderInput {
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair 相关
|
||||||
|
export interface CreatePairInput {
|
||||||
|
text1: string;
|
||||||
|
text2: string;
|
||||||
|
language1: string;
|
||||||
|
language2: string;
|
||||||
|
ipa1?: string;
|
||||||
|
ipa2?: string;
|
||||||
|
folderId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePairInput {
|
||||||
|
text1?: string;
|
||||||
|
text2?: string;
|
||||||
|
language1?: string;
|
||||||
|
language2?: string;
|
||||||
|
ipa1?: string;
|
||||||
|
ipa2?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translation 相关
|
||||||
|
export interface CreateTranslationHistoryInput {
|
||||||
|
userId?: string;
|
||||||
|
sourceText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
translatedText: string;
|
||||||
|
sourceIpa?: string;
|
||||||
|
targetIpa?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationHistoryQuery {
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dictionary 相关
|
||||||
|
export interface CreateDictionaryLookUpInput {
|
||||||
|
userId?: string;
|
||||||
|
text: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
dictionaryWordId?: number;
|
||||||
|
dictionaryPhraseId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryLookUpQuery {
|
||||||
|
userId?: string;
|
||||||
|
text?: string;
|
||||||
|
queryLang?: string;
|
||||||
|
definitionLang?: string;
|
||||||
|
dictionaryWordId?: number;
|
||||||
|
dictionaryPhraseId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryWordInput {
|
||||||
|
standardForm: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryPhraseInput {
|
||||||
|
standardForm: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryWordEntryInput {
|
||||||
|
wordId: number;
|
||||||
|
ipa: string;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech: string;
|
||||||
|
example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictionaryPhraseEntryInput {
|
||||||
|
phraseId: number;
|
||||||
|
definition: string;
|
||||||
|
example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译相关 - 统一翻译函数
|
||||||
|
export interface TranslateTextInput {
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
forceRetranslate?: boolean; // 默认 false
|
||||||
|
needIpa?: boolean; // 默认 true
|
||||||
|
userId?: string; // 可选用户 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslateTextOutput {
|
||||||
|
sourceText: string;
|
||||||
|
translatedText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
sourceIpa: string; // 如果 needIpa=false,返回空字符串
|
||||||
|
targetIpa: string; // 如果 needIpa=false,返回空字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationLLMResponse {
|
||||||
|
translatedText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
sourceIpa?: string; // 可选,根据 needIpa 决定
|
||||||
|
targetIpa?: string; // 可选,根据 needIpa 决定
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import prisma from "@/lib/db";
|
import prisma from "@/lib/db";
|
||||||
import { UserCreateInput } from "../../../../generated/prisma/models";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
export async function createUserIfNotExists(email: string, name?: string | null) {
|
export async function createUserIfNotExists(email: string, name?: string | null) {
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
@@ -8,9 +8,10 @@ export async function createUserIfNotExists(email: string, name?: string | null)
|
|||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
create: {
|
create: {
|
||||||
|
id: randomUUID(),
|
||||||
email: email,
|
email: email,
|
||||||
name: name || "New User",
|
name: name || "New User",
|
||||||
} as UserCreateInput,
|
},
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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},然后直接发给我,不要附带任何说明,不要擅自增减符号。`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
63
src/lib/shared/dictionaryTypes.ts
Normal file
63
src/lib/shared/dictionaryTypes.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export type DictLookUpRequest = {
|
||||||
|
text: string,
|
||||||
|
queryLang: string,
|
||||||
|
definitionLang: string,
|
||||||
|
userId?: string,
|
||||||
|
forceRelook: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictWordEntry = {
|
||||||
|
ipa: string;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech: string;
|
||||||
|
example: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictPhraseEntry = {
|
||||||
|
definition: string;
|
||||||
|
example: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictErrorResponse = {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictWordResponse = {
|
||||||
|
standardForm: string;
|
||||||
|
entries: DictWordEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictPhraseResponse = {
|
||||||
|
standardForm: string;
|
||||||
|
entries: DictPhraseEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictLookUpResponse =
|
||||||
|
| DictErrorResponse
|
||||||
|
| DictWordResponse
|
||||||
|
| DictPhraseResponse;
|
||||||
|
|
||||||
|
// 类型守卫:判断是否为错误响应
|
||||||
|
export function isDictErrorResponse(
|
||||||
|
response: DictLookUpResponse
|
||||||
|
): response is DictErrorResponse {
|
||||||
|
return "error" in response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型守卫:判断是否为单词响应
|
||||||
|
export function isDictWordResponse(
|
||||||
|
response: DictLookUpResponse
|
||||||
|
): response is DictWordResponse {
|
||||||
|
if (isDictErrorResponse(response)) return false;
|
||||||
|
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
|
||||||
|
return entries.length > 0 && "ipa" in entries[0] && "partOfSpeech" in entries[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型守卫:判断是否为短语响应
|
||||||
|
export function isDictPhraseResponse(
|
||||||
|
response: DictLookUpResponse
|
||||||
|
): response is DictPhraseResponse {
|
||||||
|
if (isDictErrorResponse(response)) return false;
|
||||||
|
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
|
||||||
|
return entries.length > 0 && !("ipa" in entries[0] || "partOfSpeech" in entries[0]);
|
||||||
|
}
|
||||||
1
src/lib/shared/index.ts
Normal file
1
src/lib/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dictionaryTypes";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user