This commit is contained in:
2026-03-24 17:25:12 +08:00
parent 9c220f1322
commit aee736fe0b
3 changed files with 140 additions and 67 deletions

View File

@@ -19,6 +19,7 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"react": "18.2.0", "react": "18.2.0",
"react-native": "0.72.10", "react-native": "0.72.10",
"react-native-animatable": "^1.4.0",
"react-native-background-actions": "^4.0.1", "react-native-background-actions": "^4.0.1",
"react-native-device-info": "14.0.4", "react-native-device-info": "14.0.4",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",

View File

@@ -4,6 +4,7 @@ import {
ScrollView, StyleSheet, Text, TextInput, ScrollView, StyleSheet, Text, TextInput,
TouchableOpacity, View, ActivityIndicator, TouchableOpacity, View, ActivityIndicator,
} from "react-native"; } from "react-native";
import * as Animatable from 'react-native-animatable';
import DeviceInfo from 'react-native-device-info'; import DeviceInfo from 'react-native-device-info';
import { import {
PhonePeBusinessBind, PhonePeBusinessBind,
@@ -98,9 +99,11 @@ interface HomeScreenState {
// wallet list // wallet list
wallets: WalletItem[]; wallets: WalletItem[];
loadingWallets: boolean; loadingWallets: boolean;
expandedWalletId: string | null; // vpa modal
walletVpas: Record<string, string[]>; vpaModalWallet: WalletItem | null;
loadingVpas: Record<string, boolean>; vpaModalVpas: string[];
vpaModalLoading: boolean;
vpaModalSelected: string;
// add wallet // add wallet
showAddWallet: boolean; showAddWallet: boolean;
} }
@@ -130,9 +133,10 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
settingsPort: '', settingsPort: '',
wallets: [], wallets: [],
loadingWallets: false, loadingWallets: false,
expandedWalletId: null, vpaModalWallet: null,
walletVpas: {}, vpaModalVpas: [],
loadingVpas: {}, vpaModalLoading: false,
vpaModalSelected: '',
showAddWallet: false, showAddWallet: false,
}; };
this.deviceId = DeviceInfo.getUniqueIdSync(); this.deviceId = DeviceInfo.getUniqueIdSync();
@@ -217,28 +221,36 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
} }
}; };
handleToggleExpand = async (walletId: string) => { openVpaModal = async (item: WalletItem) => {
const { expandedWalletId, walletVpas } = this.state; this.setState({
if (expandedWalletId === walletId) { vpaModalWallet: item,
this.setState({ expandedWalletId: null }); vpaModalVpas: [],
return; vpaModalLoading: true,
} vpaModalSelected: item.upi ?? '',
this.setState({ expandedWalletId: walletId }); });
if (!walletVpas[walletId]) { try {
this.setState(s => ({ loadingVpas: { ...s.loadingVpas, [walletId]: true } })); const vpas = await Api.instance.getWalletVpas(item.id);
try { this.setState({ vpaModalVpas: vpas, vpaModalLoading: false });
const vpas = await Api.instance.getWalletVpas(walletId); } catch {
this.setState(s => ({ walletVpas: { ...s.walletVpas, [walletId]: vpas } })); this.setState({ vpaModalLoading: false });
} catch {}
this.setState(s => ({ loadingVpas: { ...s.loadingVpas, [walletId]: false } }));
} }
}; };
handleSetVpa = async (walletId: string, vpaIndex: number) => { closeVpaModal = () => this.setState({ vpaModalWallet: null });
confirmVpa = async () => {
const { vpaModalWallet, vpaModalVpas, vpaModalSelected } = this.state;
if (!vpaModalWallet || !vpaModalSelected) return;
const idx = vpaModalVpas.indexOf(vpaModalSelected);
if (idx < 0) return;
try { try {
const vpa = await Api.instance.setCurrentVpa(walletId, vpaIndex); await Api.instance.setCurrentVpa(vpaModalWallet.id, idx);
Alert.alert('已设置', `当前 VPA: ${vpa}`); const walletId = vpaModalWallet.id;
this.fetchWallets(); const vpa = vpaModalSelected;
this.setState(s => ({
vpaModalWallet: null,
wallets: s.wallets.map(w => w.id === walletId ? { ...w, upi: vpa } : w),
}));
} catch (e) { } catch (e) {
Alert.alert('设置失败', (e as Error).message); Alert.alert('设置失败', (e as Error).message);
} }
@@ -398,18 +410,24 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
renderAddWalletModal() { renderAddWalletModal() {
return ( return (
<Modal visible={this.state.showAddWallet} transparent animationType="slide" onRequestClose={() => this.setState({ showAddWallet: false })}> <Modal visible={this.state.showAddWallet} transparent animationType="none" onRequestClose={() => this.setState({ showAddWallet: false })}>
<View style={s.modalOverlay}> <View style={s.modalOverlay}>
<View style={s.addModalBox}> <Animatable.View
animation="zoomIn"
duration={220}
easing="ease-out-back"
useNativeDriver
style={s.addModalBox}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={s.addModalTitle}></Text> <Text style={s.addModalTitle}></Text>
<TouchableOpacity onPress={() => this.setState({ showAddWallet: false })}> <TouchableOpacity onPress={() => this.setState({ showAddWallet: false })}>
<Text style={{ fontSize: 20, color: '#999' }}></Text> <Text style={{ fontSize: 20, color: '#999' }}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView> <ScrollView bounces={false}>
{WALLET_TYPE_OPTIONS.map(opt => ( {WALLET_TYPE_OPTIONS.map(opt => (
<TouchableOpacity key={opt.key} style={s.walletTypeRow} onPress={() => this.openWalletBind(opt.key)}> <TouchableOpacity key={opt.key} style={s.walletTypeRow} onPress={() => this.openWalletBind(opt.key)} activeOpacity={0.7}>
{WALLET_ICONS[opt.walletType] {WALLET_ICONS[opt.walletType]
? <Image source={WALLET_ICONS[opt.walletType]} style={s.walletTypeIcon} resizeMode="contain" /> ? <Image source={WALLET_ICONS[opt.walletType]} style={s.walletTypeIcon} resizeMode="contain" />
: <View style={[s.walletTypeDot, { backgroundColor: WALLET_TYPE_COLORS[opt.walletType] ?? '#888' }]} /> : <View style={[s.walletTypeDot, { backgroundColor: WALLET_TYPE_COLORS[opt.walletType] ?? '#888' }]} />
@@ -418,7 +436,7 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
</TouchableOpacity> </TouchableOpacity>
))} ))}
</ScrollView> </ScrollView>
</View> </Animatable.View>
</View> </View>
</Modal> </Modal>
); );
@@ -468,15 +486,10 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
} }
renderWalletItem = ({ item }: { item: WalletItem }) => { renderWalletItem = ({ item }: { item: WalletItem }) => {
const { expandedWalletId, walletVpas, loadingVpas } = this.state;
const isExpanded = expandedWalletId === item.id;
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888'; const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
const vpas = walletVpas[item.id] ?? [];
const loadingV = loadingVpas[item.id];
const isActive = item.status === 'ACTIVE'; const isActive = item.status === 'ACTIVE';
return ( return (
<TouchableOpacity style={s.walletCard} onPress={() => this.handleToggleExpand(item.id)} activeOpacity={0.8}> <TouchableOpacity style={s.walletCard} onPress={() => this.openVpaModal(item)} activeOpacity={0.8}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={[s.walletBadge, !isActive && s.walletBadgeInactive]}> <View style={[s.walletBadge, !isActive && s.walletBadgeInactive]}>
{WALLET_ICONS[item.walletType] {WALLET_ICONS[item.walletType]
@@ -490,34 +503,70 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
<Text style={s.walletPhone}>{item.phone || '—'}</Text> <Text style={s.walletPhone}>{item.phone || '—'}</Text>
<Text style={s.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text> <Text style={s.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text>
</View> </View>
<View style={{ alignItems: 'flex-end' }}> <View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
<Text style={{ fontSize: 10, color: '#aaa', marginTop: 4 }}>{isExpanded ? '▲' : '▼'}</Text>
</View>
</View> </View>
{isExpanded && (
<View style={s.vpaSection}>
<Text style={s.vpaSectionTitle}>VPAs</Text>
{loadingV ? (
<ActivityIndicator size="small" color={color} />
) : vpas.length === 0 ? (
<Text style={{ color: '#aaa', fontSize: 13 }}> VPA </Text>
) : (
vpas.map((vpa, idx) => (
<TouchableOpacity key={vpa} style={[s.vpaRow, item.upi === vpa && s.vpaRowActive]}
onPress={(e) => { e.stopPropagation?.(); this.handleSetVpa(item.id, idx); }}>
<Text style={[s.vpaText, item.upi === vpa && { color: '#fff' }]}>{vpa}</Text>
{item.upi === vpa && <Text style={{ color: '#fff', fontSize: 12 }}></Text>}
</TouchableOpacity>
))
)}
</View>
)}
</TouchableOpacity> </TouchableOpacity>
); );
}; };
renderVpaModal() {
const { vpaModalWallet, vpaModalVpas, vpaModalLoading, vpaModalSelected } = this.state;
const color = WALLET_TYPE_COLORS[vpaModalWallet?.walletType ?? ''] ?? '#3498db';
return (
<Modal visible={!!vpaModalWallet} transparent animationType="none" onRequestClose={this.closeVpaModal}>
<View style={s.modalOverlay}>
<Animatable.View
animation="zoomIn"
duration={220}
easing="ease-out-back"
useNativeDriver
style={s.vpaModalBox}
>
<Text style={s.vpaModalTitle}> VPA</Text>
<Text style={s.vpaModalSub}>{vpaModalWallet?.phone}</Text>
<ScrollView style={s.vpaModalList} bounces={false}>
{vpaModalLoading
? <ActivityIndicator size="large" color={color} style={{ marginVertical: 28 }} />
: vpaModalVpas.length === 0
? <Text style={{ color: '#aaa', textAlign: 'center', marginVertical: 28 }}> VPA </Text>
: vpaModalVpas.map(vpa => {
const selected = vpa === vpaModalSelected;
return (
<TouchableOpacity
key={vpa}
style={[s.vpaOptionRow, selected && { borderColor: color, backgroundColor: color + '10' }]}
onPress={() => this.setState({ vpaModalSelected: vpa })}
activeOpacity={0.7}
>
<View style={[s.radioOuter, selected && { borderColor: color }]}>
{selected && <View style={[s.radioInner, { backgroundColor: color }]} />}
</View>
<Text style={[s.vpaOptionText, selected && { color, fontWeight: '600' }]}>{vpa}</Text>
</TouchableOpacity>
);
})
}
</ScrollView>
<View style={s.vpaModalFooter}>
<TouchableOpacity style={s.vpaModalCancelBtn} onPress={this.closeVpaModal}>
<Text style={{ color: '#666', fontSize: 15 }}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.vpaModalConfirmBtn, { backgroundColor: vpaModalSelected ? color : '#ccc' }]}
onPress={this.confirmVpa}
disabled={!vpaModalSelected}
>
<Text style={{ color: '#fff', fontSize: 15, fontWeight: '600' }}></Text>
</TouchableOpacity>
</View>
</Animatable.View>
</View>
</Modal>
);
}
render() { render() {
const { proxyStatus, proxyError, wallets, loadingWallets } = this.state; const { proxyStatus, proxyError, wallets, loadingWallets } = this.state;
const proxyCfg: Record<string, { label: string; color: string }> = { const proxyCfg: Record<string, { label: string; color: string }> = {
@@ -579,6 +628,7 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
{this.renderBindModal()} {this.renderBindModal()}
{this.renderServerSettingsModal()} {this.renderServerSettingsModal()}
{this.renderAddWalletModal()} {this.renderAddWalletModal()}
{this.renderVpaModal()}
</View> </View>
); );
} }
@@ -622,15 +672,37 @@ const s = StyleSheet.create({
walletPhone: { fontSize: 15, fontWeight: '600', color: '#222' }, walletPhone: { fontSize: 15, fontWeight: '600', color: '#222' },
walletUpi: { fontSize: 12, color: '#888', marginTop: 2 }, walletUpi: { fontSize: 12, color: '#888', marginTop: 2 },
statusDot: { width: 8, height: 8, borderRadius: 4 }, statusDot: { width: 8, height: 8, borderRadius: 4 },
vpaSection: { marginTop: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#f0f0f0' }, vpaModalBox: {
vpaSectionTitle: { fontSize: 12, color: '#999', marginBottom: 6, fontWeight: '600' }, backgroundColor: '#fff', borderRadius: 20, width: '88%',
vpaRow: { maxHeight: '72%', overflow: 'hidden',
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 20, shadowOffset: { width: 0, height: 8 },
paddingVertical: 8, paddingHorizontal: 10, borderRadius: 6, elevation: 10,
backgroundColor: '#f5f5f5', marginBottom: 4, },
vpaModalTitle: { fontSize: 17, fontWeight: '700', color: '#222', paddingHorizontal: 20, paddingTop: 22 },
vpaModalSub: { fontSize: 13, color: '#999', paddingHorizontal: 20, marginTop: 3, marginBottom: 14 },
vpaModalList: { paddingHorizontal: 14, maxHeight: 260 },
vpaOptionRow: {
flexDirection: 'row', alignItems: 'center',
paddingVertical: 12, paddingHorizontal: 12,
borderRadius: 8, borderWidth: 1.5, borderColor: '#eee',
marginBottom: 8,
},
radioOuter: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2,
borderColor: '#ccc', alignItems: 'center', justifyContent: 'center', marginRight: 12,
},
radioInner: { width: 10, height: 10, borderRadius: 5 },
vpaOptionText: { fontSize: 14, color: '#333', flex: 1 },
vpaModalFooter: {
flexDirection: 'row', borderTopWidth: 1, borderTopColor: '#f0f0f0', marginTop: 8,
},
vpaModalCancelBtn: {
flex: 1, paddingVertical: 16, alignItems: 'center',
borderRightWidth: 1, borderRightColor: '#f0f0f0',
},
vpaModalConfirmBtn: {
flex: 1, paddingVertical: 16, alignItems: 'center', borderRadius: 0,
}, },
vpaRowActive: { backgroundColor: '#3498db' },
vpaText: { fontSize: 13, color: '#333' },
modalOverlay: { modalOverlay: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center', alignItems: 'center', justifyContent: 'center', alignItems: 'center',