Compare commits
5 Commits
0881846717
...
6ba5ae993a
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ba5ae993a | |||
| b643205f72 | |||
| c6878ed1e5 | |||
| e74cd80fac | |||
| c01c94abd0 |
@@ -241,6 +241,7 @@
|
||||
"definitionLanguage": "Definition Language",
|
||||
"definitionLanguageHint": "What language do you want the definitions in",
|
||||
"otherLanguagePlaceholder": "Or enter another language...",
|
||||
"other": "Other",
|
||||
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
||||
"relookup": "Re-search",
|
||||
"saveToFolder": "Save to folder",
|
||||
@@ -267,7 +268,9 @@
|
||||
"unknownUser": "Unknown User",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first"
|
||||
"pleaseLogin": "Please login first",
|
||||
"sortByFavorites": "Sort by favorites",
|
||||
"sortByFavoritesActive": "Undo sort by favorites"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "My Favorites",
|
||||
|
||||
@@ -241,6 +241,7 @@
|
||||
"definitionLanguage": "释义语言",
|
||||
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||
"other": "其他",
|
||||
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||
"relookup": "重新查询",
|
||||
"saveToFolder": "保存到文件夹",
|
||||
@@ -267,7 +268,9 @@
|
||||
"unknownUser": "未知用户",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
"pleaseLogin": "请先登录",
|
||||
"sortByFavorites": "按收藏数排序",
|
||||
"sortByFavoritesActive": "取消按收藏数排序"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏",
|
||||
|
||||
@@ -8,7 +8,7 @@ export default async function LogoutPage(
|
||||
}
|
||||
) {
|
||||
const searchParams = await props.searchParams;
|
||||
const redirectTo = props.searchParams ?? null;
|
||||
const redirectTo = searchParams.redirect ?? null;
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
|
||||
239
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
239
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDictionaryStore } from "./stores/dictionaryStore";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { DictionaryEntry } from "./DictionaryEntry";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-aciton";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface DictionaryClientProps {
|
||||
initialFolders: TSharedFolder[];
|
||||
}
|
||||
|
||||
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const {
|
||||
query,
|
||||
queryLang,
|
||||
definitionLang,
|
||||
searchResult,
|
||||
isSearching,
|
||||
setQuery,
|
||||
setQueryLang,
|
||||
setDefinitionLang,
|
||||
search,
|
||||
relookup,
|
||||
syncFromUrl,
|
||||
} = useDictionaryStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q") || undefined;
|
||||
const ql = searchParams.get("ql") || undefined;
|
||||
const dl = searchParams.get("dl") || undefined;
|
||||
|
||||
syncFromUrl({ q, ql, dl });
|
||||
|
||||
if (q) {
|
||||
search();
|
||||
}
|
||||
}, [searchParams, syncFromUrl, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
actionGetFoldersByUserId(session.user.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [session?.user?.id]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!query.trim()) return;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
ql: queryLang,
|
||||
dl: definitionLang,
|
||||
});
|
||||
|
||||
router.push(`/dictionary?${params.toString()}`);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if (!searchResult) return;
|
||||
|
||||
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 (
|
||||
<PageLayout>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-700 text-lg">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
name="searchQuery"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
variant="search"
|
||||
required
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
|
||||
loading={isSearching}
|
||||
>
|
||||
{t("search")}
|
||||
</LightButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<LanguageSelector
|
||||
label={t("queryLanguage")}
|
||||
hint={t("queryLanguageHint")}
|
||||
value={queryLang}
|
||||
onChange={setQueryLang}
|
||||
/>
|
||||
|
||||
<LanguageSelector
|
||||
label={t("definitionLanguage")}
|
||||
hint={t("definitionLanguageHint")}
|
||||
value={definitionLang}
|
||||
onChange={setDefinitionLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{isSearching ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-gray-600">{t("searching")}</p>
|
||||
</div>
|
||||
) : query && !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>
|
||||
) : searchResult ? (
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
<select
|
||||
id="folder-select"
|
||||
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) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<LightButton
|
||||
onClick={handleSave}
|
||||
className="w-10 h-10 shrink-0"
|
||||
title="Save to folder"
|
||||
>
|
||||
<Plus />
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
<DictionaryEntry entry={entry} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<LightButton
|
||||
onClick={relookup}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm"
|
||||
loading={isSearching}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Re-lookup
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { POPULAR_LANGUAGES } from "./constants";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageSelector({ label, hint, value, onChange }: LanguageSelectorProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
const [customLang, setCustomLang] = useState("");
|
||||
|
||||
const isPresetLanguage = POPULAR_LANGUAGES.some((lang) => lang.code === value);
|
||||
|
||||
const handlePresetSelect = (code: string) => {
|
||||
onChange(code);
|
||||
setShowCustomInput(false);
|
||||
setCustomLang("");
|
||||
};
|
||||
|
||||
const handleCustomToggle = () => {
|
||||
setShowCustomInput(!showCustomInput);
|
||||
if (!showCustomInput && customLang.trim()) {
|
||||
onChange(customLang.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomChange = (newValue: string) => {
|
||||
setCustomLang(newValue);
|
||||
if (newValue.trim()) {
|
||||
onChange(newValue.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
{label} ({hint})
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
type="button"
|
||||
selected={isPresetLanguage && value === lang.code}
|
||||
onClick={() => handlePresetSelect(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.nativeName}
|
||||
</LightButton>
|
||||
))}
|
||||
<LightButton
|
||||
type="button"
|
||||
selected={!isPresetLanguage && !!value}
|
||||
onClick={handleCustomToggle}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
</div>
|
||||
{(showCustomInput || (!isPresetLanguage && value)) && (
|
||||
<Input
|
||||
type="text"
|
||||
value={isPresetLanguage ? customLang : value}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
placeholder={t("otherLanguagePlaceholder")}
|
||||
className="text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { POPULAR_LANGUAGES } from "./constants";
|
||||
|
||||
interface SearchFormProps {
|
||||
defaultQueryLang?: string;
|
||||
defaultDefinitionLang?: string;
|
||||
}
|
||||
|
||||
export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
|
||||
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 (
|
||||
<>
|
||||
{/* 页面标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-gray-700 text-lg">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索表单 */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
name="searchQuery"
|
||||
defaultValue=""
|
||||
placeholder={t("searchPlaceholder")}
|
||||
variant="search"
|
||||
required
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
|
||||
>
|
||||
{t("search")}
|
||||
</LightButton>
|
||||
</form>
|
||||
|
||||
{/* 语言设置 */}
|
||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 查询语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
{t("queryLanguage")} ({t("queryLanguageHint")})
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
type="button"
|
||||
selected={queryLang === lang.code}
|
||||
onClick={() => setQueryLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.nativeName}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 释义语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
{t("definitionLanguage")} ({t("definitionLanguageHint")})
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
type="button"
|
||||
selected={definitionLang === lang.code}
|
||||
onClick={() => setDefinitionLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.nativeName}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
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 (
|
||||
<CircleButton
|
||||
onClick={handleSave}
|
||||
className="w-10 h-10 shrink-0"
|
||||
title="Save to folder"
|
||||
>
|
||||
<Plus />
|
||||
</CircleButton>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<LightButton
|
||||
onClick={handleRelookup}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm"
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
>
|
||||
Re-lookup
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { DictionaryEntry } from "./DictionaryEntry";
|
||||
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";
|
||||
|
||||
interface SearchResultProps {
|
||||
searchResult: TSharedItem | null;
|
||||
searchQuery: string;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
}
|
||||
|
||||
export async function SearchResult({
|
||||
searchResult,
|
||||
searchQuery,
|
||||
queryLang,
|
||||
definitionLang
|
||||
}: SearchResultProps) {
|
||||
// 获取用户会话和文件夹
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
let folders: TSharedFolder[] = [];
|
||||
|
||||
if (session?.user?.id) {
|
||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||
if (result.success && result.data) {
|
||||
folders = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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="flex items-start justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
<select
|
||||
id="folder-select"
|
||||
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) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<SaveButtonClient
|
||||
session={session}
|
||||
folders={folders}
|
||||
searchResult={searchResult}
|
||||
queryLang={queryLang}
|
||||
definitionLang={definitionLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="space-y-6">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
<DictionaryEntry entry={entry} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 重新查询按钮 */}
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<ReLookupButtonClient
|
||||
searchQuery={searchQuery}
|
||||
queryLang={queryLang}
|
||||
definitionLang={definitionLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,20 @@
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { SearchForm } from "./SearchForm";
|
||||
import { SearchResult } from "./SearchResult";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
import { DictionaryClient } from "./DictionaryClient";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
|
||||
interface DictionaryPageProps {
|
||||
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
|
||||
}
|
||||
export default async function DictionaryPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
|
||||
const t = await getTranslations("dictionary");
|
||||
|
||||
// 从 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 result = await actionLookUpDictionary({
|
||||
text: searchQuery,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: false
|
||||
});
|
||||
let folders: TSharedFolder[] = [];
|
||||
|
||||
if (session?.user?.id) {
|
||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||
if (result.success && result.data) {
|
||||
searchResult = result.data;
|
||||
folders = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 搜索区域 */}
|
||||
<div className="mb-8">
|
||||
<SearchForm
|
||||
defaultQueryLang={queryLang}
|
||||
defaultDefinitionLang={definitionLang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果区域 */}
|
||||
<div>
|
||||
{searchQuery && (
|
||||
<SearchResult
|
||||
searchResult={searchResult}
|
||||
searchQuery={searchQuery}
|
||||
queryLang={queryLang}
|
||||
definitionLang={definitionLang}
|
||||
/>
|
||||
)}
|
||||
{!searchQuery && (
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
return <DictionaryClient initialFolders={folders} />;
|
||||
}
|
||||
|
||||
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const POPULAR_LANGUAGES_MAP: Record<string, string> = {
|
||||
english: "English",
|
||||
chinese: "中文",
|
||||
japanese: "日本語",
|
||||
korean: "한국어",
|
||||
italian: "Italiano",
|
||||
uyghur: "ئۇيغۇرچە",
|
||||
};
|
||||
|
||||
export function getNativeName(code: string): string {
|
||||
return POPULAR_LANGUAGES_MAP[code] || code;
|
||||
}
|
||||
|
||||
export interface DictionaryState {
|
||||
query: string;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
searchResult: TSharedItem | null;
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
export interface DictionaryActions {
|
||||
setQuery: (query: string) => void;
|
||||
setQueryLang: (lang: string) => void;
|
||||
setDefinitionLang: (lang: string) => void;
|
||||
setSearchResult: (result: TSharedItem | null) => void;
|
||||
search: () => Promise<void>;
|
||||
relookup: () => Promise<void>;
|
||||
syncFromUrl: (params: { q?: string; ql?: string; dl?: string }) => void;
|
||||
}
|
||||
|
||||
export type DictionaryStore = DictionaryState & DictionaryActions;
|
||||
|
||||
const initialState: DictionaryState = {
|
||||
query: "",
|
||||
queryLang: "english",
|
||||
definitionLang: "chinese",
|
||||
searchResult: null,
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
export const useDictionaryStore = create<DictionaryStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setQuery: (query) => set({ query }),
|
||||
|
||||
setQueryLang: (queryLang) => set({ queryLang }),
|
||||
|
||||
setDefinitionLang: (definitionLang) => set({ definitionLang }),
|
||||
|
||||
setSearchResult: (searchResult) => set({ searchResult }),
|
||||
|
||||
search: async () => {
|
||||
const { query, queryLang, definitionLang } = get();
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isSearching: true });
|
||||
|
||||
try {
|
||||
const result = await actionLookUpDictionary({
|
||||
text: query,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: false,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
set({ searchResult: result.data });
|
||||
} else {
|
||||
set({ searchResult: null });
|
||||
if (result.message) {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
set({ searchResult: null });
|
||||
toast.error("Search failed");
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
relookup: async () => {
|
||||
const { query, queryLang, definitionLang } = get();
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isSearching: true });
|
||||
|
||||
try {
|
||||
const result = await actionLookUpDictionary({
|
||||
text: query,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: true,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
set({ searchResult: result.data });
|
||||
toast.success("Re-lookup successful");
|
||||
} else {
|
||||
if (result.message) {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Re-lookup failed");
|
||||
} finally {
|
||||
set({ isSearching: false });
|
||||
}
|
||||
},
|
||||
|
||||
syncFromUrl: (params) => {
|
||||
const updates: Partial<DictionaryState> = {};
|
||||
|
||||
if (params.q !== undefined) {
|
||||
updates.query = params.q;
|
||||
}
|
||||
if (params.ql !== undefined) {
|
||||
updates.queryLang = params.ql;
|
||||
}
|
||||
if (params.dl !== undefined) {
|
||||
updates.definitionLang = params.dl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
set(updates);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'dictionary-store' }
|
||||
)
|
||||
);
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -13,7 +13,6 @@ import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import {
|
||||
actionSearchPublicFolders,
|
||||
actionToggleFavorite,
|
||||
@@ -25,10 +24,10 @@ import { authClient } from "@/lib/auth-client";
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
currentUserId?: string;
|
||||
onFavoriteChange?: () => void;
|
||||
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
|
||||
}
|
||||
|
||||
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => {
|
||||
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("explore");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
@@ -55,7 +54,7 @@ const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFol
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onFavoriteChange?.();
|
||||
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -63,45 +62,39 @@ const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFol
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Heart
|
||||
size={14}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
<span>{favoriteCount}</span>
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3">
|
||||
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Fd size={18} className="sm:hidden" />
|
||||
<Fd size={22} className="hidden sm:block" />
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
size={16}
|
||||
className={`sm:w-[18px] sm:h-[18px] sm:text-[18px] ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
|
||||
/>
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
|
||||
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs sm:text-sm text-gray-400">
|
||||
<Heart size={12} className="sm:w-3.5 sm:h-3.5" />
|
||||
<span>{favoriteCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -117,6 +110,7 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortByFavorites, setSortByFavorites] = useState(false);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
@@ -134,20 +128,27 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const refreshFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim() || "");
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
const handleToggleSort = () => {
|
||||
setSortByFavorites((prev) => !prev);
|
||||
};
|
||||
|
||||
const sortedFolders = sortByFavorites
|
||||
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||
: publicFolders;
|
||||
|
||||
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||
setPublicFolders((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === folderId ? { ...f, favoriteCount } : f
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
@@ -159,19 +160,24 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleSort}
|
||||
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
|
||||
className={sortByFavorites ? "bg-primary-100 text-primary-600 hover:bg-primary-200" : ""}
|
||||
>
|
||||
<ArrowUpDown size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : publicFolders.length === 0 ? (
|
||||
) : sortedFolders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
@@ -179,17 +185,17 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
<p className="text-sm">{t("noFolders")}</p>
|
||||
</div>
|
||||
) : (
|
||||
publicFolders.map((folder) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{sortedFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
currentUserId={currentUserId}
|
||||
onFavoriteChange={refreshFolders}
|
||||
onUpdateFavorite={handleUpdateFavorite}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetUserFavorites } from "@/modules/folder/folder-aciton";
|
||||
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-aciton";
|
||||
|
||||
type UserFavorite = {
|
||||
id: number;
|
||||
@@ -27,11 +28,27 @@ type UserFavorite = {
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: UserFavorite;
|
||||
onRemoveFavorite: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FavoriteCard = ({ favorite }: FavoriteCardProps) => {
|
||||
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("favorites");
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const handleRemoveFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isRemoving) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
const result = await actionToggleFavorite(favorite.folderId);
|
||||
if (result.success) {
|
||||
onRemoveFavorite(favorite.folderId);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
setIsRemoving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -57,7 +74,11 @@ const FavoriteCard = ({ favorite }: FavoriteCardProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart size={18} className="fill-red-500 text-red-500" />
|
||||
<Heart
|
||||
size={18}
|
||||
className="fill-red-500 text-red-500 cursor-pointer hover:scale-110 transition-transform"
|
||||
onClick={handleRemoveFavorite}
|
||||
/>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,11 +107,14 @@ export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = (folderId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
@@ -106,11 +130,14 @@ export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((favorite) => (
|
||||
<FavoriteCard key={favorite.id} favorite={favorite} />
|
||||
<FavoriteCard
|
||||
key={favorite.id}
|
||||
favorite={favorite}
|
||||
onRemoveFavorite={handleRemoveFavorite}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ export default function SrtPlayerPage() {
|
||||
key={i}
|
||||
href={`/dictionary?q=${s}`}
|
||||
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
|
||||
@@ -28,10 +28,11 @@ import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
refresh: () => void;
|
||||
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
|
||||
onDeleteFolder: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FolderCard = ({ folder, refresh }: FolderCardProps) => {
|
||||
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
|
||||
@@ -40,12 +41,38 @@ const FolderCard = ({ folder, refresh }: FolderCardProps) => {
|
||||
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionSetFolderVisibility(folder.id, newVisibility);
|
||||
if (result.success) {
|
||||
refresh();
|
||||
onUpdateFolder(folder.id, { visibility: newVisibility });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
const result = await actionRenameFolderById(folder.id, newName);
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { name: newName });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
const result = await actionDeleteFolderById(folder.id);
|
||||
if (result.success) {
|
||||
onDeleteFolder(folder.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
@@ -91,42 +118,16 @@ const FolderCard = ({ folder, refresh }: FolderCardProps) => {
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
actionRenameFolderById(folder.id, newName).then((result) => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CircleButton onClick={handleRename}>
|
||||
<FolderPen size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
actionDeleteFolderById(folder.id).then((result) => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
className="hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400 ml-1" />
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -142,10 +143,6 @@ export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
@@ -155,34 +152,42 @@ export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (folderId: number) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await actionCreateFolder(userId, folderName);
|
||||
if (!folderName?.trim()) return;
|
||||
|
||||
const result = await actionCreateFolder(userId, folderName.trim());
|
||||
if (result.success) {
|
||||
loadFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<LightButton
|
||||
onClick={handleCreateFolder}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed mb-4"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
<div className="mb-4">
|
||||
<LightButton onClick={handleCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
{t("newFolder")}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
@@ -193,15 +198,18 @@ export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
) : folders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<FolderPlus size={24} className="text-gray-400" />
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
folders
|
||||
.toSorted((a, b) => b.id - a.id)
|
||||
.map((folder) => (
|
||||
<FolderCard key={folder.id} folder={folder} refresh={loadFolders} />
|
||||
folders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onUpdateFolder={handleUpdateFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { determineSemanticMapping } from "./stage2-semanticMapping";
|
||||
import { generateStandardForm } from "./stage3-standardForm";
|
||||
import { generateEntries } from "./stage4-entriesGeneration";
|
||||
import { LookUpError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-orchestrator");
|
||||
|
||||
export async function executeDictionaryLookup(
|
||||
text: string,
|
||||
@@ -11,35 +14,31 @@ export async function executeDictionaryLookup(
|
||||
definitionLang: string
|
||||
): Promise<ServiceOutputLookUp> {
|
||||
try {
|
||||
// ========== 阶段 1:输入分析 ==========
|
||||
console.log("[阶段1] 开始输入分析...");
|
||||
log.debug("[Stage 1] Starting input analysis");
|
||||
const analysis = await analyzeInput(text);
|
||||
|
||||
// 代码层面验证:输入是否有效
|
||||
if (!analysis.isValid) {
|
||||
console.log("[阶段1] 输入无效:", analysis.reason);
|
||||
throw analysis.reason || "无效输入";
|
||||
log.debug("[Stage 1] Invalid input", { reason: analysis.reason });
|
||||
throw new LookUpError(analysis.reason || "无效输入");
|
||||
}
|
||||
|
||||
if (analysis.isEmpty) {
|
||||
console.log("[阶段1] 输入为空");
|
||||
throw "输入为空";
|
||||
log.debug("[Stage 1] Empty input");
|
||||
throw new LookUpError("输入为空");
|
||||
}
|
||||
|
||||
console.log("[阶段1] 输入分析完成:", analysis);
|
||||
log.debug("[Stage 1] Analysis complete", { analysis });
|
||||
|
||||
// ========== 阶段 2:语义映射 ==========
|
||||
console.log("[阶段2] 开始语义映射...");
|
||||
log.debug("[Stage 2] Starting semantic mapping");
|
||||
const semanticMapping = await determineSemanticMapping(
|
||||
text,
|
||||
queryLang,
|
||||
analysis.inputLanguage || text
|
||||
analysis.inputLanguage ?? text
|
||||
);
|
||||
|
||||
console.log("[阶段2] 语义映射完成:", semanticMapping);
|
||||
log.debug("[Stage 2] Semantic mapping complete", { semanticMapping });
|
||||
|
||||
// ========== 阶段 3:生成标准形式 ==========
|
||||
console.log("[阶段3] 开始生成标准形式...");
|
||||
log.debug("[Stage 3] Generating standard form");
|
||||
|
||||
// 如果进行了语义映射,标准形式要基于映射后的结果
|
||||
// 同时传递原始输入作为语义参考
|
||||
@@ -52,16 +51,14 @@ export async function executeDictionaryLookup(
|
||||
shouldUseMapping ? text : undefined // 如果进行了映射,传递原始输入作为语义参考
|
||||
);
|
||||
|
||||
// 代码层面验证:标准形式不能为空
|
||||
if (!standardFormResult.standardForm) {
|
||||
console.error("[阶段3] 标准形式为空");
|
||||
log.error("[Stage 3] Standard form is empty");
|
||||
throw "无法生成标准形式";
|
||||
}
|
||||
|
||||
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
|
||||
log.debug("[Stage 3] Standard form complete", { standardFormResult });
|
||||
|
||||
// ========== 阶段 4:生成词条 ==========
|
||||
console.log("[阶段4] 开始生成词条...");
|
||||
log.debug("[Stage 4] Generating entries");
|
||||
const entriesResult = await generateEntries(
|
||||
standardFormResult.standardForm,
|
||||
queryLang,
|
||||
@@ -71,19 +68,18 @@ export async function executeDictionaryLookup(
|
||||
: analysis.inputType
|
||||
);
|
||||
|
||||
console.log("[阶段4] 词条生成完成:", entriesResult);
|
||||
log.debug("[Stage 4] Entries complete", { entriesResult });
|
||||
|
||||
// ========== 组装最终结果 ==========
|
||||
const finalResult: ServiceOutputLookUp = {
|
||||
standardForm: standardFormResult.standardForm,
|
||||
entries: entriesResult.entries,
|
||||
};
|
||||
|
||||
console.log("[完成] 词典查询成功");
|
||||
log.info("Dictionary lookup completed successfully");
|
||||
return finalResult;
|
||||
|
||||
} catch (error) {
|
||||
console.error("[错误] 词典查询失败:", error);
|
||||
log.error("Dictionary lookup failed", { error });
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
throw new LookUpError(errorMessage);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { InputAnalysisResult } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-stage1");
|
||||
|
||||
/**
|
||||
* 阶段 1:输入解析与语言识别
|
||||
@@ -59,7 +62,7 @@ export async function analyzeInput(text: string): Promise<InputAnalysisResult> {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("阶段1失败:", error);
|
||||
log.error("Stage 1 failed", { error });
|
||||
// 失败时抛出错误,包含 reason
|
||||
throw new Error("输入分析失败:无法识别输入类型或语言");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { SemanticMappingResult } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-stage2");
|
||||
|
||||
/**
|
||||
* 阶段 2:跨语言语义映射决策
|
||||
@@ -71,7 +74,7 @@ b) 输入是明确、基础、可词典化的语义概念
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
]).then(parseAIGeneratedJSON<any>);
|
||||
]).then(parseAIGeneratedJSON<SemanticMappingResult>);
|
||||
|
||||
// 代码层面的数据验证
|
||||
if (typeof result.shouldMap !== "boolean") {
|
||||
@@ -99,7 +102,7 @@ b) 输入是明确、基础、可词典化的语义概念
|
||||
reason: result.reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("阶段2失败:", error);
|
||||
log.error("Stage 2 failed", { error });
|
||||
// 失败时直接抛出错误,让编排器返回错误响应
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { StandardFormResult } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-stage3");
|
||||
|
||||
/**
|
||||
* 阶段 3:standardForm 生成与规范化
|
||||
@@ -63,7 +66,7 @@ ${originalInput ? `
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
]).then(parseAIGeneratedJSON<any>);
|
||||
]).then(parseAIGeneratedJSON<StandardFormResult>);
|
||||
|
||||
// 代码层面的数据验证
|
||||
if (!result.standardForm || result.standardForm.trim().length === 0) {
|
||||
@@ -90,7 +93,7 @@ ${originalInput ? `
|
||||
reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("阶段3失败:", error);
|
||||
log.error("Stage 3 failed", { error });
|
||||
// 失败时抛出错误
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { EntriesGenerationResult } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-stage4");
|
||||
|
||||
/**
|
||||
* 阶段 4:释义与词条生成
|
||||
@@ -95,15 +98,11 @@ ${isWord ? `
|
||||
if (isWord && !entry.partOfSpeech) {
|
||||
throw new Error("阶段4:单词条目缺少 partOfSpeech");
|
||||
}
|
||||
|
||||
if (isWord && !entry.ipa) {
|
||||
throw new Error("阶段4:单词条目缺少 ipa");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("阶段4失败:", error);
|
||||
log.error("Stage 4 failed", { error });
|
||||
throw error; // 阶段4失败应该返回错误,因为这个阶段是核心
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { LanguageDetectionResult, TranslationLLMResponse } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("translator-orchestrator");
|
||||
|
||||
async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
|
||||
const prompt = `
|
||||
@@ -40,7 +43,7 @@ async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Language detection failed:", error);
|
||||
log.error("Language detection failed", { error });
|
||||
throw new Error("Failed to detect source language");
|
||||
}
|
||||
}
|
||||
@@ -82,7 +85,7 @@ async function performTranslation(
|
||||
|
||||
return result.trim();
|
||||
} catch (error) {
|
||||
console.error("Translation failed:", error);
|
||||
log.error("Translation failed", { error });
|
||||
throw new Error("Translation failed");
|
||||
}
|
||||
}
|
||||
@@ -121,7 +124,7 @@ async function generateIPA(
|
||||
|
||||
return result.trim();
|
||||
} catch (error) {
|
||||
console.error("IPA generation failed:", error);
|
||||
log.error("IPA generation failed", { error });
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -132,24 +135,19 @@ export async function executeTranslation(
|
||||
needIpa: boolean
|
||||
): Promise<TranslationLLMResponse> {
|
||||
try {
|
||||
console.log("[翻译] 开始翻译流程...");
|
||||
console.log("[翻译] 源文本:", sourceText);
|
||||
console.log("[翻译] 目标语言:", targetLanguage);
|
||||
console.log("[翻译] 需要 IPA:", needIpa);
|
||||
log.debug("Starting translation", { sourceText, targetLanguage, needIpa });
|
||||
|
||||
// Stage 1: Detect source language
|
||||
console.log("[阶段1] 检测源语言...");
|
||||
log.debug("[Stage 1] Detecting source language");
|
||||
const detectionResult = await detectLanguage(sourceText);
|
||||
console.log("[阶段1] 检测结果:", detectionResult);
|
||||
log.debug("[Stage 1] Detection result", { detectionResult });
|
||||
|
||||
// Stage 2: Perform translation
|
||||
console.log("[阶段2] 执行翻译...");
|
||||
log.debug("[Stage 2] Performing translation");
|
||||
const translatedText = await performTranslation(
|
||||
sourceText,
|
||||
detectionResult.sourceLanguage,
|
||||
targetLanguage
|
||||
);
|
||||
console.log("[阶段2] 翻译完成:", translatedText);
|
||||
log.debug("[Stage 2] Translation complete", { translatedText });
|
||||
|
||||
// Validate translation result
|
||||
if (!translatedText) {
|
||||
@@ -161,12 +159,12 @@ export async function executeTranslation(
|
||||
let targetIpa: string | undefined;
|
||||
|
||||
if (needIpa) {
|
||||
console.log("[阶段3] 生成 IPA...");
|
||||
log.debug("[Stage 3] Generating IPA");
|
||||
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
|
||||
console.log("[阶段3] 源文本 IPA:", sourceIpa);
|
||||
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
||||
|
||||
targetIpa = await generateIPA(translatedText, targetLanguage);
|
||||
console.log("[阶段3] 目标文本 IPA:", targetIpa);
|
||||
log.debug("[Stage 3] Target IPA", { targetIpa });
|
||||
}
|
||||
|
||||
// Assemble final result
|
||||
@@ -179,10 +177,10 @@ export async function executeTranslation(
|
||||
targetIpa,
|
||||
};
|
||||
|
||||
console.log("[完成] 翻译流程成功");
|
||||
log.info("Translation completed successfully");
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
console.error("[错误] 翻译失败:", error);
|
||||
log.error("Translation failed", { error });
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("tts");
|
||||
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
/**
|
||||
@@ -147,7 +151,7 @@ class QwenTTSService {
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('语音合成请求失败:', error);
|
||||
log.error("TTS request failed", { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -157,11 +161,7 @@ export type TTS_SUPPORTED_LANGUAGES = 'Auto' | 'Chinese' | 'English' | 'German'
|
||||
export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
|
||||
try {
|
||||
if (!process.env.DASHSCORE_API_KEY) {
|
||||
console.warn(
|
||||
`⚠️ 环境变量 DASHSCORE_API_KEY 未设置\n` +
|
||||
` 请在 .env 文件中设置或直接传入API Key\n` +
|
||||
` 获取API Key: https://help.aliyun.com/zh/model-studio/get-api-key`
|
||||
);
|
||||
log.warn("DASHSCORE_API_KEY not set");
|
||||
throw "API Key设置错误";
|
||||
}
|
||||
const ttsService = new QwenTTSService(
|
||||
@@ -176,7 +176,7 @@ export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
|
||||
);
|
||||
return result.output.audio.url;
|
||||
} catch (error) {
|
||||
console.error('TTS合成失败:', error instanceof Error ? error.message : error);
|
||||
log.error("TTS synthesis failed", { error: error instanceof Error ? error.message : error });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
ActionInputGetUserProfileByUsername,
|
||||
ActionInputSignIn,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
// Re-export types for use in components
|
||||
export type { ActionOutputAuth, ActionOutputUserProfile } from "./auth-action-dto";
|
||||
|
||||
const log = createLogger("auth-action");
|
||||
|
||||
/**
|
||||
* Sign up action
|
||||
* Creates a new user account
|
||||
@@ -68,7 +71,7 @@ export async function actionSignUp(prevState: ActionOutputAuth | undefined, form
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
console.error("Sign up error:", e);
|
||||
log.error("Sign up failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Registration failed. Please try again later.",
|
||||
@@ -121,7 +124,7 @@ export async function actionSignIn(_prevState: ActionOutputAuth | undefined, for
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
console.error("Sign in error:", e);
|
||||
log.error("Sign in failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Sign in failed. Please check your credentials.",
|
||||
@@ -144,7 +147,7 @@ export async function signOutAction() {
|
||||
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
|
||||
throw e;
|
||||
}
|
||||
console.error("Sign out error:", e);
|
||||
log.error("Sign out failed", { error: e });
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
@@ -170,7 +173,7 @@ export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProf
|
||||
data: userProfile,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Get user profile error:", e);
|
||||
log.error("Get user profile failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to retrieve user profile",
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { serviceLookUp } from "./dictionary-service";
|
||||
|
||||
const log = createLogger("dictionary-action");
|
||||
|
||||
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
|
||||
try {
|
||||
return {
|
||||
@@ -18,7 +21,7 @@ export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary):
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Dictionary lookup failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
|
||||
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
|
||||
import { ServiceInputLookUp } from "./dictionary-service-dto";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-service");
|
||||
|
||||
export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||
const {
|
||||
@@ -39,7 +42,7 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||
},
|
||||
response.entries
|
||||
).catch(error => {
|
||||
console.error('Failed to save dictionary data:', error);
|
||||
log.error("Failed to save dictionary data", { error });
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -51,7 +54,7 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||
definitionLang: definitionLang,
|
||||
dictionaryItemId: lastLookUpResult.id
|
||||
}).catch(error => {
|
||||
console.error('Failed to save dictionary data:', error);
|
||||
log.error("Failed to save dictionary data", { error });
|
||||
});
|
||||
return {
|
||||
standardForm: lastLookUpResult.standardForm,
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("folder-action");
|
||||
import {
|
||||
ActionInputCreatePair,
|
||||
ActionInputUpdatePairById,
|
||||
@@ -68,7 +71,7 @@ export async function actionGetPairsByFolderId(folderId: number) {
|
||||
data: await repoGetPairsByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -93,7 +96,7 @@ export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePai
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -109,7 +112,7 @@ export async function actionGetUserIdByFolderId(folderId: number) {
|
||||
data: await repoGetUserIdByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -125,7 +128,7 @@ export async function actionGetFolderVisibility(folderId: number) {
|
||||
data: await repoGetFolderVisibility(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -149,7 +152,7 @@ export async function actionDeleteFolderById(folderId: number) {
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -173,7 +176,7 @@ export async function actionDeletePairById(id: number) {
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -189,7 +192,7 @@ export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promis
|
||||
data: await repoGetFoldersWithTotalPairsByUserId(id)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -205,7 +208,7 @@ export async function actionGetFoldersByUserId(userId: string) {
|
||||
data: await repoGetFoldersByUserId(userId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -236,7 +239,7 @@ export async function actionCreatePair(dto: ActionInputCreatePair) {
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -266,7 +269,7 @@ export async function actionCreateFolder(userId: string, folderName: string) {
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -302,7 +305,7 @@ export async function actionRenameFolderById(id: number, newName: string) {
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
@@ -332,7 +335,7 @@ export async function actionSetFolderVisibility(
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
@@ -352,7 +355,7 @@ export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFol
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
@@ -372,7 +375,7 @@ export async function actionSearchPublicFolders(query: string): Promise<ActionOu
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
@@ -411,7 +414,7 @@ export async function actionToggleFavorite(
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
@@ -449,7 +452,7 @@ export async function actionCheckFavorite(
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
@@ -487,7 +490,7 @@ export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavor
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export type ServiceInputCreateFolder = {
|
||||
name: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputRenameFolder = {
|
||||
folderId: number;
|
||||
newName: string;
|
||||
};
|
||||
|
||||
export type ServiceInputDeleteFolder = {
|
||||
folderId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputSetVisibility = {
|
||||
folderId: number;
|
||||
visibility: Visibility;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckOwnership = {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckPairOwnership = {
|
||||
pairId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCreatePair = {
|
||||
folderId: number;
|
||||
text1: string;
|
||||
text2: string;
|
||||
language1: string;
|
||||
language2: string;
|
||||
};
|
||||
|
||||
export type ServiceInputUpdatePair = {
|
||||
pairId: number;
|
||||
text1?: string;
|
||||
text2?: string;
|
||||
language1?: string;
|
||||
language2?: string;
|
||||
};
|
||||
|
||||
export type ServiceInputDeletePair = {
|
||||
pairId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetPublicFolders = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputSearchPublicFolders = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputToggleFavorite = {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckFavorite = {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetUserFavorites = {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: Visibility;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputFolderWithDetails = ServiceOutputFolder & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputFavoriteStatus = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
validateActionInputTranslateText,
|
||||
} from "./translator-action-dto";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { serviceTranslateText } from "./translator-service";
|
||||
import { getAnswer } from "@/lib/bigmodel/zhipu";
|
||||
|
||||
const log = createLogger("translator-action");
|
||||
|
||||
export const actionTranslateText = async (
|
||||
dto: ActionInputTranslateText
|
||||
): Promise<ActionOutputTranslateText> => {
|
||||
@@ -25,7 +28,7 @@ export const actionTranslateText = async (
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Translation action failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Unknown error occurred.",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
|
||||
import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository";
|
||||
import { ServiceInputTranslateText, ServiceOutputTranslateText } from "./translator-service-dto";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("translator-service");
|
||||
|
||||
export const serviceTranslateText = async (
|
||||
dto: ServiceInputTranslateText
|
||||
@@ -31,7 +34,7 @@ export const serviceTranslateText = async (
|
||||
sourceIpa: needIpa ? response.sourceIpa : undefined,
|
||||
targetIpa: needIpa ? response.targetIpa : undefined,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to save translation data:", error);
|
||||
log.error("Failed to save translation data", { error });
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -54,7 +57,7 @@ export const serviceTranslateText = async (
|
||||
sourceIpa: lastTranslation.sourceIpa || undefined,
|
||||
targetIpa: lastTranslation.targetIpa || undefined,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to save translation data:", error);
|
||||
log.error("Failed to save translation data", { error });
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user