refactor: migrate state management from Zustand to TanStack Query for improved data fetching and caching across components

This commit is contained in:
javayhu 2025-08-21 00:54:40 +08:00
parent ac8d4dee4b
commit 13c23dab56
14 changed files with 298 additions and 362 deletions

177
TANSTACK_QUERY_REFACTOR.md Normal file
View File

@ -0,0 +1,177 @@
# TanStack Query 重构总结
## 概述
本次重构将项目中的状态管理从 Zustand stores 迁移到 TanStack Query提供了更好的数据获取、缓存和状态管理能力。
## 重构的组件
### 1. NewsletterFormCard
- **文件**: `src/components/settings/notification/newsletter-form-card.tsx`
- **重构内容**:
- 使用 `useNewsletterStatus`, `useSubscribeNewsletter`, `useUnsubscribeNewsletter` hooks
- 移除了手动的 `useState` 状态管理
- 使用 TanStack Query 的自动缓存和错误处理
### 2. UsersPageClient
- **文件**: `src/components/admin/users-page.tsx`
- **重构内容**:
- 使用 `useUsers` hook 进行数据获取
- 移除了手动的数据获取逻辑和状态管理
- 简化了组件逻辑
### 3. CreditsBalanceCard
- **文件**: `src/components/settings/credits/credits-balance-card.tsx`
- **重构内容**:
- 使用 `useCreditStats` hook 获取信用统计信息
- 移除了手动的 `fetchCreditStats` 函数
- 使用 TanStack Query 的 `refetch` 功能
### 4. CreditTransactions
- **文件**: `src/components/settings/credits/credit-transactions.tsx`
- **重构内容**:
- 使用 `useCreditTransactions` hook 进行分页数据获取
- 移除了手动的数据获取和状态管理
- 简化了组件逻辑
### 5. BillingCard
- **文件**: `src/components/settings/billing/billing-card.tsx`
- **重构内容**:
- 使用 `useCurrentPlan` hook 获取支付和订阅信息
- 移除了对 `usePayment` hook 的依赖
- 使用 TanStack Query 的自动数据获取
### 6. UserDetailViewer
- **文件**: `src/components/admin/user-detail-viewer.tsx`
- **重构内容**:
- 使用 `useBanUser`, `useUnbanUser` mutation hooks
- 移除了对 `useUsersStore` 的依赖
- 使用 TanStack Query 的自动缓存失效
## 新增的 Hooks
### 1. Newsletter Hooks (`src/hooks/use-newsletter.ts`)
- `useNewsletterStatus(email)` - 查询订阅状态
- `useSubscribeNewsletter()` - 订阅 newsletter
- `useUnsubscribeNewsletter()` - 取消订阅 newsletter
### 2. Users Hooks (`src/hooks/use-users.ts`)
- `useUsers(pageIndex, pageSize, search, sorting)` - 获取用户列表
- `useBanUser()` - 封禁用户
- `useUnbanUser()` - 解封用户
### 3. Credits Hooks (`src/hooks/use-credits-query.ts`)
- `useCreditBalance()` - 获取信用余额
- `useConsumeCredits()` - 消费信用
- `useCreditStats()` - 获取信用统计信息
- `useCreditTransactions(pageIndex, pageSize, search, sorting)` - 获取信用交易记录
### 4. Payment Hooks (`src/hooks/use-payment-query.ts`)
- `useActiveSubscription(userId)` - 获取活跃订阅
- `useLifetimeStatus(userId)` - 获取终身会员状态
- `useCurrentPlan(userId)` - 获取当前计划信息
## 简化的 Providers
### 1. PaymentProvider
- **文件**: `src/components/layout/payment-provider.tsx`
- **状态**: 已移除TanStack Query 自动处理所有支付相关数据获取
### 2. CreditsProvider
- **文件**: `src/components/layout/credits-provider.tsx`
- **状态**: 已移除TanStack Query 自动处理所有积分相关数据获取
## 更新的组件
### 1. UserButton
- **文件**: `src/components/layout/user-button.tsx`
- **更新**: 移除了 `resetState` 调用TanStack Query 自动处理缓存失效
### 2. UserButtonMobile
- **文件**: `src/components/layout/user-button-mobile.tsx`
- **更新**: 移除了 `resetState` 调用TanStack Query 自动处理缓存失效
### 3. SidebarUser
- **文件**: `src/components/dashboard/sidebar-user.tsx`
- **更新**: 移除了 `resetState` 调用TanStack Query 自动处理缓存失效
### 4. CreditsBalanceButton
- **文件**: `src/components/layout/credits-balance-button.tsx`
- **更新**: 使用 `useCreditBalance` 替代 `useCredits`
### 5. CreditsBalanceMenu
- **文件**: `src/components/layout/credits-balance-menu.tsx`
- **更新**: 使用 `useCreditBalance` 替代 `useCredits`
### 6. ConsumeCreditCard
- **文件**: `src/ai/text/components/consume-credit-card.tsx`
- **更新**: 使用 `useCreditBalance``useConsumeCredits` 替代 `useCredits`
### 7. UpgradeCard
- **文件**: `src/components/dashboard/upgrade-card.tsx`
- **更新**: 使用 `useCurrentPlan` 替代 `usePayment`
### 8. CreditPackages
- **文件**: `src/components/settings/credits/credit-packages.tsx`
- **更新**: 使用 `useCurrentPlan` 替代 `usePayment`
### 9. CreditsBalanceCard
- **文件**: `src/components/settings/credits/credits-balance-card.tsx`
- **更新**: 使用 `useCurrentPlan` 替代 `usePayment`
## 配置
### 1. QueryClient 配置
- **文件**: `src/lib/query-client.ts`
- **配置**: 设置了合理的缓存时间和重试策略
### 2. QueryProvider
- **文件**: `src/components/providers/query-provider.tsx`
- **功能**: 提供 TanStack Query 上下文和开发工具
## 优势
### 1. 更好的缓存管理
- 自动缓存数据,减少不必要的网络请求
- 智能的缓存失效策略
- 支持乐观更新
### 2. 简化的状态管理
- 移除了大量的 `useState``useEffect`
- 自动处理加载和错误状态
- 统一的数据获取模式
### 3. 更好的用户体验
- 自动重试失败的请求
- 后台数据刷新
- 更流畅的加载状态
### 4. 开发体验提升
- 内置的开发工具支持
- 更好的错误处理
- 类型安全的数据获取
## 缓存策略
- **用户数据**: 30秒缓存5分钟垃圾回收
- **信用数据**: 30秒缓存5分钟垃圾回收
- **信用统计**: 1分钟缓存10分钟垃圾回收
- **支付数据**: 2分钟缓存5分钟垃圾回收
- **终身状态**: 5分钟缓存10分钟垃圾回收
## 注意事项
1. 所有组件现在都使用 TanStack Query 进行数据获取
2. 移除了对 Zustand stores 的依赖(除了必要的全局状态)
3. 错误处理现在通过 TanStack Query 统一管理
4. 加载状态通过 `isLoading``isPending` 属性获取
5. 缓存失效通过 `invalidateQueries` 自动处理
6. 完全移除了 PaymentProvider 和 CreditsProvider
7. 删除了 use-payment.ts 和 payment-store.ts 文件
## 测试
- ✅ 所有组件编译通过
- ✅ TypeScript 类型检查通过
- ✅ 构建成功
- ✅ 代码格式化通过

