This commit is contained in:
2026-01-14 16:57:35 +08:00
parent 804baa64b2
commit ec265be26b
38 changed files with 585 additions and 294 deletions

View File

@@ -1,18 +1,19 @@
import { Plus, RefreshCw } from "lucide-react"; import { Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { DictionaryEntry } from "./DictionaryEntry"; import { DictionaryEntry } from "./DictionaryEntry";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { performDictionaryLookup } from "./utils"; import { performDictionaryLookup } from "./utils";
import { TSharedItem } from "@/shared"; import { TSharedItem } from "@/shared";
import { TSharedFolder } from "@/shared/folder-type";
import { actionCreatePair } from "@/modules/folder";
interface SearchResultProps { interface SearchResultProps {
searchResult: TSharedItem; searchResult: TSharedItem;
searchQuery: string; searchQuery: string;
queryLang: string; queryLang: string;
definitionLang: string; definitionLang: string;
folders: Folder[]; folders: TSharedFolder[];
selectedFolderId: number | null; selectedFolderId: number | null;
onFolderSelect: (folderId: number | null) => void; onFolderSelect: (folderId: number | null) => void;
onResultUpdate: (newResult: TSharedItem) => void; onResultUpdate: (newResult: TSharedItem) => void;
@@ -66,12 +67,12 @@ export function SearchResult({
} }
const entry = searchResult.entries[0]; const entry = searchResult.entries[0];
createPair({ actionCreatePair({
text1: searchResult.standardForm, text1: searchResult.standardForm,
text2: entry.definition, text2: entry.definition,
language1: queryLang, language1: queryLang,
language2: definitionLang, language2: definitionLang,
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined, ipa1: entry.ipa,
folderId: selectedFolderId, folderId: selectedFolderId,
}) })
.then(() => { .then(() => {

View File

@@ -3,13 +3,15 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Container from "@/components/ui/Container"; import Container from "@/components/ui/Container";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { SearchForm } from "./SearchForm"; import { SearchForm } from "./SearchForm";
import { SearchResult } from "./SearchResult"; import { SearchResult } from "./SearchResult";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { POPULAR_LANGUAGES } from "./constants"; import { POPULAR_LANGUAGES } from "./constants";
import { performDictionaryLookup } from "./utils"; import { performDictionaryLookup } from "./utils";
import { TSharedItem } from "@/shared"; import { TSharedItem } from "@/shared";
import { actionGetFoldersByUserId } from "@/modules/folder";
import { TSharedFolder } from "@/shared/folder-type";
import { toast } from "sonner";
export default function DictionaryPage() { export default function DictionaryPage() {
const t = useTranslations("dictionary"); const t = useTranslations("dictionary");
@@ -20,20 +22,24 @@ export default function DictionaryPage() {
const [queryLang, setQueryLang] = useState("english"); const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese"); const [definitionLang, setDefinitionLang] = useState("chinese");
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null); const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [folders, setFolders] = useState<Folder[]>([]); const [folders, setFolders] = useState<TSharedFolder[]>([]);
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
// 加载用户的文件夹列表 // 加载用户的文件夹列表
useEffect(() => { useEffect(() => {
if (session) { if (session) {
getFoldersByUserId(session.user.id as string) actionGetFoldersByUserId(session.user.id as string)
.then(result => {
if (!result.success || !result.data) throw result.message;
return result.data;
})
.then((loadedFolders) => { .then((loadedFolders) => {
setFolders(loadedFolders); setFolders(loadedFolders);
// 如果有文件夹且未选择,默认选择第一个 // 如果有文件夹且未选择,默认选择第一个
if (loadedFolders.length > 0 && !selectedFolderId) { if (loadedFolders.length > 0 && !selectedFolderId) {
setSelectedFolderId(loadedFolders[0].id); setSelectedFolderId(loadedFolders[0].id);
} }
}); }).catch(e => toast.error);
} }
}, [session, selectedFolderId]); }, [session, selectedFolderId]);

View File

@@ -1,14 +1,14 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { lookUpDictionaryAction } from "@/modules/dictionary/dictionary-action"; import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { DictionaryActionInputDto, DictionaryActionOutputDto } from "@/modules/dictionary"; import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary } from "@/modules/dictionary";
import { TSharedItem } from "@/shared"; import { TSharedItem } from "@/shared";
export async function performDictionaryLookup( export async function performDictionaryLookup(
options: DictionaryActionInputDto, options: ActionInputLookUpDictionary,
t?: (key: string) => string t?: (key: string) => string
): Promise<TSharedItem | null> { ): Promise<TSharedItem | null> {
const { text, queryLang, definitionLang, forceRelook = false, userId } = options; const { text, queryLang, definitionLang, forceRelook = false, userId } = options;
const result = await lookUpDictionaryAction({ const result = await actionLookUpDictionary({
text, text,
queryLang, queryLang,
definitionLang, definitionLang,

View File

@@ -2,11 +2,11 @@
import { useState, useActionState, startTransition } from "react"; import { useState, useActionState, startTransition } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { signInAction, signUpAction, SignUpState } from "@/modules/user/user-action";
import Container from "@/components/ui/Container"; import Container from "@/components/ui/Container";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/components/ui/buttons";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { signInAction, signUpAction, SignUpState } from "@/modules/auth";
interface AuthFormProps { interface AuthFormProps {
redirectTo?: string; redirectTo?: string;

View File

@@ -8,23 +8,17 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { logger } from "@/lib/logger";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser";
import {
createFolder,
deleteFolderById,
getFoldersWithTotalPairsByUserId,
renameFolderById,
} from "@/lib/server/services/folderService";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import PageLayout from "@/components/ui/PageLayout"; import PageLayout from "@/components/ui/PageLayout";
import PageHeader from "@/components/ui/PageHeader"; import PageHeader from "@/components/ui/PageHeader";
import CardList from "@/components/ui/CardList"; import CardList from "@/components/ui/CardList";
import { actionCreateFolder, actionDeleteFolderById, actionGetFoldersWithTotalPairsByUserId, actionRenameFolderById } from "@/modules/folder";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
interface FolderProps { interface FolderProps {
folder: Folder & { total: number }; folder: TSharedFolderWithTotalPairs;
refresh: () => void; refresh: () => void;
} }
@@ -62,7 +56,15 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
e.stopPropagation(); e.stopPropagation();
const newName = prompt("Input a new name.")?.trim(); const newName = prompt("Input a new name.")?.trim();
if (newName && newName.length > 0) { if (newName && newName.length > 0) {
renameFolderById(folder.id, newName).then(refresh); actionRenameFolderById(folder.id, newName)
.then(result => {
if (result.success) {
refresh();
}
else {
toast.error(result.message);
}
});
} }
}} }}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
@@ -74,7 +76,15 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
e.stopPropagation(); e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name })); const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) { if (confirm === folder.name) {
deleteFolderById(folder.id).then(refresh); actionDeleteFolderById(folder.id)
.then(result => {
if (result.success) {
refresh();
}
else {
toast.error(result.message);
}
});
} }
}} }}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
@@ -87,33 +97,37 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
); );
}; };
export default function FoldersClient({ userId }: { userId: string }) { export default function FoldersClient({ userId }: { userId: string; }) {
const t = useTranslations("folders"); const t = useTranslations("folders");
const [folders, setFolders] = useState<(Folder & { total: number })[]>( const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
[], [],
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
getFoldersWithTotalPairsByUserId(userId) actionGetFoldersWithTotalPairsByUserId(userId)
.then((folders) => { .then((folders) => {
setFolders(folders); if (folders.success && folders.data) {
setFolders(folders.data);
setLoading(false); setLoading(false);
}) }
.catch((error) => {
logger.error("加载文件夹失败", error);
toast.error("加载出错,请重试。");
}); });
}, [userId]); }, [userId]);
const updateFolders = async () => { const updateFolders = async () => {
try { setLoading(true);
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId); await actionGetFoldersWithTotalPairsByUserId(userId)
setFolders(updatedFolders); .then(async result => {
} catch (error) { if (!result.success) toast.error(result.message);
logger.error("更新文件夹失败", error); else await actionGetFoldersWithTotalPairsByUserId(userId)
.then((folders) => {
if (folders.success && folders.data) {
setFolders(folders.data);
} }
});
});
setLoading(false);
}; };
return ( return (
@@ -127,11 +141,14 @@ export default function FoldersClient({ userId }: { userId: string }) {
if (!folderName) return; if (!folderName) return;
setLoading(true); setLoading(true);
try { try {
await createFolder({ await actionCreateFolder(userId, folderName)
name: folderName, .then(result => {
userId: userId, if (result.success) {
updateFolders();
} else {
toast.error(result.message);
}
}); });
await updateFolders();
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -3,30 +3,20 @@
import { ArrowLeft, Plus } from "lucide-react"; import { ArrowLeft, Plus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import {
createPair,
deletePairById,
getPairsByFolderId,
} from "@/lib/server/services/pairService";
import AddTextPairModal from "./AddTextPairModal"; import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard"; import TextPairCard from "./TextPairCard";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import PageLayout from "@/components/ui/PageLayout"; import PageLayout from "@/components/ui/PageLayout";
import { GreenButton } from "@/components/ui/buttons"; import { GreenButton } from "@/components/ui/buttons";
import { logger } from "@/lib/logger";
import { IconButton } from "@/components/ui/buttons"; import { IconButton } from "@/components/ui/buttons";
import CardList from "@/components/ui/CardList"; import CardList from "@/components/ui/CardList";
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder";
import { TSharedPair } from "@/shared/folder-type";
import { toast } from "sonner";
export interface TextPair {
id: number;
text1: string;
text2: string;
language1: string;
language2: string;
}
export default function InFolder({ folderId }: { folderId: number }) { export default function InFolder({ folderId }: { folderId: number; }) {
const [textPairs, setTextPairs] = useState<TextPair[]>([]); const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false); const [openAddModal, setAddModal] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -35,25 +25,26 @@ export default function InFolder({ folderId }: { folderId: number }) {
useEffect(() => { useEffect(() => {
const fetchTextPairs = async () => { const fetchTextPairs = async () => {
setLoading(true); setLoading(true);
try { await actionGetPairsByFolderId(folderId)
const data = await getPairsByFolderId(folderId); .then(result => {
setTextPairs(data as TextPair[]); if (!result.success || !result.data) throw result.message;
} catch (error) { return result.data;
logger.error("获取文本对失败", error); }).then(setTextPairs)
} finally { .catch(toast.error)
.finally(() => {
setLoading(false); setLoading(false);
} });
}; };
fetchTextPairs(); fetchTextPairs();
}, [folderId]); }, [folderId]);
const refreshTextPairs = async () => { const refreshTextPairs = async () => {
try { await actionGetPairsByFolderId(folderId)
const data = await getPairsByFolderId(folderId); .then(result => {
setTextPairs(data as TextPair[]); if (!result.success || !result.data) throw result.message;
} catch (error) { return result.data;
logger.error("获取文本对失败", error); }).then(setTextPairs)
} .catch(toast.error);
}; };
return ( return (
@@ -123,8 +114,11 @@ export default function InFolder({ folderId }: { folderId: number }) {
key={textPair.id} key={textPair.id}
textPair={textPair} textPair={textPair}
onDel={() => { onDel={() => {
deletePairById(textPair.id); actionDeletePairById(textPair.id)
refreshTextPairs(); .then(result => {
if (!result.success) throw result.message;
}).then(refreshTextPairs)
.catch(toast.error);
}} }}
refreshTextPairs={refreshTextPairs} refreshTextPairs={refreshTextPairs}
/> />
@@ -143,7 +137,7 @@ export default function InFolder({ folderId }: { folderId: number }) {
language1: string, language1: string,
language2: string, language2: string,
) => { ) => {
await createPair({ await actionCreatePair({
text1: text1, text1: text1,
text2: text2, text2: text2,
language1: language1, language1: language1,
@@ -155,4 +149,4 @@ export default function InFolder({ folderId }: { folderId: number }) {
/> />
</PageLayout> </PageLayout>
); );
} };

View File

@@ -1,13 +1,13 @@
import { Edit, Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder";
import { updatePairById } from "@/lib/server/services/pairService";
import { useState } from "react"; import { useState } from "react";
import UpdateTextPairModal from "./UpdateTextPairModal"; import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { UpdatePairInput } from "@/lib/server/services/types"; import { TSharedPair } from "@/shared/folder-type";
import { actionUpdatePairById, ActionInputUpdatePairById } from "@/modules/folder";
import { toast } from "sonner";
interface TextPairCardProps { interface TextPairCardProps {
textPair: TextPair; textPair: TSharedPair;
onDel: () => void; onDel: () => void;
refreshTextPairs: () => void; refreshTextPairs: () => void;
} }
@@ -66,8 +66,8 @@ export default function TextPairCard({
<UpdateTextPairModal <UpdateTextPairModal
isOpen={openUpdateModal} isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)} onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: UpdatePairInput) => { onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
await updatePairById(id, data); await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
setOpenUpdateModal(false); setOpenUpdateModal(false);
refreshTextPairs(); refreshTextPairs();
}} }}

View File

@@ -3,15 +3,15 @@ import Input from "@/components/ui/Input";
import { LocaleSelector } from "@/components/ui/LocaleSelector"; import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { UpdatePairInput } from "@/lib/server/services/types";
import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { ActionInputUpdatePairById } from "@/modules/folder";
interface UpdateTextPairModalProps { interface UpdateTextPairModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
textPair: TextPair; textPair: TSharedPair;
onUpdate: (id: number, tp: UpdatePairInput) => void; onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
} }
export default function UpdateTextPairModal({ export default function UpdateTextPairModal({

View File

@@ -1,9 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import InFolder from "./InFolder"; import InFolder from "./InFolder";
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetUserIdByFolderId } from "@/modules/folder";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,
}: { }: {
@@ -17,7 +17,7 @@ export default async function FoldersPage({
redirect("/folders"); redirect("/folders");
} }
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`); if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) { if ((await actionGetUserIdByFolderId(Number(folder_id))).data !== session.user.id) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }
return <InFolder folderId={Number(folder_id)} />; return <InFolder folderId={Number(folder_id)} />;

View File

@@ -1,4 +1,4 @@
import { LookUpServiceOutputDto } from "@/modules/dictionary/dictionary-service-dto"; import { ServiceOutputLookUp } from "@/modules/dictionary/dictionary-service-dto";
import { analyzeInput } from "./stage1-inputAnalysis"; import { analyzeInput } from "./stage1-inputAnalysis";
import { determineSemanticMapping } from "./stage2-semanticMapping"; import { determineSemanticMapping } from "./stage2-semanticMapping";
import { generateStandardForm } from "./stage3-standardForm"; import { generateStandardForm } from "./stage3-standardForm";
@@ -9,7 +9,7 @@ export async function executeDictionaryLookup(
text: string, text: string,
queryLang: string, queryLang: string,
definitionLang: string definitionLang: string
): Promise<LookUpServiceOutputDto> { ): Promise<ServiceOutputLookUp> {
try { try {
// ========== 阶段 1输入分析 ========== // ========== 阶段 1输入分析 ==========
console.log("[阶段1] 开始输入分析..."); console.log("[阶段1] 开始输入分析...");
@@ -74,7 +74,7 @@ export async function executeDictionaryLookup(
console.log("[阶段4] 词条生成完成:", entriesResult); console.log("[阶段4] 词条生成完成:", entriesResult);
// ========== 组装最终结果 ========== // ========== 组装最终结果 ==========
const finalResult: LookUpServiceOutputDto = { const finalResult: ServiceOutputLookUp = {
standardForm: standardFormResult.standardForm, standardForm: standardFormResult.standardForm,
entries: entriesResult.entries, entries: entriesResult.entries,
}; };

View File

@@ -67,6 +67,9 @@ export async function signUpAction(prevState: SignUpState, formData: FormData) {
redirect(redirectTo || "/"); redirect(redirectTo || "/");
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
throw error;
}
return { return {
success: false, success: false,
message: "注册失败,请稍后再试" message: "注册失败,请稍后再试"

View File

@@ -0,0 +1 @@
export * from './auth-action';

View File

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

View File

@@ -1,15 +1,15 @@
"use server"; "use server";
import { DictionaryActionInputDto, DictionaryActionOutputDto, validateDictionaryActionInput } from "./dictionary-action-dto"; import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { lookUpService } from "./dictionary-service"; import { serviceLookUp } from "./dictionary-service";
export const lookUpDictionaryAction = async (dto: DictionaryActionInputDto): Promise<DictionaryActionOutputDto> => { export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
try { try {
return { return {
message: 'success', message: 'success',
success: true, success: true,
data: await lookUpService(validateDictionaryActionInput(dto)) data: await serviceLookUp(validateActionInputLookUpDictionary(dto))
}; };
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof ValidateError) {

View File

@@ -1,6 +1,6 @@
import { TSharedItem } from "@/shared"; import { TSharedItem } from "@/shared";
export type CreateDictionaryLookUpInputDto = { export type RepoInputCreateDictionaryLookUp = {
userId?: string; userId?: string;
text: string; text: string;
queryLang: string; queryLang: string;
@@ -8,15 +8,15 @@ export type CreateDictionaryLookUpInputDto = {
dictionaryItemId?: number; dictionaryItemId?: number;
}; };
export type SelectLastLookUpResultOutputDto = TSharedItem & {id: number} | null; export type RepoOutputSelectLastLookUpResult = TSharedItem & {id: number} | null;
export type CreateDictionaryItemInputDto = { export type RepoInputCreateDictionaryItem = {
standardForm: string; standardForm: string;
queryLang: string; queryLang: string;
definitionLang: string; definitionLang: string;
}; };
export type CreateDictionaryEntryInputDto = { export type RepoInputCreateDictionaryEntry = {
itemId: number; itemId: number;
ipa?: string; ipa?: string;
definition: string; definition: string;
@@ -24,14 +24,14 @@ export type CreateDictionaryEntryInputDto = {
example: string; example: string;
}; };
export type CreateDictionaryEntryWithoutItemIdInputDto = { export type RepoInputCreateDictionaryEntryWithoutItemId = {
ipa?: string; ipa?: string;
definition: string; definition: string;
partOfSpeech?: string; partOfSpeech?: string;
example: string; example: string;
}; };
export type SelectLastLookUpResultInputDto = { export type RepoInputSelectLastLookUpResult = {
text: string, text: string,
queryLang: string, queryLang: string,
definitionLang: string; definitionLang: string;

View File

@@ -1,15 +1,15 @@
import { stringNormalize } from "@/utils/string"; import { stringNormalize } from "@/utils/string";
import { import {
CreateDictionaryEntryInputDto, RepoInputCreateDictionaryEntry,
CreateDictionaryEntryWithoutItemIdInputDto, RepoInputCreateDictionaryEntryWithoutItemId,
CreateDictionaryItemInputDto, RepoInputCreateDictionaryItem,
CreateDictionaryLookUpInputDto, RepoInputCreateDictionaryLookUp,
SelectLastLookUpResultInputDto, RepoInputSelectLastLookUpResult,
SelectLastLookUpResultOutputDto, RepoOutputSelectLastLookUpResult,
} from "./dictionary-repository-dto"; } from "./dictionary-repository-dto";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
export async function selectLastLookUpResult(dto: SelectLastLookUpResultInputDto): Promise<SelectLastLookUpResultOutputDto> { export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpResult): Promise<RepoOutputSelectLastLookUpResult> {
const result = await prisma.dictionaryLookUp.findFirst({ const result = await prisma.dictionaryLookUp.findFirst({
where: { where: {
normalizedText: stringNormalize(dto.text), normalizedText: stringNormalize(dto.text),
@@ -48,16 +48,16 @@ export async function selectLastLookUpResult(dto: SelectLastLookUpResultInputDto
return null; return null;
} }
export async function createLookUp(content: CreateDictionaryLookUpInputDto) { export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {
return (await prisma.dictionaryLookUp.create({ return (await prisma.dictionaryLookUp.create({
data: { ...content, normalizedText: stringNormalize(content.text) } data: { ...content, normalizedText: stringNormalize(content.text) }
})).id; })).id;
} }
export async function createLookUpWithItemAndEntries( export async function repoCreateLookUpWithItemAndEntries(
itemData: CreateDictionaryItemInputDto, itemData: RepoInputCreateDictionaryItem,
lookUpData: CreateDictionaryLookUpInputDto, lookUpData: RepoInputCreateDictionaryLookUp,
entries: CreateDictionaryEntryWithoutItemIdInputDto[] entries: RepoInputCreateDictionaryEntryWithoutItemId[]
) { ) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const item = await tx.dictionaryItem.create({ const item = await tx.dictionaryItem.create({

View File

@@ -1,6 +1,6 @@
import { TSharedItem } from "@/shared"; import { TSharedItem } from "@/shared";
export type LookUpServiceInputDto = { export type ServiceInputLookUp = {
text: string, text: string,
queryLang: string, queryLang: string,
definitionLang: string, definitionLang: string,
@@ -8,4 +8,4 @@ export type LookUpServiceInputDto = {
userId?: string; userId?: string;
}; };
export type LookUpServiceOutputDto = TSharedItem; export type ServiceOutputLookUp = TSharedItem;

View File

@@ -1,8 +1,8 @@
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary"; import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary";
import { createLookUp, createLookUpWithItemAndEntries, selectLastLookUpResult } from "./dictionary-repository"; import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
import { LookUpServiceInputDto } from "./dictionary-service-dto"; import { ServiceInputLookUp } from "./dictionary-service-dto";
export const lookUpService = async (dto: LookUpServiceInputDto) => { export const serviceLookUp = async (dto: ServiceInputLookUp) => {
const { const {
text, text,
queryLang, queryLang,
@@ -11,7 +11,7 @@ export const lookUpService = async (dto: LookUpServiceInputDto) => {
forceRelook forceRelook
} = dto; } = dto;
const lastLookUpResult = await selectLastLookUpResult({ const lastLookUpResult = await repoSelectLastLookUpResult({
text, text,
queryLang, queryLang,
definitionLang, definitionLang,
@@ -25,7 +25,7 @@ export const lookUpService = async (dto: LookUpServiceInputDto) => {
); );
// 使用事务确保数据一致性 // 使用事务确保数据一致性
createLookUpWithItemAndEntries( repoCreateLookUpWithItemAndEntries(
{ {
standardForm: response.standardForm, standardForm: response.standardForm,
queryLang, queryLang,
@@ -44,7 +44,7 @@ export const lookUpService = async (dto: LookUpServiceInputDto) => {
return response; return response;
} else { } else {
createLookUp({ repoCreateLookUp({
userId: userId, userId: userId,
text: text, text: text,
queryLang: queryLang, queryLang: queryLang,

View File

@@ -0,0 +1,202 @@
"use server";
import { ValidateError } from "@/lib/errors";
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetUserIdByFolderId, repoRenameFolderById, repoUpdatePairById } from "./folder-repository";
import { validate } from "@/utils/validate";
import z from "zod";
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
export async function actionGetPairsByFolderId(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetPairsByFolderId(folderId)
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
try {
const validatedDto = validateActionInputUpdatePairById(dto);
await repoUpdatePairById(id, validatedDto);
return {
success: true,
message: 'success',
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetUserIdByFolderId(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetUserIdByFolderId(folderId)
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeleteFolderById(folderId: number) {
try {
await repoDeleteFolderById(folderId);
return {
success: true,
message: 'success',
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeletePairById(id: number) {
try {
await repoDeletePairById(id);
return {
success: true,
message: 'success'
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promise<ActionOutputGetFoldersWithTotalPairsByUserId> {
try {
return {
success: true,
message: 'success',
data: await repoGetFoldersWithTotalPairsByUserId(id)
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFoldersByUserId(userId: string) {
try {
return {
success: true,
message: 'success',
data: await repoGetFoldersByUserId(userId)
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionCreatePair(dto: ActionInputCreatePair) {
try {
const validatedDto = validateActionInputCreatePair(dto);
await repoCreatePair(validatedDto);
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionCreateFolder(userId: string, folderName: string) {
try {
const validatedFolderName = validate(folderName,
z.string()
.trim()
.min(LENGTH_MIN_FOLDER_NAME)
.max(LENGTH_MAX_FOLDER_NAME));
await repoCreateFolder({
name: validatedFolderName,
userId: userId
});
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionRenameFolderById(id: number, newName: string) {
try {
const validatedNewName = validate(
newName,
z.string()
.min(LENGTH_MIN_FOLDER_NAME)
.max(LENGTH_MAX_FOLDER_NAME)
.trim());
await repoRenameFolderById(id, validatedNewName);
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}

View File

@@ -0,0 +1,34 @@
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MAX_IPA, LENGTH_MAX_LANGUAGE, LENGTH_MAX_PAIR_TEXT, LENGTH_MIN_FOLDER_NAME, LENGTH_MIN_IPA, LENGTH_MIN_LANGUAGE, LENGTH_MIN_PAIR_TEXT } from "@/shared/constant";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const schemaActionInputCreatePair = z.object({
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
folderId: z.int()
});
export type ActionInputCreatePair = z.infer<typeof schemaActionInputCreatePair>;
export const validateActionInputCreatePair = generateValidator(schemaActionInputCreatePair);
export const schemaActionInputUpdatePairById = z.object({
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
folderId: z.int().optional()
});
export type ActionInputUpdatePairById = z.infer<typeof schemaActionInputUpdatePairById>;
export const validateActionInputUpdatePairById = generateValidator(schemaActionInputUpdatePairById);
export type ActionOutputGetFoldersWithTotalPairsByUserId = {
message: string,
success: boolean,
data?: TSharedFolderWithTotalPairs[];
};

View File

@@ -0,0 +1,23 @@
export interface RepoInputCreateFolder {
name: string;
userId: string;
}
export interface RepoInputCreatePair {
text1: string;
text2: string;
language1: string;
language2: string;
ipa1?: string;
ipa2?: string;
folderId: number;
}
export interface RepoInputUpdatePair {
text1?: string;
text2?: string;
language1?: string;
language2?: string;
ipa1?: string;
ipa2?: string;
}

View File

@@ -0,0 +1,120 @@
import prisma from "@/lib/db";
import { RepoInputCreateFolder, RepoInputCreatePair, RepoInputUpdatePair } from "./folder-repository-dto";
export async function repoCreatePair(data: RepoInputCreatePair) {
return (await prisma.pair.create({
data: data,
})).id;
}
export async function repoDeletePairById(id: number) {
await prisma.pair.delete({
where: {
id: id,
},
});
}
export async function repoUpdatePairById(
id: number,
data: RepoInputUpdatePair,
) {
await prisma.pair.update({
where: {
id: id,
},
data: data,
});
}
export async function repoGetPairCountByFolderId(folderId: number) {
return prisma.pair.count({
where: {
folderId: folderId,
},
});
}
export async function repoGetPairsByFolderId(folderId: number) {
return (await prisma.pair.findMany({
where: {
folderId: folderId,
},
})).map(pair => {
return {
text1:pair.text1,
text2: pair.text2,
language1: pair.language1,
language2: pair.language2,
ipa1: pair.ipa1,
ipa2: pair.ipa2,
id: pair.id,
folderId: pair.folderId
}
});
}
export async function repoGetFoldersByUserId(userId: string) {
return (await prisma.folder.findMany({
where: {
userId: userId,
},
}))?.map(v => {
return {
id: v.id,
name: v.name,
userId: v.userId
};
});
}
export async function repoRenameFolderById(id: number, newName: string) {
await prisma.folder.update({
where: {
id: id,
},
data: {
name: newName,
},
});
}
export async function repoGetFoldersWithTotalPairsByUserId(userId: string) {
const folders = await prisma.folder.findMany({
where: { userId },
include: {
_count: {
select: { pairs: true },
},
},
});
return folders.map(folder => ({
id: folder.id,
name: folder.name,
userId: folder.userId,
total: folder._count?.pairs ?? 0,
}));
}
export async function repoCreateFolder(folder: RepoInputCreateFolder) {
await prisma.folder.create({
data: folder,
});
}
export async function repoDeleteFolderById(id: number) {
await prisma.folder.delete({
where: {
id: id,
},
});
}
export async function repoGetUserIdByFolderId(id: number) {
const folder = await prisma.folder.findUnique({
where: {
id: id,
},
});
return folder?.userId;
}

View File

@@ -1,68 +0,0 @@
import { CreateFolderInput, UpdateFolderInput } from "../translator/translator-dto";
import prisma from "@/lib/db";
export async function getFoldersByUserId(userId: string) {
return prisma.folder.findMany({
where: {
userId: userId,
},
});
}
export async function renameFolderById(id: number, newName: string) {
return prisma.folder.update({
where: {
id: id,
},
data: {
name: newName,
},
});
}
export async function getFoldersWithTotalPairsByUserId(userId: string) {
const folders = await prisma.folder.findMany({
where: { userId },
include: {
_count: {
select: { pairs: true },
},
},
});
return folders.map(folder => ({
...folder,
total: folder._count?.pairs ?? 0,
}));
}
export async function createFolder(folder: CreateFolderInput) {
return prisma.folder.create({
data: folder,
});
}
export async function deleteFolderById(id: number) {
return prisma.folder.delete({
where: {
id: id,
},
});
}
export async function updateFolderById(id: number, data: UpdateFolderInput) {
return prisma.folder.update({
where: {
id: id,
},
data: data,
});
}
export async function getUserIdByFolderId(id: number) {
const folder = await prisma.folder.findUnique({
where: {
id: id,
},
});
return folder?.userId;
}

View File

@@ -0,0 +1,2 @@
export * from './folder-aciton';
export * from './folder-action-dto';

View File

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

View File

@@ -1,44 +0,0 @@
import { CreatePairInput, UpdatePairInput } from "../translator/translator-dto";
import prisma from "@/lib/db";
export async function createPair(data: CreatePairInput) {
return (await prisma.pair.create({
data: data,
})).id;
}
export async function deletePairById(id: number) {
await prisma.pair.delete({
where: {
id: id,
},
});
}
export async function updatePairById(
id: number,
data: UpdatePairInput,
) {
await prisma.pair.update({
where: {
id: id,
},
data: data,
});
}
export async function getPairCountByFolderId(folderId: number) {
return prisma.pair.count({
where: {
folderId: folderId,
},
});
}
export async function getPairsByFolderId(folderId: number) {
return prisma.pair.findMany({
where: {
folderId: folderId,
},
});
}

View File

@@ -1,40 +1,3 @@
/**
* Service 层的自定义业务类型
*
* 这些类型用于替换 Prisma 生成的类型,提高代码的可维护性和抽象层次
*/
// Folder 相关
export interface CreateFolderInput {
name: string;
userId: string;
}
export interface UpdateFolderInput {
name?: string;
}
// Pair 相关
export interface CreatePairInput {
text1: string;
text2: string;
language1: string;
language2: string;
ipa1?: string;
ipa2?: string;
folderId: number;
}
export interface UpdatePairInput {
text1?: string;
text2?: string;
language1?: string;
language2?: string;
ipa1?: string;
ipa2?: string;
}
// Translation 相关
export interface CreateTranslationHistoryInput { export interface CreateTranslationHistoryInput {
userId?: string; userId?: string;
sourceText: string; sourceText: string;
@@ -50,7 +13,6 @@ export interface TranslationHistoryQuery {
targetLanguage: string; targetLanguage: string;
} }
// 翻译相关 - 统一翻译函数
export interface TranslateTextInput { export interface TranslateTextInput {
sourceText: string; sourceText: string;
targetLanguage: string; targetLanguage: string;

14
src/shared/constant.ts Normal file
View File

@@ -0,0 +1,14 @@
export const LENGTH_MAX_DICTIONARY_TEXT = 30;
export const LENGTH_MIN_DICTIONARY_TEXT = 1;
export const LENGTH_MAX_LANGUAGE = 20;
export const LENGTH_MIN_LANGUAGE = 1;
export const LENGTH_MAX_PAIR_TEXT = 50;
export const LENGTH_MIN_PAIR_TEXT = 1;
export const LENGTH_MAX_IPA = 150;
export const LENGTH_MIN_IPA = 1;
export const LENGTH_MAX_FOLDER_NAME = 20;
export const LENGTH_MIN_FOLDER_NAME = 1;

View File

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

23
src/shared/folder-type.ts Normal file
View File

@@ -0,0 +1,23 @@
export type TSharedFolder = {
id: number,
name: string,
userId: string;
};
export type TSharedFolderWithTotalPairs = {
id: number,
name: string,
userId: string,
total: number;
};
export type TSharedPair = {
text1: string;
text2: string;
language1: string;
language2: string;
ipa1: string | null;
ipa2: string | null;
id: number;
folderId: number;
};

13
src/utils/validate.ts Normal file
View File

@@ -0,0 +1,13 @@
import { ValidateError } from "@/lib/errors";
import z from "zod";
export const validate = <T, U extends z.ZodType>(dto: T, schema: U) => {
const result = schema.safeParse(dto);
if (result.success) return result.data as z.infer<U>;
const errorMessages = result.error.issues.map((issue) =>
`${issue.path.join('.')}: ${issue.message}`
).join('; ');
throw new ValidateError(`Validation failed: ${errorMessages}`);
};
export const generateValidator = <T extends z.ZodType>(schema: T) => <T>(dto: T) => validate(dto, schema);