feat: implement user balance management with hooks and store
This commit is contained in:
@@ -21,6 +21,7 @@ import { signOut } from '@/lib/auth'
|
|||||||
import { useTemplateGenerations, type TemplateGeneration } from '@/hooks'
|
import { useTemplateGenerations, type TemplateGeneration } from '@/hooks'
|
||||||
import { MySkeleton } from '@/components/skeleton/MySkeleton'
|
import { MySkeleton } from '@/components/skeleton/MySkeleton'
|
||||||
import { useSession } from '@/lib/auth'
|
import { useSession } from '@/lib/auth'
|
||||||
|
import { useUserBalance } from '@/hooks/use-user-balance'
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window')
|
const { width: screenWidth } = Dimensions.get('window')
|
||||||
const GALLERY_GAP = 2
|
const GALLERY_GAP = 2
|
||||||
@@ -38,6 +39,9 @@ export default function My() {
|
|||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const [editDrawerVisible, setEditDrawerVisible] = useState(false)
|
const [editDrawerVisible, setEditDrawerVisible] = useState(false)
|
||||||
|
|
||||||
|
// 获取积分余额
|
||||||
|
const { balance } = useUserBalance()
|
||||||
|
|
||||||
// 获取当前登录用户信息
|
// 获取当前登录用户信息
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const userName = session?.user?.name || session?.user?.username || '用户'
|
const userName = session?.user?.name || session?.user?.username || '用户'
|
||||||
@@ -133,7 +137,7 @@ export default function My() {
|
|||||||
onPress={() => router.push('/membership' as any)}
|
onPress={() => router.push('/membership' as any)}
|
||||||
>
|
>
|
||||||
<PointsIcon />
|
<PointsIcon />
|
||||||
<Text style={styles.pointsPillText}>60</Text>
|
<Text style={styles.pointsPillText}>{balance}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
options={settingsOptions}
|
options={settingsOptions}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { CheckMarkIcon } from '@/components/icon/checkMark'
|
|||||||
import PointsDrawer from '@/components/drawer/PointsDrawer'
|
import PointsDrawer from '@/components/drawer/PointsDrawer'
|
||||||
import Dropdown from '@/components/ui/dropdown'
|
import Dropdown from '@/components/ui/dropdown'
|
||||||
import GradientText from '@/components/GradientText'
|
import GradientText from '@/components/GradientText'
|
||||||
|
import { useUserBalance } from '@/hooks/use-user-balance'
|
||||||
|
|
||||||
// 使用唯一 id 的 PointsIcon,避免与其他页面的图标 id 冲突
|
// 使用唯一 id 的 PointsIcon,避免与其他页面的图标 id 冲突
|
||||||
const MembershipPointsIcon = () => {
|
const MembershipPointsIcon = () => {
|
||||||
@@ -67,8 +68,11 @@ export default function MembershipScreen() {
|
|||||||
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
|
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(false)
|
||||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// 获取积分余额
|
||||||
|
const { balance } = useUserBalance()
|
||||||
|
|
||||||
// 订阅计划数据(使用国际化)
|
// 订阅计划数据(使用国际化)
|
||||||
const plans: Plan[] = [
|
const plans: Plan[] = [
|
||||||
@@ -174,7 +178,7 @@ export default function MembershipScreen() {
|
|||||||
>
|
>
|
||||||
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
|
||||||
<MembershipPointsIcon />
|
<MembershipPointsIcon />
|
||||||
<Text style={styles.pointsValue}>60</Text>
|
<Text style={styles.pointsValue}>{balance}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.settingsButtonContainer}>
|
<View style={styles.settingsButtonContainer}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -419,7 +423,7 @@ export default function MembershipScreen() {
|
|||||||
<PointsDrawer
|
<PointsDrawer
|
||||||
visible={pointsDrawerVisible}
|
visible={pointsDrawerVisible}
|
||||||
onClose={() => setPointsDrawerVisible(false)}
|
onClose={() => setPointsDrawerVisible(false)}
|
||||||
totalPoints={60}
|
totalPoints={balance}
|
||||||
subscriptionPoints={0}
|
subscriptionPoints={0}
|
||||||
topUpPoints={0}
|
topUpPoints={0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,26 +3,16 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ScrollView,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { CloseIcon } from '@/components/icon'
|
||||||
import TopUpDrawer, { TopUpOption } from '@/components/drawer/TopUpDrawer'
|
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 {
|
export interface PointsDrawerProps {
|
||||||
/**
|
/**
|
||||||
* 是否显示抽屉
|
* 是否显示抽屉
|
||||||
@@ -44,29 +34,22 @@ export interface PointsDrawerProps {
|
|||||||
* 额外充值积分
|
* 额外充值积分
|
||||||
*/
|
*/
|
||||||
topUpPoints?: number
|
topUpPoints?: number
|
||||||
/**
|
|
||||||
* 交易记录列表
|
|
||||||
*/
|
|
||||||
transactions?: PointsTransaction[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PointsDrawer({
|
export default function PointsDrawer({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
totalPoints = 60,
|
totalPoints = 0,
|
||||||
subscriptionPoints = 0,
|
subscriptionPoints = 0,
|
||||||
topUpPoints = 0,
|
topUpPoints = 0,
|
||||||
transactions = [],
|
|
||||||
}: PointsDrawerProps) {
|
}: PointsDrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { height: screenHeight } = useWindowDimensions()
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||||
const [pointsTab, setPointsTab] = useState<PointsTabType>('all')
|
|
||||||
const [topUpDrawerVisible, setTopUpDrawerVisible] = useState(false)
|
const [topUpDrawerVisible, setTopUpDrawerVisible] = useState(false)
|
||||||
|
|
||||||
const snapPoints = useMemo(() => [screenHeight * 0.85], [screenHeight])
|
const snapPoints = useMemo(() => [380], [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetRef.current?.expand()
|
bottomSheetRef.current?.expand()
|
||||||
@@ -74,13 +57,13 @@ export default function PointsDrawer({
|
|||||||
bottomSheetRef.current?.close()
|
bottomSheetRef.current?.close()
|
||||||
}
|
}
|
||||||
}, [visible])
|
}, [visible])
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: any) => (
|
(props: any) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -93,40 +76,6 @@ export default function PointsDrawer({
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
// 标签页配置
|
|
||||||
const tabOptions: Array<{ value: PointsTabType; label: string }> = [
|
|
||||||
{ 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 (
|
return (
|
||||||
<BottomSheet
|
<BottomSheet
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
@@ -150,75 +99,24 @@ export default function PointsDrawer({
|
|||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Text style={styles.title}>{t('pointsDrawer.title')}</Text>
|
<Text style={styles.title}>{t('pointsDrawer.title')}</Text>
|
||||||
{/* 积分总额 */}
|
{/* 积分总额 */}
|
||||||
<View style={styles.balance}>
|
<View style={styles.balance}>
|
||||||
<Text style={styles.balanceValue}>{totalPoints}</Text>
|
<Text style={styles.balanceValue}>{totalPoints}</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 积分类型细分 */}
|
|
||||||
<View style={styles.breakdown}>
|
|
||||||
<Text style={styles.breakdownText}>{t('pointsDrawer.subscriptionPoints')}
|
|
||||||
<Text style={styles.breakdownTextValue}>{subscriptionPoints}</Text>
|
|
||||||
</Text>
|
|
||||||
<View style={styles.breakdownTextSeparator} />
|
|
||||||
<Text style={styles.breakdownText}>{t('pointsDrawer.topUpPoints')}
|
|
||||||
<Text style={styles.breakdownTextValue}>{topUpPoints}</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 标签页 */}
|
|
||||||
<View style={styles.tabs}>
|
|
||||||
{tabOptions.map((tab) => {
|
|
||||||
const isActive = pointsTab === tab.value
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={tab.value}
|
|
||||||
style={[styles.tab]}
|
|
||||||
onPress={() => setPointsTab(tab.value)}
|
|
||||||
>
|
|
||||||
<View style={styles.tabContent}>
|
|
||||||
{isActive && (
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.tabGradient}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>{tab.label}</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 交易历史列表 */}
|
{/* 积分类型细分 */}
|
||||||
<BottomSheetScrollView
|
<View style={styles.breakdown}>
|
||||||
style={styles.list}
|
<Text style={styles.breakdownText}>{t('pointsDrawer.subscriptionPoints')}
|
||||||
contentContainerStyle={styles.listContent}
|
<Text style={styles.breakdownTextValue}>{subscriptionPoints}</Text>
|
||||||
showsVerticalScrollIndicator={false}
|
</Text>
|
||||||
>
|
<View style={styles.breakdownTextSeparator} />
|
||||||
{displayTransactions.map((transaction) => (
|
<Text style={styles.breakdownText}>{t('pointsDrawer.topUpPoints')}
|
||||||
<View key={transaction.id} style={styles.item}>
|
<Text style={styles.breakdownTextValue}>{topUpPoints}</Text>
|
||||||
<View style={styles.itemLeft}>
|
</Text>
|
||||||
<Text style={styles.itemTitle}>{transaction.title}</Text>
|
</View>
|
||||||
<Text style={styles.itemDate}>{transaction.date}</Text>
|
</View>
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.itemPoints,
|
|
||||||
transaction.points < 0 && styles.itemPointsNegative,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{transaction.points > 0 ? '+' : ''}
|
|
||||||
{transaction.points}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
{/* 底部按钮 */}
|
||||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||||
@@ -233,7 +131,6 @@ export default function PointsDrawer({
|
|||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.subscribeButtonGradient}
|
style={styles.subscribeButtonGradient}
|
||||||
|
|
||||||
>
|
>
|
||||||
<Text style={styles.subscribeButtonText}>{t('pointsDrawer.subscribeForPoints')}</Text>
|
<Text style={styles.subscribeButtonText}>{t('pointsDrawer.subscribeForPoints')}</Text>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
@@ -248,7 +145,7 @@ export default function PointsDrawer({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
|
|
||||||
{/* 充值抽屉 */}
|
{/* 充值抽屉 */}
|
||||||
<TopUpDrawer
|
<TopUpDrawer
|
||||||
visible={topUpDrawerVisible}
|
visible={topUpDrawerVisible}
|
||||||
@@ -276,9 +173,6 @@ const styles = StyleSheet.create({
|
|||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
},
|
},
|
||||||
handleIndicator: {
|
|
||||||
backgroundColor: '#666666',
|
|
||||||
},
|
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
@@ -296,8 +190,6 @@ const styles = StyleSheet.create({
|
|||||||
titleContainer: {
|
titleContainer: {
|
||||||
paddingLeft: 20,
|
paddingLeft: 20,
|
||||||
marginTop: -4,
|
marginTop: -4,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#3A3A3A',
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: '#F5F5F5',
|
color: '#F5F5F5',
|
||||||
@@ -340,77 +232,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#3A3A3A',
|
backgroundColor: '#3A3A3A',
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
tabs: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
marginTop:20,
|
|
||||||
marginBottom:24,
|
|
||||||
},
|
|
||||||
tab: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
tabContent: {
|
|
||||||
position: 'relative',
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
tabGradient: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: '#FF9966',
|
|
||||||
},
|
|
||||||
tabText: {
|
|
||||||
color: '#ABABAB',
|
|
||||||
fontSize: 14,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
tabTextActive: {
|
|
||||||
color: '#F5F5F5',
|
|
||||||
fontSize: 14,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
height: 400,
|
|
||||||
},
|
|
||||||
listContent: {
|
|
||||||
paddingBottom: 16,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#1C1E20',
|
|
||||||
},
|
|
||||||
itemLeft: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
itemTitle: {
|
|
||||||
color: '#F5F5F5',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
itemDate: {
|
|
||||||
color: '#ABABAB',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '400',
|
|
||||||
},
|
|
||||||
itemPoints: {
|
|
||||||
color: '#4CAF50',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
itemPointsNegative: {
|
|
||||||
color: '#F5F5F5',
|
|
||||||
},
|
|
||||||
footer: {
|
footer: {
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@@ -445,4 +266,3 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,27 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Pressable,
|
Pressable,
|
||||||
useWindowDimensions,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
|
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
|
||||||
|
import { root } from '@repo/core'
|
||||||
|
import { AlipayController } from '@repo/sdk'
|
||||||
import { CloseIcon, CheckIcon, UncheckedIcon, PointsIcon } from '@/components/icon'
|
import { CloseIcon, CheckIcon, UncheckedIcon, PointsIcon } from '@/components/icon'
|
||||||
|
import Toast from '@/components/ui/Toast'
|
||||||
|
import { useUserBalanceStore } from '@/stores/userBalanceStore'
|
||||||
|
|
||||||
|
// 动态导入支付宝 SDK(需要安装 expo-native-alipay)
|
||||||
|
let Alipay: any = null
|
||||||
|
try {
|
||||||
|
Alipay = require('expo-native-alipay').default
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('expo-native-alipay not installed, payment will not work')
|
||||||
|
}
|
||||||
|
|
||||||
export interface TopUpOption {
|
export interface TopUpOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -65,6 +78,9 @@ const defaultOptions: TopUpOption[] = [
|
|||||||
{ id: '4', points: 10000, price: 20 },
|
{ id: '4', points: 10000, price: 20 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 支付宝回调 scheme(需要与 app.json 中配置一致)
|
||||||
|
const ALIPAY_SCHEME = 'popcore'
|
||||||
|
|
||||||
export default function TopUpDrawer({
|
export default function TopUpDrawer({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -82,6 +98,10 @@ export default function TopUpDrawer({
|
|||||||
options[0] || null
|
options[0] || null
|
||||||
)
|
)
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// 获取余额 store 的方法
|
||||||
|
const { load: loadBalance, restartPolling } = useUserBalanceStore()
|
||||||
|
|
||||||
const snapPoints = useMemo(() => [420], [])
|
const snapPoints = useMemo(() => [420], [])
|
||||||
|
|
||||||
@@ -119,9 +139,66 @@ export default function TopUpDrawer({
|
|||||||
// 如果没有传入标题,使用默认翻译
|
// 如果没有传入标题,使用默认翻译
|
||||||
const displayTitle = topUpTitle || t('topUp.title')
|
const displayTitle = topUpTitle || t('topUp.title')
|
||||||
|
|
||||||
const handleConfirm = () => {
|
// 初始化支付宝 SDK
|
||||||
if (selectedOption && agreed) {
|
useEffect(() => {
|
||||||
onConfirm?.(selectedOption)
|
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({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.confirmButton}
|
style={styles.confirmButton}
|
||||||
onPress={handleConfirm}
|
onPress={handleConfirm}
|
||||||
disabled={!agreed || !selectedOption}
|
disabled={!agreed || !selectedOption || loading}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||||
@@ -221,10 +298,14 @@ export default function TopUpDrawer({
|
|||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={[
|
style={[
|
||||||
styles.confirmButtonGradient,
|
styles.confirmButtonGradient,
|
||||||
(!agreed || !selectedOption) && styles.confirmButtonDisabled,
|
(!agreed || !selectedOption || loading) && styles.confirmButtonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.confirmButtonText}>{t('topUp.confirm')}</Text>
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#F5F5F5" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmButtonText}>{t('topUp.confirm')}</Text>
|
||||||
|
)}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.agreementContainer}>
|
<View style={styles.agreementContainer}>
|
||||||
|
|||||||
27
hooks/use-user-balance.ts
Normal file
27
hooks/use-user-balance.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
162
stores/userBalanceStore.ts
Normal file
162
stores/userBalanceStore.ts
Normal file
@@ -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<void>
|
||||||
|
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<typeof setInterval> | null = null
|
||||||
|
let loadingPromise: Promise<void> | null = null
|
||||||
|
let appStateSubscription: { remove: () => void } | null = null
|
||||||
|
|
||||||
|
export const useUserBalanceStore = create<UserBalanceStore>((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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
176
tests/stores/userBalanceStore.test.ts
Normal file
176
tests/stores/userBalanceStore.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user