Compare commits

...

11 Commits

Author SHA1 Message Date
0af99b6b70 修改语言图标 2026-02-03 17:04:41 +08:00
eaf97b8279 ... 2026-02-02 23:57:01 +08:00
76749549ff ... 2026-02-02 23:32:39 +08:00
fa6301538b ... 2026-01-22 16:01:07 +08:00
d4d5a53747 补全翻译 2026-01-18 13:06:08 +08:00
ec265be26b 重构 2026-01-14 16:57:35 +08:00
804baa64b2 重构 2026-01-13 23:02:07 +08:00
a1e42127e6 update ignore 2026-01-13 15:17:59 +08:00
f1d706e20c ... 2026-01-13 14:46:27 +08:00
c7cdf40f2f change varchar to text 2026-01-08 10:18:05 +08:00
a55e763525 解决dictionary搜索框溢出问题 2026-01-08 09:45:08 +08:00
124 changed files with 1890 additions and 1803 deletions

View File

@@ -35,3 +35,5 @@ build.sh
# prisma # prisma
/generated/prisma /generated/prisma
.claude

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ test.js
/generated/prisma /generated/prisma
certificates certificates
.claude

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "翻訳",

View File

@@ -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": "번역",

View File

@@ -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": "تەرجىمە قىلىش",

View File

@@ -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": "翻译",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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")
@@ -107,27 +107,27 @@ model Folder {
} }
model DictionaryLookUp { 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
queryLang String @map("query_lang") normalizedText String @default("") @map("normalized_text")
definitionLang String @map("definition_lang") queryLang String @map("query_lang")
createdAt DateTime @default(now()) @map("created_at") definitionLang String @map("definition_lang")
dictionaryWordId Int? @map("dictionary_word_id") createdAt DateTime @default(now()) @map("created_at")
dictionaryPhraseId Int? @map("dictionary_phrase_id") dictionaryItemId Int? @map("dictionary_item_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())
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lookups DictionaryLookUp[]
entries DictionaryPhraseEntry[]
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_phrases")
}
model DictionaryWordEntry {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
wordId Int @map("word_id") itemId Int @map("item_id")
ipa String 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")

View File

@@ -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);

View File

@@ -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,
}: { }: {

View File

@@ -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");

View File

@@ -1,75 +1,42 @@
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 (
<div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{wordEntry.ipa && (
<span className="text-gray-600 text-lg">
[{wordEntry.ipa}]
</span>
)}
{wordEntry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{wordEntry.partOfSpeech}
</span>
)}
</div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{wordEntry.definition}</p>
</div>
{/* 例句 */}
{wordEntry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{wordEntry.example}
</p>
</div>
)}
</div>
);
}
// 短语条目
const phraseEntry = entry as DictPhraseEntry;
return ( return (
<div> <div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{entry.ipa && (
<span className="text-gray-600 text-lg">
[{entry.ipa}]
</span>
)}
{entry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{entry.partOfSpeech}
</span>
)}
</div>
{/* 释义 */} {/* 释义 */}
<div className="mb-3"> <div className="mb-3">
<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">{phraseEntry.definition}</p> <p className="text-gray-800">{entry.definition}</p>
</div> </div>
{/* 例句 */} {/* 例句 */}
{phraseEntry.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]">
{phraseEntry.example} {entry.example}
</p> </p>
</div> </div>
)} )}

View File

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

View File

@@ -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>

View File

@@ -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); onResultUpdate(result);
} else {
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(() => {

View File

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

View File

@@ -1 +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>
);
}

View File

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

View 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;
}

View File

@@ -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 };

View File

@@ -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,31 +176,28 @@ 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" }`}
}`}
> >
{t("reverse")} {t("reverse")}
</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" }`}
}`}
> >
{t("dictation")} {t("dictation")}
</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" }`}
}`}
> >
{t("disorder")} {t("disorder")}
</button> </button>
@@ -211,4 +208,4 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
); );
}; };
export default Memorize; export { Memorize };

View File

@@ -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!} />;
} }

View File

@@ -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 (

View File

@@ -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 };

View File

@@ -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(() => {

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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]);

View File

@@ -46,4 +46,4 @@ const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
VideoElement.displayName = "VideoElement"; VideoElement.displayName = "VideoElement";
export default VideoElement; export { VideoElement };

View File

@@ -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,

View File

@@ -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(

View File

@@ -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();

View File

@@ -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 };

View File

@@ -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() {

View File

@@ -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>(

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {
setLastTranslation({ setTranslationResult(result.data);
sourceText, setLastTranslation({
targetLanguage, sourceText,
}); targetLanguage,
});
// 更新本地历史记录 } else {
const historyItem = { toast.error(result.message || "翻译失败,请重试");
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>
)}
</> </>
); );
} }

View File

@@ -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);
@@ -225,7 +225,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
className="w-full mt-4 py-2 flex items-center justify-center gap-2" className="w-full mt-4 py-2 flex items-center justify-center gap-2"
> >
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')} {t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
</LightButton> </LightButton>

View File

@@ -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: {

View File

@@ -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) {
setLoading(false); setFolders(folders.data);
}) 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();
await updateFolders(); } else {
toast.error(result.message);
}
});
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -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,

View File

@@ -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)
setLoading(false); .finally(() => {
} 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>
); );
} };

View File

@@ -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();
}} }}

View File

@@ -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,

View File

@@ -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)} />;

View File

@@ -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";

View File

@@ -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 () => {

View File

@@ -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");

View File

@@ -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, {

View File

@@ -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>

View File

@@ -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";

View File

@@ -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 = `

