diff --git a/src/components/articles/article-detail-page.tsx b/src/components/articles/article-detail-page.tsx index 2f3e203..1635d94 100644 --- a/src/components/articles/article-detail-page.tsx +++ b/src/components/articles/article-detail-page.tsx @@ -9,15 +9,25 @@ import { estimateReadTime } from "#/lib/utils/article-content" import { Button } from "#/components/ui/button" import { cn } from "#/lib/utils" import { Route } from "#/routes/articles.$slug" +import { useQuery } from "@tanstack/react-query" +import { articleKeys } from "#/hooks/keys/article-keys" import { useBookmarkStatus, useLikeStatus } from "#/hooks/queries" import { useBookmarkArticle, useUnbookmarkArticle, useLikeArticle, useUnlikeArticle } from "#/hooks/mutations" import { authClient } from "#/lib/auth-client" export function ArticleDetailPage() { - const { article } = Route.useLoaderData() + const loaderData = Route.useLoaderData() const navigate = useNavigate() const { data: session } = authClient.useSession() + // 使用 useQuery 获取文章数据,以响应实时更新(如点赞计数) + // loader 已将数据放入缓存,所以这里不会重复请求 + const { data: article } = useQuery({ + queryKey: articleKeys.detail(loaderData.article.id), + initialData: loaderData.article, + staleTime: 1000 * 60 * 5, // 5 分钟内不重新请求 + }) + // 获取收藏和点赞状态 const { data: bookmarkStatus } = useBookmarkStatus(article.id) const { data: likeStatus } = useLikeStatus(article.id) diff --git a/src/hooks/mutations/use-like-mutations.ts b/src/hooks/mutations/use-like-mutations.ts index 867a8a2..6e93c22 100644 --- a/src/hooks/mutations/use-like-mutations.ts +++ b/src/hooks/mutations/use-like-mutations.ts @@ -3,6 +3,7 @@ import { likeKeys } from '#/hooks/keys/like-keys' import { articleKeys } from '#/hooks/keys/article-keys' import { likeArticleFn, unlikeArticleFn } from '#/data/like' import type { UseMutationOptions } from '#/hooks/types' +import type { Article } from '#/types/article' import { createToggleOnMutate, createToggleOnError, @@ -10,26 +11,135 @@ import { createToggleOnSettled, } from '#/hooks/utils/optimistic-update' +/** + * 乐观更新文章详情中的 likeCount + */ +function updateArticleLikeCount( + queryClient: ReturnType, + articleId: string, + delta: number +) { + const articleData = queryClient.getQueryData
(articleKeys.detail(articleId)) + if (articleData) { + queryClient.setQueryData(articleKeys.detail(articleId), { + ...articleData, + likeCount: articleData.likeCount + delta, + }) + } +} + +/** + * 乐观更新无限列表中的文章 likeCount + */ +function updateInfiniteArticleLikeCount( + queryClient: ReturnType, + articleId: string, + delta: number +) { + const infiniteData = queryClient.getQueryData<{ pages: Array<{ articles: Article[] }> }>( + articleKeys.infinite() + ) + if (!infiniteData) return + + const updatedPages = infiniteData.pages.map((page) => ({ + ...page, + articles: page.articles.map((article) => + article.id === articleId ? { ...article, likeCount: article.likeCount + delta } : article + ), + })) + + queryClient.setQueryData(articleKeys.infinite(), { ...infiniteData, pages: updatedPages }) +} + +/** + * 回滚文章详情中的 likeCount + */ +function rollbackArticleLikeCount( + queryClient: ReturnType, + articleId: string, + previousCount: number +) { + const articleData = queryClient.getQueryData
(articleKeys.detail(articleId)) + if (articleData) { + queryClient.setQueryData(articleKeys.detail(articleId), { + ...articleData, + likeCount: previousCount, + }) + } +} + +/** + * 回滚无限列表中的文章 likeCount + */ +function rollbackInfiniteArticleLikeCount( + queryClient: ReturnType, + articleId: string, + previousCount: number +) { + const infiniteData = queryClient.getQueryData<{ pages: Array<{ articles: Article[] }> }>( + articleKeys.infinite() + ) + if (!infiniteData) return + + const updatedPages = infiniteData.pages.map((page) => ({ + ...page, + articles: page.articles.map((article) => + article.id === articleId ? { ...article, likeCount: previousCount } : article + ), + })) + + queryClient.setQueryData(articleKeys.infinite(), { ...infiniteData, pages: updatedPages }) +} + export function useLikeArticle(options?: UseMutationOptions) { const queryClient = useQueryClient() return useMutation({ mutationFn: (articleId: string) => likeArticleFn({ data: { articleId } }), - onMutate: createToggleOnMutate<{ isLiked: boolean }>(queryClient, { - statusKey: likeKeys.status, - batchBaseKey: likeKeys.multipleStatusBase, - statusField: 'isLiked', - newStatus: true, - }), - onError: createToggleOnError(queryClient, { - statusKey: likeKeys.status, - batchBaseKey: likeKeys.multipleStatusBase, - statusField: 'isLiked', - options, - }), + onMutate: (articleId: string) => { + // 先执行原有的点赞状态乐观更新 + const context = createToggleOnMutate<{ isLiked: boolean }>(queryClient, { + statusKey: likeKeys.status, + batchBaseKey: likeKeys.multipleStatusBase, + statusField: 'isLiked', + newStatus: true, + })(articleId) + + // 保存当前文章的 likeCount 用于回滚 + const articleData = queryClient.getQueryData
(articleKeys.detail(articleId)) + const previousLikeCount = articleData?.likeCount ?? 0 + + // 乐观增加 likeCount(详情页和无限列表) + updateArticleLikeCount(queryClient, articleId, 1) + updateInfiniteArticleLikeCount(queryClient, articleId, 1) + + return { ...context, previousLikeCount } + }, + onError: (error: unknown, articleId: string, context: unknown) => { + const ctx = context as { previousStatus?: { isLiked: boolean }; previousLikeCount?: number } + + // 回滚点赞状态 + createToggleOnError(queryClient, { + statusKey: likeKeys.status, + batchBaseKey: likeKeys.multipleStatusBase, + statusField: 'isLiked', + options, + })(error, articleId, { previousStatus: ctx?.previousStatus }) + + // 回滚 likeCount(详情页和无限列表) + if (ctx?.previousLikeCount !== undefined) { + rollbackArticleLikeCount(queryClient, articleId, ctx.previousLikeCount) + rollbackInfiniteArticleLikeCount(queryClient, articleId, ctx.previousLikeCount) + } + }, onSuccess: createToggleOnSuccess(queryClient, { successMessage: '已点赞', - invalidateKeys: (id) => [likeKeys.myLikes(), articleKeys.detail(id), articleKeys.lists()], + invalidateKeys: (id) => [ + likeKeys.myLikes(), + articleKeys.detail(id), + articleKeys.lists(), + articleKeys.infinite(), + ], options, }), onSettled: createToggleOnSettled(queryClient, { @@ -44,21 +154,50 @@ export function useUnlikeArticle(options?: UseMutationOptions) { return useMutation({ mutationFn: (articleId: string) => unlikeArticleFn({ data: { articleId } }), - onMutate: createToggleOnMutate<{ isLiked: boolean }>(queryClient, { - statusKey: likeKeys.status, - batchBaseKey: likeKeys.multipleStatusBase, - statusField: 'isLiked', - newStatus: false, - }), - onError: createToggleOnError(queryClient, { - statusKey: likeKeys.status, - batchBaseKey: likeKeys.multipleStatusBase, - statusField: 'isLiked', - options, - }), + onMutate: (articleId: string) => { + // 先执行原有的点赞状态乐观更新 + const context = createToggleOnMutate<{ isLiked: boolean }>(queryClient, { + statusKey: likeKeys.status, + batchBaseKey: likeKeys.multipleStatusBase, + statusField: 'isLiked', + newStatus: false, + })(articleId) + + // 保存当前文章的 likeCount 用于回滚 + const articleData = queryClient.getQueryData
(articleKeys.detail(articleId)) + const previousLikeCount = articleData?.likeCount ?? 0 + + // 乐观减少 likeCount(详情页和无限列表) + updateArticleLikeCount(queryClient, articleId, -1) + updateInfiniteArticleLikeCount(queryClient, articleId, -1) + + return { ...context, previousLikeCount } + }, + onError: (error: unknown, articleId: string, context: unknown) => { + const ctx = context as { previousStatus?: { isLiked: boolean }; previousLikeCount?: number } + + // 回滚点赞状态 + createToggleOnError(queryClient, { + statusKey: likeKeys.status, + batchBaseKey: likeKeys.multipleStatusBase, + statusField: 'isLiked', + options, + })(error, articleId, { previousStatus: ctx?.previousStatus }) + + // 回滚 likeCount(详情页和无限列表) + if (ctx?.previousLikeCount !== undefined) { + rollbackArticleLikeCount(queryClient, articleId, ctx.previousLikeCount) + rollbackInfiniteArticleLikeCount(queryClient, articleId, ctx.previousLikeCount) + } + }, onSuccess: createToggleOnSuccess(queryClient, { successMessage: '已取消点赞', - invalidateKeys: (id) => [likeKeys.myLikes(), articleKeys.detail(id), articleKeys.lists()], + invalidateKeys: (id) => [ + likeKeys.myLikes(), + articleKeys.detail(id), + articleKeys.lists(), + articleKeys.infinite(), + ], options, }), onSettled: createToggleOnSettled(queryClient, { diff --git a/src/routes/articles.$slug.tsx b/src/routes/articles.$slug.tsx index 6fcee8e..b373441 100644 --- a/src/routes/articles.$slug.tsx +++ b/src/routes/articles.$slug.tsx @@ -1,13 +1,19 @@ import { createFileRoute, notFound } from "@tanstack/react-router" import { getArticleByIdFn } from "#/data/articles" import { ArticleDetailPage } from "#/components/articles" +import { articleKeys } from "#/hooks/keys/article-keys" export const Route = createFileRoute("/articles/$slug")({ - loader: async ({ params }) => { + loader: async ({ params, context }) => { const article = await getArticleByIdFn({ data: { id: params.slug } }) if (!article) { throw notFound() } + + // 将文章数据放入 Query 缓存,以便组件能响应实时更新 + const queryClient = context.queryClient + queryClient.setQueryData(articleKeys.detail(article.id), article) + return { article } }, component: ArticleDetailPage,