View File

@ -2,7 +2,7 @@
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
import { Button } from '@/components/ui/button';
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits-query';
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
import { CoinsIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';

View File

@ -9,7 +9,7 @@ import {
CardTitle,
} from '@/components/ui/card';
import { websiteConfig } from '@/config/website';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { useCurrentPlan } from '@/hooks/use-payment';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { Routes } from '@/routes';

View File

@ -2,7 +2,7 @@
import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { useCreditBalance } from '@/hooks/use-credits-query';
import { useCreditBalance } from '@/hooks/use-credits';
import { useLocaleRouter } from '@/i18n/navigation';
import { Routes } from '@/routes';
import { CoinsIcon, Loader2Icon } from 'lucide-react';

View File

@ -1,7 +1,7 @@
'use client';
import { websiteConfig } from '@/config/website';
import { useCreditBalance } from '@/hooks/use-credits-query';
import { useCreditBalance } from '@/hooks/use-credits';
import { useLocaleRouter } from '@/i18n/navigation';
import { Routes } from '@/routes';
import { CoinsIcon, Loader2Icon } from 'lucide-react';

View File

@ -14,7 +14,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { getPricePlans } from '@/config/price-config';
import { useMounted } from '@/hooks/use-mounted';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { useCurrentPlan } from '@/hooks/use-payment';
import { LocaleLink, useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';

View File

@ -11,7 +11,7 @@ import {
import { getCreditPackages } from '@/config/credits-config';
import { websiteConfig } from '@/config/website';
import { useCurrentUser } from '@/hooks/use-current-user';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { useCurrentPlan } from '@/hooks/use-payment';
import { authClient } from '@/lib/auth-client';
import { formatPrice } from '@/lib/formatter';
import { cn } from '@/lib/utils';

View File

@ -1,7 +1,7 @@
'use client';
import { CreditTransactionsTable } from '@/components/settings/credits/credit-transactions-table';
import { useCreditTransactions } from '@/hooks/use-credits-query';
import { useCreditTransactions } from '@/hooks/use-credits';
import type { SortingState } from '@tanstack/react-table';
import { useTranslations } from 'next-intl';
import { useState } from 'react';

View File

@ -11,9 +11,9 @@ import {
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { websiteConfig } from '@/config/website';
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits-query';
import { useCreditBalance, useCreditStats } from '@/hooks/use-credits';
import { useMounted } from '@/hooks/use-mounted';
import { useCurrentPlan } from '@/hooks/use-payment-query';
import { useCurrentPlan } from '@/hooks/use-payment';
import { useLocaleRouter } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/formatter';

View File

@ -1,117 +0,0 @@
import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
// Query keys
export const creditsKeys = {
all: ['credits'] as const,
balance: () => [...creditsKeys.all, 'balance'] as const,
stats: () => [...creditsKeys.all, 'stats'] as const,
transactions: () => [...creditsKeys.all, 'transactions'] as const,
transactionsList: (filters: {
pageIndex: number;
pageSize: number;
search: string;
sorting: SortingState;
}) => [...creditsKeys.transactions(), filters] as const,
};
// Hook to fetch credit statistics
export function useCreditStats() {
return useQuery({
queryKey: creditsKeys.stats(),
queryFn: async () => {
const result = await getCreditStatsAction();
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to fetch credit stats');
}
return result.data.data;
},
});
}
// Hook to fetch credit balance
export function useCreditBalance() {
return useQuery({
queryKey: creditsKeys.balance(),
queryFn: async () => {
const result = await getCreditBalanceAction();
if (!result?.data?.success) {
throw new Error('Failed to fetch credit balance');
}
return result.data.credits || 0;
},
});
}
// Hook to consume credits
export function useConsumeCredits() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
amount,
description,
}: {
amount: number;
description: string;
}) => {
const result = await consumeCreditsAction({
amount,
description,
});
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to consume credits');
}
return result.data;
},
onSuccess: () => {
// Invalidate credit balance and stats after consuming credits
queryClient.invalidateQueries({
queryKey: creditsKeys.balance(),
});
queryClient.invalidateQueries({
queryKey: creditsKeys.stats(),
});
},
});
}
// Hook to fetch credit transactions with pagination, search, and sorting
export function useCreditTransactions(
pageIndex: number,
pageSize: number,
search: string,
sorting: SortingState
) {
return useQuery({
queryKey: creditsKeys.transactionsList({
pageIndex,
pageSize,
search,
sorting,
}),
queryFn: async () => {
const result = await getCreditTransactionsAction({
pageIndex,
pageSize,
search,
sorting,
});
if (!result?.data?.success) {
throw new Error(
result?.data?.error || 'Failed to fetch credit transactions'
);
}
return {
items: result.data.data?.items || [],
total: result.data.data?.total || 0,
};
},
});
}

