diff --git a/prisma/migrations/20260105081337_dictionary_add/migration.sql b/prisma/migrations/20260105081337_dictionary_add/migration.sql new file mode 100644 index 0000000..bb57941 --- /dev/null +++ b/prisma/migrations/20260105081337_dictionary_add/migration.sql @@ -0,0 +1,138 @@ +/* + Warnings: + + - You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost. + - You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost. + +*/ +-- AlterTable +-- 重命名并修改类型为 TEXT +ALTER TABLE "pairs" +RENAME COLUMN "locale1" TO "language1"; + +ALTER TABLE "pairs" +ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20); + +ALTER TABLE "pairs" +RENAME COLUMN "locale2" TO "language2"; + +ALTER TABLE "pairs" +ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20); + +-- CreateTable +CREATE TABLE "dictionary_lookups" ( + "id" SERIAL NOT NULL, + "user_id" TEXT, + "text" TEXT NOT NULL, + "query_lang" TEXT NOT NULL, + "definition_lang" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dictionary_word_id" INTEGER, + "dictionary_phrase_id" INTEGER, + + CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dictionary_words" ( + "id" SERIAL NOT NULL, + "standard_form" TEXT NOT NULL, + "query_lang" TEXT NOT NULL, + "definition_lang" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dictionary_phrases" ( + "id" SERIAL NOT NULL, + "standard_form" TEXT NOT NULL, + "query_lang" TEXT NOT NULL, + "definition_lang" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dictionary_word_entries" ( + "id" SERIAL NOT NULL, + "word_id" INTEGER NOT NULL, + "ipa" TEXT NOT NULL, + "definition" TEXT NOT NULL, + "part_of_speech" TEXT NOT NULL, + "example" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dictionary_phrase_entries" ( + "id" SERIAL NOT NULL, + "phrase_id" INTEGER NOT NULL, + "definition" TEXT NOT NULL, + "example" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id"); + +-- CreateIndex +CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at"); + +-- CreateIndex +CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang"); + +-- CreateIndex +CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form"); + +-- CreateIndex +CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang"); + +-- CreateIndex +CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang"); + +-- CreateIndex +CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form"); + +-- CreateIndex +CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang"); + +-- CreateIndex +CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang"); + +-- CreateIndex +CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id"); + +-- CreateIndex +CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at"); + +-- CreateIndex +CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id"); + +-- CreateIndex +CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at"); + +-- AddForeignKey +ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 97da0ea..e4df3ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,4 +1,3 @@ - generator client { provider = "prisma-client" output = "../generated/prisma" @@ -8,50 +7,18 @@ datasource db { provider = "postgresql" } -model Pair { - id Int @id @default(autoincrement()) - locale1 String @db.VarChar(10) - locale2 String @db.VarChar(10) - text1 String - text2 String - ipa1 String? - ipa2 String? - folderId Int @map("folder_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade) - - @@unique([folderId, locale1, locale2, text1]) - @@index([folderId]) - @@map("pairs") -} - -model Folder { - id Int @id @default(autoincrement()) - name String - userId String @map("user_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - pairs Pair[] - - @@index([userId]) - @@map("folders") -} - model User { - id String @id - name String - email String - emailVerified Boolean @default(false) - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - sessions Session[] - accounts Account[] - folders Folder[] + id String @id + name String + email String + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sessions Session[] + accounts Account[] + folders Folder[] + dictionaryLookUps DictionaryLookUp[] @@unique([email]) @@map("user") @@ -104,3 +71,122 @@ model Verification { @@index([identifier]) @@map("verification") } + +model Pair { + id Int @id @default(autoincrement()) + text1 String + text2 String + language1 String @db.VarChar(20) + language2 String @db.VarChar(20) + ipa1 String? + ipa2 String? + folderId Int @map("folder_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade) + + @@unique([folderId, language1, language2, text1]) + @@index([folderId]) + @@map("pairs") +} + +model Folder { + id Int @id @default(autoincrement()) + name String + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + pairs Pair[] + + @@index([userId]) + @@map("folders") +} + +model DictionaryLookUp { + id Int @id @default(autoincrement()) + userId String? @map("user_id") + text String + queryLang String @map("query_lang") + definitionLang String @map("definition_lang") + createdAt DateTime @default(now()) @map("created_at") + dictionaryWordId Int? @map("dictionary_word_id") + dictionaryPhraseId Int? @map("dictionary_phrase_id") + + user User? @relation(fields: [userId], references: [id]) + dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull) + dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@index([createdAt]) + @@index([text, queryLang, definitionLang]) + @@map("dictionary_lookups") +} + +model DictionaryWord { + id Int @id @default(autoincrement()) + standardForm String @map("standard_form") + queryLang String @map("query_lang") + definitionLang String @map("definition_lang") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + lookups DictionaryLookUp[] + entries DictionaryWordEntry[] + + @@unique([standardForm, queryLang, definitionLang]) + @@index([standardForm]) + @@index([queryLang, definitionLang]) + @@map("dictionary_words") +} + +model DictionaryPhrase { + id Int @id @default(autoincrement()) + standardForm String @map("standard_form") + queryLang String @map("query_lang") + definitionLang String @map("definition_lang") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + lookups DictionaryLookUp[] + entries DictionaryPhraseEntry[] + + @@unique([standardForm, queryLang, definitionLang]) + @@index([standardForm]) + @@index([queryLang, definitionLang]) + @@map("dictionary_phrases") +} + +model DictionaryWordEntry { + id Int @id @default(autoincrement()) + wordId Int @map("word_id") + ipa String + definition String + partOfSpeech String @map("part_of_speech") + example String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade) + + @@index([wordId]) + @@index([createdAt]) + @@map("dictionary_word_entries") +} + +model DictionaryPhraseEntry { + id Int @id @default(autoincrement()) + phraseId Int @map("phrase_id") + definition String + example String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade) + + @@index([phraseId]) + @@index([createdAt]) + @@map("dictionary_phrase_entries") +} diff --git a/src/app/(features)/dictionary/AddToFolder.tsx b/src/app/(features)/dictionary/AddToFolder.tsx new file mode 100644 index 0000000..0e84e7c --- /dev/null +++ b/src/app/(features)/dictionary/AddToFolder.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { LightButton } from "@/components/ui/buttons"; +import Container from "@/components/ui/Container"; +import { useEffect, useState } from "react"; +import { Folder } from "../../../../generated/prisma/browser"; +import { getFoldersByUserId } from "@/lib/server/services/folderService"; +import { Folder as Fd } from "lucide-react"; +import { createPair } from "@/lib/server/services/pairService"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; + +interface AddToFolderProps { + definitionLang: string; + queryLang: string; + standardForm: string; + definition: string; + ipa?: string; + setShow: (show: boolean) => void; +} + +const AddToFolder: React.FC = ({ + definitionLang, + queryLang, + standardForm, + definition, + ipa, + setShow, +}) => { + const { data: session } = authClient.useSession(); + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!session) return; + const userId = session.user.id as string; + getFoldersByUserId(userId) + .then(setFolders) + .then(() => setLoading(false)); + }, [session]); + + if (!session) { + return null; + } + + return ( +
+ +

选择文件夹保存

+
+ {loading ? ( + 加载中... + ) : folders.length > 0 ? ( + folders.map((folder) => ( + + )) + ) : ( +
暂无文件夹
+ )} +
+
+ setShow(false)}>关闭 +
+
+
+ ); +}; + +export default AddToFolder; diff --git a/src/app/(features)/dictionary/page.tsx b/src/app/(features)/dictionary/page.tsx index cae909a..0aca18e 100644 --- a/src/app/(features)/dictionary/page.tsx +++ b/src/app/(features)/dictionary/page.tsx @@ -1,321 +1,398 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Container from "@/components/ui/Container"; import { LightButton } from "@/components/ui/buttons"; import { lookUp } from "@/lib/server/bigmodel/dictionaryActions"; import { toast } from "sonner"; +import { Plus } from "lucide-react"; +import { authClient } from "@/lib/auth-client"; +import { Folder } from "../../../../generated/prisma/browser"; +import { getFoldersByUserId } from "@/lib/server/services/folderService"; +import { createPair } from "@/lib/server/services/pairService"; // 主流语言列表 const POPULAR_LANGUAGES = [ - { code: "english", name: "英语" }, - { code: "chinese", name: "中文" }, - { code: "japanese", name: "日语" }, - { code: "korean", name: "韩语" }, - { code: "french", name: "法语" }, - { code: "german", name: "德语" }, - { code: "italian", name: "意大利语" }, - { code: "spanish", name: "西班牙语" }, + { code: "english", name: "英语" }, + { code: "chinese", name: "中文" }, + { code: "japanese", name: "日语" }, + { code: "korean", name: "韩语" }, + { code: "french", name: "法语" }, + { code: "german", name: "德语" }, + { code: "italian", name: "意大利语" }, + { code: "spanish", name: "西班牙语" }, ]; type DictionaryWordEntry = { - ipa: string; - definition: string; - partOfSpeech: string; - example: string; + ipa: string; + definition: string; + partOfSpeech: string; + example: string; }; type DictionaryPhraseEntry = { - definition: string; - example: string; + definition: string; + example: string; }; type DictionaryErrorResponse = { - error: string; + error: string; }; type DictionarySuccessResponse = { - standardForm: string; - entries: (DictionaryWordEntry | DictionaryPhraseEntry)[]; + standardForm: string; + entries: (DictionaryWordEntry | DictionaryPhraseEntry)[]; }; type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse; // 类型守卫:判断是否为单词条目 function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry { - return "ipa" in entry && "partOfSpeech" in entry; + return "ipa" in entry && "partOfSpeech" in entry; } // 类型守卫:判断是否为错误响应 function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse { - return "error" in response; + return "error" in response; } export default function Dictionary() { - const [searchQuery, setSearchQuery] = useState(""); - const [searchResult, setSearchResult] = useState(null); - const [isSearching, setIsSearching] = useState(false); - const [hasSearched, setHasSearched] = useState(false); - const [queryLang, setQueryLang] = useState("english"); - const [definitionLang, setDefinitionLang] = useState("chinese"); - const [showLangSettings, setShowLangSettings] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResult, setSearchResult] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const [queryLang, setQueryLang] = useState("english"); + const [definitionLang, setDefinitionLang] = useState("chinese"); + const [showLangSettings, setShowLangSettings] = useState(false); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [folders, setFolders] = useState([]); + const { data: session } = authClient.useSession(); - const handleSearch = async (e: React.FormEvent) => { - e.preventDefault(); - if (!searchQuery.trim()) return; + // 加载用户的文件夹列表 + useEffect(() => { + if (session) { + getFoldersByUserId(session.user.id as string) + .then((loadedFolders) => { + setFolders(loadedFolders); + // 如果有文件夹且未选择,默认选择第一个 + if (loadedFolders.length > 0 && !selectedFolderId) { + setSelectedFolderId(loadedFolders[0].id); + } + }); + } + }, [session, selectedFolderId]); - setIsSearching(true); - setHasSearched(true); - setSearchResult(null); + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + if (!searchQuery.trim()) return; - try { - // 使用查询语言和释义语言 - const result = await lookUp(searchQuery, queryLang, definitionLang); - - // 检查是否为错误响应 - if (isErrorResponse(result)) { - toast.error(result.error); + setIsSearching(true); + setHasSearched(true); setSearchResult(null); - } else { - setSearchResult(result); - } - } catch (error) { - console.error("词典查询失败:", error); - toast.error("查询失败,请稍后重试"); - setSearchResult(null); - } finally { - setIsSearching(false); - } - }; - return ( -
- {/* 搜索区域 */} -
- - {/* 页面标题 */} -
-

- 词典 -

-

- 查询单词和短语,提供详细的释义和例句 -

-
+ try { + // 使用查询语言和释义语言 + const result = await lookUp(searchQuery, queryLang, definitionLang); - {/* 搜索表单 */} -
- ) => setSearchQuery(e.target.value)} - placeholder="输入要查询的单词或短语..." - className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" - /> - - {isSearching ? "查询中..." : "查询"} - -
+ // 检查是否为错误响应 + if (isErrorResponse(result)) { + toast.error(result.error); + setSearchResult(null); + } else { + setSearchResult(result); + } + } catch (error) { + console.error("词典查询失败:", error); + toast.error("查询失败,请稍后重试"); + setSearchResult(null); + } finally { + setIsSearching(false); + } + }; - {/* 语言设置 */} -
-
- 语言设置 - setShowLangSettings(!showLangSettings)} - className="text-sm px-4 py-2" - > - {showLangSettings ? "收起" : "展开"} - -
- - {showLangSettings && ( -
- {/* 查询语言 */} -
- -
- {POPULAR_LANGUAGES.map((lang) => ( - setQueryLang(lang.code)} - className="text-sm px-3 py-1" - > - {lang.name} - - ))} -
- setQueryLang(e.target.value)} - placeholder="或输入其他语言..." - className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" - /> -
- - {/* 释义语言 */} -
- -
- {POPULAR_LANGUAGES.map((lang) => ( - setDefinitionLang(lang.code)} - className="text-sm px-3 py-1" - > - {lang.name} - - ))} -
- setDefinitionLang(e.target.value)} - placeholder="或输入其他语言..." - className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" - /> -
- - {/* 当前设置显示 */} -
- 当前设置:查询 {POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang} - ,释义 {POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang} -
-
- )} -
- - {/* 搜索提示 */} -
-

