This commit is contained in:
2026-01-13 23:02:07 +08:00
parent a1e42127e6
commit 804baa64b2
71 changed files with 658 additions and 925 deletions

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

@@ -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,59 +135,29 @@ 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 {

View File

@@ -1,75 +1,42 @@
import { DictWordEntry, DictPhraseEntry } from "./types"; import { TSharedEntry } from "@/shared";
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,133 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Container from "@/components/ui/Container";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { DictLookUpResponse } from "./types";
import { SearchForm } from "./SearchForm";
import { SearchResult } from "./SearchResult";
import { useTranslations } from "next-intl";
import { POPULAR_LANGUAGES } from "./constants";
import { performDictionaryLookup } from "./utils";
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);
const result = await performDictionaryLookup(
{
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang)
},
t
);
if (result.success && result.data) {
setSearchResult(result.data);
} else {
setSearchResult(null);
}
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

@@ -2,26 +2,20 @@ 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 { Folder } from "../../../../generated/prisma/browser";
import { createPair } from "@/lib/server/services/pairService";
import {
DictWordResponse,
DictPhraseResponse,
isDictWordResponse,
DictWordEntry,
} from "./types";
import { DictionaryEntry } from "./DictionaryEntry"; import { DictionaryEntry } from "./DictionaryEntry";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { performDictionaryLookup } from "./utils"; import { performDictionaryLookup } from "./utils";
import { TSharedItem } from "@/shared";
interface SearchResultProps { interface SearchResultProps {
searchResult: DictWordResponse | DictPhraseResponse; searchResult: TSharedItem;
searchQuery: string; searchQuery: string;
queryLang: string; queryLang: string;
definitionLang: string; definitionLang: string;
folders: Folder[]; folders: Folder[];
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;
} }
@@ -54,8 +48,8 @@ export function SearchResult({
t t
); );
if (result.success && result.data) { if (result) {
onResultUpdate(result.data); onResultUpdate(result);
} }
onSearchingChange(false); onSearchingChange(false);

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,127 @@
export { default } from "./DictionaryPage"; "use client";
import { useState, useEffect } from "react";
import Container from "@/components/ui/Container";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
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";
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<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);
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

@@ -1,51 +1,25 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions"; import { lookUpDictionaryAction } from "@/modules/dictionary/dictionary-action";
import { import { DictionaryActionInputDto, DictionaryActionOutputDto } from "@/modules/dictionary";
DictWordResponse, import { TSharedItem } from "@/shared";
DictPhraseResponse,
} from "./types";
interface LookupOptions {
text: string;
queryLang: string;
definitionLang: string;
forceRelook?: boolean;
}
interface LookupResult {
success: boolean;
data?: DictWordResponse | DictPhraseResponse;
error?: string;
}
/**
* 执行词典查询的通用函数
* @param options - 查询选项
* @param t - 翻译函数
* @returns 查询结果
*/
export async function performDictionaryLookup( export async function performDictionaryLookup(
options: LookupOptions, options: DictionaryActionInputDto,
t?: (key: string) => string t?: (key: string) => string
): Promise<LookupResult> { ): Promise<TSharedItem | null> {
const { text, queryLang, definitionLang, forceRelook = false } = options; const { text, queryLang, definitionLang, forceRelook = false, userId } = options;
const result = await lookUpDictionaryAction({
text,
queryLang,
definitionLang,
forceRelook,
userId
});
try { if (!result.success || !result.data) return null;
const result = await lookUp({
text,
queryLang,
definitionLang,
forceRelook
});
// 成功时显示提示(仅强制重新查询时) if (forceRelook && t) {
if (forceRelook && t) { toast.success(t("relookupSuccess"));
toast.success(t("relookupSuccess"));
}
return { success: true, data: result };
} catch (error) {
toast.error(String(error));
return { success: false, error: String(error) };
} }
return result.data;
} }

View File

