This commit is contained in:
2026-02-03 19:18:29 +08:00
parent 56552863bf
commit c4a9247cad
5 changed files with 298 additions and 319 deletions

View File

@@ -1,29 +1,37 @@
"use client";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/components/ui/buttons";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { POPULAR_LANGUAGES } from "./constants";
interface SearchFormProps { interface SearchFormProps {
searchQuery: string; defaultQueryLang?: string;
onSearchQueryChange: (query: string) => void; defaultDefinitionLang?: string;
isSearching: boolean;
onSearch: (e: React.FormEvent) => void;
queryLang: string;
onQueryLangChange: (lang: string) => void;
definitionLang: string;
onDefinitionLangChange: (lang: string) => void;
} }
export function SearchForm({ export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
searchQuery,
onSearchQueryChange,
isSearching,
onSearch,
queryLang,
onQueryLangChange,
definitionLang,
onDefinitionLangChange,
}: SearchFormProps) {
const t = useTranslations("dictionary"); const t = useTranslations("dictionary");
const [queryLang, setQueryLang] = useState(defaultQueryLang);
const [definitionLang, setDefinitionLang] = useState(defaultDefinitionLang);
const router = useRouter();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const searchQuery = formData.get("searchQuery") as string;
if (!searchQuery?.trim()) return;
const params = new URLSearchParams({
q: searchQuery,
ql: queryLang,
dl: definitionLang,
});
router.push(`/dictionary?${params.toString()}`);
};
return ( return (
<> <>
@@ -38,20 +46,20 @@ export function SearchForm({
</div> </div>
{/* 搜索表单 */} {/* 搜索表单 */}
<form onSubmit={onSearch} className="flex flex-col sm:flex-row gap-2"> <form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={searchQuery} name="searchQuery"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)} defaultValue=""
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
className="flex-1 min-w-0 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" className="flex-1 min-w-0 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
required
/> />
<LightButton <LightButton
type="submit" type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30" className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
> >
{isSearching ? t("searching") : t("search")} {t("search")}
</LightButton> </LightButton>
</form> </form>
@@ -67,25 +75,19 @@ export function SearchForm({
<label className="block text-gray-700 text-sm mb-2"> <label className="block text-gray-700 text-sm mb-2">
{t("queryLanguage")} ({t("queryLanguageHint")}) {t("queryLanguage")} ({t("queryLanguageHint")})
</label> </label>
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => ( {POPULAR_LANGUAGES.map((lang) => (
<LightButton <LightButton
key={lang.code} key={lang.code}
type="button"
selected={queryLang === lang.code} selected={queryLang === lang.code}
onClick={() => onQueryLangChange(lang.code)} onClick={() => setQueryLang(lang.code)}
className="text-sm px-3 py-1" className="text-sm px-3 py-1"
> >
{lang.nativeName} {lang.nativeName}
</LightButton> </LightButton>
))} ))}
</div> </div>
<input
type="text"
value={queryLang}
onChange={(e) => onQueryLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div> </div>
{/* 释义语言 */} {/* 释义语言 */}
@@ -93,37 +95,22 @@ export function SearchForm({
<label className="block text-gray-700 text-sm mb-2"> <label className="block text-gray-700 text-sm mb-2">
{t("definitionLanguage")} ({t("definitionLanguageHint")}) {t("definitionLanguage")} ({t("definitionLanguageHint")})
</label> </label>
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => ( {POPULAR_LANGUAGES.map((lang) => (
<LightButton <LightButton
key={lang.code} key={lang.code}
type="button"
selected={definitionLang === lang.code} selected={definitionLang === lang.code}
onClick={() => onDefinitionLangChange(lang.code)} onClick={() => setDefinitionLang(lang.code)}
className="text-sm px-3 py-1" className="text-sm px-3 py-1"
> >
{lang.nativeName} {lang.nativeName}
</LightButton> </LightButton>
))} ))}
</div> </div>
<input
type="text"
value={definitionLang}
onChange={(e) => onDefinitionLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 当前设置显示 */}
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
{t("currentSettings", {
queryLang: POPULAR_LANGUAGES.find(l => l.code === queryLang)?.nativeName || queryLang,
definitionLang: POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.nativeName || definitionLang
})}
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }

View File

@@ -0,0 +1,121 @@
"use client";
import { Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { actionCreatePair } from "@/modules/folder/folder-aciton";
import { TSharedItem } from "@/shared/dictionary-type";
import { TSharedFolder } from "@/shared/folder-type";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { useRouter } from "next/navigation";
type Session = {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
} | null;
interface SaveButtonClientProps {
session: Session;
folders: TSharedFolder[];
searchResult: TSharedItem;
queryLang: string;
definitionLang: string;
}
export function SaveButtonClient({ session, folders, searchResult, queryLang, definitionLang }: SaveButtonClientProps) {
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
const definition = searchResult.entries.reduce((p, e) => {
return { ...p, definition: p.definition + ' | ' + e.definition };
}).definition;
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0].ipa,
folderId: folderId,
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
} catch (error) {
toast.error("Save failed");
}
};
return (
<button
onClick={handleSave}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center shrink-0"
title="Save to folder"
>
<Plus />
</button>
);
}
interface ReLookupButtonClientProps {
searchQuery: string;
queryLang: string;
definitionLang: string;
}
export function ReLookupButtonClient({ searchQuery, queryLang, definitionLang }: ReLookupButtonClientProps) {
const router = useRouter();
const handleRelookup = async () => {
const getNativeName = (code: string): string => {
const popularLanguages: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
return popularLanguages[code] || code;
};
try {
await actionLookUpDictionary({
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true
});
toast.success("Re-lookup successful");
// 刷新页面以显示新结果
router.refresh();
} catch (error) {
toast.error("Re-lookup failed");
}
};
return (
<button
onClick={handleRelookup}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
Re-lookup
</button>
);
}

