diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index 4837c79..efde3ec 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -21,6 +21,7 @@ import { signOut } from '@/lib/auth' import { useTemplateGenerations, type TemplateGeneration } from '@/hooks' import { MySkeleton } from '@/components/skeleton/MySkeleton' import { useSession } from '@/lib/auth' +import { useUserBalance } from '@/hooks/use-user-balance' const { width: screenWidth } = Dimensions.get('window') const GALLERY_GAP = 2 @@ -38,6 +39,9 @@ export default function My() { const { t, i18n } = useTranslation() const [editDrawerVisible, setEditDrawerVisible] = useState(false) + // 获取积分余额 + const { balance } = useUserBalance() + // 获取当前登录用户信息 const { data: session } = useSession() const userName = session?.user?.name || session?.user?.username || '用户' @@ -133,7 +137,7 @@ export default function My() { onPress={() => router.push('/membership' as any)} > - 60 + {balance} { @@ -67,8 +68,11 @@ export default function MembershipScreen() { const [selectedPlan, setSelectedPlan] = useState('pro') const [agreed, setAgreed] = useState(false) const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false) - + const { t } = useTranslation() + + // 获取积分余额 + const { balance } = useUserBalance() // 订阅计划数据(使用国际化) const plans: Plan[] = [ @@ -174,7 +178,7 @@ export default function MembershipScreen() { > {t('membership.myPoints')} - 60 + {balance} setPointsDrawerVisible(false)} - totalPoints={60} + totalPoints={balance} subscriptionPoints={0} topUpPoints={0} /> diff --git a/components/drawer/PointsDrawer.tsx b/components/drawer/PointsDrawer.tsx index 7e99918..70717b2 100644 --- a/components/drawer/PointsDrawer.tsx +++ b/components/drawer/PointsDrawer.tsx @@ -3,26 +3,16 @@ import { View, Text, StyleSheet, - ScrollView, Pressable, useWindowDimensions, } from 'react-native' import { LinearGradient } from 'expo-linear-gradient' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' -import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet' +import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet' import { CloseIcon } from '@/components/icon' import TopUpDrawer, { TopUpOption } from '@/components/drawer/TopUpDrawer' -export type PointsTabType = 'all' | 'consume' | 'obtain' - -export interface PointsTransaction { - id: string - title: string - date: string - points: number // 正数表示获得,负数表示消耗 -} - export interface PointsDrawerProps { /** * 是否显示抽屉 @@ -44,29 +34,22 @@ export interface PointsDrawerProps { * 额外充值积分 */ topUpPoints?: number - /** - * 交易记录列表 - */ - transactions?: PointsTransaction[] } export default function PointsDrawer({ visible, onClose, - totalPoints = 60, + totalPoints = 0, subscriptionPoints = 0, topUpPoints = 0, - transactions = [], }: PointsDrawerProps) { const { t } = useTranslation() - const { height: screenHeight } = useWindowDimensions() const insets = useSafeAreaInsets() const bottomSheetRef = useRef(null) - const [pointsTab, setPointsTab] = useState('all') const [topUpDrawerVisible, setTopUpDrawerVisible] = useState(false) - - const snapPoints = useMemo(() => [screenHeight * 0.85], [screenHeight]) - + + const snapPoints = useMemo(() => [380], []) + useEffect(() => { if (visible) { bottomSheetRef.current?.expand() @@ -74,13 +57,13 @@ export default function PointsDrawer({ bottomSheetRef.current?.close() } }, [visible]) - + const handleSheetChanges = useCallback((index: number) => { if (index === -1) { onClose() } }, [onClose]) - + const renderBackdrop = useCallback( (props: any) => ( = [ - { value: 'all', label: t('pointsDrawer.all') }, - { value: 'consume', label: t('pointsDrawer.consume') }, - { value: 'obtain', label: t('pointsDrawer.obtain') }, - ] - - // 根据标签页过滤交易记录 - const filteredTransactions = transactions.filter((transaction) => { - if (pointsTab === 'all') return true - if (pointsTab === 'consume') return transaction.points < 0 - if (pointsTab === 'obtain') return transaction.points > 0 - return true - }) - - // 如果没有提供交易记录,使用示例数据 - const displayTransactions = - filteredTransactions.length > 0 - ? filteredTransactions - : [ - { - id: '1', - title: t('pointsDrawer.dailyFreePoints'), - date: '2025年11月28日 10:33', - points: 60, - }, - ...Array.from({ length: 60 }, (_, i) => ({ - id: `example-${i + 2}`, - title: t('pointsDrawer.dailyFreePoints'), - date: '2025年11月28日 10:33', - points: -60, - })), - ] - return ( - - {t('pointsDrawer.title')} - {/* 积分总额 */} - - {totalPoints} - - - {/* 积分类型细分 */} - - {t('pointsDrawer.subscriptionPoints')} - {subscriptionPoints} - - - {t('pointsDrawer.topUpPoints')} - {topUpPoints} - - - - - {/* 标签页 */} - - {tabOptions.map((tab) => { - const isActive = pointsTab === tab.value - return ( - setPointsTab(tab.value)} - > - - {isActive && ( - - )} - {tab.label} - - - ) - })} + + {t('pointsDrawer.title')} + {/* 积分总额 */} + + {totalPoints} - {/* 交易历史列表 */} - - {displayTransactions.map((transaction) => ( - - - {transaction.title} - {transaction.date} - - - {transaction.points > 0 ? '+' : ''} - {transaction.points} - - - ))} - + {/* 积分类型细分 */} + + {t('pointsDrawer.subscriptionPoints')} + {subscriptionPoints} + + + {t('pointsDrawer.topUpPoints')} + {topUpPoints} + + + {/* 底部按钮 */} @@ -233,7 +131,6 @@ export default function PointsDrawer({ start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.subscribeButtonGradient} - > {t('pointsDrawer.subscribeForPoints')} @@ -248,7 +145,7 @@ export default function PointsDrawer({ - + {/* 充值抽屉 */} [420], []) @@ -119,9 +139,66 @@ export default function TopUpDrawer({ // 如果没有传入标题,使用默认翻译 const displayTitle = topUpTitle || t('topUp.title') - const handleConfirm = () => { - if (selectedOption && agreed) { - onConfirm?.(selectedOption) + // 初始化支付宝 SDK + useEffect(() => { + if (Alipay && Platform.OS === 'ios') { + Alipay.setAlipayScheme(ALIPAY_SCHEME) + } + }, []) + + const handleConfirm = async () => { + if (!selectedOption || !agreed) return + + // 检查支付宝 SDK 是否可用 + if (!Alipay) { + Toast.show(t('topUp.alipayNotInstalled') || '支付宝 SDK 未安装') + return + } + + setLoading(true) + + try { + // 1. 调用后端 API 创建订单 + const alipay = root.get(AlipayController) + const response = await alipay.preRecharge({ + credits: selectedOption.points, + }) + + if (!response?.orderStr) { + Toast.show(t('topUp.createOrderFailed') || '创建订单失败') + setLoading(false) + return + } + + // 2. 调用支付宝 SDK 发起支付 + const result = await Alipay.pay(response.orderStr) + console.log('Alipay payment result:', result) + + // 3. 处理支付结果 + if (result.resultStatus === '9000') { + // 支付成功 + Toast.show(t('topUp.paymentSuccess') || '支付成功!积分正在到账中...') + // 刷新余额 + loadBalance(true) + restartPolling() + // 关闭抽屉 + onClose() + // 调用外部回调 + onConfirm?.(selectedOption) + } else if (result.resultStatus === '6001') { + // 用户取消 + Toast.show(t('topUp.paymentCancelled') || '支付已取消') + } else { + // 支付失败 + Toast.show(t('topUp.paymentFailed') || '支付失败,请重试') + } + } catch (err: any) { + console.error('Payment error:', err) + Toast.show(err?.message || t('topUp.paymentError') || '支付出错') + } finally { + setLoading(false) + // 无论成功失败都刷新余额 + loadBalance(true) } } @@ -213,7 +290,7 @@ export default function TopUpDrawer({ - {t('topUp.confirm')} + {loading ? ( + + ) : ( + {t('topUp.confirm')} + )} diff --git a/hooks/use-user-balance.ts b/hooks/use-user-balance.ts new file mode 100644 index 0000000..4d4fd39 --- /dev/null +++ b/hooks/use-user-balance.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react' + +import { useUserBalanceStore } from '@/stores/userBalanceStore' + +/** + * 用户积分余额 Hook + * 提供积分余额的获取和管理功能 + */ +export const useUserBalance = () => { + const { balance, loading, error, load, startPolling, stopPolling, deductBalance } = useUserBalanceStore() + + // 组件挂载时开始轮询,卸载时停止 + useEffect(() => { + startPolling() + return () => { + stopPolling() + } + }, [startPolling, stopPolling]) + + return { + balance, + loading, + error, + load, + deductBalance, + } +} diff --git a/stores/userBalanceStore.ts b/stores/userBalanceStore.ts new file mode 100644 index 0000000..0e45ffb --- /dev/null +++ b/stores/userBalanceStore.ts @@ -0,0 +1,162 @@ +import { AppState, type AppStateStatus } from 'react-native' +import { create } from 'zustand' + +import { subscription } from '@/lib/auth' +import type { ApiError } from '@/lib/types' + +interface UserBalanceState { + balance: number + loading: boolean + error: ApiError | null + lastLoadTime: number + pollingEnabled: boolean +} + +interface UserBalanceActions { + load: (force?: boolean) => Promise + setBalance: (balance: number) => void + deductBalance: (amount: number) => void + startPolling: () => void + stopPolling: () => void + restartPolling: () => void + reset: () => void +} + +type UserBalanceStore = UserBalanceState & UserBalanceActions + +const POLLING_INTERVAL = 60e3 // 60秒轮询一次 +const DEBOUNCE_TIME = 5e3 // 5秒防抖 + +let pollingInterval: ReturnType | null = null +let loadingPromise: Promise | null = null +let appStateSubscription: { remove: () => void } | null = null + +export const useUserBalanceStore = create((set, get) => ({ + // State + balance: 0, + loading: false, + error: null, + lastLoadTime: 0, + pollingEnabled: false, + + // Actions + load: async (force = false) => { + const state = get() + const now = Date.now() + const timeSinceLastLoad = now - state.lastLoadTime + + // 如果不是强制刷新,且距离上次调用少于5秒,则跳过 + if (!force && timeSinceLastLoad < DEBOUNCE_TIME) { + console.log('跳过余额加载,距离上次调用时间过短:', timeSinceLastLoad + 'ms') + return + } + + // 如果已经在加载中且不是强制刷新,直接返回 + if (state.loading && !force) { + console.log('余额加载中,跳过重复请求') + return loadingPromise || undefined + } + + set({ lastLoadTime: now, loading: true }) + + loadingPromise = (async () => { + try { + const { data, error } = await subscription.list() + + if (error) { + set({ error, loading: false }) + return + } + + const meteredSubscriptions = data?.filter((sub) => sub.type === 'metered') || [] + const creditBalance = meteredSubscriptions[0]?.creditBalance?.remainingTokenBalance || 0 + set({ balance: creditBalance, error: null, loading: false }) + } catch (e) { + console.error('加载余额失败:', e) + set({ loading: false }) + } finally { + loadingPromise = null + } + })() + + return loadingPromise + }, + + setBalance: (balance: number) => { + set({ balance }) + }, + + deductBalance: (amount: number) => { + set((state) => ({ balance: Math.max(0, state.balance - amount) })) + }, + + startPolling: () => { + const { stopPolling, load } = get() + + // 先清理可能存在的旧轮询状态 + stopPolling() + + console.log('开始余额轮询,间隔:', POLLING_INTERVAL / 1000, '秒') + set({ pollingEnabled: true }) + + // 立即执行一次 + load(false) + + // 设置定时器 + pollingInterval = setInterval(() => { + const state = get() + if (state.pollingEnabled) { + load(false) + } + }, POLLING_INTERVAL) + + // 设置应用状态监听 + if (!appStateSubscription) { + appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { + const state = get() + if (nextAppState === 'active' && state.pollingEnabled) { + console.log('应用回到前台,重启余额轮询') + get().restartPolling() + } else if (nextAppState === 'background' || nextAppState === 'inactive') { + console.log('应用进入后台,暂停余额轮询') + get().stopPolling() + } + }) + } + }, + + stopPolling: () => { + console.log('停止余额轮询') + set({ pollingEnabled: false }) + + if (pollingInterval) { + clearInterval(pollingInterval) + pollingInterval = null + } + }, + + restartPolling: () => { + const { stopPolling, startPolling } = get() + stopPolling() + setTimeout(() => startPolling(), 1000) // 延迟1秒重启 + }, + + reset: () => { + const { stopPolling } = get() + stopPolling() + + set({ + balance: 0, + loading: false, + error: null, + lastLoadTime: 0, + pollingEnabled: false, + }) + + // 清理应用状态监听器 + if (appStateSubscription) { + appStateSubscription.remove() + appStateSubscription = null + } + }, +})) diff --git a/tests/stores/userBalanceStore.test.ts b/tests/stores/userBalanceStore.test.ts new file mode 100644 index 0000000..3652255 --- /dev/null +++ b/tests/stores/userBalanceStore.test.ts @@ -0,0 +1,176 @@ +import { act, renderHook } from '@testing-library/react-native' + +import { useUserBalanceStore } from '@/stores/userBalanceStore' +import { subscription } from '@/lib/auth' + +// Mock subscription API +jest.mock('@/lib/auth', () => ({ + subscription: { + list: jest.fn(), + }, +})) + +// Mock AppState +jest.mock('react-native', () => ({ + AppState: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }, +})) + +describe('userBalanceStore', () => { + beforeEach(() => { + // Reset store state before each test + const { reset } = useUserBalanceStore.getState() + reset() + jest.clearAllMocks() + }) + + describe('load', () => { + it('should load balance from API', async () => { + const mockData = [ + { + type: 'metered', + creditBalance: { + remainingTokenBalance: 1000, + }, + }, + ] + ;(subscription.list as jest.Mock).mockResolvedValue({ data: mockData }) + + const { load } = useUserBalanceStore.getState() + await act(async () => { + await load(true) + }) + + const { balance } = useUserBalanceStore.getState() + expect(balance).toBe(1000) + }) + + it('should handle API error', async () => { + const mockError = { message: 'API Error', status: 500, statusText: 'Internal Server Error' } + ;(subscription.list as jest.Mock).mockResolvedValue({ error: mockError }) + + const { load } = useUserBalanceStore.getState() + await act(async () => { + await load(true) + }) + + const { error } = useUserBalanceStore.getState() + expect(error).toEqual(mockError) + }) + + it('should debounce requests within 5 seconds', async () => { + const mockData = [ + { + type: 'metered', + creditBalance: { + remainingTokenBalance: 500, + }, + }, + ] + ;(subscription.list as jest.Mock).mockResolvedValue({ data: mockData }) + + const { load } = useUserBalanceStore.getState() + + // First call + await act(async () => { + await load(false) + }) + + // Second call within 5 seconds (should be skipped) + await act(async () => { + await load(false) + }) + + // API should only be called once + expect(subscription.list).toHaveBeenCalledTimes(1) + }) + + it('should force load when force=true', async () => { + const mockData = [ + { + type: 'metered', + creditBalance: { + remainingTokenBalance: 500, + }, + }, + ] + ;(subscription.list as jest.Mock).mockResolvedValue({ data: mockData }) + + const { load } = useUserBalanceStore.getState() + + // First call + await act(async () => { + await load(true) + }) + + // Second call with force=true (should not be skipped) + await act(async () => { + await load(true) + }) + + // API should be called twice + expect(subscription.list).toHaveBeenCalledTimes(2) + }) + }) + + describe('setBalance', () => { + it('should set balance directly', () => { + const { setBalance } = useUserBalanceStore.getState() + + act(() => { + setBalance(2000) + }) + + const { balance } = useUserBalanceStore.getState() + expect(balance).toBe(2000) + }) + }) + + describe('deductBalance', () => { + it('should deduct balance', () => { + const { setBalance, deductBalance } = useUserBalanceStore.getState() + + act(() => { + setBalance(1000) + deductBalance(300) + }) + + const { balance } = useUserBalanceStore.getState() + expect(balance).toBe(700) + }) + + it('should not go below zero', () => { + const { setBalance, deductBalance } = useUserBalanceStore.getState() + + act(() => { + setBalance(100) + deductBalance(500) + }) + + const { balance } = useUserBalanceStore.getState() + expect(balance).toBe(0) + }) + }) + + describe('reset', () => { + it('should reset all state', () => { + const { setBalance, reset } = useUserBalanceStore.getState() + + act(() => { + setBalance(5000) + }) + + expect(useUserBalanceStore.getState().balance).toBe(5000) + + act(() => { + reset() + }) + + const state = useUserBalanceStore.getState() + expect(state.balance).toBe(0) + expect(state.loading).toBe(false) + expect(state.error).toBe(null) + }) + }) +})