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)
+ })
+ })
+})