试试搜索:hello, look up, dictionary

-
-
-
- - {/* 搜索结果区域 */} -
- - {isSearching && ( -
-
-

加载中...

-
- )} - - {!isSearching && hasSearched && !searchResult && ( -
-

未找到结果

-

尝试其他单词或短语

-
- )} - - {!isSearching && searchResult && !isErrorResponse(searchResult) && ( -
-
- {/* 标准形式标题 */} -
-

- {searchResult.standardForm} -

- {searchResult.standardForm !== searchQuery && ( -

- 原始输入: {searchQuery} -

- )} -
- - {/* 条目列表 */} -
- {searchResult.entries.map((entry, index) => ( -
- {isWordEntry(entry) ? ( - // 单词条目 -
- {/* 音标和词性 */} -
- {entry.ipa && ( - - {entry.ipa} - - )} - {entry.partOfSpeech && ( - - {entry.partOfSpeech} - - )} -
- - {/* 释义 */} -
-

- 释义 -

-

{entry.definition}

-
- - {/* 例句 */} - {entry.example && ( -
-

- 例句 -

-

- {entry.example} -

-
- )} -
- ) : ( - // 短语条目 -
- {/* 释义 */} -
-

- 释义 -

-

{entry.definition}

-
- - {/* 例句 */} - {entry.example && ( -
-

- 例句 -

-

- {entry.example} -

-
- )} -
- )} + return ( +
+ {/* 搜索区域 */} +
+ + {/* 页面标题 */} +
+

+ 词典 +

+

+ 查询单词和短语,提供详细的释义和例句 +

- ))} -
-
-
- )} - {!hasSearched && ( -
-
📚
-

欢迎使用词典

-

在上方搜索框中输入单词或短语开始查询

+ {/* 搜索表单 */} +
+ ) => setSearchQuery(e.target.value)} + placeholder="输入要查询的单词或短语..." + className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" + /> + + {isSearching ? "查询中..." : "查询"} + +
+ + {/* 语言设置 */} +
+
+ 语言设置 + setShowLangSettings(!showLangSettings)} + className="text-sm px-4 py-2" + > + {showLangSettings ? "收起" : "展开"} + +
+ + {showLangSettings && ( +
+ {/* 查询语言 */} +
+ +
+ {POPULAR_LANGUAGES.map((lang) => ( + setQueryLang(lang.code)} + className="text-sm px-3 py-1" + > + {lang.name} + + ))} +
+ setQueryLang(e.target.value)} + placeholder="或输入其他语言..." + className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" + /> +
+ + {/* 释义语言 */} +
+ +
+ {POPULAR_LANGUAGES.map((lang) => ( + setDefinitionLang(lang.code)} + className="text-sm px-3 py-1" + > + {lang.name} + + ))} +
+ setDefinitionLang(e.target.value)} + placeholder="或输入其他语言..." + className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" + /> +
+ + {/* 当前设置显示 */} +
+ 当前设置:查询 {POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang} + ,释义 {POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang} +
+
+ )} +
+ + {/* 搜索提示 */} +
+