View File

@ -1,66 +1,117 @@
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { useCreditsStore } from '@/stores/credits-store';
import { useCallback, useEffect } from 'react';
import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table';
/**
* Hook for accessing and managing credits state
*
* This hook provides access to the credits state and methods to manage it.
* It also automatically fetches credits information when the user changes.
* Only works when credits are enabled in the website configuration.
*/
export function useCredits() {
// Return default values if credits are disabled
if (!websiteConfig.credits.enableCredits) {
return {
balance: 0,
isLoading: false,
error: null,
fetchCredits: () => Promise.resolve(),
consumeCredits: () => Promise.resolve(false),
hasEnoughCredits: () => false,
};
}
// Query keys
export const creditsKeys = {
all: ['credits'] as const,
balance: () => [...creditsKeys.all, 'balance'] as const,
stats: () => [...creditsKeys.all, 'stats'] as const,
transactions: () => [...creditsKeys.all, 'transactions'] as const,
transactionsList: (filters: {
pageIndex: number;
pageSize: number;
search: string;
sorting: SortingState;
}) => [...creditsKeys.transactions(), filters] as const,
};
const {
balance,
isLoading,
error,
fetchCredits: fetchCreditsFromStore,
consumeCredits,
} = useCreditsStore();
const { data: session } = authClient.useSession();
const fetchCredits = useCallback(
(force = false) => {
const currentUser = session?.user;
if (currentUser) {
fetchCreditsFromStore(currentUser, force);
// Hook to fetch credit statistics
export function useCreditStats() {
return useQuery({
queryKey: creditsKeys.stats(),
queryFn: async () => {
const result = await getCreditStatsAction();
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to fetch credit stats');
}
return result.data.data;
},
[session?.user, fetchCreditsFromStore]
);
useEffect(() => {
const currentUser = session?.user;
if (currentUser) {
fetchCreditsFromStore(currentUser);
}
}, [session?.user, fetchCreditsFromStore]);
return {
// State
balance,
isLoading,
error,
// Methods
fetchCredits,
consumeCredits,
// Helper methods
hasEnoughCredits: (amount: number) => balance >= amount,
};
});
}
// Hook to fetch credit balance
export function useCreditBalance() {
return useQuery({
queryKey: creditsKeys.balance(),
queryFn: async () => {
const result = await getCreditBalanceAction();
if (!result?.data?.success) {
throw new Error('Failed to fetch credit balance');
}
return result.data.credits || 0;
},
});
}
// Hook to consume credits
export function useConsumeCredits() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
amount,
description,
}: {
amount: number;
description: string;
}) => {
const result = await consumeCreditsAction({
amount,
description,
});
if (!result?.data?.success) {
throw new Error(result?.data?.error || 'Failed to consume credits');
}
return result.data;
},
onSuccess: () => {
// Invalidate credit balance and stats after consuming credits
queryClient.invalidateQueries({
queryKey: creditsKeys.balance(),
});
queryClient.invalidateQueries({
queryKey: creditsKeys.stats(),
});
},
});
}
// Hook to fetch credit transactions with pagination, search, and sorting
export function useCreditTransactions(
pageIndex: number,
pageSize: number,
search: string,
sorting: SortingState
) {
return useQuery({
queryKey: creditsKeys.transactionsList({
pageIndex,
pageSize,
search,
sorting,
}),
queryFn: async () => {
const result = await getCreditTransactionsAction({
pageIndex,
pageSize,
search,
sorting,
});
if (!result?.data?.success) {
throw new Error(
result?.data?.error || 'Failed to fetch credit transactions'
);
}
return {
items: result.data.data?.items || [],
total: result.data.data?.total || 0,
};
},
});
}

