refactor(folders): 优化刷新逻辑,只更新特定文件夹而非全量刷新

- FoldersClient: 使用 onUpdateFolder/onDeleteFolder 回调局部更新
- ExploreClient: 使用 onUpdateFavorite 只更新收藏数
- FavoritesClient: 使用 onRemoveFavorite 从列表移除,避免重新请求
This commit is contained in:
2026-03-08 15:07:05 +08:00
parent c6878ed1e5
commit b643205f72
3 changed files with 126 additions and 92 deletions

View File

@@ -23,10 +23,10 @@ import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps { interface PublicFolderCardProps {
folder: TPublicFolder; folder: TPublicFolder;
currentUserId?: string; 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 router = useRouter();
const t = useTranslations("explore"); const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
@@ -53,7 +53,7 @@ const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFol
if (result.success && result.data) { if (result.success && result.data) {
setIsFavorited(result.data.isFavorited); setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount); setFavoriteCount(result.data.favoriteCount);
onFavoriteChange?.(); onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -128,13 +128,12 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
setLoading(false); setLoading(false);
}; };
const refreshFolders = async () => { const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setLoading(true); setPublicFolders((prev) =>
const result = await actionSearchPublicFolders(searchQuery.trim() || ""); prev.map((f) =>
if (result.success && result.data) { f.id === folderId ? { ...f, favoriteCount } : f
setPublicFolders(result.data); )
} );
setLoading(false);
}; };
return ( return (
@@ -177,7 +176,7 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
key={folder.id} key={folder.id}
folder={folder} folder={folder}
currentUserId={currentUserId} currentUserId={currentUserId}
onFavoriteChange={refreshFolders} onUpdateFavorite={handleUpdateFavorite}
/> />
))} ))}
</div> </div>

View File

@@ -8,10 +8,11 @@ import {
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
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 { actionGetUserFavorites } from "@/modules/folder/folder-aciton"; import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-aciton";
type UserFavorite = { type UserFavorite = {
id: number; id: number;
@@ -27,11 +28,27 @@ type UserFavorite = {
interface FavoriteCardProps { interface FavoriteCardProps {
favorite: UserFavorite; favorite: UserFavorite;
onRemoveFavorite: (folderId: number) => void;
} }
const FavoriteCard = ({ favorite }: FavoriteCardProps) => { const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("favorites"); 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 ( return (
<div <div
@@ -57,7 +74,11 @@ const FavoriteCard = ({ favorite }: FavoriteCardProps) => {
</div> </div>
<div className="flex items-center gap-2"> <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" /> <ChevronRight size={20} className="text-gray-400" />
</div> </div>
</div> </div>
@@ -86,31 +107,37 @@ export function FavoritesClient({ userId }: FavoritesClientProps) {
setLoading(false); setLoading(false);
}; };
const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
};
return ( return (
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="mt-4"> <CardList>
<CardList> {loading ? (
{loading ? ( <div className="p-8 text-center">
<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>
<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>
<p className="text-sm text-gray-500">{t("loading")}</p> </div>
) : favorites.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">
<Heart size={24} className="text-gray-400" />
</div> </div>
) : favorites.length === 0 ? ( <p className="text-sm">{t("noFavorites")}</p>
<div className="text-center py-12 text-gray-400"> </div>
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> ) : (
<Heart size={24} className="text-gray-400" /> favorites.map((favorite) => (
</div> <FavoriteCard
<p className="text-sm">{t("noFavorites")}</p> key={favorite.id}
</div> favorite={favorite}
) : ( onRemoveFavorite={handleRemoveFavorite}
favorites.map((favorite) => ( />
<FavoriteCard key={favorite.id} favorite={favorite} /> ))
)) )}
)} </CardList>
</CardList>
</div>
</PageLayout> </PageLayout>
); );
} }

View File

@@ -28,10 +28,11 @@ import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
interface FolderCardProps { interface FolderCardProps {
folder: TSharedFolderWithTotalPairs; 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 router = useRouter();
const t = useTranslations("folders"); const t = useTranslations("folders");
@@ -40,12 +41,38 @@ const FolderCard = ({ folder, refresh }: FolderCardProps) => {
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC"; const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionSetFolderVisibility(folder.id, newVisibility); const result = await actionSetFolderVisibility(folder.id, newVisibility);
if (result.success) { if (result.success) {
refresh(); onUpdateFolder(folder.id, { visibility: newVisibility });
} else { } else {
toast.error(result.message); 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 ( return (
<div <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="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} /> <Globe size={18} />
)} )}
</CircleButton> </CircleButton>
<CircleButton <CircleButton onClick={handleRename}>
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);
}
});
}
}}
>
<FolderPen size={18} /> <FolderPen size={18} />
</CircleButton> </CircleButton>
<CircleButton <CircleButton
onClick={(e: React.MouseEvent) => { onClick={handleDelete}
e.stopPropagation(); className="hover:text-red-500 hover:bg-red-50"
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"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</CircleButton> </CircleButton>
<ChevronRight size={20} className="text-gray-400 ml-1" /> <ChevronRight size={20} className="text-gray-400" />
</div> </div>
</div> </div>
); );
@@ -155,19 +156,25 @@ export function FoldersClient({ userId }: FoldersClientProps) {
setLoading(false); setLoading(false);
}; };
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 handleCreateFolder = async () => {
const folderName = prompt(t("enterFolderName")); const folderName = prompt(t("enterFolderName"));
if (!folderName) return; if (!folderName?.trim()) return;
setLoading(true);
try { const result = await actionCreateFolder(userId, folderName.trim());
const result = await actionCreateFolder(userId, folderName); if (result.success) {
if (result.success) { loadFolders();
loadFolders(); } else {
} else { toast.error(result.message);
toast.error(result.message);
}
} finally {
setLoading(false);
} }
}; };
@@ -175,14 +182,12 @@ export function FoldersClient({ userId }: FoldersClientProps) {
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<LightButton <div className="mb-4">
onClick={handleCreateFolder} <LightButton onClick={handleCreateFolder}>
disabled={loading} <FolderPlus size={18} />
className="w-full border-dashed mb-4" {t("newFolder")}
> </LightButton>
<FolderPlus size={20} /> </div>
<span>{loading ? t("creating") : t("newFolder")}</span>
</LightButton>
<CardList> <CardList>
{loading ? ( {loading ? (
@@ -193,16 +198,19 @@ export function FoldersClient({ userId }: FoldersClientProps) {
) : folders.length === 0 ? ( ) : folders.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <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"> <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> </div>
<p className="text-sm">{t("noFoldersYet")}</p> <p className="text-sm">{t("noFoldersYet")}</p>
</div> </div>
) : ( ) : (
folders folders.map((folder) => (
.toSorted((a, b) => b.id - a.id) <FolderCard
.map((folder) => ( key={folder.id}
<FolderCard key={folder.id} folder={folder} refresh={loadFolders} /> folder={folder}
)) onUpdateFolder={handleUpdateFolder}
onDeleteFolder={handleDeleteFolder}
/>
))
)} )}
</CardList> </CardList>
</PageLayout> </PageLayout>