Compare commits
2 Commits
f1d706e20c
...
804baa64b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 804baa64b2 | |||
| a1e42127e6 |
@@ -35,3 +35,5 @@ build.sh
|
|||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
|
.claude
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,3 +50,5 @@ test.js
|
|||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -110,24 +110,24 @@ model DictionaryLookUp {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
text String
|
text String
|
||||||
|
normalizedText String @default("") @map("normalized_text")
|
||||||
queryLang String @map("query_lang")
|
queryLang String @map("query_lang")
|
||||||
definitionLang String @map("definition_lang")
|
definitionLang String @map("definition_lang")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
dictionaryWordId Int? @map("dictionary_word_id")
|
dictionaryItemId Int? @map("dictionary_item_id")
|
||||||
dictionaryPhraseId Int? @map("dictionary_phrase_id")
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
|
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
|
||||||
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([text, queryLang, definitionLang])
|
@@index([normalizedText])
|
||||||
@@map("dictionary_lookups")
|
@@map("dictionary_lookups")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DictionaryWord {
|
model DictionaryItem {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
frequency Int @default(1)
|
||||||
standardForm String @map("standard_form")
|
standardForm String @map("standard_form")
|
||||||
queryLang String @map("query_lang")
|
queryLang String @map("query_lang")
|
||||||
definitionLang String @map("definition_lang")
|
definitionLang String @map("definition_lang")
|
||||||
@@ -135,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())
|
id Int @id @default(autoincrement())
|
||||||
standardForm String @map("standard_form")
|
itemId Int @map("item_id")
|
||||||
queryLang String @map("query_lang")
|
ipa String?
|
||||||
definitionLang String @map("definition_lang")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
lookups DictionaryLookUp[]
|
|
||||||
entries DictionaryPhraseEntry[]
|
|
||||||
|
|
||||||
@@index([standardForm])
|
|
||||||
@@index([queryLang, definitionLang])
|
|
||||||
@@map("dictionary_phrases")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DictionaryWordEntry {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
wordId Int @map("word_id")
|
|
||||||
ipa String
|
|
||||||
definition String
|
definition String
|
||||||
partOfSpeech String @map("part_of_speech")
|
partOfSpeech String? @map("part_of_speech")
|
||||||
example String
|
example String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
|
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([wordId])
|
@@index([itemId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("dictionary_word_entries")
|
@@map("dictionary_entries")
|
||||||
}
|
|
||||||
|
|
||||||
model DictionaryPhraseEntry {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
phraseId Int @map("phrase_id")
|
|
||||||
definition String
|
|
||||||
example String
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([phraseId])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@map("dictionary_phrase_entries")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model TranslationHistory {
|
model TranslationHistory {
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 音标和词性 */}
|
{/* 音标和词性 */}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{wordEntry.ipa && (
|
{entry.ipa && (
|
||||||
<span className="text-gray-600 text-lg">
|
<span className="text-gray-600 text-lg">
|
||||||
[{wordEntry.ipa}]
|
[{entry.ipa}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{wordEntry.partOfSpeech && (
|
{entry.partOfSpeech && (
|
||||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||||
{wordEntry.partOfSpeech}
|
{entry.partOfSpeech}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -32,44 +26,17 @@ export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
|||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
释义
|
释义
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-800">{wordEntry.definition}</p>
|
<p className="text-gray-800">{entry.definition}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 例句 */}
|
{/* 例句 */}
|
||||||
{wordEntry.example && (
|
{entry.example && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
例句
|
例句
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
{wordEntry.example}
|
{entry.example}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 短语条目
|
|
||||||
const phraseEntry = entry as DictPhraseEntry;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 释义 */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
释义
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-800">{phraseEntry.definition}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 例句 */}
|
|
||||||
{phraseEntry.example && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
例句
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
|
||||||
{phraseEntry.example}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
// 类型定义
|
|
||||||
export * from "./types";
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
export * from "./constants";
|
|
||||||
|
|
||||||
// 组件
|
|
||||||
export { default as DictionaryPage } from "./DictionaryPage";
|
|
||||||
export { SearchForm } from "./SearchForm";
|
|
||||||
export { SearchResult } from "./SearchResult";
|
|
||||||
export { DictionaryEntry } from "./DictionaryEntry";
|
|
||||||
@@ -1 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// 从 shared 文件夹导出所有词典类型和类型守卫
|
|
||||||
export * from "@/lib/shared";
|
|
||||||
@@ -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({
|
||||||
try {
|
|
||||||
const result = await lookUp({
|
|
||||||
text,
|
text,
|
||||||
queryLang,
|
queryLang,
|
||||||
definitionLang,
|
definitionLang,
|
||||||
forceRelook
|
forceRelook,
|
||||||
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
// 成功时显示提示(仅强制重新查询时)
|
if (!result.success || !result.data) return null;
|
||||||
|
|
||||||
if (forceRelook && t) {
|
if (forceRelook && t) {
|
||||||
toast.success(t("relookupSuccess"));
|
toast.success(t("relookupSuccess"));
|
||||||
}
|
}
|
||||||
|
return result.data;
|
||||||
return { success: true, data: result };
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(String(error));
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { InputAnalysisResult } from "./types";
|
import { InputAnalysisResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { SemanticMappingResult } from "./types";
|
import { SemanticMappingResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { StandardFormResult } from "./types";
|
import { StandardFormResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAnswer } from "../zhipu";
|
import { getAnswer } from "../zhipu";
|
||||||
import { parseAIGeneratedJSON } from "@/lib/utils";
|
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||||
import { EntriesGenerationResult } from "./types";
|
import { EntriesGenerationResult } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
2
src/lib/errors.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export class ValidateError extends Error { };
|
||||||
|
export class LookUpError extends Error { };
|
||||||
@@ -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>;
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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:跨语言语义映射决策
|
|
||||||
* - 阶段3:standardForm 生成与规范化
|
|
||||||
* - 阶段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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import {
|
|
||||||
CreateDictionaryLookUpInput,
|
|
||||||
DictionaryLookUpQuery,
|
|
||||||
CreateDictionaryPhraseInput,
|
|
||||||
CreateDictionaryPhraseEntryInput,
|
|
||||||
CreateDictionaryWordInput,
|
|
||||||
CreateDictionaryWordEntryInput
|
|
||||||
} from "./types";
|
|
||||||
import prisma from "../../db";
|
|
||||||
|
|
||||||
export async function selectLastLookUp(content: DictionaryLookUpQuery) {
|
|
||||||
return prisma.dictionaryLookUp.findFirst({
|
|
||||||
where: content,
|
|
||||||
include: {
|
|
||||||
dictionaryPhrase: {
|
|
||||||
include: {
|
|
||||||
entries: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dictionaryWord: {
|
|
||||||
include: {
|
|
||||||
entries: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPhraseEntry(content: CreateDictionaryPhraseEntryInput) {
|
|
||||||
return prisma.dictionaryPhraseEntry.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWordEntry(content: CreateDictionaryWordEntryInput) {
|
|
||||||
return prisma.dictionaryWordEntry.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPhrase(content: CreateDictionaryPhraseInput) {
|
|
||||||
return prisma.dictionaryPhrase.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWord(content: CreateDictionaryWordInput) {
|
|
||||||
return prisma.dictionaryWord.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createLookUp(content: CreateDictionaryLookUpInput) {
|
|
||||||
return prisma.dictionaryLookUp.create({
|
|
||||||
data: content
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { CreatePairInput, UpdatePairInput } from "./types";
|
|
||||||
import prisma from "../../db";
|
|
||||||
|
|
||||||
export async function createPair(data: CreatePairInput) {
|
|
||||||
return prisma.pair.create({
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePairById(id: number) {
|
|
||||||
return prisma.pair.delete({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePairById(
|
|
||||||
id: number,
|
|
||||||
data: UpdatePairInput,
|
|
||||||
) {
|
|
||||||
return prisma.pair.update({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPairCountByFolderId(folderId: number) {
|
|
||||||
return prisma.pair.count({
|
|
||||||
where: {
|
|
||||||
folderId: folderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPairsByFolderId(folderId: number) {
|
|
||||||
return prisma.pair.findMany({
|
|
||||||
where: {
|
|
||||||
folderId: folderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import prisma from "@/lib/db";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
export async function createUserIfNotExists(email: string, name?: string | null) {
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: {
|
|
||||||
email: email,
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: randomUUID(),
|
|
||||||
email: email,
|
|
||||||
name: name || "New User",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserIdByEmail(email: string) {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email: email,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return user ? user.id : null;
|
|
||||||
}
|
|
||||||
@@ -1,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]);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./dictionaryTypes";
|
|
||||||
@@ -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.");
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* 主题配色常量
|
|
||||||
* 集中管理应用的品牌颜色
|
|
||||||
*
|
|
||||||
* 注意:Tailwind CSS 已有的标准颜色(gray、red 等)请直接使用 Tailwind 类名
|
|
||||||
* 这里只定义项目独有的品牌色
|
|
||||||
*/
|
|
||||||
export const COLORS = {
|
|
||||||
// ===== 主色调 =====
|
|
||||||
/** 主绿色 - 应用主题色,用于页面背景、主要按钮 */
|
|
||||||
primary: '#35786f',
|
|
||||||
/** 悬停绿色 - 按钮悬停状态 */
|
|
||||||
primaryHover: '#2d5f58'
|
|
||||||
} as const;
|
|
||||||
30
src/modules/dictionary/dictionary-action-dto.ts
Normal file
30
src/modules/dictionary/dictionary-action-dto.ts
Normal 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;
|
||||||
|
};
|
||||||
27
src/modules/dictionary/dictionary-action.ts
Normal file
27
src/modules/dictionary/dictionary-action.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { 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.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/modules/dictionary/dictionary-repository-dto.ts
Normal file
38
src/modules/dictionary/dictionary-repository-dto.ts
Normal 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;
|
||||||
|
};
|
||||||
86
src/modules/dictionary/dictionary-repository.ts
Normal file
86
src/modules/dictionary/dictionary-repository.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/modules/dictionary/dictionary-service-dto.ts
Normal file
11
src/modules/dictionary/dictionary-service-dto.ts
Normal 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;
|
||||||
61
src/modules/dictionary/dictionary-service.ts
Normal file
61
src/modules/dictionary/dictionary-service.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
2
src/modules/dictionary/index.ts
Normal file
2
src/modules/dictionary/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./dictionary-action";
|
||||||
|
export * from "./dictionary-action-dto";
|
||||||
0
src/modules/folder/folder-aciton.ts
Normal file
0
src/modules/folder/folder-aciton.ts
Normal file
0
src/modules/folder/folder-action-dto.ts
Normal file
0
src/modules/folder/folder-action-dto.ts
Normal file
0
src/modules/folder/folder-repository.ts
Normal file
0
src/modules/folder/folder-repository.ts
Normal file
0
src/modules/folder/folder-service-dto.ts
Normal file
0
src/modules/folder/folder-service-dto.ts
Normal 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({
|
||||||
0
src/modules/pair/pair-action-dto.ts
Normal file
0
src/modules/pair/pair-action-dto.ts
Normal file
1
src/modules/pair/pair-action.ts
Normal file
1
src/modules/pair/pair-action.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"use server";
|
||||||
44
src/modules/pair/pair-repository.ts
Normal file
44
src/modules/pair/pair-repository.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
0
src/modules/pair/pair-service-dto.ts
Normal file
0
src/modules/pair/pair-service-dto.ts
Normal file
0
src/modules/pair/pair-service.ts
Normal file
0
src/modules/pair/pair-service.ts
Normal file
0
src/modules/translator/translator-action-dto.ts
Normal file
0
src/modules/translator/translator-action-dto.ts
Normal 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 函数代替
|
||||||
@@ -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;
|
||||||
0
src/modules/translator/translator-repository.ts
Normal file
0
src/modules/translator/translator-repository.ts
Normal file
0
src/modules/translator/translator-service-dto.ts
Normal file
0
src/modules/translator/translator-service-dto.ts
Normal 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建翻译历史记录
|
* 创建翻译历史记录
|
||||||
0
src/modules/user/user-action-dto.ts
Normal file
0
src/modules/user/user-action-dto.ts
Normal file
0
src/modules/user/user-repository.ts
Normal file
0
src/modules/user/user-repository.ts
Normal file
0
src/modules/user/user-service-dto.ts
Normal file
0
src/modules/user/user-service-dto.ts
Normal file
0
src/modules/user/user-service.ts
Normal file
0
src/modules/user/user-service.ts
Normal file
11
src/shared/dictionary-type.ts
Normal file
11
src/shared/dictionary-type.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type TSharedEntry = {
|
||||||
|
definition: string,
|
||||||
|
example: string,
|
||||||
|
partOfSpeech?: string;
|
||||||
|
ipa?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TSharedItem = {
|
||||||
|
standardForm: string,
|
||||||
|
entries: TSharedEntry[];
|
||||||
|
};
|
||||||
3
src/shared/folder-related-type.ts
Normal file
3
src/shared/folder-related-type.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type TSharedPair = {
|
||||||
|
|
||||||
|
};
|
||||||
1
src/shared/index.ts
Normal file
1
src/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './dictionary-type';
|
||||||
25
src/utils/json.ts
Normal file
25
src/utils/json.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
1
src/utils/string.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const stringNormalize = (s: string) => s.trim().toLowerCase();
|
||||||
Reference in New Issue
Block a user