@@ -2,10 +2,10 @@
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 { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({ const myFont = localFont({

View File

@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
import { import {
getFoldersWithTotalPairsByUserId, getFoldersWithTotalPairsByUserId,
} from "@/lib/server/services/folderService"; } from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/utils/random";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize"; import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/server/services/pairService"; import { getPairsByFolderId } from "@/lib/server/services/pairService";

View File

@@ -14,10 +14,10 @@ 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

@@ -12,14 +12,14 @@ import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import z from "zod"; import z from "zod";
import AddToFolder from "./AddToFolder"; import AddToFolder from "./AddToFolder";
import { translateText } from "@/lib/server/bigmodel/translatorActions"; import { translateText } from "@/modules/translator/translator-action";
import type { TranslateTextOutput } from "@/lib/server/services/types"; import type { TranslateTextOutput } from "@/lib/server/services/types";
import { toast } from "sonner"; import { toast } from "sonner";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService"; import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/lib/utils"; import { shallowEqual } from "@/utils/random";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");

View File

@@ -2,7 +2,7 @@
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 { signInAction, signUpAction, SignUpState } from "@/modules/user/user-action";
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";

View File

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

@@ -1,21 +1,15 @@
import { DictLookUpResponse } from "@/lib/shared"; import { LookUpServiceOutputDto } 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<LookUpServiceOutputDto> {
try { try {
// ========== 阶段 1输入分析 ========== // ========== 阶段 1输入分析 ==========
console.log("[阶段1] 开始输入分析..."); console.log("[阶段1] 开始输入分析...");
@@ -80,7 +74,7 @@ export async function executeDictionaryLookup(
console.log("[阶段4] 词条生成完成:", entriesResult); console.log("[阶段4] 词条生成完成:", entriesResult);
// ========== 组装最终结果 ========== // ========== 组装最终结果 ==========
const finalResult: DictLookUpResponse = { const finalResult: LookUpServiceOutputDto = {
standardForm: standardFormResult.standardForm, standardForm: standardFormResult.standardForm,
entries: entriesResult.entries, entries: entriesResult.entries,
}; };
@@ -91,8 +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 : "未知错误";
throw errorMessage; throw new LookUpError(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

@@ -1,62 +0,0 @@
"use client";
import {
TranslationHistoryArraySchema,
TranslationHistorySchema,
} from "@/lib/interfaces";
import z from "zod";
import { shallowEqual } from "../utils";
import { logger } from "@/lib/logger";
export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
key: string,
schema: T,
) => {
return {
get: (): z.infer<T> => {
try {
if (!globalThis.localStorage) return [] as z.infer<T>;
const item = globalThis.localStorage.getItem(key);
if (!item) return [] as z.infer<T>;
const rawData = JSON.parse(item) as z.infer<T>;
const result = schema.safeParse(rawData);
if (result.success) {
return result.data;
} else {
logger.error(
"Invalid data structure in localStorage:",
result.error,
);
return [] as z.infer<T>;
}
} catch (e) {
logger.error(`Failed to parse ${key} data:`, e);
return [] as z.infer<T>;
}
},
set: (data: z.infer<T>) => {
if (!globalThis.localStorage) return;
globalThis.localStorage.setItem(key, JSON.stringify(data));
return data;
},
};
};
const MAX_HISTORY_LENGTH = 50;
export const tlso = getLocalStorageOperator<
typeof TranslationHistoryArraySchema
>("translator", TranslationHistoryArraySchema);
export const tlsoPush = (item: z.infer<typeof TranslationHistorySchema>) => {
const oldHistory = tlso.get();
if (oldHistory.some((v) => shallowEqual(v, item))) return oldHistory;
const newHistory = [...oldHistory, item].slice(-MAX_HISTORY_LENGTH);
tlso.set(newHistory);
return newHistory;
};

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 +0,0 @@
import z from "zod";
export interface Word {
word: string;
x: number;
y: number;
}
export interface Letter {
letter: string;
letter_name_ipa: string;
letter_sound_ipa: 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 +0,0 @@
/**
* 统一的日志工具
* 在生产环境中可以通过环境变量控制日志级别
*/
type LogLevel = 'info' | 'warn' | 'error';
const isDevelopment = process.env.NODE_ENV === 'development';
export const logger = {
error: (message: string, error?: unknown) => {
if (isDevelopment) {
console.error(message, error);
}
// 在生产环境中,这里可以发送到错误追踪服务(如 Sentry
},
warn: (message: string, data?: unknown) => {
if (isDevelopment) {
console.warn(message, data);
}
},
info: (message: string, data?: unknown) => {
if (isDevelopment) {
console.info(message, data);
}
},
};

View File

@@ -1,140 +0,0 @@
"use server";
import { executeDictionaryLookup } from "./dictionary";
import { createLookUp, createPhrase, createWord, createPhraseEntry, createWordEntry, selectLastLookUp } from "../services/dictionaryService";
import { DictLookUpRequest, DictWordResponse, isDictPhraseResponse, isDictWordResponse, type DictLookUpResponse } from "@/lib/shared";
import { lookUpValidation } from "@/lib/shared/validations/dictionaryValidations";
const saveResult = async (req: DictLookUpRequest, res: DictLookUpResponse) => {
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 (req: DictLookUpRequest): Promise<DictLookUpResponse> => {
const {
text,
queryLang,
forceRelook = false,
definitionLang,
userId
} = req;
lookUpValidation(req);
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 {
throw "错误D101";
}
}
};

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,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,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,49 +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 DictWordResponse = {
standardForm: string;
entries: DictWordEntry[];
};
export type DictPhraseResponse = {
standardForm: string;
entries: DictPhraseEntry[];
};
export type DictLookUpResponse =
| DictWordResponse
| DictPhraseResponse;
// 类型守卫:判断是否为单词响应
export function isDictWordResponse(
response: DictLookUpResponse
): response is DictWordResponse {
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 {
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,22 +0,0 @@
import { DictLookUpRequest } from "@/lib/shared";
export const lookUpValidation = (req: DictLookUpRequest) => {
const {
text,
queryLang,
definitionLang,
} = req;
if (text.length > 30)
throw Error("The input should not exceed 30 characters.");
if (queryLang.length > 20)
throw Error("The query language should not exceed 20 characters.");
if (definitionLang.length > 20)
throw Error("The definition language should not exceed 20 characters.");
if (queryLang.length > 20)
throw Error("The query language should not exceed 20 characters.");
if (queryLang.length > 20)
throw Error("The query language should not exceed 20 characters.");
if (queryLang.length > 20)
throw Error("The query language should not exceed 20 characters.");
};

View File

@@ -1,14 +0,0 @@
/**
* 主题配色常量
* 集中管理应用的品牌颜色
*
* 注意Tailwind CSS 已有的标准颜色gray、red 等)请直接使用 Tailwind 类名
* 这里只定义项目独有的品牌色
*/
export const COLORS = {
// ===== 主色调 =====
/** 主绿色 - 应用主题色,用于页面背景、主要按钮 */
primary: '#35786f',
/** 悬停绿色 - 按钮悬停状态 */
primaryHover: '#2d5f58'
} as const;

View File

@@ -0,0 +1,30 @@
import { ValidateError } from "@/lib/errors";
import { TSharedItem } from "@/shared";
import z from "zod";
const DictionaryActionInputDtoSchema = z.object({
text: z.string().min(1, 'Empty text.').max(30, 'Text too long.'),
queryLang: z.string().min(1, 'Query lang too short.').max(20, 'Query lang too long.'),
forceRelook: z.boolean(),
definitionLang: z.string().min(1, 'Definition lang too short.').max(20, 'Definition lang too long.'),
userId: z.string().optional()
});
export type DictionaryActionInputDto = z.infer<typeof DictionaryActionInputDtoSchema>;
export const validateDictionaryActionInput = (dto: DictionaryActionInputDto): DictionaryActionInputDto => {
const result = DictionaryActionInputDtoSchema.safeParse(dto);
if (result.success) return result.data;
const errorMessages = result.error.issues.map((issue) =>
`${issue.path.join('.')}: ${issue.message}`
).join('; ');
throw new ValidateError(`Validation failed: ${errorMessages}`);
};
export type DictionaryActionOutputDto = {
message: string,
success: boolean;
data?: TSharedItem;
};

View File

@@ -0,0 +1,27 @@
"use server";
import { DictionaryActionInputDto, DictionaryActionOutputDto, validateDictionaryActionInput } from "./dictionary-action-dto";
import { ValidateError } from "@/lib/errors";
import { lookUpService } from "./dictionary-service";
export const lookUpDictionaryAction = async (dto: DictionaryActionInputDto): Promise<DictionaryActionOutputDto> => {
try {
return {
message: 'success',
success: true,
data: await lookUpService(validateDictionaryActionInput(dto))
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
};

View File

@@ -0,0 +1,38 @@
import { TSharedItem } from "@/shared";
export type CreateDictionaryLookUpInputDto = {
userId?: string;
text: string;
queryLang: string;
definitionLang: string;
dictionaryItemId?: number;
};
export type SelectLastLookUpResultOutputDto = TSharedItem & {id: number} | null;
export type CreateDictionaryItemInputDto = {
standardForm: string;
queryLang: string;
definitionLang: string;
};
export type CreateDictionaryEntryInputDto = {
itemId: number;
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string;
};
export type CreateDictionaryEntryWithoutItemIdInputDto = {
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string;
};
export type SelectLastLookUpResultInputDto = {
text: string,
queryLang: string,
definitionLang: string;
};

View File

@@ -0,0 +1,86 @@
import { stringNormalize } from "@/utils/string";
import {
CreateDictionaryEntryInputDto,
CreateDictionaryEntryWithoutItemIdInputDto,
CreateDictionaryItemInputDto,
CreateDictionaryLookUpInputDto,
SelectLastLookUpResultInputDto,
SelectLastLookUpResultOutputDto,
} from "./dictionary-repository-dto";
import prisma from "@/lib/db";
export async function selectLastLookUpResult(dto: SelectLastLookUpResultInputDto): Promise<SelectLastLookUpResultOutputDto> {
const result = await prisma.dictionaryLookUp.findFirst({
where: {
normalizedText: stringNormalize(dto.text),
queryLang: dto.queryLang,
definitionLang: dto.definitionLang,
dictionaryItemId: {
not: null
}
},
include: {
dictionaryItem: {
include: {
entries: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
if (result && result.dictionaryItem) {
const item = result.dictionaryItem;
return {
id: item.id,
standardForm: item.standardForm,
entries: item.entries.filter(v => !!v).map(v => {
return {
ipa: v.ipa || undefined,
definition: v.definition,
partOfSpeech: v.partOfSpeech || undefined,
example: v.example
};
})
};
}
return null;
}
export async function createLookUp(content: CreateDictionaryLookUpInputDto) {
return (await prisma.dictionaryLookUp.create({
data: { ...content, normalizedText: stringNormalize(content.text) }
})).id;
}
export async function createLookUpWithItemAndEntries(
itemData: CreateDictionaryItemInputDto,
lookUpData: CreateDictionaryLookUpInputDto,
entries: CreateDictionaryEntryWithoutItemIdInputDto[]
) {
return await prisma.$transaction(async (tx) => {
const item = await tx.dictionaryItem.create({
data: itemData
});
await tx.dictionaryLookUp.create({
data: {
...lookUpData,
normalizedText: stringNormalize(lookUpData.text),
dictionaryItemId: item.id
}
});
for (const entry of entries) {
await tx.dictionaryEntry.create({
data: {
...entry,
itemId: item.id
}
});
}
return item.id;
});
}

View File

@@ -0,0 +1,11 @@
import { TSharedItem } from "@/shared";
export type LookUpServiceInputDto = {
text: string,
queryLang: string,
definitionLang: string,
forceRelook: boolean,
userId?: string;
};
export type LookUpServiceOutputDto = TSharedItem;

View File

@@ -0,0 +1,61 @@
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary";
import { createLookUp, createLookUpWithItemAndEntries, selectLastLookUpResult } from "./dictionary-repository";
import { LookUpServiceInputDto } from "./dictionary-service-dto";
export const lookUpService = async (dto: LookUpServiceInputDto) => {
const {
text,
queryLang,
userId,
definitionLang,
forceRelook
} = dto;
const lastLookUpResult = await selectLastLookUpResult({
text,
queryLang,
definitionLang,
});
if (forceRelook || !lastLookUpResult) {
const response = await executeDictionaryLookup(
text,
queryLang,
definitionLang
);
// 使用事务确保数据一致性
createLookUpWithItemAndEntries(
{
standardForm: response.standardForm,
queryLang,
definitionLang
},
{
userId,
text,
queryLang,
definitionLang,
},
response.entries
).catch(error => {
console.error('Failed to save dictionary data:', error);
});
return response;
} else {
createLookUp({
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryItemId: lastLookUpResult.id
}).catch(error => {
console.error('Failed to save dictionary data:', error);
});
return {
standardForm: lastLookUpResult.standardForm,
entries: lastLookUpResult.entries
};
}
};

View File

@@ -0,0 +1,2 @@
export * from "./dictionary-action";
export * from "./dictionary-action-dto";

View File

View File

View File

View File

View File

@@ -1,7 +1,5 @@
"use server"; import { CreateFolderInput, UpdateFolderInput } from "../translator/translator-dto";
import prisma from "@/lib/db";
import { CreateFolderInput, UpdateFolderInput } from "./types";
import prisma from "../../db";
export async function getFoldersByUserId(userId: string) { export async function getFoldersByUserId(userId: string) {
return prisma.folder.findMany({ return prisma.folder.findMany({

View File

View File

@@ -0,0 +1 @@
"use server";

View File

@@ -0,0 +1,44 @@
import { CreatePairInput, UpdatePairInput } from "../translator/translator-dto";
import prisma from "@/lib/db";
export async function createPair(data: CreatePairInput) {
return (await prisma.pair.create({
data: data,
})).id;
}
export async function deletePairById(id: number) {
await prisma.pair.delete({
where: {
id: id,
},
});
}
export async function updatePairById(
id: number,
data: UpdatePairInput,
) {
await 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

View File

View File

@@ -1,8 +1,8 @@
"use server"; "use server";
import { getAnswer } from "./zhipu"; import { getAnswer } from "@/lib/bigmodel/zhipu";
import { selectLatestTranslation, createTranslationHistory } from "../services/translatorService"; import { selectLatestTranslation, createTranslationHistory } from "./translator-service";
import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "../services/types"; import { TranslateTextInput, TranslateTextOutput, TranslationLLMResponse } from "./translator-dto";
/** /**
* @deprecated 使 translateText * @deprecated 使 translateText

View File

@@ -50,51 +50,6 @@ export interface TranslationHistoryQuery {
targetLanguage: 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 { export interface TranslateTextInput {
sourceText: string; sourceText: string;

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./types"; import { CreateTranslationHistoryInput, TranslationHistoryQuery } from "./translator-dto";
import prisma from "../../db"; import prisma from "@/lib/db";
/** /**
* *

View File

View File

View File

View File

View File

@@ -0,0 +1,11 @@
export type TSharedEntry = {
definition: string,
example: string,
partOfSpeech?: string;
ipa?: string;
};
export type TSharedItem = {
standardForm: string,
entries: TSharedEntry[];
};

View File

@@ -0,0 +1,3 @@
export type TSharedPair = {
};

1
src/shared/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './dictionary-type';

25
src/utils/json.ts Normal file
View File

@@ -0,0 +1,25 @@
export function parseAIGeneratedJSON<T>(aiResponse: string): T {
// 匹配 ```json ... ``` 包裹的内容
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
} else if (typeof error === 'string') {
throw new Error(`Failed to parse JSON: ${error}`);
} else {
throw new Error('Failed to parse JSON: Unknown error');
}
}
}
// 如果没有找到json代码块尝试直接解析整个字符串
try {
return JSON.parse(aiResponse.trim());
} catch (error) {
throw new Error('No valid JSON found in the response');
}
}

View File

@@ -1,3 +1,4 @@
export function isNonNegativeInteger(str: string): boolean { export function isNonNegativeInteger(str: string): boolean {
return /^\d+$/.test(str); return /^\d+$/.test(str);
} }
@@ -147,29 +148,3 @@ export class SeededRandom {
return shuffled; return shuffled;
} }
} }
export function parseAIGeneratedJSON<T>(aiResponse: string): T {
// 匹配 ```json ... ``` 包裹的内容
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to parse JSON: ${error.message}`);
} else if (typeof error === 'string') {
throw new Error(`Failed to parse JSON: ${error}`);
} else {
throw new Error('Failed to parse JSON: Unknown error');
}
}
}
// 如果没有找到json代码块尝试直接解析整个字符串
try {
return JSON.parse(aiResponse.trim());
} catch (error) {
throw new Error('No valid JSON found in the response');
}
}

1
src/utils/string.ts Normal file
View File

@@ -0,0 +1 @@
export const stringNormalize = (s: string) => s.trim().toLowerCase();