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

@@ -1,75 +1,42 @@
import { DictWordEntry, DictPhraseEntry } from "./types";
import { TSharedEntry } from "@/shared";
interface DictionaryEntryProps {
entry: DictWordEntry | DictPhraseEntry;
entry: TSharedEntry;
}
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 (
<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">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{phraseEntry.definition}</p>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{phraseEntry.example && (
{entry.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}
{entry.example}
</p>
</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 { authClient } from "@/lib/auth-client";
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 { useTranslations } from "next-intl";
import { performDictionaryLookup } from "./utils";
import { TSharedItem } from "@/shared";
interface SearchResultProps {
searchResult: DictWordResponse | DictPhraseResponse;
searchResult: TSharedItem;
searchQuery: string;
queryLang: string;
definitionLang: string;
folders: Folder[];
selectedFolderId: number | null;
onFolderSelect: (folderId: number | null) => void;
onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void;
onResultUpdate: (newResult: TSharedItem) => void;
onSearchingChange: (isSearching: boolean) => void;
getNativeName: (code: string) => string;
}
@@ -54,8 +48,8 @@ export function SearchResult({
t
);
if (result.success && result.data) {
onResultUpdate(result.data);
if (result) {
onResultUpdate(result);
}
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 { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import {
DictWordResponse,
DictPhraseResponse,
} from "./types";
import { lookUpDictionaryAction } from "@/modules/dictionary/dictionary-action";
import { DictionaryActionInputDto, DictionaryActionOutputDto } from "@/modules/dictionary";
import { TSharedItem } from "@/shared";
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(
options: LookupOptions,
options: DictionaryActionInputDto,
t?: (key: string) => string
): Promise<LookupResult> {
const { text, queryLang, definitionLang, forceRelook = false } = options;
): Promise<TSharedItem | null> {
const { text, queryLang, definitionLang, forceRelook = false, userId } = options;
const result = await lookUpDictionaryAction({
text,
queryLang,
definitionLang,
forceRelook,
userId
});
try {
const result = await lookUp({
text,
queryLang,
definitionLang,
forceRelook
});
if (!result.success || !result.data) return null;
// 成功时显示提示(仅强制重新查询时)
if (forceRelook && t) {
toast.success(t("relookupSuccess"));
}
return { success: true, data: result };
} catch (error) {
toast.error(String(error));
return { success: false, error: String(error) };
if (forceRelook && t) {
toast.success(t("relookupSuccess"));
}
return result.data;
}

View File

@@ -2,10 +2,10 @@
import { useState } from "react";
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 localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({

View File

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

View File

@@ -14,10 +14,10 @@ import SaveList from "./SaveList";
import { useTranslations } from "next-intl";
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 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() {
const t = useTranslations("text_speaker");

View File

@@ -12,14 +12,14 @@ import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import z from "zod";
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 { toast } from "sonner";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/lib/utils";
import { shallowEqual } from "@/utils/random";
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() {
const t = useTranslations("translator");

View File

@@ -2,7 +2,7 @@
import { useState, useActionState, startTransition } from "react";
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 Input from "@/components/ui/Input";
import { LightButton } from "@/components/ui/buttons";