View File

@@ -1,91 +1,43 @@
import { Plus, RefreshCw } from "lucide-react"; import { auth } from "@/auth";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { DictionaryEntry } from "./DictionaryEntry"; import { DictionaryEntry } from "./DictionaryEntry";
import { useTranslations } from "next-intl";
import { performDictionaryLookup } from "./utils";
import { TSharedItem } from "@/shared/dictionary-type"; import { TSharedItem } from "@/shared/dictionary-type";
import { SaveButtonClient, ReLookupButtonClient } from "./SearchResult.client";
import { headers } from "next/headers";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
import { TSharedFolder } from "@/shared/folder-type"; import { TSharedFolder } from "@/shared/folder-type";
import { actionCreatePair } from "@/modules/folder/folder-aciton";
interface SearchResultProps { interface SearchResultProps {
searchResult: TSharedItem; searchResult: TSharedItem | null;
searchQuery: string; searchQuery: string;
queryLang: string; queryLang: string;
definitionLang: string; definitionLang: string;
folders: TSharedFolder[];
selectedFolderId: number | null;
onFolderSelect: (folderId: number | null) => void;
onResultUpdate: (newResult: TSharedItem) => void;
onSearchingChange: (isSearching: boolean) => void;
getNativeName: (code: string) => string;
} }
export function SearchResult({ export async function SearchResult({
searchResult, searchResult,
searchQuery, searchQuery,
queryLang, queryLang,
definitionLang, definitionLang
folders,
selectedFolderId,
onFolderSelect,
onResultUpdate,
onSearchingChange,
getNativeName,
}: SearchResultProps) { }: SearchResultProps) {
const t = useTranslations("dictionary"); // 获取用户会话和文件夹
const { data: session } = authClient.useSession(); const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
const handleRelookup = async () => { if (session?.user?.id) {
onSearchingChange(true); const result = await actionGetFoldersByUserId(session.user.id as string);
if (result.success && result.data) {
const result = await performDictionaryLookup( folders = result.data;
{
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true
},
t
);
if (result) {
onResultUpdate(result);
} }
onSearchingChange(false);
};
const handleSave = () => {
if (!session) {
toast.error(t("pleaseLogin"));
return;
} }
if (!selectedFolderId) {
toast.error(t("pleaseCreateFolder"));
return;
}
const entry = searchResult.entries[0];
actionCreatePair({
text1: searchResult.standardForm,
text2: entry.definition,
language1: queryLang,
language2: definitionLang,
ipa1: entry.ipa,
folderId: selectedFolderId,
})
.then(() => {
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName }));
})
.catch(() => {
toast.error(t("saveFailed"));
});
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{!searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
</div>
) : (
<div className="bg-white rounded-lg p-6 shadow-lg"> <div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标题和保存按钮 */} {/* 标题和保存按钮 */}
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-6">
@@ -97,8 +49,7 @@ export function SearchResult({
<div className="flex items-center gap-2 ml-4"> <div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && ( {session && folders.length > 0 && (
<select <select
value={selectedFolderId || ""} id="folder-select"
onChange={(e) => onFolderSelect(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]" className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
> >
{folders.map((folder) => ( {folders.map((folder) => (
@@ -108,13 +59,13 @@ export function SearchResult({
))} ))}
</select> </select>
)} )}
<button <SaveButtonClient
onClick={handleSave} session={session}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center shrink-0" folders={folders}
title={t("saveToFolder")} searchResult={searchResult}
> queryLang={queryLang}
<Plus /> definitionLang={definitionLang}
</button> />
</div> </div>
</div> </div>
@@ -129,15 +80,14 @@ export function SearchResult({
{/* 重新查询按钮 */} {/* 重新查询按钮 */}
<div className="border-t border-gray-200 pt-4 mt-4"> <div className="border-t border-gray-200 pt-4 mt-4">
<button <ReLookupButtonClient
onClick={handleRelookup} searchQuery={searchQuery}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors" queryLang={queryLang}
> definitionLang={definitionLang}
<RefreshCw className="w-4 h-4" /> />
{t("relookup")}
</button>
</div> </div>
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,73 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import { Container } from "@/components/ui/Container"; import { Container } from "@/components/ui/Container";
import { authClient } from "@/lib/auth-client";
import { SearchForm } from "./SearchForm"; import { SearchForm } from "./SearchForm";
import { SearchResult } from "./SearchResult"; import { SearchResult } from "./SearchResult";
import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server";
import { POPULAR_LANGUAGES } from "./constants"; import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { performDictionaryLookup } from "./utils";
import { TSharedItem } from "@/shared/dictionary-type"; import { TSharedItem } from "@/shared/dictionary-type";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
import { TSharedFolder } from "@/shared/folder-type";
import { toast } from "sonner";
export default function DictionaryPage() { interface DictionaryPageProps {
const t = useTranslations("dictionary"); searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
const [searchQuery, setSearchQuery] = useState("");
const [searchResult, setSearchResult] = useState<TSharedItem | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese");
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [folders, setFolders] = useState<TSharedFolder[]>([]);
const { data: session } = authClient.useSession();
// 加载用户的文件夹列表
useEffect(() => {
if (session) {
actionGetFoldersByUserId(session.user.id as string)
.then(result => {
if (!result.success || !result.data) throw result.message;
return result.data;
})
.then((loadedFolders) => {
setFolders(loadedFolders);
// 如果有文件夹且未选择,默认选择第一个
if (loadedFolders.length > 0 && !selectedFolderId) {
setSelectedFolderId(loadedFolders[0].id);
} }
}).catch(e => toast.error);
}
}, [session, selectedFolderId]);
// 将 code 转换为 nativeName export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
const getNativeName = (code: string) => { const t = await getTranslations("dictionary");
return POPULAR_LANGUAGES.find(l => l.code === code)?.nativeName || code;
// 从 searchParams 获取搜索参数
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
// 如果有搜索查询,获取搜索结果
let searchResult: TSharedItem | undefined | null = null;
if (searchQuery) {
const getNativeName = (code: string): string => {
const popularLanguages: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
return popularLanguages[code] || code;
}; };
const handleSearch = async (e: React.FormEvent) => { const result = await actionLookUpDictionary({
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
setHasSearched(true);
setSearchResult(null);
const result = await performDictionaryLookup(
{
text: searchQuery, text: searchQuery,
queryLang: getNativeName(queryLang), queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang), definitionLang: getNativeName(definitionLang),
forceRelook: false forceRelook: false
}, });
t
); if (result.success && result.data) {
setSearchResult(result); searchResult = result.data;
setIsSearching(false); }
}; }
return ( return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]"> <div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
@@ -75,14 +48,8 @@ export default function DictionaryPage() {
<div className="flex items-center justify-center px-4 py-12"> <div className="flex items-center justify-center px-4 py-12">
<Container className="max-w-3xl w-full p-4"> <Container className="max-w-3xl w-full p-4">
<SearchForm <SearchForm
searchQuery={searchQuery} defaultQueryLang={queryLang}
onSearchQueryChange={setSearchQuery} defaultDefinitionLang={definitionLang}
isSearching={isSearching}
onSearch={handleSearch}
queryLang={queryLang}
onQueryLangChange={setQueryLang}
definitionLang={definitionLang}
onDefinitionLangChange={setDefinitionLang}
/> />
</Container> </Container>
</div> </div>
@@ -90,36 +57,15 @@ export default function DictionaryPage() {
{/* 搜索结果区域 */} {/* 搜索结果区域 */}
<div className="flex-1 px-4 pb-12"> <div className="flex-1 px-4 pb-12">
<Container className="max-w-3xl w-full p-4"> <Container className="max-w-3xl w-full p-4">
{isSearching && ( {searchQuery && (
<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={searchResult} searchResult={searchResult}
searchQuery={searchQuery} searchQuery={searchQuery}
queryLang={queryLang} queryLang={queryLang}
definitionLang={definitionLang} definitionLang={definitionLang}
folders={folders}
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
onResultUpdate={setSearchResult}
onSearchingChange={setIsSearching}
getNativeName={getNativeName}
/> />
)} )}
{!searchQuery && (
{!hasSearched && (
<div className="text-center py-12 bg-white/20 rounded-lg"> <div className="text-center py-12 bg-white/20 rounded-lg">
<div className="text-6xl mb-4">📚</div> <div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p> <p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>

View File

@@ -1,25 +0,0 @@
import { toast } from "sonner";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary } from "@/modules/dictionary/dictionary-action-dto";
import { TSharedItem } from "@/shared/dictionary-type";
export async function performDictionaryLookup(
options: ActionInputLookUpDictionary,
t?: (key: string) => string
): Promise<TSharedItem | null> {
const { text, queryLang, definitionLang, forceRelook = false, userId } = options;
const result = await actionLookUpDictionary({
text,
queryLang,
definitionLang,
forceRelook,
userId
});
if (!result.success || !result.data) return null;
if (forceRelook && t) {
toast.success(t("relookupSuccess"));
}
return result.data;
}