Compare commits
11 Commits
main
...
0af99b6b70
| Author | SHA1 | Date | |
|---|---|---|---|
| 0af99b6b70 | |||
| eaf97b8279 | |||
| 76749549ff | |||
| fa6301538b | |||
| d4d5a53747 | |||
| ec265be26b | |||
| 804baa64b2 | |||
| a1e42127e6 | |||
| f1d706e20c | |||
| c7cdf40f2f | |||
| a55e763525 |
@@ -35,3 +35,5 @@ build.sh
|
|||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
|
.claude
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,3 +50,5 @@ test.js
|
|||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "Übersetzen in",
|
"translateInto": "Übersetzen in",
|
||||||
"chinese": "Chinesisch",
|
"chinese": "Chinesisch",
|
||||||
"english": "Englisch",
|
"english": "Englisch",
|
||||||
|
"french": "Französisch",
|
||||||
|
"german": "Deutsch",
|
||||||
"italian": "Italienisch",
|
"italian": "Italienisch",
|
||||||
|
"japanese": "Japanisch",
|
||||||
|
"korean": "Koreanisch",
|
||||||
|
"portuguese": "Portugiesisch",
|
||||||
|
"russian": "Russisch",
|
||||||
|
"spanish": "Spanisch",
|
||||||
"other": "Andere",
|
"other": "Andere",
|
||||||
"translating": "Übersetzung läuft...",
|
"translating": "Übersetzung läuft...",
|
||||||
"translate": "Übersetzen",
|
"translate": "Übersetzen",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "translate into",
|
"translateInto": "translate into",
|
||||||
"chinese": "Chinese",
|
"chinese": "Chinese",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
|
"french": "French",
|
||||||
|
"german": "German",
|
||||||
"italian": "Italian",
|
"italian": "Italian",
|
||||||
|
"japanese": "Japanese",
|
||||||
|
"korean": "Korean",
|
||||||
|
"portuguese": "Portuguese",
|
||||||
|
"russian": "Russian",
|
||||||
|
"spanish": "Spanish",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"translating": "translating...",
|
"translating": "translating...",
|
||||||
"translate": "translate",
|
"translate": "translate",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "traduire en",
|
"translateInto": "traduire en",
|
||||||
"chinese": "Chinois",
|
"chinese": "Chinois",
|
||||||
"english": "Anglais",
|
"english": "Anglais",
|
||||||
|
"french": "Français",
|
||||||
|
"german": "Allemand",
|
||||||
"italian": "Italien",
|
"italian": "Italien",
|
||||||
|
"japanese": "Japonais",
|
||||||
|
"korean": "Coréen",
|
||||||
|
"portuguese": "Portugais",
|
||||||
|
"russian": "Russe",
|
||||||
|
"spanish": "Espagnol",
|
||||||
"other": "Autre",
|
"other": "Autre",
|
||||||
"translating": "traduction...",
|
"translating": "traduction...",
|
||||||
"translate": "traduire",
|
"translate": "traduire",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "traduci in",
|
"translateInto": "traduci in",
|
||||||
"chinese": "Cinese",
|
"chinese": "Cinese",
|
||||||
"english": "Inglese",
|
"english": "Inglese",
|
||||||
|
"french": "Francese",
|
||||||
|
"german": "Tedesco",
|
||||||
"italian": "Italiano",
|
"italian": "Italiano",
|
||||||
|
"japanese": "Giapponese",
|
||||||
|
"korean": "Coreano",
|
||||||
|
"portuguese": "Portoghese",
|
||||||
|
"russian": "Russo",
|
||||||
|
"spanish": "Spagnolo",
|
||||||
"other": "Altro",
|
"other": "Altro",
|
||||||
"translating": "traduzione...",
|
"translating": "traduzione...",
|
||||||
"translate": "traduci",
|
"translate": "traduci",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "翻訳",
|
"translateInto": "翻訳",
|
||||||
"chinese": "中国語",
|
"chinese": "中国語",
|
||||||
"english": "英語",
|
"english": "英語",
|
||||||
|
"french": "フランス語",
|
||||||
|
"german": "ドイツ語",
|
||||||
"italian": "イタリア語",
|
"italian": "イタリア語",
|
||||||
|
"japanese": "日本語",
|
||||||
|
"korean": "韓国語",
|
||||||
|
"portuguese": "ポルトガル語",
|
||||||
|
"russian": "ロシア語",
|
||||||
|
"spanish": "スペイン語",
|
||||||
"other": "その他",
|
"other": "その他",
|
||||||
"translating": "翻訳中...",
|
"translating": "翻訳中...",
|
||||||
"translate": "翻訳",
|
"translate": "翻訳",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "번역",
|
"translateInto": "번역",
|
||||||
"chinese": "중국어",
|
"chinese": "중국어",
|
||||||
"english": "영어",
|
"english": "영어",
|
||||||
|
"french": "프랑스어",
|
||||||
|
"german": "독일어",
|
||||||
"italian": "이탈리아어",
|
"italian": "이탈리아어",
|
||||||
|
"japanese": "일본어",
|
||||||
|
"korean": "한국어",
|
||||||
|
"portuguese": "포르투갈어",
|
||||||
|
"russian": "러시아어",
|
||||||
|
"spanish": "스페인어",
|
||||||
"other": "기타",
|
"other": "기타",
|
||||||
"translating": "번역 중...",
|
"translating": "번역 중...",
|
||||||
"translate": "번역",
|
"translate": "번역",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "تەرجىمە قىلىش",
|
"translateInto": "تەرجىمە قىلىش",
|
||||||
"chinese": "خەنزۇچە",
|
"chinese": "خەنزۇچە",
|
||||||
"english": "ئىنگلىزچە",
|
"english": "ئىنگلىزچە",
|
||||||
|
"french": "فرانسۇزچە",
|
||||||
|
"german": "گېرمانچە",
|
||||||
"italian": "ئىتاليانچە",
|
"italian": "ئىتاليانچە",
|
||||||
|
"japanese": "ياپونچە",
|
||||||
|
"korean": "كورېيەچە",
|
||||||
|
"portuguese": "پورتۇگالچە",
|
||||||
|
"russian": "رۇسچە",
|
||||||
|
"spanish": "ئىسپانچە",
|
||||||
"other": "باشقا",
|
"other": "باشقا",
|
||||||
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
||||||
"translate": "تەرجىمە قىلىش",
|
"translate": "تەرجىمە قىلىش",
|
||||||
|
|||||||
@@ -173,7 +173,14 @@
|
|||||||
"translateInto": "翻译为",
|
"translateInto": "翻译为",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "英文",
|
"english": "英文",
|
||||||
|
"french": "法语",
|
||||||
|
"german": "德语",
|
||||||
"italian": "意大利语",
|
"italian": "意大利语",
|
||||||
|
"japanese": "日语",
|
||||||
|
"korean": "韩语",
|
||||||
|
"portuguese": "葡萄牙语",
|
||||||
|
"russian": "俄语",
|
||||||
|
"spanish": "西班牙语",
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"translating": "翻译中...",
|
"translating": "翻译中...",
|
||||||
"translate": "翻译",
|
"translate": "翻译",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "learn-languages",
|
"name": "learn-languages",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --experimental-https",
|
"dev": "next dev --experimental-https",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
||||||
|
DROP COLUMN "dictionary_word_id",
|
||||||
|
ADD COLUMN "dictionary_item_id" INTEGER,
|
||||||
|
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_phrase_entries";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_phrases";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_word_entries";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_words";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_items" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"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_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_entries" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"item_id" INTEGER NOT NULL,
|
||||||
|
"ipa" TEXT,
|
||||||
|
"definition" TEXT NOT NULL,
|
||||||
|
"part_of_speech" TEXT,
|
||||||
|
"example" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -77,8 +77,8 @@ model Pair {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
text1 String
|
text1 String
|
||||||
text2 String
|
text2 String
|
||||||
language1 String @db.VarChar(20)
|
language1 String
|
||||||
language2 String @db.VarChar(20)
|
language2 String
|
||||||
ipa1 String?
|
ipa1 String?
|
||||||
ipa2 String?
|
ipa2 String?
|
||||||
folderId Int @map("folder_id")
|
folderId Int @map("folder_id")
|
||||||
@@ -110,24 +110,24 @@ model DictionaryLookUp {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
text String
|
text String
|
||||||
|
normalizedText String @default("") @map("normalized_text")
|
||||||
queryLang String @map("query_lang")
|
queryLang String @map("query_lang")
|
||||||
definitionLang String @map("definition_lang")
|
definitionLang String @map("definition_lang")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
dictionaryWordId Int? @map("dictionary_word_id")
|
dictionaryItemId Int? @map("dictionary_item_id")
|
||||||
dictionaryPhraseId Int? @map("dictionary_phrase_id")
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
|
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
|
||||||
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([text, queryLang, definitionLang])
|
@@index([normalizedText])
|
||||||
@@map("dictionary_lookups")
|
@@map("dictionary_lookups")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DictionaryWord {
|
model DictionaryItem {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
frequency Int @default(1)
|
||||||
standardForm String @map("standard_form")
|
standardForm String @map("standard_form")
|
||||||
queryLang String @map("query_lang")
|
queryLang String @map("query_lang")
|
||||||
definitionLang String @map("definition_lang")
|
definitionLang String @map("definition_lang")
|
||||||
@@ -135,67 +135,37 @@ model DictionaryWord {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
lookups DictionaryLookUp[]
|
lookups DictionaryLookUp[]
|
||||||
entries DictionaryWordEntry[]
|
entries DictionaryEntry[]
|
||||||
|
|
||||||
|
@@unique([standardForm, queryLang, definitionLang])
|
||||||
@@index([standardForm])
|
@@index([standardForm])
|
||||||
@@index([queryLang, definitionLang])
|
@@index([queryLang, definitionLang])
|
||||||
@@map("dictionary_words")
|
@@map("dictionary_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DictionaryPhrase {
|
model DictionaryEntry {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
standardForm String @map("standard_form")
|
itemId Int @map("item_id")
|
||||||
queryLang String @map("query_lang")
|
ipa String?
|
||||||
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
|
definition String
|
||||||
partOfSpeech String @map("part_of_speech")
|
partOfSpeech String? @map("part_of_speech")
|
||||||
example String
|
example String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
|
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([wordId])
|
@@index([itemId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("dictionary_word_entries")
|
@@map("dictionary_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 {
|
model TranslationHistory {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
sourceText String @map("source_text")
|
sourceText String @map("source_text")
|
||||||
sourceLanguage String @map("source_language") @db.VarChar(20)
|
sourceLanguage String @map("source_language")
|
||||||
targetLanguage String @map("target_language") @db.VarChar(20)
|
targetLanguage String @map("target_language")
|
||||||
translatedText String @map("translated_text")
|
translatedText String @map("translated_text")
|
||||||
sourceIpa String? @map("source_ipa")
|
sourceIpa String? @map("source_ipa")
|
||||||
targetIpa String? @map("target_ipa")
|
targetIpa String? @map("target_ipa")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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";
|
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";
|
||||||
|
|
||||||
interface AlphabetCardProps {
|
interface AlphabetCardProps {
|
||||||
@@ -13,7 +13,7 @@ interface AlphabetCardProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [showIPA, setShowIPA] = useState(true);
|
const [showIPA, setShowIPA] = useState(true);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function MemoryCard({
|
export function MemoryCard({
|
||||||
alphabet,
|
alphabet,
|
||||||
setChosenAlphabet,
|
setChosenAlphabet,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
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";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import AlphabetCard from "./AlphabetCard";
|
import { AlphabetCard } from "./AlphabetCard";
|
||||||
|
|
||||||
export default function Alphabet() {
|
export default function Alphabet() {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
import { DictWordEntry, DictPhraseEntry } from "./types";
|
import { TSharedEntry } from "@/shared/dictionary-type";
|
||||||
|
|
||||||
interface DictionaryEntryProps {
|
interface DictionaryEntryProps {
|
||||||
entry: DictWordEntry | DictPhraseEntry;
|
entry: TSharedEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||||
// 检查是否有 ipa 字段来判断是否为单词条目
|
|
||||||
const isWordEntry = "ipa" in entry && "partOfSpeech" in entry;
|
|
||||||
|
|
||||||
if (isWordEntry) {
|
|
||||||
// 单词条目
|
|
||||||
const wordEntry = entry as DictWordEntry;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 音标和词性 */}
|
{/* 音标和词性 */}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{wordEntry.ipa && (
|
{entry.ipa && (
|
||||||
<span className="text-gray-600 text-lg">
|
<span className="text-gray-600 text-lg">
|
||||||
[{wordEntry.ipa}]
|
[{entry.ipa}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{wordEntry.partOfSpeech && (
|
{entry.partOfSpeech && (
|
||||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||||
{wordEntry.partOfSpeech}
|
{entry.partOfSpeech}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -32,44 +26,17 @@ export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
|||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
释义
|
释义
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-800">{wordEntry.definition}</p>
|
<p className="text-gray-800">{entry.definition}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 例句 */}
|
{/* 例句 */}
|
||||||
{wordEntry.example && (
|
{entry.example && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
例句
|
例句
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
{wordEntry.example}
|
{entry.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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { DictLookUpResponse, isDictErrorResponse } from "./types";
|
|
||||||
import { SearchForm } from "./SearchForm";
|
|
||||||
import { SearchResult } from "./SearchResult";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { POPULAR_LANGUAGES } from "./constants";
|
|
||||||
|
|
||||||
export default function Dictionary() {
|
|
||||||
const t = useTranslations("dictionary");
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [searchResult, setSearchResult] = useState<DictLookUpResponse | null>(null);
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
|
||||||
const [queryLang, setQueryLang] = useState("english");
|
|
||||||
const [definitionLang, setDefinitionLang] = useState("chinese");
|
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
// 加载用户的文件夹列表
|
|
||||||
useEffect(() => {
|
|
||||||
if (session) {
|
|
||||||
getFoldersByUserId(session.user.id as string)
|
|
||||||
.then((loadedFolders) => {
|
|
||||||
setFolders(loadedFolders);
|
|
||||||
// 如果有文件夹且未选择,默认选择第一个
|
|
||||||
if (loadedFolders.length > 0 && !selectedFolderId) {
|
|
||||||
setSelectedFolderId(loadedFolders[0].id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [session, selectedFolderId]);
|
|
||||||
|
|
||||||
// 将 code 转换为 nativeName
|
|
||||||
const getNativeName = (code: string) => {
|
|
||||||
return POPULAR_LANGUAGES.find(l => l.code === code)?.nativeName || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!searchQuery.trim()) return;
|
|
||||||
|
|
||||||
setIsSearching(true);
|
|
||||||
setHasSearched(true);
|
|
||||||
setSearchResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 使用查询语言和释义语言的 nativeName
|
|
||||||
const result = await lookUp({
|
|
||||||
text: searchQuery,
|
|
||||||
definitionLang: getNativeName(definitionLang),
|
|
||||||
queryLang: getNativeName(queryLang),
|
|
||||||
forceRelook: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查是否为错误响应
|
|
||||||
if (isDictErrorResponse(result)) {
|
|
||||||
toast.error(result.error);
|
|
||||||
setSearchResult(null);
|
|
||||||
} else {
|
|
||||||
setSearchResult(result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("词典查询失败:", error);
|
|
||||||
toast.error(t("lookupFailed"));
|
|
||||||
setSearchResult(null);
|
|
||||||
} finally {
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
|
|
||||||
{/* 搜索区域 */}
|
|
||||||
<div className="flex items-center justify-center px-4 py-12">
|
|
||||||
<Container className="max-w-3xl w-full p-4">
|
|
||||||
<SearchForm
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchQueryChange={setSearchQuery}
|
|
||||||
isSearching={isSearching}
|
|
||||||
onSearch={handleSearch}
|
|
||||||
queryLang={queryLang}
|
|
||||||
onQueryLangChange={setQueryLang}
|
|
||||||
definitionLang={definitionLang}
|
|
||||||
onDefinitionLangChange={setDefinitionLang}
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索结果区域 */}
|
|
||||||
<div className="flex-1 px-4 pb-12">
|
|
||||||
<Container className="max-w-3xl w-full p-4">
|
|
||||||
{isSearching && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
|
||||||
<p className="mt-4 text-white">{t("loading")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && hasSearched && !searchResult && (
|
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
|
||||||
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
|
||||||
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && searchResult && !isDictErrorResponse(searchResult) && (
|
|
||||||
<SearchResult
|
|
||||||
searchResult={searchResult}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
queryLang={queryLang}
|
|
||||||
definitionLang={definitionLang}
|
|
||||||
folders={folders}
|
|
||||||
selectedFolderId={selectedFolderId}
|
|
||||||
onFolderSelect={setSelectedFolderId}
|
|
||||||
onResultUpdate={setSearchResult}
|
|
||||||
onSearchingChange={setIsSearching}
|
|
||||||
getNativeName={getNativeName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasSearched && (
|
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
|
||||||
<div className="text-6xl mb-4">📚</div>
|
|
||||||
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
|
|
||||||
<p className="text-gray-600">{t("welcomeHint")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -38,18 +38,18 @@ export function SearchForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索表单 */}
|
{/* 搜索表单 */}
|
||||||
<form onSubmit={onSearch} className="flex gap-2">
|
<form onSubmit={onSearch} className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)}
|
||||||
placeholder={t("searchPlaceholder")}
|
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"
|
className="flex-1 min-w-0 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
||||||
/>
|
/>
|
||||||
<LightButton
|
<LightButton
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSearching || !searchQuery.trim()}
|
disabled={isSearching || !searchQuery.trim()}
|
||||||
className="px-6 py-3"
|
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
|
||||||
>
|
>
|
||||||
{isSearching ? t("searching") : t("search")}
|
{isSearching ? t("searching") : t("search")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
import { Plus, RefreshCw } from "lucide-react";
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { authClient } from "@/lib/auth-client";
|
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 { DictionaryEntry } from "./DictionaryEntry";
|
||||||
import { POPULAR_LANGUAGES } from "./constants";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { performDictionaryLookup } from "./utils";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
import { TSharedFolder } from "@/shared/folder-type";
|
||||||
|
import { actionCreatePair } from "@/modules/folder/folder-aciton";
|
||||||
|
|
||||||
interface SearchResultProps {
|
interface SearchResultProps {
|
||||||
searchResult: DictWordResponse | DictPhraseResponse;
|
searchResult: TSharedItem;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
queryLang: string;
|
queryLang: string;
|
||||||
definitionLang: string;
|
definitionLang: string;
|
||||||
folders: Folder[];
|
folders: TSharedFolder[];
|
||||||
selectedFolderId: number | null;
|
selectedFolderId: number | null;
|
||||||
onFolderSelect: (folderId: number | null) => void;
|
onFolderSelect: (folderId: number | null) => void;
|
||||||
onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void;
|
onResultUpdate: (newResult: TSharedItem) => void;
|
||||||
onSearchingChange: (isSearching: boolean) => void;
|
onSearchingChange: (isSearching: boolean) => void;
|
||||||
getNativeName: (code: string) => string;
|
getNativeName: (code: string) => string;
|
||||||
}
|
}
|
||||||
@@ -46,26 +39,21 @@ export function SearchResult({
|
|||||||
const handleRelookup = async () => {
|
const handleRelookup = async () => {
|
||||||
onSearchingChange(true);
|
onSearchingChange(true);
|
||||||
|
|
||||||
try {
|
const result = await performDictionaryLookup(
|
||||||
const result = await lookUp({
|
{
|
||||||
text: searchQuery,
|
text: searchQuery,
|
||||||
definitionLang: getNativeName(definitionLang),
|
|
||||||
queryLang: getNativeName(queryLang),
|
queryLang: getNativeName(queryLang),
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
forceRelook: true
|
forceRelook: true
|
||||||
});
|
},
|
||||||
|
t
|
||||||
|
);
|
||||||
|
|
||||||
if (isDictErrorResponse(result)) {
|
if (result) {
|
||||||
toast.error(result.error);
|
|
||||||
} else {
|
|
||||||
onResultUpdate(result);
|
onResultUpdate(result);
|
||||||
toast.success(t("relookupSuccess"));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("词典重新查询失败:", error);
|
|
||||||
toast.error(t("lookupFailed"));
|
|
||||||
} finally {
|
|
||||||
onSearchingChange(false);
|
onSearchingChange(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -79,12 +67,12 @@ export function SearchResult({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = searchResult.entries[0];
|
const entry = searchResult.entries[0];
|
||||||
createPair({
|
actionCreatePair({
|
||||||
text1: searchResult.standardForm,
|
text1: searchResult.standardForm,
|
||||||
text2: entry.definition,
|
text2: entry.definition,
|
||||||
language1: queryLang,
|
language1: queryLang,
|
||||||
language2: definitionLang,
|
language2: definitionLang,
|
||||||
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
|
ipa1: entry.ipa,
|
||||||
folderId: selectedFolderId,
|
folderId: selectedFolderId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
// 类型定义
|
|
||||||
export * from "./types";
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
export * from "./constants";
|
|
||||||
|
|
||||||
// 组件
|
|
||||||
export { default as DictionaryPage } from "./DictionaryPage";
|
|
||||||
export { SearchForm } from "./SearchForm";
|
|
||||||
export { SearchResult } from "./SearchResult";
|
|
||||||
export { DictionaryEntry } from "./DictionaryEntry";
|
|
||||||
@@ -1 +1,133 @@
|
|||||||
export { default } from "./DictionaryPage";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Container } from "@/components/ui/Container";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { SearchForm } from "./SearchForm";
|
||||||
|
import { SearchResult } from "./SearchResult";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { POPULAR_LANGUAGES } from "./constants";
|
||||||
|
import { performDictionaryLookup } from "./utils";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
|
||||||
|
import { TSharedFolder } from "@/shared/folder-type";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function DictionaryPage() {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResult, setSearchResult] = useState<TSharedItem | 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<TSharedFolder[]>([]);
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
|
// 加载用户的文件夹列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) {
|
||||||
|
actionGetFoldersByUserId(session.user.id as string)
|
||||||
|
.then(result => {
|
||||||
|
if (!result.success || !result.data) throw result.message;
|
||||||
|
return result.data;
|
||||||
|
})
|
||||||
|
.then((loadedFolders) => {
|
||||||
|
setFolders(loadedFolders);
|
||||||
|
// 如果有文件夹且未选择,默认选择第一个
|
||||||
|
if (loadedFolders.length > 0 && !selectedFolderId) {
|
||||||
|
setSelectedFolderId(loadedFolders[0].id);
|
||||||
|
}
|
||||||
|
}).catch(e => toast.error);
|
||||||
|
}
|
||||||
|
}, [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);
|
||||||
|
|
||||||
|
const result = await performDictionaryLookup(
|
||||||
|
{
|
||||||
|
text: searchQuery,
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
forceRelook: false
|
||||||
|
},
|
||||||
|
t
|
||||||
|
);
|
||||||
|
setSearchResult(result);
|
||||||
|
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 && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// 从 shared 文件夹导出所有词典类型和类型守卫
|
|
||||||
export * from "@/lib/shared";
|
|
||||||
25
src/app/(features)/dictionary/utils.ts
Normal file
25
src/app/(features)/dictionary/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
|
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||||
|
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary } from "@/modules/dictionary/dictionary-action-dto";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
|
||||||
|
export async function performDictionaryLookup(
|
||||||
|
options: ActionInputLookUpDictionary,
|
||||||
|
t?: (key: string) => string
|
||||||
|
): Promise<TSharedItem | null> {
|
||||||
|
const { text, queryLang, definitionLang, forceRelook = false, userId } = options;
|
||||||
|
const result = await actionLookUpDictionary({
|
||||||
|
text,
|
||||||
|
queryLang,
|
||||||
|
definitionLang,
|
||||||
|
forceRelook,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) return null;
|
||||||
|
|
||||||
|
if (forceRelook && t) {
|
||||||
|
toast.success(t("relookupSuccess"));
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
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 as Fd } from "lucide-react";
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
interface FolderSelectorProps {
|
||||||
folders: (Folder & { total: number })[];
|
folders: TSharedFolderWithTotalPairs[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
@@ -50,7 +50,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
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"
|
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">
|
<div className="shrink-0">
|
||||||
<Fd className="text-gray-600" size={24} />
|
<Fd className="text-gray-600" size={24} />
|
||||||
</div>
|
</div>
|
||||||
{/* 文件夹信息 */}
|
{/* 文件夹信息 */}
|
||||||
@@ -93,4 +93,4 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FolderSelector;
|
export { FolderSelector };
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
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 "@/utils/random";
|
||||||
import { Pair } from "../../../../generated/prisma/browser";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
|
||||||
const myFont = localFont({
|
const myFont = localFont({
|
||||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MemorizeProps {
|
interface MemorizeProps {
|
||||||
textPairs: Pair[];
|
textPairs: TSharedPair[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||||
@@ -176,8 +176,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleReverse}
|
onClick={toggleReverse}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${reverse
|
||||||
reverse
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
@@ -186,8 +185,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDictation}
|
onClick={toggleDictation}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${dictation
|
||||||
dictation
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
@@ -196,8 +194,7 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDisorder}
|
onClick={toggleDisorder}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
className={`px-4 py-2 rounded-full transition-colors text-sm ${disorder
|
||||||
disorder
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
@@ -211,4 +208,4 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Memorize;
|
export { Memorize };
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import { isNonNegativeInteger } from "@/utils/random";
|
||||||
getFoldersWithTotalPairsByUserId,
|
import { FolderSelector } from "./FolderSelector";
|
||||||
} from "@/lib/server/services/folderService";
|
import { Memorize } from "./Memorize";
|
||||||
import { isNonNegativeInteger } from "@/lib/utils";
|
|
||||||
import FolderSelector from "./FolderSelector";
|
|
||||||
import Memorize from "./Memorize";
|
|
||||||
import { getPairsByFolderId } from "@/lib/server/services/pairService";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
|
|
||||||
export default async function MemorizePage({
|
export default async function MemorizePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -27,13 +24,14 @@ export default async function MemorizePage({
|
|||||||
|
|
||||||
if (!folder_id) {
|
if (!folder_id) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if(!session) redirect("/auth?redirect=/memorize")
|
if (!session) redirect("/auth?redirect=/memorize");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FolderSelector
|
<FolderSelector
|
||||||
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
export function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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";
|
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";
|
||||||
@@ -213,4 +213,4 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
|||||||
|
|
||||||
VideoPanel.displayName = "VideoPanel";
|
VideoPanel.displayName = "VideoPanel";
|
||||||
|
|
||||||
export default VideoPanel;
|
export { VideoPanel };
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface FileInputComponentProps extends FileInputProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
export function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleClick = React.useCallback(() => {
|
const handleClick = React.useCallback(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
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 function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||||
const t = useTranslations("srt_player");
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { SeekBarProps } from "../../types/player";
|
import { SeekBarProps } from "../../types/player";
|
||||||
|
|
||||||
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
export function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = parseInt(event.target.value);
|
const newValue = parseInt(event.target.value);
|
||||||
onChange(newValue);
|
onChange(newValue);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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";
|
||||||
|
|
||||||
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
export function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||||
const speedOptions = getPlaybackRateOptions();
|
const speedOptions = getPlaybackRateOptions();
|
||||||
|
|
||||||
const handleSpeedChange = React.useCallback(() => {
|
const handleSpeedChange = React.useCallback(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { SubtitleTextProps } from "../../types/subtitle";
|
import { SubtitleTextProps } from "../../types/subtitle";
|
||||||
|
|
||||||
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
export function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||||
const handleWordClick = React.useCallback((word: string) => {
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
onWordClick?.(word);
|
onWordClick?.(word);
|
||||||
}, [onWordClick]);
|
}, [onWordClick]);
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
|||||||
|
|
||||||
VideoElement.displayName = "VideoElement";
|
VideoElement.displayName = "VideoElement";
|
||||||
|
|
||||||
export default VideoElement;
|
export { VideoElement };
|
||||||
@@ -5,10 +5,10 @@ import { useTranslations } from "next-intl";
|
|||||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
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";
|
||||||
|
|
||||||
export default function ControlBar({
|
export function ControlBar({
|
||||||
isPlaying,
|
isPlaying,
|
||||||
onPlayPause,
|
onPlayPause,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { SubtitleDisplayProps } from "../../types/subtitle";
|
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||||
import SubtitleText from "../atoms/SubtitleText";
|
import { SubtitleText } from "../atoms/SubtitleText";
|
||||||
|
|
||||||
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
export function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
||||||
const handleWordClick = React.useCallback((word: string) => {
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
// 打开有道词典页面查询单词
|
// 打开有道词典页面查询单词
|
||||||
window.open(
|
window.open(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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";
|
||||||
|
|
||||||
export default function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
export function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
||||||
const t = useTranslations("srt_player");
|
const t = useTranslations("srt_player");
|
||||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import { VideoElementProps } from "../../types/player";
|
import { VideoElementProps } from "../../types/player";
|
||||||
import VideoElement from "../atoms/VideoElement";
|
import { VideoElement } from "../atoms/VideoElement";
|
||||||
|
|
||||||
interface VideoPlayerComponentProps extends VideoElementProps {
|
interface VideoPlayerComponentProps extends VideoElementProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -38,4 +38,4 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
|
|||||||
|
|
||||||
VideoPlayer.displayName = "VideoPlayer";
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|
||||||
export default VideoPlayer;
|
export { VideoPlayer };
|
||||||
@@ -9,11 +9,11 @@ import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
|||||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import { useFileUpload } from "./hooks/useFileUpload";
|
import { useFileUpload } from "./hooks/useFileUpload";
|
||||||
import { loadSubtitle } from "./utils/subtitleParser";
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
import VideoPlayer from "./components/compounds/VideoPlayer";
|
import { VideoPlayer } from "./components/compounds/VideoPlayer";
|
||||||
import SubtitleArea from "./components/compounds/SubtitleArea";
|
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 { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
|
|
||||||
export default function SrtPlayerPage() {
|
export default function SrtPlayerPage() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
TextSpeakerItemSchema,
|
TextSpeakerItemSchema,
|
||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
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";
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ interface SaveListProps {
|
|||||||
show?: boolean;
|
show?: boolean;
|
||||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
}
|
}
|
||||||
export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
||||||
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import {
|
import {
|
||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import SaveList from "./SaveList";
|
import { SaveList } from "./SaveList";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
|
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
|
|
||||||
export default function TextSpeakerPage() {
|
export default function TextSpeakerPage() {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
|
||||||
import { Dispatch, useEffect, useState } from "react";
|
|
||||||
import z from "zod";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
interface AddToFolderProps {
|
|
||||||
item: z.infer<typeof TranslationHistorySchema>;
|
|
||||||
setShow: Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const t = useTranslations("translator.add_to_folder");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session) return;
|
|
||||||
const userId = session.user.id;
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<div>{t("notAuthenticated")}</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<h1>{t("chooseFolder")}</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl">
|
|
||||||
{(loading && <span>...</span>) ||
|
|
||||||
(folders.length > 0 &&
|
|
||||||
folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
|
||||||
onClick={() => {
|
|
||||||
createPair({
|
|
||||||
text1: item.text1,
|
|
||||||
text2: item.text2,
|
|
||||||
language1: item.language1,
|
|
||||||
language2: item.language2,
|
|
||||||
folderId: folder.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(t("success"));
|
|
||||||
setShow(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(t("error"));
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{t("folderInfo", { id: folder.id, name: folder.name })}
|
|
||||||
</button>
|
|
||||||
))) || <div>{t("noFolders")}</div>}
|
|
||||||
</div>
|
|
||||||
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddToFolder;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
|
||||||
setSelectedFolderId: (id: number) => void;
|
|
||||||
userId: string;
|
|
||||||
cancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({
|
|
||||||
setSelectedFolderId,
|
|
||||||
userId,
|
|
||||||
cancel,
|
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
|
|
||||||
>
|
|
||||||
<Container className="p-6">
|
|
||||||
{(loading && <p>Loading...</p>) ||
|
|
||||||
(folders.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h1>Select a Folder</h1>
|
|
||||||
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
className="p-2 w-full flex hover:bg-gray-50 gap-2"
|
|
||||||
key={folder.id}
|
|
||||||
onClick={() => setSelectedFolderId(folder.id)}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{folder.id}. {folder.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)) || <p>No folders found</p>}
|
|
||||||
<LightButton onClick={cancel}>Cancel</LightButton>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FolderSelector;
|
|
||||||
@@ -2,31 +2,21 @@
|
|||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
|
||||||
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { Plus, Trash } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import z from "zod";
|
import { actionTranslateText } from "@/modules/translator/translator-action";
|
||||||
import AddToFolder from "./AddToFolder";
|
|
||||||
import { translateText } from "@/lib/server/bigmodel/translatorActions";
|
|
||||||
import type { TranslateTextOutput } from "@/lib/server/services/types";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import FolderSelector from "./FolderSelector";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||||
import { shallowEqual } from "@/lib/utils";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | null>(null);
|
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||||
const [needIpa, setNeedIpa] = useState(true);
|
const [needIpa, setNeedIpa] = useState(true);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [lastTranslation, setLastTranslation] = useState<{
|
const [lastTranslation, setLastTranslation] = useState<{
|
||||||
@@ -34,18 +24,10 @@ export default function TranslatorPage() {
|
|||||||
targetLanguage: string;
|
targetLanguage: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
|
|
||||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
|
||||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
|
||||||
typeof TranslationHistorySchema
|
|
||||||
> | null>(null);
|
|
||||||
const lastTTS = useRef({
|
const lastTTS = useRef({
|
||||||
text: "",
|
text: "",
|
||||||
url: "",
|
url: "",
|
||||||
});
|
});
|
||||||
const [autoSave, setAutoSave] = useState(false);
|
|
||||||
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
const tts = async (text: string, locale: string) => {
|
const tts = async (text: string, locale: string) => {
|
||||||
if (lastTTS.current.text !== text) {
|
if (lastTTS.current.text !== text) {
|
||||||
@@ -65,14 +47,13 @@ export default function TranslatorPage() {
|
|||||||
|
|
||||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||||
await load(url);
|
await load(url);
|
||||||
|
await play();
|
||||||
lastTTS.current.text = text;
|
lastTTS.current.text = text;
|
||||||
lastTTS.current.url = url;
|
lastTTS.current.url = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to generate audio");
|
toast.error("Failed to generate audio");
|
||||||
logger.error("生成音频失败", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await play();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
@@ -89,46 +70,21 @@ export default function TranslatorPage() {
|
|||||||
lastTranslation?.targetLanguage === targetLanguage;
|
lastTranslation?.targetLanguage === targetLanguage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await translateText({
|
const result = await actionTranslateText({
|
||||||
sourceText,
|
sourceText,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
forceRetranslate,
|
forceRetranslate,
|
||||||
needIpa,
|
needIpa,
|
||||||
userId: session?.user?.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setTranslationResult(result);
|
if (result.success && result.data) {
|
||||||
|
setTranslationResult(result.data);
|
||||||
setLastTranslation({
|
setLastTranslation({
|
||||||
sourceText,
|
sourceText,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
// 更新本地历史记录
|
toast.error(result.message || "翻译失败,请重试");
|
||||||
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) {
|
} catch (error) {
|
||||||
toast.error("翻译失败,请重试");
|
toast.error("翻译失败,请重试");
|
||||||
@@ -261,84 +217,6 @@ export default function TranslatorPage() {
|
|||||||
{t("translate")}
|
{t("translate")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AutoSave Component */}
|
|
||||||
<div className="w-screen flex justify-center items-center">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoSave}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
if (checked === true && !session) {
|
|
||||||
toast.warning("Please login to enable auto-save");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checked === false) setAutoSaveFolderId(null);
|
|
||||||
setAutoSave(checked);
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
{t("autoSave")}
|
|
||||||
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.length > 0 && (
|
|
||||||
<div className="m-6 flex flex-col items-center">
|
|
||||||
<h1 className="text-2xl font-light">{t("history")}</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl m-4">
|
|
||||||
{history.toReversed().map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
|
|
||||||
>
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<p className="text-sm font-light">{item.text1}</p>
|
|
||||||
<p className="text-sm font-light">{item.text2}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!session?.user) {
|
|
||||||
toast.info("请先登录后再保存到文件夹");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowAddToFolder(true);
|
|
||||||
setAddToFolderItem(item);
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setHistory(
|
|
||||||
tlso.set(
|
|
||||||
tlso.get().filter((v) => !shallowEqual(v, item)),
|
|
||||||
) || [],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<Trash />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{showAddToFolder && (
|
|
||||||
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
|
||||||
)}
|
|
||||||
{autoSave && !autoSaveFolderId && (
|
|
||||||
<FolderSelector
|
|
||||||
userId={session!.user.id as string}
|
|
||||||
cancel={() => setAutoSave(false)}
|
|
||||||
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useActionState, startTransition } from "react";
|
import { useState, useActionState, startTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
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";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { signInAction, signUpAction, SignUpState } from "@/modules/auth/auth-action";
|
||||||
|
|
||||||
interface AuthFormProps {
|
interface AuthFormProps {
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthForm({ redirectTo }: AuthFormProps) {
|
export function AuthForm({ redirectTo }: AuthFormProps) {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||||
const [clearSignIn, setClearSignIn] = useState(false);
|
const [clearSignIn, setClearSignIn] = useState(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import AuthForm from "./AuthForm";
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
export default async function AuthPage(
|
export default async function AuthPage(
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -8,23 +8,17 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Folder } from "../../../generated/prisma/browser";
|
|
||||||
import {
|
|
||||||
createFolder,
|
|
||||||
deleteFolderById,
|
|
||||||
getFoldersWithTotalPairsByUserId,
|
|
||||||
renameFolderById,
|
|
||||||
} 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 { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import CardList from "@/components/ui/CardList";
|
import { CardList } from "@/components/ui/CardList";
|
||||||
|
import { actionCreateFolder, actionDeleteFolderById, actionGetFoldersWithTotalPairsByUserId, actionRenameFolderById } from "@/modules/folder/folder-aciton";
|
||||||
|
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
|
||||||
interface FolderProps {
|
interface FolderProps {
|
||||||
folder: Folder & { total: number };
|
folder: TSharedFolderWithTotalPairs;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +56,15 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newName = prompt("Input a new name.")?.trim();
|
const newName = prompt("Input a new name.")?.trim();
|
||||||
if (newName && newName.length > 0) {
|
if (newName && newName.length > 0) {
|
||||||
renameFolderById(folder.id, newName).then(refresh);
|
actionRenameFolderById(folder.id, newName)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
@@ -74,7 +76,15 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||||
if (confirm === folder.name) {
|
if (confirm === folder.name) {
|
||||||
deleteFolderById(folder.id).then(refresh);
|
actionDeleteFolderById(folder.id)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
@@ -87,33 +97,37 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FoldersClient({ userId }: { userId: string }) {
|
export function FoldersClient({ userId }: { userId: string; }) {
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("folders");
|
||||||
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
|
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getFoldersWithTotalPairsByUserId(userId)
|
actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
.then((folders) => {
|
.then((folders) => {
|
||||||
setFolders(folders);
|
if (folders.success && folders.data) {
|
||||||
|
setFolders(folders.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
|
||||||
logger.error("加载文件夹失败", error);
|
|
||||||
toast.error("加载出错,请重试。");
|
|
||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const updateFolders = async () => {
|
const updateFolders = async () => {
|
||||||
try {
|
setLoading(true);
|
||||||
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
setFolders(updatedFolders);
|
.then(async result => {
|
||||||
} catch (error) {
|
if (!result.success) toast.error(result.message);
|
||||||
logger.error("更新文件夹失败", error);
|
else await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then((folders) => {
|
||||||
|
if (folders.success && folders.data) {
|
||||||
|
setFolders(folders.data);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,11 +141,14 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
if (!folderName) return;
|
if (!folderName) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createFolder({
|
await actionCreateFolder(userId, folderName)
|
||||||
name: folderName,
|
.then(result => {
|
||||||
userId: userId,
|
if (result.success) {
|
||||||
|
updateFolders();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await updateFolders();
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import Input from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -16,7 +16,7 @@ interface AddTextPairModalProps {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddTextPairModal({
|
export function AddTextPairModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
|||||||
@@ -3,30 +3,20 @@
|
|||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import {
|
import { AddTextPairModal } from "./AddTextPairModal";
|
||||||
createPair,
|
import { TextPairCard } from "./TextPairCard";
|
||||||
deletePairById,
|
|
||||||
getPairsByFolderId,
|
|
||||||
} from "@/lib/server/services/pairService";
|
|
||||||
import AddTextPairModal from "./AddTextPairModal";
|
|
||||||
import TextPairCard from "./TextPairCard";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { GreenButton } from "@/components/ui/buttons";
|
import { GreenButton } from "@/components/ui/buttons";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { IconButton } from "@/components/ui/buttons";
|
import { IconButton } from "@/components/ui/buttons";
|
||||||
import CardList from "@/components/ui/CardList";
|
import { CardList } from "@/components/ui/CardList";
|
||||||
|
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export interface TextPair {
|
|
||||||
id: number;
|
|
||||||
text1: string;
|
|
||||||
text2: string;
|
|
||||||
language1: string;
|
|
||||||
language2: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InFolder({ folderId }: { folderId: number }) {
|
export function InFolder({ folderId }: { folderId: number; }) {
|
||||||
const [textPairs, setTextPairs] = useState<TextPair[]>([]);
|
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [openAddModal, setAddModal] = useState(false);
|
const [openAddModal, setAddModal] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,25 +25,26 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTextPairs = async () => {
|
const fetchTextPairs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
await actionGetPairsByFolderId(folderId)
|
||||||
const data = await getPairsByFolderId(folderId);
|
.then(result => {
|
||||||
setTextPairs(data as TextPair[]);
|
if (!result.success || !result.data) throw result.message;
|
||||||
} catch (error) {
|
return result.data;
|
||||||
logger.error("获取文本对失败", error);
|
}).then(setTextPairs)
|
||||||
} finally {
|
.catch(toast.error)
|
||||||
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
fetchTextPairs();
|
fetchTextPairs();
|
||||||
}, [folderId]);
|
}, [folderId]);
|
||||||
|
|
||||||
const refreshTextPairs = async () => {
|
const refreshTextPairs = async () => {
|
||||||
try {
|
await actionGetPairsByFolderId(folderId)
|
||||||
const data = await getPairsByFolderId(folderId);
|
.then(result => {
|
||||||
setTextPairs(data as TextPair[]);
|
if (!result.success || !result.data) throw result.message;
|
||||||
} catch (error) {
|
return result.data;
|
||||||
logger.error("获取文本对失败", error);
|
}).then(setTextPairs)
|
||||||
}
|
.catch(toast.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,8 +114,11 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
key={textPair.id}
|
key={textPair.id}
|
||||||
textPair={textPair}
|
textPair={textPair}
|
||||||
onDel={() => {
|
onDel={() => {
|
||||||
deletePairById(textPair.id);
|
actionDeletePairById(textPair.id)
|
||||||
refreshTextPairs();
|
.then(result => {
|
||||||
|
if (!result.success) throw result.message;
|
||||||
|
}).then(refreshTextPairs)
|
||||||
|
.catch(toast.error);
|
||||||
}}
|
}}
|
||||||
refreshTextPairs={refreshTextPairs}
|
refreshTextPairs={refreshTextPairs}
|
||||||
/>
|
/>
|
||||||
@@ -143,7 +137,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
language1: string,
|
language1: string,
|
||||||
language2: string,
|
language2: string,
|
||||||
) => {
|
) => {
|
||||||
await createPair({
|
await actionCreatePair({
|
||||||
text1: text1,
|
text1: text1,
|
||||||
text2: text2,
|
text2: text2,
|
||||||
language1: language1,
|
language1: language1,
|
||||||
@@ -155,4 +149,4 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Edit, Trash2 } from "lucide-react";
|
import { Edit, Trash2 } from "lucide-react";
|
||||||
import { TextPair } from "./InFolder";
|
|
||||||
import { updatePairById } from "@/lib/server/services/pairService";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { UpdatePairInput } from "@/lib/server/services/types";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { actionUpdatePairById } from "@/modules/folder/folder-aciton";
|
||||||
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface TextPairCardProps {
|
interface TextPairCardProps {
|
||||||
textPair: TextPair;
|
textPair: TSharedPair;
|
||||||
onDel: () => void;
|
onDel: () => void;
|
||||||
refreshTextPairs: () => void;
|
refreshTextPairs: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TextPairCard({
|
export function TextPairCard({
|
||||||
textPair,
|
textPair,
|
||||||
onDel,
|
onDel,
|
||||||
refreshTextPairs,
|
refreshTextPairs,
|
||||||
@@ -66,8 +67,8 @@ export default function TextPairCard({
|
|||||||
<UpdateTextPairModal
|
<UpdateTextPairModal
|
||||||
isOpen={openUpdateModal}
|
isOpen={openUpdateModal}
|
||||||
onClose={() => setOpenUpdateModal(false)}
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
onUpdate={async (id: number, data: UpdatePairInput) => {
|
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
|
||||||
await updatePairById(id, data);
|
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
|
||||||
setOpenUpdateModal(false);
|
setOpenUpdateModal(false);
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import Input from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { UpdatePairInput } from "@/lib/server/services/types";
|
|
||||||
import { TextPair } from "./InFolder";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
|
|
||||||
interface UpdateTextPairModalProps {
|
interface UpdateTextPairModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
textPair: TextPair;
|
textPair: TSharedPair;
|
||||||
onUpdate: (id: number, tp: UpdatePairInput) => void;
|
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdateTextPairModal({
|
export function UpdateTextPairModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import InFolder from "./InFolder";
|
import { InFolder } from "./InFolder";
|
||||||
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
export default async function FoldersPage({
|
export default async function FoldersPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -17,7 +17,7 @@ export default async function FoldersPage({
|
|||||||
redirect("/folders");
|
redirect("/folders");
|
||||||
}
|
}
|
||||||
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
|
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
|
||||||
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
|
if ((await actionGetUserIdByFolderId(Number(folder_id))).data !== session.user.id) {
|
||||||
return <p>{t("unauthorized")}</p>;
|
return <p>{t("unauthorized")}</p>;
|
||||||
}
|
}
|
||||||
return <InFolder folderId={Number(folder_id)} />;
|
return <InFolder folderId={Number(folder_id)} />;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import FoldersClient from "./FoldersClient";
|
import { FoldersClient } from "./FoldersClient";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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";
|
||||||
|
|
||||||
export default function LogoutButton() {
|
export function LogoutButton() {
|
||||||
const t = useTranslations("profile");
|
const t = useTranslations("profile");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return <LightButton onClick={async () => {
|
return <LightButton onClick={async () => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
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";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import LogoutButton from "./LogoutButton";
|
import { LogoutButton } from "./LogoutButton";
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const t = await getTranslations("profile");
|
const t = await getTranslations("profile");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
import { nextCookies } from "better-auth/next-js";
|
import { nextCookies } from "better-auth/next-js";
|
||||||
import prisma from "./lib/db";
|
import { prisma } from "./lib/db";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import IMAGES from "@/config/images";
|
import { GhostButton } from "./ui/buttons";
|
||||||
import { IconClick, GhostButton } from "./ui/buttons";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Languages } from "lucide-react";
|
||||||
|
|
||||||
export default function LanguageSettings() {
|
export function LanguageSettings() {
|
||||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||||
const handleLanguageClick = () => {
|
const handleLanguageClick = () => {
|
||||||
setShowLanguageMenu((prev) => !prev);
|
setShowLanguageMenu((prev) => !prev);
|
||||||
@@ -15,13 +15,7 @@ export default function LanguageSettings() {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconClick
|
<Languages />
|
||||||
src={IMAGES.language_white}
|
|
||||||
alt="language"
|
|
||||||
disableOnHoverBgChange={true}
|
|
||||||
onClick={handleLanguageClick}
|
|
||||||
size={40}
|
|
||||||
></IconClick>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{showLanguageMenu && (
|
{showLanguageMenu && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { Folder, Home, User } 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";
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface ButtonProps {
|
|||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Button({
|
export function Button({
|
||||||
variant = "secondary",
|
variant = "secondary",
|
||||||
size = "md",
|
size = "md",
|
||||||
selected = false,
|
selected = false,
|
||||||
@@ -87,7 +87,7 @@ export default function Button({
|
|||||||
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
|
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
|
||||||
|
|
||||||
// Background color for primary variant
|
// Background color for primary variant
|
||||||
const backgroundColor = variant === "primary" ? COLORS.primary : undefined;
|
const backgroundColor = variant === "primary" ? '#35786f' : undefined;
|
||||||
|
|
||||||
// Combine all classes
|
// Combine all classes
|
||||||
const combinedClasses = `
|
const combinedClasses = `
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface CardListProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CardList({ children, className = "" }: CardListProps) {
|
export function CardList({ children, className = "" }: CardListProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ interface ContainerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Container({ children, className }: ContainerProps) {
|
export function Container({ children, className }: ContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl ${className}`}
|
className={`w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl ${className}`}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input({
|
export function Input({
|
||||||
ref,
|
ref,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
type = "text",
|
type = "text",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface PageHeaderProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageHeader({ title, subtitle }: PageHeaderProps) {
|
export function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface PageLayoutProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageLayout({ children, className = "" }: PageLayoutProps) {
|
export function PageLayout({ children, className = "" }: PageLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8 ${className}`}>
|
<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="w-full max-w-2xl">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 向后兼容的按钮组件包装器
|
// 向后兼容的按钮组件包装器
|
||||||
// 这些组件将新 Button 组件包装,以保持向后兼容
|
// 这些组件将新 Button 组件包装,以保持向后兼容
|
||||||
|
|
||||||
import Button from "../Button";
|
import { Button } from "../Button";
|
||||||
|
|
||||||
// LightButton: 次要按钮,支持 selected 状态
|
// LightButton: 次要按钮,支持 selected 状态
|
||||||
export const LightButton = (props: any) => <Button variant="secondary" {...props} />;
|
export const LightButton = (props: any) => <Button variant="secondary" {...props} />;
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ const IMAGES = {
|
|||||||
github_mark_white: "/images/github-mark/github-mark-white.svg",
|
github_mark_white: "/images/github-mark/github-mark-white.svg",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IMAGES;
|
export { IMAGES };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
export default function useFileUpload(callback: (file: File) => void) {
|
export function useFileUpload(callback: (file: File) => void) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const upload = (type: string = "*") => {
|
const upload = (type: string = "*") => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { DictLookUpResponse } from "@/lib/shared";
|
import { ServiceOutputLookUp } from "@/modules/dictionary/dictionary-service-dto";
|
||||||
|
|
||||||
import { analyzeInput } from "./stage1-inputAnalysis";
|
import { analyzeInput } from "./stage1-inputAnalysis";
|
||||||
import { determineSemanticMapping } from "./stage2-semanticMapping";
|
import { determineSemanticMapping } from "./stage2-semanticMapping";
|
||||||
import { generateStandardForm } from "./stage3-standardForm";
|
import { generateStandardForm } from "./stage3-standardForm";
|
||||||
import { generateEntries } from "./stage4-entriesGeneration";
|
import { generateEntries } from "./stage4-entriesGeneration";
|
||||||
|
import { LookUpError } from "@/lib/errors";
|
||||||
|
|
||||||
/**
|
|
||||||
* 词典查询主编排器
|
|
||||||
*
|
|
||||||
* 将多个独立的 LLM 调用串联起来,每个阶段都有代码层面的数据验证
|
|
||||||
* 只要有一环失败,直接返回错误
|
|
||||||
*/
|
|
||||||
export async function executeDictionaryLookup(
|
export async function executeDictionaryLookup(
|
||||||
text: string,
|
text: string,
|
||||||
queryLang: string,
|
queryLang: string,
|
||||||
definitionLang: string
|
definitionLang: string
|
||||||
): Promise<DictLookUpResponse> {
|
): Promise<ServiceOutputLookUp> {
|
||||||
try {
|
try {
|
||||||
// ========== 阶段 1:输入分析 ==========
|
// ========== 阶段 1:输入分析 ==========
|
||||||
console.log("[阶段1] 开始输入分析...");
|
console.log("[阶段1] 开始输入分析...");
|
||||||
@@ -24,16 +18,12 @@ export async function executeDictionaryLookup(
|
|||||||
// 代码层面验证:输入是否有效
|
// 代码层面验证:输入是否有效
|
||||||
if (!analysis.isValid) {
|
if (!analysis.isValid) {
|
||||||
console.log("[阶段1] 输入无效:", analysis.reason);
|
console.log("[阶段1] 输入无效:", analysis.reason);
|
||||||
return {
|
throw analysis.reason || "无效输入";
|
||||||
error: analysis.reason || "无效输入",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (analysis.isEmpty) {
|
if (analysis.isEmpty) {
|
||||||
console.log("[阶段1] 输入为空");
|
console.log("[阶段1] 输入为空");
|
||||||
return {
|
throw "输入为空";
|
||||||
error: "输入为空",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[阶段1] 输入分析完成:", analysis);
|
console.log("[阶段1] 输入分析完成:", analysis);
|
||||||
@@ -65,9 +55,7 @@ export async function executeDictionaryLookup(
|
|||||||
// 代码层面验证:标准形式不能为空
|
// 代码层面验证:标准形式不能为空
|
||||||
if (!standardFormResult.standardForm) {
|
if (!standardFormResult.standardForm) {
|
||||||
console.error("[阶段3] 标准形式为空");
|
console.error("[阶段3] 标准形式为空");
|
||||||
return {
|
throw "无法生成标准形式";
|
||||||
error: "无法生成标准形式",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
|
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
|
||||||
@@ -86,7 +74,7 @@ export async function executeDictionaryLookup(
|
|||||||
console.log("[阶段4] 词条生成完成:", entriesResult);
|
console.log("[阶段4] 词条生成完成:", entriesResult);
|
||||||
|
|
||||||
// ========== 组装最终结果 ==========
|
// ========== 组装最终结果 ==========
|
||||||
const finalResult: DictLookUpResponse = {
|
const finalResult: ServiceOutputLookUp = {
|
||||||
standardForm: standardFormResult.standardForm,
|
standardForm: standardFormResult.standardForm,
|
||||||
entries: entriesResult.entries,
|
entries: entriesResult.entries,
|
||||||
};
|
};
|
||||||
@@ -97,10 +85,7 @@ export async function executeDictionaryLookup(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[错误] 词典查询失败:", error);
|
console.error("[错误] 词典查询失败:", error);
|
||||||
|
|
||||||
// 任何阶段失败都返回错误(包含 reason)
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||||
return {
|
throw new LookUpError(errorMessage);
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { InputAnalysisResult } from "./types";
|
import { InputAnalysisResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { SemanticMappingResult } from "./types";
|
import { SemanticMappingResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { StandardFormResult } from "./types";
|
import { StandardFormResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { EntriesGenerationResult } from "./types";
|
import { EntriesGenerationResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
189
src/lib/bigmodel/translator/orchestrator.ts
Normal file
189
src/lib/bigmodel/translator/orchestrator.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { getAnswer } from "../zhipu";
|
||||||
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
|
import { LanguageDetectionResult, TranslationLLMResponse } from "./types";
|
||||||
|
|
||||||
|
async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
|
||||||
|
const prompt = `
|
||||||
|
你是一个语言识别专家。分析用户输入并返回 JSON 结果。
|
||||||
|
|
||||||
|
用户输入位于 <text> 标签内:
|
||||||
|
<text>${text}</text>
|
||||||
|
|
||||||
|
你的任务是:
|
||||||
|
1. 识别输入文本的语言
|
||||||
|
2. 评估识别置信度
|
||||||
|
|
||||||
|
返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"sourceLanguage": "检测到的语言名称(如 English、中文、日本語、Français、Deutsch等)",
|
||||||
|
"confidence": "high/medium/low"
|
||||||
|
}
|
||||||
|
|
||||||
|
只返回 JSON,不要任何其他文字。
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "你是一个语言识别专家,只返回 JSON 格式的分析结果。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]).then(parseAIGeneratedJSON<LanguageDetectionResult>);
|
||||||
|
|
||||||
|
if (typeof result.sourceLanguage !== "string" || !result.sourceLanguage) {
|
||||||
|
throw new Error("Invalid source language detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Language detection failed:", error);
|
||||||
|
throw new Error("Failed to detect source language");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performTranslation(
|
||||||
|
sourceText: string,
|
||||||
|
sourceLanguage: string,
|
||||||
|
targetLanguage: string
|
||||||
|
): Promise<string> {
|
||||||
|
const prompt = `
|
||||||
|
你是一个专业翻译。将文本翻译成目标语言。
|
||||||
|
|
||||||
|
源文本位于 <source_text> 标签内:
|
||||||
|
<source_text>${sourceText}</source_text>
|
||||||
|
|
||||||
|
源语言:${sourceLanguage}
|
||||||
|
目标语言:${targetLanguage}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 保持原意准确
|
||||||
|
2. 符合目标语言的表达习惯
|
||||||
|
3. 如果是成语、俗语或文化特定表达,在目标语言中寻找对应表达
|
||||||
|
4. 只返回翻译结果,不要任何解释或说明
|
||||||
|
|
||||||
|
请直接返回翻译结果:
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "你是一个专业翻译,只返回翻译结果。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.trim();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Translation failed:", error);
|
||||||
|
throw new Error("Translation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateIPA(
|
||||||
|
text: string,
|
||||||
|
language: string
|
||||||
|
): Promise<string> {
|
||||||
|
const prompt = `
|
||||||
|
你是一个语音学专家。为文本生成国际音标(IPA)标注。
|
||||||
|
|
||||||
|
文本位于 <text> 标签内:
|
||||||
|
<text>${text}</text>
|
||||||
|
|
||||||
|
语言:${language}
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 生成准确的国际音标(IPA)标注
|
||||||
|
2. 使用标准的 IPA 符号
|
||||||
|
3. 只返回 IPA 标注,不要任何其他文字
|
||||||
|
|
||||||
|
请直接返回 IPA 标注:
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAnswer([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "你是一个语音学专家,只返回 IPA 标注。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return result.trim();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("IPA generation failed:", error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeTranslation(
|
||||||
|
sourceText: string,
|
||||||
|
targetLanguage: string,
|
||||||
|
needIpa: boolean
|
||||||
|
): Promise<TranslationLLMResponse> {
|
||||||
|
try {
|
||||||
|
console.log("[翻译] 开始翻译流程...");
|
||||||
|
console.log("[翻译] 源文本:", sourceText);
|
||||||
|
console.log("[翻译] 目标语言:", targetLanguage);
|
||||||
|
console.log("[翻译] 需要 IPA:", needIpa);
|
||||||
|
|
||||||
|
// Stage 1: Detect source language
|
||||||
|
console.log("[阶段1] 检测源语言...");
|
||||||
|
const detectionResult = await detectLanguage(sourceText);
|
||||||
|
console.log("[阶段1] 检测结果:", detectionResult);
|
||||||
|
|
||||||
|
// Stage 2: Perform translation
|
||||||
|
console.log("[阶段2] 执行翻译...");
|
||||||
|
const translatedText = await performTranslation(
|
||||||
|
sourceText,
|
||||||
|
detectionResult.sourceLanguage,
|
||||||
|
targetLanguage
|
||||||
|
);
|
||||||
|
console.log("[阶段2] 翻译完成:", translatedText);
|
||||||
|
|
||||||
|
// Validate translation result
|
||||||
|
if (!translatedText) {
|
||||||
|
throw new Error("Translation result is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 3 (Optional): Generate IPA
|
||||||
|
let sourceIpa: string | undefined;
|
||||||
|
let targetIpa: string | undefined;
|
||||||
|
|
||||||
|
if (needIpa) {
|
||||||
|
console.log("[阶段3] 生成 IPA...");
|
||||||
|
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
|
||||||
|
console.log("[阶段3] 源文本 IPA:", sourceIpa);
|
||||||
|
|
||||||
|
targetIpa = await generateIPA(translatedText, targetLanguage);
|
||||||
|
console.log("[阶段3] 目标文本 IPA:", targetIpa);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble final result
|
||||||
|
const finalResult: TranslationLLMResponse = {
|
||||||
|
sourceText,
|
||||||
|
translatedText,
|
||||||
|
sourceLanguage: detectionResult.sourceLanguage,
|
||||||
|
targetLanguage,
|
||||||
|
sourceIpa,
|
||||||
|
targetIpa,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[完成] 翻译流程成功");
|
||||||
|
return finalResult;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[错误] 翻译失败:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/lib/bigmodel/translator/types.ts
Normal file
13
src/lib/bigmodel/translator/types.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface LanguageDetectionResult {
|
||||||
|
sourceLanguage: string;
|
||||||
|
confidence: "high" | "medium" | "low";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationLLMResponse {
|
||||||
|
sourceText: string;
|
||||||
|
translatedText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
sourceIpa?: string;
|
||||||
|
targetIpa?: string;
|
||||||
|
}
|
||||||
@@ -1,62 +1,44 @@
|
|||||||
"use client";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
interface LocalStorageOperator<T> {
|
||||||
TranslationHistoryArraySchema,
|
get: () => T;
|
||||||
TranslationHistorySchema,
|
set: (value: T) => void;
|
||||||
} from "@/lib/interfaces";
|
}
|
||||||
import z from "zod";
|
|
||||||
import { shallowEqual } from "../utils";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
export function getLocalStorageOperator<T extends z.ZodType>(
|
||||||
key: string,
|
key: string,
|
||||||
schema: T,
|
schema: T
|
||||||
) => {
|
): LocalStorageOperator<z.infer<T>> {
|
||||||
return {
|
const get = (): z.infer<T> => {
|
||||||
get: (): z.infer<T> => {
|
if (typeof window === "undefined") {
|
||||||
|
return [] as unknown as z.infer<T>;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!globalThis.localStorage) return [] as z.infer<T>;
|
const item = localStorage.getItem(key);
|
||||||
const item = globalThis.localStorage.getItem(key);
|
if (item === null) {
|
||||||
|
return [] as unknown as z.infer<T>;
|
||||||
if (!item) return [] as z.infer<T>;
|
|
||||||
|
|
||||||
const rawData = JSON.parse(item) as z.infer<T>;
|
|
||||||
const result = schema.safeParse(rawData);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return result.data;
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
"Invalid data structure in localStorage:",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return [] as z.infer<T>;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
logger.error(`Failed to parse ${key} data:`, e);
|
const parsed = JSON.parse(item);
|
||||||
return [] as z.infer<T>;
|
return schema.parse(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading from localStorage key "${key}":`, error);
|
||||||
|
return [] as unknown as z.infer<T>;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
set: (data: z.infer<T>) => {
|
|
||||||
if (!globalThis.localStorage) return;
|
|
||||||
globalThis.localStorage.setItem(key, JSON.stringify(data));
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const set = (value: z.infer<T>): void => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_HISTORY_LENGTH = 50;
|
try {
|
||||||
export const tlso = getLocalStorageOperator<
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
typeof TranslationHistoryArraySchema
|
} catch (error) {
|
||||||
>("translator", TranslationHistoryArraySchema);
|
console.error(`Error writing to localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
|
|
||||||
const oldHistory = tlso.get();
|
|
||||||
if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;
|
|
||||||
|
|
||||||
const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH);
|
|
||||||
tlso.set(newHistory);
|
|
||||||
|
|
||||||
return newHistory;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { get, set };
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ const adapter = new PrismaPg({
|
|||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
adapter: adapter,
|
adapter: adapter,
|
||||||
});
|
});
|
||||||
export default prisma;
|
export { prisma };
|
||||||
|
|||||||
2
src/lib/errors.ts
Normal file
2
src/lib/errors.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export class ValidateError extends Error { };
|
||||||
|
export class LookUpError extends Error { };
|
||||||
@@ -1,56 +1,19 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
export interface Word {
|
// Text Speaker types
|
||||||
word: string;
|
export const TextSpeakerItemSchema = z.object({
|
||||||
x: number;
|
text: z.string(),
|
||||||
y: number;
|
language: z.string(),
|
||||||
}
|
ipa: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
||||||
|
|
||||||
|
// Alphabet types
|
||||||
|
export type SupportedAlphabets = "japanese" | "english" | "uyghur" | "esperanto";
|
||||||
|
|
||||||
export interface Letter {
|
export interface Letter {
|
||||||
letter: string;
|
letter: string;
|
||||||
letter_name_ipa: string;
|
|
||||||
letter_sound_ipa: string;
|
letter_sound_ipa: string;
|
||||||
roman_letter?: string;
|
roman_letter?: string;
|
||||||
}
|
}
|
||||||
export type SupportedAlphabets =
|
|
||||||
| "japanese"
|
|
||||||
| "english"
|
|
||||||
| "esperanto"
|
|
||||||
| "uyghur";
|
|
||||||
export const TextSpeakerItemSchema = z.object({
|
|
||||||
text: z.string(),
|
|
||||||
ipa: z.string().optional(),
|
|
||||||
language: z.string(),
|
|
||||||
});
|
|
||||||
export const TextSpeakerArraySchema = z.array(TextSpeakerItemSchema);
|
|
||||||
|
|
||||||
export const WordDataSchema = z.object({
|
|
||||||
languages: z
|
|
||||||
.tuple([z.string(), z.string()])
|
|
||||||
.refine(([first, second]) => first !== second, {
|
|
||||||
message: "Languages must be different",
|
|
||||||
}),
|
|
||||||
wordPairs: z
|
|
||||||
.array(z.tuple([z.string(), z.string()]))
|
|
||||||
.min(1, "At least one word pair is required")
|
|
||||||
.refine(
|
|
||||||
(pairs) => {
|
|
||||||
return pairs.every(
|
|
||||||
([first, second]) => first.trim() !== "" && second.trim() !== "",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Word pairs cannot contain empty strings",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TranslationHistorySchema = z.object({
|
|
||||||
text1: z.string(),
|
|
||||||
text2: z.string(),
|
|
||||||
language1: z.string(),
|
|
||||||
language2: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TranslationHistoryArraySchema = z.array(TranslationHistorySchema);
|
|
||||||
|
|
||||||
export type WordData = z.infer<typeof WordDataSchema>;
|
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
/**
|
class Logger {
|
||||||
* 统一的日志工具
|
error(message: string, error?: unknown): void {
|
||||||
* 在生产环境中可以通过环境变量控制日志级别
|
if (error instanceof Error) {
|
||||||
*/
|
console.error(`[ERROR] ${message}:`, error.message, error.stack);
|
||||||
|
} else {
|
||||||
type LogLevel = 'info' | 'warn' | 'error';
|
console.error(`[ERROR] ${message}:`, 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) => {
|
warn(message: string, ...args: unknown[]): void {
|
||||||
if (isDevelopment) {
|
console.warn(`[WARN] ${message}`, ...args);
|
||||||
console.info(message, data);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
};
|
info(message: string, ...args: unknown[]): void {
|
||||||
|
console.info(`[INFO] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, ...args: unknown[]): void {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.debug(`[DEBUG] ${message}`, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger();
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* 词典查询模块 - 多阶段 LLM 调用架构
|
|
||||||
*
|
|
||||||
* 将词典查询拆分为 4 个独立的 LLM 调用阶段,每个阶段都有代码层面的数据验证
|
|
||||||
* 只要有一环失败,直接返回错误
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 导出主编排器
|
|
||||||
export { executeDictionaryLookup } from "./orchestrator";
|
|
||||||
|
|
||||||
// 导出各阶段的独立函数(可选,用于调试或单独使用)
|
|
||||||
export { analyzeInput } from "./stage1-inputAnalysis";
|
|
||||||
export { determineSemanticMapping } from "./stage2-semanticMapping";
|
|
||||||
export { generateStandardForm } from "./stage3-standardForm";
|
|
||||||
export { generateEntries } from "./stage4-entriesGeneration";
|
|
||||||
|
|
||||||
// 导出类型定义
|
|
||||||
export * from "./types";
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { executeDictionaryLookup } from "./dictionary";
|
|
||||||
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
|
|
||||||
import { DictLookUpRequest, DictWordResponse, isDictErrorResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
|
|
||||||
|
|
||||||
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
|
|
||||||
if (isDictErrorResponse(res)) return;
|
|
||||||
else if (isDictPhraseResponse(res)) {
|
|
||||||
// 先创建 Phrase
|
|
||||||
const phrase = await createPhrase({
|
|
||||||
standardForm: res.standardForm,
|
|
||||||
queryLang: req.queryLang,
|
|
||||||
definitionLang: req.definitionLang,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建 Lookup
|
|
||||||
await createLookUp({
|
|
||||||
userId: req.userId,
|
|
||||||
text: req.text,
|
|
||||||
queryLang: req.queryLang,
|
|
||||||
definitionLang: req.definitionLang,
|
|
||||||
dictionaryPhraseId: phrase.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建 Entries
|
|
||||||
for (const entry of res.entries) {
|
|
||||||
await createPhraseEntry({
|
|
||||||
phraseId: phrase.id,
|
|
||||||
definition: entry.definition,
|
|
||||||
example: entry.example,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (isDictWordResponse(res)) {
|
|
||||||
// 先创建 Word
|
|
||||||
const word = await createWord({
|
|
||||||
standardForm: (res as DictWordResponse).standardForm,
|
|
||||||
queryLang: req.queryLang,
|
|
||||||
definitionLang: req.definitionLang,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建 Lookup
|
|
||||||
await createLookUp({
|
|
||||||
userId: req.userId,
|
|
||||||
text: req.text,
|
|
||||||
queryLang: req.queryLang,
|
|
||||||
definitionLang: req.definitionLang,
|
|
||||||
dictionaryWordId: word.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建 Entries
|
|
||||||
for (const entry of (res as DictWordResponse).entries) {
|
|
||||||
await createWordEntry({
|
|
||||||
wordId: word.id,
|
|
||||||
ipa: entry.ipa,
|
|
||||||
definition: entry.definition,
|
|
||||||
partOfSpeech: entry.partOfSpeech,
|
|
||||||
example: entry.example,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询单词或短语
|
|
||||||
*
|
|
||||||
* 使用模块化的词典查询系统,将提示词拆分为6个阶段:
|
|
||||||
* - 阶段0:基础系统提示
|
|
||||||
* - 阶段1:输入解析与语言识别
|
|
||||||
* - 阶段2:跨语言语义映射决策
|
|
||||||
* - 阶段3:standardForm 生成与规范化
|
|
||||||
* - 阶段4:释义与词条生成
|
|
||||||
* - 阶段5:错误处理
|
|
||||||
* - 阶段6:最终输出封装
|
|
||||||
*/
|
|
||||||
export const lookUp = async ({
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang,
|
|
||||||
userId,
|
|
||||||
forceRelook = false
|
|
||||||
}: DictLookUpRequest): Promise<DictLookUpResponse> => {
|
|
||||||
try {
|
|
||||||
const lastLookUp = await selectLastLookUp({
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang
|
|
||||||
});
|
|
||||||
|
|
||||||
if (forceRelook || !lastLookUp) {
|
|
||||||
// 使用新的模块化查询系统
|
|
||||||
const response = await executeDictionaryLookup(
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang
|
|
||||||
);
|
|
||||||
|
|
||||||
saveResult({
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang,
|
|
||||||
userId,
|
|
||||||
forceRelook
|
|
||||||
}, response);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
// 从数据库返回缓存的结果
|
|
||||||
if (lastLookUp.dictionaryWordId) {
|
|
||||||
createLookUp({
|
|
||||||
userId: userId,
|
|
||||||
text: text,
|
|
||||||
queryLang: queryLang,
|
|
||||||
definitionLang: definitionLang,
|
|
||||||
dictionaryWordId: lastLookUp.dictionaryWordId,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
standardForm: lastLookUp.dictionaryWord!.standardForm,
|
|
||||||
entries: lastLookUp.dictionaryWord!.entries
|
|
||||||
};
|
|
||||||
} else if (lastLookUp.dictionaryPhraseId) {
|
|
||||||
createLookUp({
|
|
||||||
userId: userId,
|
|
||||||
text: text,
|
|
||||||
queryLang: queryLang,
|
|
||||||
definitionLang: definitionLang,
|
|
||||||
dictionaryPhraseId: lastLookUp.dictionaryPhraseId
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
standardForm: lastLookUp.dictionaryPhrase!.standardForm,
|
|
||||||
entries: lastLookUp.dictionaryPhrase!.entries
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return { error: "Database structure error!" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
return { error: "LOOK_UP_ERROR" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { getAnswer } from "./zhipu";
|
|
||||||
import { selectLatestTranslation, createTranslationHistory } from "../services/translatorService";
|
|
||||||
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "../services/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genIPA = async (text: string) => {
|
|
||||||
return (
|
|
||||||
"[" +
|
|
||||||
(
|
|
||||||
await getAnswer(
|
|
||||||
`
|
|
||||||
<text>${text}</text>
|
|
||||||
|
|
||||||
请生成以上文本的严式国际音标
|
|
||||||
然后直接发给我
|
|
||||||
不要附带任何说明
|
|
||||||
不要擅自增减符号
|
|
||||||
不许用"/"或者"[]"包裹
|
|
||||||
`.trim(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.replaceAll("[", "")
|
|
||||||
.replaceAll("]", "") +
|
|
||||||
"]"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genLocale = async (text: string) => {
|
|
||||||
return await getAnswer(
|
|
||||||
`
|
|
||||||
<text>${text}</text>
|
|
||||||
|
|
||||||
推断以上文本的地区(locale)
|
|
||||||
然后直接发给我
|
|
||||||
形如如zh-CN
|
|
||||||
不要附带任何说明
|
|
||||||
不要擅自增减符号
|
|
||||||
`.trim(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genLanguage = async (text: string) => {
|
|
||||||
const language = await getAnswer([
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: `
|
|
||||||
你是一个语言检测工具。请识别文本的语言并返回语言名称。
|
|
||||||
|
|
||||||
返回语言的标准英文名称,例如:
|
|
||||||
- 中文: Chinese
|
|
||||||
- 英语: English
|
|
||||||
- 日语: Japanese
|
|
||||||
- 韩语: Korean
|
|
||||||
- 法语: French
|
|
||||||
- 德语: German
|
|
||||||
- 意大利语: Italian
|
|
||||||
- 葡萄牙语: Portuguese
|
|
||||||
- 西班牙语: Spanish
|
|
||||||
- 俄语: Russian
|
|
||||||
- 阿拉伯语: Arabic
|
|
||||||
- 印地语: Hindi
|
|
||||||
- 泰语: Thai
|
|
||||||
- 越南语: Vietnamese
|
|
||||||
- 等等...
|
|
||||||
|
|
||||||
如果无法识别语言,返回 "Unknown"
|
|
||||||
|
|
||||||
规则:
|
|
||||||
1. 只返回语言的标准英文名称
|
|
||||||
2. 首字母大写,其余小写
|
|
||||||
3. 不要附带任何说明
|
|
||||||
4. 不要擅自增减符号
|
|
||||||
`.trim()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: `<text>${text}</text>`
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
return language.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用 translateText 函数代替
|
|
||||||
* 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genTranslation = async (text: string, targetLanguage: string) => {
|
|
||||||
|
|
||||||
return await getAnswer(
|
|
||||||
`
|
|
||||||
<text>${text}</text>
|
|
||||||
|
|
||||||
请将以上文本翻译到 <target_language>${targetLanguage}</target_language>
|
|
||||||
然后直接发给我
|
|
||||||
不要附带任何说明
|
|
||||||
不要擅自增减符号
|
|
||||||
`.trim(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一的翻译函数
|
|
||||||
* 一次调用生成所有信息,支持缓存查询
|
|
||||||
*/
|
|
||||||
export async function translateText(options: TranslateTextInput): Promise<TranslateTextOutput> {
|
|
||||||
const {
|
|
||||||
sourceText,
|
|
||||||
targetLanguage,
|
|
||||||
forceRetranslate = false,
|
|
||||||
needIpa = true,
|
|
||||||
userId,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// 1. 检查缓存(如果未强制重新翻译)并获取翻译数据
|
|
||||||
let translatedData: TranslationLLMResponse | null = null;
|
|
||||||
let fromCache = false;
|
|
||||||
|
|
||||||
if (!forceRetranslate) {
|
|
||||||
const cached = await selectLatestTranslation({
|
|
||||||
sourceText,
|
|
||||||
targetLanguage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cached && cached.translatedText && cached.sourceLanguage) {
|
|
||||||
// 如果不需要 IPA,或缓存已有 IPA,使用缓存
|
|
||||||
if (!needIpa || (cached.sourceIpa && cached.targetIpa)) {
|
|
||||||
console.log("✅ 翻译缓存命中");
|
|
||||||
translatedData = {
|
|
||||||
translatedText: cached.translatedText,
|
|
||||||
sourceLanguage: cached.sourceLanguage,
|
|
||||||
targetLanguage: cached.targetLanguage,
|
|
||||||
sourceIpa: cached.sourceIpa || undefined,
|
|
||||||
targetIpa: cached.targetIpa || undefined,
|
|
||||||
};
|
|
||||||
fromCache = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果缓存未命中,调用 LLM 生成翻译
|
|
||||||
if (!fromCache) {
|
|
||||||
translatedData = await callTranslationLLM({
|
|
||||||
sourceText,
|
|
||||||
targetLanguage,
|
|
||||||
needIpa,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 保存到数据库(不管缓存是否命中都保存)
|
|
||||||
if (translatedData) {
|
|
||||||
try {
|
|
||||||
await createTranslationHistory({
|
|
||||||
userId,
|
|
||||||
sourceText,
|
|
||||||
sourceLanguage: translatedData.sourceLanguage,
|
|
||||||
targetLanguage: translatedData.targetLanguage,
|
|
||||||
translatedText: translatedData.translatedText,
|
|
||||||
sourceIpa: needIpa ? translatedData.sourceIpa : undefined,
|
|
||||||
targetIpa: needIpa ? translatedData.targetIpa : undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("保存翻译历史失败:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sourceText,
|
|
||||||
translatedText: translatedData!.translatedText,
|
|
||||||
sourceLanguage: translatedData!.sourceLanguage,
|
|
||||||
targetLanguage: translatedData!.targetLanguage,
|
|
||||||
sourceIpa: needIpa ? (translatedData!.sourceIpa || "") : "",
|
|
||||||
targetIpa: needIpa ? (translatedData!.targetIpa || "") : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用 LLM 生成翻译和相关数据
|
|
||||||
*/
|
|
||||||
async function callTranslationLLM(params: {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
needIpa: boolean;
|
|
||||||
}): Promise<TranslationLLMResponse> {
|
|
||||||
const { sourceText, targetLanguage, needIpa } = params;
|
|
||||||
|
|
||||||
console.log("🤖 调用 LLM 翻译");
|
|
||||||
|
|
||||||
let systemPrompt = "你是一个专业的翻译助手。请根据用户的要求翻译文本,并返回 JSON 格式的结果。\n\n返回的 JSON 必须严格符合以下格式:\n{\n \"translatedText\": \"翻译后的文本\",\n \"sourceLanguage\": \"源语言的标准英文名称(如 Chinese, English, Japanese)\",\n \"targetLanguage\": \"目标语言的标准英文名称\"";
|
|
||||||
|
|
||||||
if (needIpa) {
|
|
||||||
systemPrompt += ",\n \"sourceIpa\": \"源文本的严式国际音标(用方括号包裹,如 [tɕɪn˥˩]\",\n \"targetIpa\": \"译文的严式国际音标(用方括号包裹)\"";
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPrompt += "}\n\n规则:\n1. 只返回 JSON,不要包含任何其他文字说明\n2. 语言名称必须是标准英文名称,首字母大写\n";
|
|
||||||
|
|
||||||
if (needIpa) {
|
|
||||||
systemPrompt += "3. 国际音标必须用方括号 [] 包裹,使用严式音标\n";
|
|
||||||
} else {
|
|
||||||
systemPrompt += "3. 本次请求不需要生成国际音标\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
systemPrompt += needIpa ? "4. 确保翻译准确、自然" : "4. 确保翻译准确、自然";
|
|
||||||
|
|
||||||
const userPrompt = `请将以下文本翻译成 ${targetLanguage}:\n\n<text>${sourceText}</text>\n\n返回 JSON 格式的翻译结果。`;
|
|
||||||
|
|
||||||
const response = await getAnswer([
|
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content: systemPrompt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: userPrompt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 解析 LLM 返回的 JSON
|
|
||||||
try {
|
|
||||||
// 清理响应:移除 markdown 代码块标记和多余空白
|
|
||||||
let cleanedResponse = response
|
|
||||||
.replace(/```json\s*\n/g, "") // 移除 ```json 开头
|
|
||||||
.replace(/```\s*\n/g, "") // 移除 ``` 结尾
|
|
||||||
.replace(/```\s*$/g, "") // 移除末尾的 ```
|
|
||||||
.replace(/```json\s*$/g, "") // 移除末尾的 ```json
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const parsed = JSON.parse(cleanedResponse) as TranslationLLMResponse;
|
|
||||||
|
|
||||||
// 验证必需字段
|
|
||||||
if (!parsed.translatedText || !parsed.sourceLanguage || !parsed.targetLanguage) {
|
|
||||||
throw new Error("LLM 返回的数据缺少必需字段");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("LLM 翻译成功");
|
|
||||||
return parsed;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("LLM 翻译失败:", error);
|
|
||||||
console.error("原始响应:", response);
|
|
||||||
throw new Error("翻译失败:无法解析 LLM 响应");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CreateDictionaryLookUpInput,
|
|
||||||
DictionaryLookUpQuery,
|
|
||||||
CreateDictionaryPhraseInput,
|
|
||||||
CreateDictionaryPhraseEntryInput,
|
|
||||||
CreateDictionaryWordInput,
|
|
||||||
CreateDictionaryWordEntryInput
|
|
||||||
} from "./types";
|
|
||||||
import prisma from "../../db";
|
|
||||||
|
|
||||||
export async function selectLastLookUp(content: DictionaryLookUpQuery) {
|
|
||||||
return prisma.dictionaryLookUp.findFirst({
|
|
||||||
where: content,
|
|
||||||
include: {
|
|
||||||
dictionaryPhrase: {
|
|
||||||
include: {
|
|
||||||
entries: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dictionaryWord: {
|
|
||||||
include: {
|
|
||||||
entries: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPhraseEntry(content: CreateDictionaryPhraseEntryInput) {
|
|
||||||
return prisma.dictionaryPhraseEntry.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWordEntry(content: CreateDictionaryWordEntryInput) {
|
|
||||||
return prisma.dictionaryWordEntry.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPhrase(content: CreateDictionaryPhraseInput) {
|
|
||||||
return prisma.dictionaryPhrase.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWord(content: CreateDictionaryWordInput) {
|
|
||||||
return prisma.dictionaryWord.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createLookUp(content: CreateDictionaryLookUpInput) {
|
|
||||||
return prisma.dictionaryLookUp.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { CreateFolderInput, UpdateFolderInput } from "./types";
|
|
||||||
import prisma from "../../db";
|
|
||||||
|
|
||||||
export async function getFoldersByUserId(userId: string) {
|
|
||||||
return prisma.folder.findMany({
|
|
||||||
where: {
|
|
||||||
userId: userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renameFolderById(id: number, newName: string) {
|
|
||||||
return prisma.folder.update({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
name: newName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFoldersWithTotalPairsByUserId(userId: string) {
|
|
||||||
const folders = await prisma.folder.findMany({
|
|
||||||
where: { userId },
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { pairs: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return folders.map(folder => ({
|
|
||||||
...folder,
|
|
||||||
total: folder._count?.pairs ?? 0,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFolder(folder: CreateFolderInput) {
|
|
||||||
return prisma.folder.create({
|
|
||||||
data: folder,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteFolderById(id: number) {
|
|
||||||
return prisma.folder.delete({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateFolderById(id: number, data: UpdateFolderInput) {
|
|
||||||
return prisma.folder.update({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserIdByFolderId(id: number) {
|
|
||||||
const folder = await prisma.folder.findUnique({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return folder?.userId;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { CreatePairInput, UpdatePairInput } from "./types";
|
|
||||||
import prisma from "../../db";
|
|
||||||
|
|
||||||
export async function createPair(data: CreatePairInput) {
|
|
||||||
return prisma.pair.create({
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePairById(id: number) {
|
|
||||||
return prisma.pair.delete({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePairById(
|
|
||||||
id: number,
|
|
||||||
data: UpdatePairInput,
|
|
||||||
) {
|
|
||||||
return prisma.pair.update({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPairCountByFolderId(folderId: number) {
|
|
||||||
return prisma.pair.count({
|
|
||||||
where: {
|
|
||||||
folderId: folderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPairsByFolderId(folderId: number) {
|
|
||||||
return prisma.pair.findMany({
|
|
||||||
where: {
|
|
||||||
folderId: folderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./types";
|
|
||||||
import prisma from "../../db";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建翻译历史记录
|
|
||||||
*/
|
|
||||||
export async function createTranslationHistory(data: CreateTranslationHistoryInput) {
|
|
||||||
return prisma.translationHistory.create({
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询最新的翻译记录
|
|
||||||
* @param sourceText 源文本
|
|
||||||
* @param targetLanguage 目标语言
|
|
||||||
* @returns 最新的翻译记录,如果不存在则返回 null
|
|
||||||
*/
|
|
||||||
export async function selectLatestTranslation(query: TranslationHistoryQuery) {
|
|
||||||
return prisma.translationHistory.findFirst({
|
|
||||||
where: {
|
|
||||||
sourceText: query.sourceText,
|
|
||||||
targetLanguage: query.targetLanguage,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service 层的自定义业务类型
|
|
||||||
*
|
|
||||||
* 这些类型用于替换 Prisma 生成的类型,提高代码的可维护性和抽象层次
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Folder 相关
|
|
||||||
export interface CreateFolderInput {
|
|
||||||
name: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateFolderInput {
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pair 相关
|
|
||||||
export interface CreatePairInput {
|
|
||||||
text1: string;
|
|
||||||
text2: string;
|
|
||||||
language1: string;
|
|
||||||
language2: string;
|
|
||||||
ipa1?: string;
|
|
||||||
ipa2?: string;
|
|
||||||
folderId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePairInput {
|
|
||||||
text1?: string;
|
|
||||||
text2?: string;
|
|
||||||
language1?: string;
|
|
||||||
language2?: string;
|
|
||||||
ipa1?: string;
|
|
||||||
ipa2?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translation 相关
|
|
||||||
export interface CreateTranslationHistoryInput {
|
|
||||||
userId?: string;
|
|
||||||
sourceText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
translatedText: string;
|
|
||||||
sourceIpa?: string;
|
|
||||||
targetIpa?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslationHistoryQuery {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dictionary 相关
|
|
||||||
export interface CreateDictionaryLookUpInput {
|
|
||||||
userId?: string;
|
|
||||||
text: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
dictionaryWordId?: number;
|
|
||||||
dictionaryPhraseId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DictionaryLookUpQuery {
|
|
||||||
userId?: string;
|
|
||||||
text?: string;
|
|
||||||
queryLang?: string;
|
|
||||||
definitionLang?: string;
|
|
||||||
dictionaryWordId?: number;
|
|
||||||
dictionaryPhraseId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateDictionaryWordInput {
|
|
||||||
standardForm: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateDictionaryPhraseInput {
|
|
||||||
standardForm: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateDictionaryWordEntryInput {
|
|
||||||
wordId: number;
|
|
||||||
ipa: string;
|
|
||||||
definition: string;
|
|
||||||
partOfSpeech: string;
|
|
||||||
example: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateDictionaryPhraseEntryInput {
|
|
||||||
phraseId: number;
|
|
||||||
definition: string;
|
|
||||||
example: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 翻译相关 - 统一翻译函数
|
|
||||||
export interface TranslateTextInput {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
forceRetranslate?: boolean; // 默认 false
|
|
||||||
needIpa?: boolean; // 默认 true
|
|
||||||
userId?: string; // 可选用户 ID
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslateTextOutput {
|
|
||||||
sourceText: string;
|
|
||||||
translatedText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
sourceIpa: string; // 如果 needIpa=false,返回空字符串
|
|
||||||
targetIpa: string; // 如果 needIpa=false,返回空字符串
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslationLLMResponse {
|
|
||||||
translatedText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
sourceIpa?: string; // 可选,根据 needIpa 决定
|
|
||||||
targetIpa?: string; // 可选,根据 needIpa 决定
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import prisma from "@/lib/db";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
export async function createUserIfNotExists(email: string, name?: string | null) {
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: {
|
|
||||||
email: email,
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: randomUUID(),
|
|
||||||
email: email,
|
|
||||||
name: name || "New User",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserIdByEmail(email: string) {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email: email,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return user ? user.id : null;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
export type DictLookUpRequest = {
|
|
||||||
text: string,
|
|
||||||
queryLang: string,
|
|
||||||
definitionLang: string,
|
|
||||||
userId?: string,
|
|
||||||
forceRelook: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DictWordEntry = {
|
|
||||||
ipa: string;
|
|
||||||
definition: string;
|
|
||||||
partOfSpeech: string;
|
|
||||||
example: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DictPhraseEntry = {
|
|
||||||
definition: string;
|
|
||||||
example: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DictErrorResponse = {
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DictWordResponse = {
|
|
||||||
standardForm: string;
|
|
||||||
entries: DictWordEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DictPhraseResponse = {
|
|
||||||
standardForm: string;
|
|
||||||
entries: DictPhraseEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DictLookUpResponse =
|
|
||||||
| DictErrorResponse
|
|
||||||
| DictWordResponse
|
|
||||||
| DictPhraseResponse;
|
|
||||||
|
|
||||||
// 类型守卫:判断是否为错误响应
|
|
||||||
export function isDictErrorResponse(
|
|
||||||
response: DictLookUpResponse
|
|
||||||
): response is DictErrorResponse {
|
|
||||||
return "error" in response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 类型守卫:判断是否为单词响应
|
|
||||||
export function isDictWordResponse(
|
|
||||||
response: DictLookUpResponse
|
|
||||||
): response is DictWordResponse {
|
|
||||||
if (isDictErrorResponse(response)) return false;
|
|
||||||
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
|
|
||||||
return entries.length > 0 && "ipa" in entries[0] && "partOfSpeech" in entries[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 类型守卫:判断是否为短语响应
|
|
||||||
export function isDictPhraseResponse(
|
|
||||||
response: DictLookUpResponse
|
|
||||||
): response is DictPhraseResponse {
|
|
||||||
if (isDictErrorResponse(response)) return false;
|
|
||||||
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
|
|
||||||
return entries.length > 0 && !("ipa" in entries[0] || "partOfSpeech" in entries[0]);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./dictionaryTypes";
|
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
/**
|
|
||||||
* 主题配色常量
|
|
||||||
* 集中管理应用的品牌颜色
|
|
||||||
*
|
|
||||||
* 注意:Tailwind CSS 已有的标准颜色(gray、red 等)请直接使用 Tailwind 类名
|
|
||||||
* 这里只定义项目独有的品牌色
|
|
||||||
*/
|
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
// ===== 主色调 =====
|
primary: "#35786f",
|
||||||
/** 主绿色 - 应用主题色,用于页面背景、主要按钮 */
|
secondary: "#2d5f58",
|
||||||
primary: '#35786f',
|
ghost: "#f3f4f6",
|
||||||
/** 悬停绿色 - 按钮悬停状态 */
|
icon: "#6b7280",
|
||||||
primaryHover: '#2d5f58'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ export async function signUpAction(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: "注册失败,请稍后再试"
|
||||||
22
src/modules/dictionary/dictionary-action-dto.ts
Normal file
22
src/modules/dictionary/dictionary-action-dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
import { LENGTH_MAX_DICTIONARY_TEXT, LENGTH_MAX_LANGUAGE, LENGTH_MIN_DICTIONARY_TEXT, LENGTH_MIN_LANGUAGE } from "@/shared/constant";
|
||||||
|
import { generateValidator } from "@/utils/validate";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const schemaActionInputLookUpDictionary = z.object({
|
||||||
|
text: z.string().min(LENGTH_MIN_DICTIONARY_TEXT).max(LENGTH_MAX_DICTIONARY_TEXT),
|
||||||
|
queryLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
||||||
|
forceRelook: z.boolean(),
|
||||||
|
definitionLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
||||||
|
userId: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ActionInputLookUpDictionary = z.infer<typeof schemaActionInputLookUpDictionary>;
|
||||||
|
|
||||||
|
export const validateActionInputLookUpDictionary = generateValidator(schemaActionInputLookUpDictionary);
|
||||||
|
|
||||||
|
export type ActionOutputLookUpDictionary = {
|
||||||
|
message: string,
|
||||||
|
success: boolean;
|
||||||
|
data?: TSharedItem;
|
||||||
|
};
|
||||||
27
src/modules/dictionary/dictionary-action.ts
Normal file
27
src/modules/dictionary/dictionary-action.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
|
||||||
|
import { ValidateError } from "@/lib/errors";
|
||||||
|
import { serviceLookUp } from "./dictionary-service";
|
||||||
|
|
||||||
|
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
message: 'success',
|
||||||
|
success: true,
|
||||||
|
data: await serviceLookUp(validateActionInputLookUpDictionary(dto))
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: e.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log(e);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Unknown error occured.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user