View File

@@ -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}

View File

@@ -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}`}

View File

@@ -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",

View File

@@ -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">

View File

@@ -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">

View File

@@ -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} />;

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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,
};
} }
} }

View File

@@ -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";
/** /**

View File

@@ -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";
/** /**

View File

@@ -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";
/** /**

View File

@@ -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";
/** /**

View 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);
}
}

View 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;
}

View File

@@ -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") {
try { return [] as unknown as z.infer<T>;
if (!globalThis.localStorage) return [] as z.infer<T>; }
const item = globalThis.localStorage.getItem(key);
if (!item) return [] as z.infer<T>; try {
const item = localStorage.getItem(key);
const rawData = JSON.parse(item) as z.infer<T>; if (item === null) {
const result = schema.safeParse(rawData); return [] as unknown as z.infer<T>;
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);
return [] as z.infer<T>;
} }
},
set: (data: z.infer<T>) => { const parsed = JSON.parse(item);
if (!globalThis.localStorage) return; return schema.parse(parsed);
globalThis.localStorage.setItem(key, JSON.stringify(data)); } catch (error) {
return data; console.error(`Error reading from localStorage key "${key}":`, error);
}, return [] as unknown as z.infer<T>;
}
}; };
};
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>) => { return { get, set };
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;
};

View File

@@ -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
View File

@@ -0,0 +1,2 @@
export class ValidateError extends Error { };
export class LookUpError extends Error { };

View File

@@ -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>;

View File

@@ -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) => { warn(message: string, ...args: unknown[]): void {
if (isDevelopment) { console.warn(`[WARN] ${message}`, ...args);
console.warn(message, data); }
}
},
info: (message: string, data?: unknown) => { info(message: string, ...args: unknown[]): void {
if (isDevelopment) { console.info(`[INFO] ${message}`, ...args);
console.info(message, data); }
debug(message: string, ...args: unknown[]): void {
if (process.env.NODE_ENV === "development") {
console.debug(`[DEBUG] ${message}`, ...args);
} }
}, }
}; }
export const logger = new Logger();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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;
}

View File

@@ -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,
},
});
}

View File

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

View File

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

View File

@@ -1,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;
}

View File

@@ -1,63 +0,0 @@
export type DictLookUpRequest = {
text: string,
queryLang: string,
definitionLang: string,
userId?: string,
forceRelook: boolean;
};
export type DictWordEntry = {
ipa: string;
definition: string;
partOfSpeech: string;
example: string;
};
export type DictPhraseEntry = {
definition: string;
example: string;
};
export type DictErrorResponse = {
error: string;
};
export type DictWordResponse = {
standardForm: string;
entries: DictWordEntry[];
};
export type DictPhraseResponse = {
standardForm: string;
entries: DictPhraseEntry[];
};
export type DictLookUpResponse =
| DictErrorResponse
| DictWordResponse
| DictPhraseResponse;
// 类型守卫:判断是否为错误响应
export function isDictErrorResponse(
response: DictLookUpResponse
): response is DictErrorResponse {
return "error" in response;
}
// 类型守卫:判断是否为单词响应
export function isDictWordResponse(
response: DictLookUpResponse
): response is DictWordResponse {
if (isDictErrorResponse(response)) return false;
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
return entries.length > 0 && "ipa" in entries[0] && "partOfSpeech" in entries[0];
}
// 类型守卫:判断是否为短语响应
export function isDictPhraseResponse(
response: DictLookUpResponse
): response is DictPhraseResponse {
if (isDictErrorResponse(response)) return false;
const entries = (response as DictWordResponse | DictPhraseResponse).entries;
return entries.length > 0 && !("ipa" in entries[0] || "partOfSpeech" in entries[0]);
}

View File

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

View File

@@ -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;

View File

@@ -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: "注册失败,请稍后再试"

View 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;
};

View 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