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

@@ -4,6 +4,7 @@ import {
ScrollView, StyleSheet, Text, TextInput,
TouchableOpacity, View, ActivityIndicator,
} from "react-native";
import * as Animatable from 'react-native-animatable';
import DeviceInfo from 'react-native-device-info';
import {
PhonePeBusinessBind,
@@ -98,9 +99,11 @@ interface HomeScreenState {
// wallet list
wallets: WalletItem[];
loadingWallets: boolean;
expandedWalletId: string | null;
walletVpas: Record<string, string[]>;
loadingVpas: Record<string, boolean>;
// vpa modal
vpaModalWallet: WalletItem | null;
vpaModalVpas: string[];
vpaModalLoading: boolean;
vpaModalSelected: string;
// add wallet
showAddWallet: boolean;
}
@@ -130,9 +133,10 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
settingsPort: '',
wallets: [],
loadingWallets: false,
expandedWalletId: null,
walletVpas: {},
loadingVpas: {},
vpaModalWallet: null,
vpaModalVpas: [],
vpaModalLoading: false,
vpaModalSelected: '',
showAddWallet: false,
};
this.deviceId = DeviceInfo.getUniqueIdSync();
@@ -217,28 +221,36 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
}
};
handleToggleExpand = async (walletId: string) => {
const { expandedWalletId, walletVpas } = this.state;
if (expandedWalletId === walletId) {
this.setState({ expandedWalletId: null });
return;
}
this.setState({ expandedWalletId: walletId });
if (!walletVpas[walletId]) {
this.setState(s => ({ loadingVpas: { ...s.loadingVpas, [walletId]: true } }));
try {
const vpas = await Api.instance.getWalletVpas(walletId);
this.setState(s => ({ walletVpas: { ...s.walletVpas, [walletId]: vpas } }));
} catch {}
this.setState(s => ({ loadingVpas: { ...s.loadingVpas, [walletId]: false } }));
openVpaModal = async (item: WalletItem) => {
this.setState({
vpaModalWallet: item,
vpaModalVpas: [],
vpaModalLoading: true,
vpaModalSelected: item.upi ?? '',
});
try {
const vpas = await Api.instance.getWalletVpas(item.id);
this.setState({ vpaModalVpas: vpas, vpaModalLoading: false });
} catch {
this.setState({ vpaModalLoading: 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 {
const vpa = await Api.instance.setCurrentVpa(walletId, vpaIndex);
Alert.alert('已设置', `当前 VPA: ${vpa}`);
this.fetchWallets();
await Api.instance.setCurrentVpa(vpaModalWallet.id, idx);
const walletId = vpaModalWallet.id;
const vpa = vpaModalSelected;
this.setState(s => ({
vpaModalWallet: null,
wallets: s.wallets.map(w => w.id === walletId ? { ...w, upi: vpa } : w),
}));
} catch (e) {
Alert.alert('设置失败', (e as Error).message);
}
@@ -398,18 +410,24 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
renderAddWalletModal() {
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.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 }}>
<Text style={s.addModalTitle}></Text>
<TouchableOpacity onPress={() => this.setState({ showAddWallet: false })}>
<Text style={{ fontSize: 20, color: '#999' }}></Text>
</TouchableOpacity>
</View>
<ScrollView>
<ScrollView bounces={false}>
{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]
? <Image source={WALLET_ICONS[opt.walletType]} style={s.walletTypeIcon} resizeMode="contain" />
: <View style={[s.walletTypeDot, { backgroundColor: WALLET_TYPE_COLORS[opt.walletType] ?? '#888' }]} />
@@ -418,7 +436,7 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
</TouchableOpacity>
))}
</ScrollView>
</View>
</Animatable.View>
</View>
</Modal>
);
@@ -468,15 +486,10 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
}
renderWalletItem = ({ item }: { item: WalletItem }) => {
const { expandedWalletId, walletVpas, loadingVpas } = this.state;
const isExpanded = expandedWalletId === item.id;
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
const vpas = walletVpas[item.id] ?? [];
const loadingV = loadingVpas[item.id];
const isActive = item.status === 'ACTIVE';
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={[s.walletBadge, !isActive && s.walletBadgeInactive]}>
{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.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
<Text style={{ fontSize: 10, color: '#aaa', marginTop: 4 }}>{isExpanded ? '▲' : '▼'}</Text>
</View>
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
</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>
);
};
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() {
const { proxyStatus, proxyError, wallets, loadingWallets } = this.state;
const proxyCfg: Record<string, { label: string; color: string }> = {
@@ -579,6 +628,7 @@ export default class HomeScreen extends Component<any, HomeScreenState> {
{this.renderBindModal()}
{this.renderServerSettingsModal()}
{this.renderAddWalletModal()}
{this.renderVpaModal()}
</View>
);
}
@@ -622,15 +672,37 @@ const s = StyleSheet.create({
walletPhone: { fontSize: 15, fontWeight: '600', color: '#222' },
walletUpi: { fontSize: 12, color: '#888', marginTop: 2 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
vpaSection: { marginTop: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#f0f0f0' },
vpaSectionTitle: { fontSize: 12, color: '#999', marginBottom: 6, fontWeight: '600' },
vpaRow: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingVertical: 8, paddingHorizontal: 10, borderRadius: 6,
backgroundColor: '#f5f5f5', marginBottom: 4,
vpaModalBox: {
backgroundColor: '#fff', borderRadius: 20, width: '88%',
maxHeight: '72%', overflow: 'hidden',
shadowColor: '#000', shadowOpacity: 0.18, shadowRadius: 20, shadowOffset: { width: 0, height: 8 },
elevation: 10,
},
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: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center', alignItems: 'center',