试试搜索:hello, look up, dictionary

+
+
- )} - -
-
- ); + + {/* 搜索结果区域 */} +
+ + {isSearching && ( +
+
+

加载中...

+
+ )} + + {!isSearching && hasSearched && !searchResult && ( +
+

未找到结果

+

尝试其他单词或短语

+
+ )} + + {!isSearching && searchResult && !isErrorResponse(searchResult) && ( +
+
+ {/* 标题和保存按钮 */} +
+
+

+ {searchResult.standardForm} +

+ {searchResult.standardForm !== searchQuery && ( +

+ 原始输入: {searchQuery} +

+ )} +
+
+ {session && folders.length > 0 && ( + + )} + +
+
+ + {/* 条目列表 */} +
+ {searchResult.entries.map((entry, index) => ( +
+ {isWordEntry(entry) ? ( + // 单词条目 +
+ {/* 音标和词性 */} +
+ {entry.ipa && ( + + {entry.ipa} + + )} + {entry.partOfSpeech && ( + + {entry.partOfSpeech} + + )} +
+ + {/* 释义 */} +
+

+ 释义 +

+

{entry.definition}

+
+ + {/* 例句 */} + {entry.example && ( +
+

+ 例句 +

+

+ {entry.example} +

+
+ )} +
+ ) : ( + // 短语条目 +
+ {/* 释义 */} +
+

+ 释义 +

+

{entry.definition}

+
+ + {/* 例句 */} + {entry.example && ( +
+

+ 例句 +

+

+ {entry.example} +

+
+ )} +
+ )} +
+ ))} +
+
+
+ )} + + {!hasSearched && ( +
+
📚
+

欢迎使用词典

+

在上方搜索框中输入单词或短语开始查询

+
+ )} +
+
+
+ ); } diff --git a/src/app/(features)/text-speaker/page.tsx b/src/app/(features)/text-speaker/page.tsx index 9697c5a..ede969a 100644 --- a/src/app/(features)/text-speaker/page.tsx +++ b/src/app/(features)/text-speaker/page.tsx @@ -15,10 +15,10 @@ import SaveList from "./SaveList"; import { VOICES } from "@/config/locales"; import { useTranslations } from "next-intl"; import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; -import { getTTSAudioUrl } from "@/lib/browser/tts"; -import { genIPA, genLocale } from "@/lib/server/bigmodel/translatorActions"; +import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions"; import { logger } from "@/lib/logger"; import PageLayout from "@/components/ui/PageLayout"; +import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts"; export default function TextSpeakerPage() { const t = useTranslations("text_speaker"); @@ -31,7 +31,7 @@ export default function TextSpeakerPage() { const [pause, setPause] = useState(true); const [autopause, setAutopause] = useState(true); const textRef = useRef(""); - const [locale, setLocale] = useState(null); + const [language, setLanguage] = useState(null); const [ipa, setIPA] = useState(""); const objurlRef = useRef(null); const [processing, setProcessing] = useState(false); @@ -95,38 +95,35 @@ export default function TextSpeakerPage() { } else { // 第一次播放 try { - let theLocale = locale; - if (!theLocale) { - const tmp_locale = await genLocale(textRef.current.slice(0, 30)); - setLocale(tmp_locale); - theLocale = tmp_locale; + let theLanguage = language; + if (!theLanguage) { + const tmp_language = await genLanguage(textRef.current.slice(0, 30)); + setLanguage(tmp_language); + theLanguage = tmp_language; } - const voice = VOICES.find((v) => v.locale.startsWith(theLocale)); - if (!voice) throw "Voice not found."; + theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase()); - objurlRef.current = await getTTSAudioUrl( + // 检查语言是否在 TTS 支持列表中 + const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [ + "Auto", "Chinese", "English", "German", "Italian", "Portuguese", + "Spanish", "Japanese", "Korean", "French", "Russian" + ]; + + if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) { + theLanguage = "Auto"; + } + + objurlRef.current = await getTTSUrl( textRef.current, - voice.short_name, - (() => { - if (speed === 1) return {}; - else if (speed < 1) - return { - rate: `-${100 - speed * 100}%`, - }; - else - return { - rate: `+${speed * 100 - 100}%`, - }; - })(), + theLanguage as TTS_SUPPORTED_LANGUAGES ); load(objurlRef.current); play(); } catch (e) { logger.error("播放音频失败", e); setPause(true); - setLocale(null); - + setLanguage(null); setProcessing(false); } } @@ -142,7 +139,7 @@ export default function TextSpeakerPage() { const handleInputChange = (e: ChangeEvent) => { textRef.current = e.target.value.trim(); - setLocale(null); + setLanguage(null); setIPA(""); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); objurlRef.current = null; @@ -163,7 +160,7 @@ export default function TextSpeakerPage() { const handleUseItem = (item: z.infer) => { if (textareaRef.current) textareaRef.current.value = item.text; textRef.current = item.text; - setLocale(item.locale); + setLanguage(item.locale); setIPA(item.ipa || ""); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); objurlRef.current = null; @@ -178,11 +175,11 @@ export default function TextSpeakerPage() { setSaving(true); try { - let theLocale = locale; - if (!theLocale) { - const tmp_locale = await genLocale(textRef.current.slice(0, 30)); - setLocale(tmp_locale); - theLocale = tmp_locale; + let theLanguage = language; + if (!theLanguage) { + const tmp_language = await genLanguage(textRef.current.slice(0, 30)); + setLanguage(tmp_language); + theLanguage = tmp_language; } let theIPA = ipa; @@ -205,19 +202,19 @@ export default function TextSpeakerPage() { } else if (theIPA.length === 0) { save.push({ text: textRef.current, - locale: theLocale, + locale: theLanguage as string, }); } else { save.push({ text: textRef.current, - locale: theLocale, + locale: theLanguage as string, ipa: theIPA, }); } setIntoLocalStorage(save); } catch (e) { logger.error("保存到本地存储失败", e); - setLocale(null); + setLanguage(null); } finally { setSaving(false); } diff --git a/src/app/(features)/translator/page.tsx b/src/app/(features)/translator/page.tsx index 4e1b0f8..4090ee2 100644 --- a/src/app/(features)/translator/page.tsx +++ b/src/app/(features)/translator/page.tsx @@ -7,7 +7,6 @@ import { VOICES } from "@/config/locales"; import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { TranslationHistorySchema } from "@/lib/interfaces"; import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators"; -import { getTTSAudioUrl } from "@/lib/browser/tts"; import { logger } from "@/lib/logger"; import { Plus, Trash } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -24,6 +23,7 @@ import FolderSelector from "./FolderSelector"; import { createPair } from "@/lib/server/services/pairService"; import { shallowEqual } from "@/lib/utils"; import { authClient } from "@/lib/auth-client"; +import { getTTSUrl } from "@/lib/server/bigmodel/tts"; export default function TranslatorPage() { const t = useTranslations("translator"); @@ -50,13 +50,8 @@ export default function TranslatorPage() { const tts = async (text: string, locale: string) => { if (lastTTS.current.text !== text) { - const shortName = VOICES.find((v) => v.locale === locale)?.short_name; - if (!shortName) { - toast.error("Voice not found"); - return; - } try { - const url = await getTTSAudioUrl(text, shortName); + const url = await getTTSUrl(text, locale); await load(url); lastTTS.current.text = text; lastTTS.current.url = url; diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index 6d3ead3..0c12e7c 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -5,7 +5,7 @@ import AuthForm from "./AuthForm"; export default async function AuthPage( props: { - searchParams: Promise<{ [key: string]: string | string[] | undefined; }> + searchParams: Promise<{ [key: string]: string | string[] | undefined; }>; } ) { const searchParams = await props.searchParams; diff --git a/src/app/folders/[folder_id]/InFolder.tsx b/src/app/folders/[folder_id]/InFolder.tsx index a421772..b05554e 100644 --- a/src/app/folders/[folder_id]/InFolder.tsx +++ b/src/app/folders/[folder_id]/InFolder.tsx @@ -21,8 +21,8 @@ export interface TextPair { id: number; text1: string; text2: string; - locale1: string; - locale2: string; + language1: string; + language2: string; } export default function InFolder({ folderId }: { folderId: number }) { @@ -146,8 +146,8 @@ export default function InFolder({ folderId }: { folderId: number }) { await createPair({ text1: text1, text2: text2, - locale1: locale1, - locale2: locale2, + language1: locale1, + language2: locale2, folder: { connect: { id: folderId, diff --git a/src/app/folders/[folder_id]/TextPairCard.tsx b/src/app/folders/[folder_id]/TextPairCard.tsx index e87578d..630bcac 100644 --- a/src/app/folders/[folder_id]/TextPairCard.tsx +++ b/src/app/folders/[folder_id]/TextPairCard.tsx @@ -25,11 +25,11 @@ export default function TextPairCard({
- {textPair.locale1.toUpperCase()} + {textPair.language1.toUpperCase()} - {textPair.locale2.toUpperCase()} + {textPair.language2.toUpperCase()}
diff --git a/src/app/folders/[folder_id]/UpdateTextPairModal.tsx b/src/app/folders/[folder_id]/UpdateTextPairModal.tsx index a002429..6661caa 100644 --- a/src/app/folders/[folder_id]/UpdateTextPairModal.tsx +++ b/src/app/folders/[folder_id]/UpdateTextPairModal.tsx @@ -23,8 +23,8 @@ export default function UpdateTextPairModal({ const t = useTranslations("folder_id"); const input1Ref = useRef(null); const input2Ref = useRef(null); - const [locale1, setLocale1] = useState(textPair.locale1); - const [locale2, setLocale2] = useState(textPair.locale2); + const [locale1, setLocale1] = useState(textPair.language1); + const [locale2, setLocale2] = useState(textPair.language2); if (!isOpen) return null; diff --git a/src/lib/actions/auth.ts b/src/lib/actions/auth.ts index 0c43439..737b777 100644 --- a/src/lib/actions/auth.ts +++ b/src/lib/actions/auth.ts @@ -5,19 +5,19 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; export interface SignUpFormData { - username: string; - email: string; - password: string; + username: string; + email: string; + password: string; } export interface SignUpState { - success?: boolean; - message?: string; - errors?: { - username?: string[]; - email?: string[]; - password?: string[]; - }; + success?: boolean; + message?: string; + errors?: { + username?: string[]; + email?: string[]; + password?: string[]; + }; } export async function signUpAction(prevState: SignUpState, formData: FormData) { @@ -111,6 +111,9 @@ export async function signInAction(prevState: SignUpState, formData: FormData) { redirect(redirectTo || "/"); } catch (error) { + if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) { + throw error; + } return { success: false, message: "登录失败,请检查您的邮箱和密码" diff --git a/src/lib/browser/tts.ts b/src/lib/browser/tts.ts deleted file mode 100644 index 6245761..0000000 --- a/src/lib/browser/tts.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ProsodyOptions, EdgeTTS } from "edge-tts-universal/browser"; - -export async function getTTSAudioUrl( - text: string, - short_name: string, - options: ProsodyOptions | undefined = undefined, -) { - const tts = new EdgeTTS(text, short_name, options); - try { - const result = await tts.synthesize(); - return URL.createObjectURL(result.audio); - } catch (e) { - throw e; - } -} diff --git a/src/lib/server/bigmodel/translatorActions.ts b/src/lib/server/bigmodel/translatorActions.ts index d57f1e7..623fa7d 100644 --- a/src/lib/server/bigmodel/translatorActions.ts +++ b/src/lib/server/bigmodel/translatorActions.ts @@ -38,6 +38,47 @@ export const genLocale = async (text: string) => { ); }; +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}` + } + ]); + return language.trim(); +}; + export const genTranslation = async (text: string, targetLanguage: string) => { return await getAnswer( ` diff --git a/src/lib/server/bigmodel/tts.ts b/src/lib/server/bigmodel/tts.ts new file mode 100644 index 0000000..7a16b26 --- /dev/null +++ b/src/lib/server/bigmodel/tts.ts @@ -0,0 +1,248 @@ +// ==================== 类型定义 ==================== +/** + * 支持的语音合成模型 + */ +type TTSModel = 'qwen3-tts-flash' | string; // 主要模型为 'qwen3-tts-flash' + +/** + * API 支持的语言类型(必须严格按文档使用) + */ +type SupportedLanguage = + | 'Auto' // 自动检测(混合语言场景) + | 'Chinese' // 中文 + | 'English' // 英文 + | 'German' // 德文 + | 'Italian' | 'Portuguese' | 'Spanish' + | 'Japanese' | 'Korean' | 'French' + | 'Russian'; + +/** + * API 请求参数接口 + */ +interface TTSRequest { + model: TTSModel; + input: { + text: string; // 要合成的文本(qwen3-tts-flash最长600字符) + voice: string; // 音色名称,如 'Cherry' + language_type?: SupportedLanguage; // 可选,默认为 'Auto' + }; + parameters?: { + stream?: boolean; // 是否流式输出(需配合特定Header) + }; +} + +/** + * API 响应接口(通用结构) + */ +interface TTSResponse { + status_code: number; // HTTP状态码,200表示成功 + request_id: string; // 请求唯一标识,用于排查问题 + code: string; // 错误码,成功时为 '' + message: string; // 错误信息,成功时为 '' + output: { + audio: { + data: string; // Base64编码的音频数据(流式输出时有效) + url: string; // 音频文件下载URL(非流式输出时有效) + id: string; // 音频ID + expires_at: number; // URL过期时间戳 + }; + text: null; // 文档注明:始终为null + choices: null; // 文档注明:始终为null + finish_reason: string; // 生成状态 + }; + usage: { + characters: number; // 计费字符数(qwen3-tts-flash) + input_tokens?: number; + output_tokens?: number; + }; +} + +// ==================== TTS 服务类 ==================== +class QwenTTSService { + private baseUrl: string; + private apiKey: string; + private region: 'cn-beijing' | 'intl-singapore'; // 地域 + + /** + * 构造函数 + * @param apiKey - DashScope API Key(从环境变量获取更安全) + * @param region - 服务地域,默认北京 + */ + constructor( + apiKey: string, + region: 'cn-beijing' | 'intl-singapore' = 'cn-beijing' + ) { + this.apiKey = apiKey; + this.region = region; + + // 根据地域设置API端点(文档中特别强调) + this.baseUrl = region === 'cn-beijing' + ? 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' + : 'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'; + } + + /** + * 验证文本长度(qwen3-tts-flash模型限制600字符) + */ + private validateTextLength(text: string, model: TTSModel): void { + const maxLength = model.includes('qwen3-tts-flash') ? 600 : 512; + if (text.length > maxLength) { + throw new Error( + `文本长度 ${text.length} 字符超过模型限制(最大 ${maxLength} 字符)` + ); + } + } + + /** + * 合成语音(非流式输出,返回音频URL) + */ + async synthesize( + text: string, + options: { + voice?: string; // 音色,默认 'Cherry' + language?: SupportedLanguage; // 语种,默认 'Auto' + model?: TTSModel; // 模型,默认 'qwen3-tts-flash' + } = {} + ): Promise { + const { + voice = 'Cherry', + language = 'Auto', + model = 'qwen3-tts-flash' + } = options; + + // 1. 文本长度验证 + this.validateTextLength(text, model); + + // 2. 构建请求体 + const requestBody: TTSRequest = { + model, + input: { + text, + voice, + language_type: language + } + // 非流式输出不需要 stream 参数 + }; + + try { + // 3. 调用API + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const data: TTSResponse = await response.json(); + + // 4. 错误处理 + if (data.status_code !== 200) { + throw new Error(`API错误: [${data.code}] ${data.message}`); + } + + return data; + + } catch (error) { + console.error('语音合成请求失败:', error); + throw error; + } + } + + /** + * 流式合成语音(边生成边输出Base64音频数据) + */ + async synthesizeStream( + text: string, + options: { + voice?: string; + language?: SupportedLanguage; + model?: TTSModel; + onAudioChunk?: (chunk: string) => void; // 接收音频片段的回调 + } = {} + ): Promise { + const { + voice = 'Cherry', + language = 'Auto', + model = 'qwen3-tts-flash', + onAudioChunk + } = options; + + this.validateTextLength(text, model); + + const requestBody: TTSRequest = { + model, + input: { + text, + voice, + language_type: language + }, + parameters: { + stream: true // 启用流式输出 + } + }; + + try { + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'X-DashScope-SSE': 'enable' // 关键:启用服务器发送事件 + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok || !response.body) { + throw new Error(`流式请求失败: ${response.status}`); + } + + // 处理流式响应(此处为简化示例,实际需解析SSE格式) + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + if (onAudioChunk && chunk.trim()) { + onAudioChunk(chunk); // 处理音频数据片段 + } + } + + } catch (error) { + console.error('流式合成失败:', error); + throw error; + } + } +} + +export type TTS_SUPPORTED_LANGUAGES = 'Auto' | 'Chinese' | 'English' | 'German' | 'Italian' | 'Portuguese' | 'Spanish' | 'Japanese' | 'Korean' | 'French' | 'Russian'; +export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) { + try { + if (!process.env.DASHSCORE_API_KEY) { + console.warn( + `⚠️ 环境变量 DASHSCORE_API_KEY 未设置\n` + + ` 请在 .env 文件中设置或直接传入API Key\n` + + ` 获取API Key: https://help.aliyun.com/zh/model-studio/get-api-key` + ); + throw "API Key设置错误"; + } + const ttsService = new QwenTTSService( + process.env.DASHSCOPE_API_KEY || 'sk-xxx', + ); + const result = await ttsService.synthesize( + text, + { + voice: 'Cherry', + language: lang + } + ); + return result.output.audio.url; + } catch (error) { + console.error('TTS合成失败:', error instanceof Error ? error.message : error); + return "error"; + } +}