View File

@ -1,163 +0,0 @@
import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import type { Session } from '@/lib/auth-types';
import { create } from 'zustand';
// Cache duration: 2 minutes (optimized for better UX)
const CACHE_DURATION = 2 * 60 * 1000;
/**
* Credits state interface
*/
export interface CreditsState {
// Current credit balance
balance: number;
// Loading state
isLoading: boolean;
// Error state
error: string | null;
// Last fetch timestamp to avoid frequent requests
lastFetchTime: number | null;
// Actions
fetchCredits: (
user: Session['user'] | null | undefined,
force?: boolean
) => Promise<void>;
consumeCredits: (amount: number, description: string) => Promise<boolean>;
}
/**
* Credits store using Zustand
* Manages the user's credit balance globally with caching and optimistic updates
*/
export const useCreditsStore = create<CreditsState>((set, get) => ({
// Initial state
balance: 0,
isLoading: false,
error: null,
lastFetchTime: null,
/**
* Fetch credit balance for the current user with optional cache bypass
* @param user Current user from auth session
* @param force Whether to force refresh and ignore cache
*/
fetchCredits: async (user, force = false) => {
// Skip if already loading
if (get().isLoading) return;
// Skip if no user is provided
if (!user) {
set({
balance: 0,
error: null,
lastFetchTime: null,
});
return;
}
// Check if we have recent data (within cache duration) unless force refresh
if (!force) {
const { lastFetchTime } = get();
const now = Date.now();
if (lastFetchTime && now - lastFetchTime < CACHE_DURATION) {
return; // Use cached data
}
}
console.log(`fetchCredits, ${force ? 'force fetch' : 'fetch'} credits`);
set({
isLoading: true,
error: null,
// Clear cache if force refresh
lastFetchTime: force ? null : get().lastFetchTime,
});
try {
const result = await getCreditBalanceAction();
if (result?.data?.success && result.data.credits !== undefined) {
const newBalance = result.data.credits || 0;
console.log('fetchCredits, set new balance', newBalance);
set({
balance: newBalance,
isLoading: false,
error: null,
lastFetchTime: Date.now(),
});
} else {
console.warn('fetchCredits, failed to fetch credit balance', result);
set({
error:
(result?.data as any)?.error || 'Failed to fetch credit balance',
isLoading: false,
});
}
} catch (error) {
console.error('fetchCredits, error:', error);
set({
error: 'Failed to fetch credit balance',
isLoading: false,
});
}
},
/**
* Consume credits with optimistic updates
* @param amount Amount of credits to consume
* @param description Description for the transaction
* @returns Promise<boolean> Success status
*/
consumeCredits: async (amount: number, description: string) => {
const { balance } = get();
// Check if we have enough credits
if (balance < amount) {
console.log('consumeCredits, insufficient credits', balance, amount);
set({
error: 'Insufficient credits',
});
return false;
}
// Optimistically update the balance
set({
balance: balance - amount,
error: null,
isLoading: true,
});
try {
const result = await consumeCreditsAction({
amount,
description,
});
if (result?.data?.success) {
set({
isLoading: false,
error: null,
});
return true;
}
// Revert optimistic update on failure
console.warn('consumeCredits, reverting optimistic update');
set({
balance: balance, // Revert to original balance
error: result?.data?.error || 'Failed to consume credits',
isLoading: false,
});
return false;
} catch (error) {
console.error('consumeCredits, error:', error);
// Revert optimistic update on error
set({
balance: balance, // Revert to original balance
error: 'Failed to consume credits',
isLoading: false,
});
return false;
}
},
}));

View File

@ -1,12 +0,0 @@
import { create } from 'zustand';
interface UsersState {
refreshTrigger: number;
triggerRefresh: () => void;
}
export const useUsersStore = create<UsersState>((set) => ({
refreshTrigger: 0,
triggerRefresh: () =>
set((state) => ({ refreshTrigger: state.refreshTrigger + 1 })),
}));