757 lines
40 KiB
TypeScript
757 lines
40 KiB
TypeScript
import React, { Component } from "react";
|
||
import {
|
||
Alert, AppState, AppStateStatus, FlatList, Image, Modal,
|
||
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,
|
||
GooglePayBusinessBind,
|
||
WalletType,
|
||
PaytmBusinessBindResult,
|
||
PhonePeBusinessBindResult,
|
||
PaytmPersonalBind,
|
||
PaytmPersonalBindResult,
|
||
MobikwikPersonalBindResult,
|
||
FreechargePersonalBindResult,
|
||
GooglePayBusinessBindResult,
|
||
BharatPeBusinessBindResult,
|
||
onSmsMessage,
|
||
startSmsListener,
|
||
stopSmsListener,
|
||
checkSmsPermission,
|
||
requestSmsPermission,
|
||
PhonePePersonalBindResult,
|
||
PhonePePersonalBind,
|
||
SmsMessage,
|
||
proxyBackgroundService,
|
||
} from "rnwalletman";
|
||
|
||
import {
|
||
FreeChargeBind,
|
||
MobikwikOTPBind,
|
||
PayTmPersonalOTPBind,
|
||
PhonePePersonalOTPBind,
|
||
BharatPeBusinessOTPBind,
|
||
PaytmBusinessOTPBind,
|
||
} from '../components/WalletBindComponents';
|
||
|
||
import Api, { WalletItem, loadServerDomain, saveServerDomain, getServerDomain } from '../services/api';
|
||
|
||
// key 与服务端 WalletType 字符串一致(见 types.go)
|
||
const WALLET_ICONS: Record<string, any> = {
|
||
'paytm': require('../res/paytm.png'),
|
||
'paytm business': require('../res/paytm-business.png'),
|
||
'phonepe': require('../res/phonepe.webp'),
|
||
'phonepe business': require('../res/phonepe-business.webp'),
|
||
'googlepay business': require('../res/googlepay-business.webp'),
|
||
'bharatpe business': require('../res/bharatpe-business.webp'),
|
||
'mobikwik': require('../res/mobikwik.png'),
|
||
'freecharge': require('../res/freecharge.png'),
|
||
};
|
||
|
||
const WALLET_TYPE_COLORS: Record<string, string> = {
|
||
'paytm': '#002970',
|
||
'paytm business': '#002970',
|
||
'phonepe': '#5a2d9c',
|
||
'phonepe business': '#5a2d9c',
|
||
'googlepay business': '#4285f4',
|
||
'bharatpe business': '#e91e63',
|
||
'mobikwik': '#00bcd4',
|
||
'freecharge': '#ff5722',
|
||
};
|
||
|
||
// 钱包类型展示信息(walletType 与服务端一致)
|
||
const WALLET_TYPE_OPTIONS = [
|
||
{ key: 'paytm_personal_otp', walletType: 'paytm', label: 'Paytm Personal (OTP)', mode: 'otp' },
|
||
{ key: 'paytm_personal_token', walletType: 'paytm', label: 'Paytm Personal (Token)', mode: 'token' },
|
||
{ key: 'paytm_business', walletType: 'paytm business', label: 'Paytm Business (OTP)', mode: 'otp' },
|
||
{ key: 'phonepe_personal_otp', walletType: 'phonepe', label: 'PhonePe Personal (OTP)', mode: 'otp' },
|
||
{ key: 'phonepe_personal_token', walletType: 'phonepe', label: 'PhonePe Personal (Token)', mode: 'token' },
|
||
{ key: 'phonepe_business', walletType: 'phonepe business', label: 'PhonePe Business (OTP-WEB)', mode: 'otp' },
|
||
{ key: 'googlepay_business', walletType: 'googlepay business', label: 'GooglePay Business', mode: 'token' },
|
||
{ key: 'bharatpe_business', walletType: 'bharatpe business', label: 'BharatPe Business (OTP)', mode: 'otp' },
|
||
{ key: 'mobikwik_personal', walletType: 'mobikwik', label: 'Mobikwik Personal (OTP)', mode: 'otp' },
|
||
{ key: 'freecharge_personal', walletType: 'freecharge', label: 'Freecharge Personal (OTP)', mode: 'otp' },
|
||
];
|
||
|
||
interface HomeScreenState {
|
||
// bind modals
|
||
showPaytmPersonalBind: boolean;
|
||
paytmPersonalBindType: 'otpMode' | 'tokenMode';
|
||
showPaytmBusinessBind: boolean;
|
||
showPhonePePersonalBind: boolean;
|
||
phonePePersonalBindType: 'otpMode' | 'tokenMode';
|
||
showPhonePeBusinessBind: boolean;
|
||
showGooglePayBusinessBind: boolean;
|
||
showBharatPeBusinessBind: boolean;
|
||
showMobikwikPersonalBind: boolean;
|
||
showFreechargePersonalBind: boolean;
|
||
// proxy
|
||
proxyStatus: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
||
proxyError?: string;
|
||
// server settings
|
||
showServerSettings: boolean;
|
||
settingsHost: string;
|
||
settingsPort: string;
|
||
// wallet list
|
||
wallets: WalletItem[];
|
||
loadingWallets: boolean;
|
||
// vpa modal
|
||
vpaModalWallet: WalletItem | null;
|
||
vpaModalVpas: string[];
|
||
vpaModalLoading: boolean;
|
||
vpaModalSelected: string;
|
||
// add wallet
|
||
showAddWallet: boolean;
|
||
}
|
||
|
||
export default class HomeScreen extends Component<any, HomeScreenState> {
|
||
private deviceId: string;
|
||
private tuneUserId: string;
|
||
private clientId: string = '';
|
||
private appStateSubscription?: any;
|
||
|
||
constructor(props: any) {
|
||
super(props);
|
||
this.state = {
|
||
paytmPersonalBindType: 'otpMode',
|
||
showPaytmPersonalBind: false,
|
||
showPaytmBusinessBind: false,
|
||
showPhonePePersonalBind: false,
|
||
phonePePersonalBindType: 'otpMode',
|
||
showPhonePeBusinessBind: false,
|
||
showGooglePayBusinessBind: false,
|
||
showBharatPeBusinessBind: false,
|
||
showMobikwikPersonalBind: false,
|
||
showFreechargePersonalBind: false,
|
||
proxyStatus: 'idle',
|
||
showServerSettings: false,
|
||
settingsHost: '',
|
||
settingsPort: '',
|
||
wallets: [],
|
||
loadingWallets: false,
|
||
vpaModalWallet: null,
|
||
vpaModalVpas: [],
|
||
vpaModalLoading: false,
|
||
vpaModalSelected: '',
|
||
showAddWallet: false,
|
||
};
|
||
this.deviceId = DeviceInfo.getUniqueIdSync();
|
||
this.tuneUserId = Math.random().toString(36).substring(2, 15);
|
||
}
|
||
|
||
async componentDidMount() {
|
||
await loadServerDomain();
|
||
await this.setupPermissions();
|
||
|
||
const doLogin = () => {
|
||
Api.instance.login('test123', '123456')
|
||
.then(async () => {
|
||
await this.startProxyClient();
|
||
this.fetchWallets();
|
||
})
|
||
.catch((error) => {
|
||
console.log('[Login] retry in 3s:', error);
|
||
setTimeout(doLogin, 3000);
|
||
});
|
||
};
|
||
doLogin();
|
||
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
|
||
}
|
||
|
||
componentWillUnmount() {
|
||
this.stopProxyClient();
|
||
stopSmsListener();
|
||
this.appStateSubscription?.remove();
|
||
}
|
||
|
||
handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||
if (nextAppState === 'active') {
|
||
this.fetchWallets();
|
||
}
|
||
}
|
||
|
||
async setupPermissions() {
|
||
const hasSms = await checkSmsPermission();
|
||
if (!hasSms) await requestSmsPermission();
|
||
startSmsListener();
|
||
onSmsMessage((msg: SmsMessage) => {
|
||
console.log('[SMS]', msg.address, msg.body);
|
||
});
|
||
}
|
||
|
||
async startProxyClient() {
|
||
try {
|
||
this.clientId = DeviceInfo.getUniqueIdSync();
|
||
const userId = Api.instance.getUserId();
|
||
this.setState({ proxyStatus: 'connecting' });
|
||
await proxyBackgroundService.start({
|
||
wsUrl: Api.WS_URL,
|
||
clientId: this.clientId || '',
|
||
userId,
|
||
debug: true,
|
||
heartbeatInterval: 10000,
|
||
reconnectInterval: 5000,
|
||
reconnectMaxAttempts: Infinity,
|
||
onConnected: () => this.setState({ proxyStatus: 'connected' }),
|
||
onDisconnected: () => this.setState({ proxyStatus: 'disconnected' }),
|
||
onError: (error: string) => this.setState({ proxyStatus: 'error', proxyError: error }),
|
||
});
|
||
} catch (error) {
|
||
console.error('[Proxy] 初始化失败:', error);
|
||
}
|
||
}
|
||
|
||
stopProxyClient() {
|
||
try { proxyBackgroundService.stop(); } catch {}
|
||
}
|
||
|
||
fetchWallets = async () => {
|
||
this.setState({ loadingWallets: true });
|
||
try {
|
||
const wallets = await Api.instance.listWallets();
|
||
this.setState({ wallets });
|
||
} catch (e) {
|
||
console.log('[fetchWallets]', e);
|
||
} finally {
|
||
this.setState({ loadingWallets: 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 });
|
||
}
|
||
};
|
||
|
||
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 {
|
||
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);
|
||
}
|
||
};
|
||
|
||
// ---- bind handlers ----
|
||
|
||
handleBindSuccess = (key: keyof HomeScreenState, msg: string) => async (result: any) => {
|
||
try {
|
||
const typeMap: Record<string, string> = {
|
||
showPaytmPersonalBind: WalletType.PAYTM_PERSONAL,
|
||
showPaytmBusinessBind: WalletType.PAYTM_BUSINESS,
|
||
showPhonePePersonalBind: WalletType.PHONEPE_PERSONAL,
|
||
showPhonePeBusinessBind: WalletType.PHONEPE_BUSINESS,
|
||
showGooglePayBusinessBind: WalletType.GOOGLEPAY_BUSINESS,
|
||
showBharatPeBusinessBind: WalletType.BHARATPE_BUSINESS,
|
||
showMobikwikPersonalBind: WalletType.MOBIKWIK_PERSONAL,
|
||
showFreechargePersonalBind: WalletType.FREECHARGE_PERSONAL,
|
||
};
|
||
const wt = typeMap[key as string];
|
||
if (wt) await Api.instance.register(wt as WalletType, result);
|
||
this.setState({ [key]: false } as any);
|
||
Alert.alert('绑定成功', msg);
|
||
this.fetchWallets();
|
||
} catch (error) {
|
||
this.setState({ [key]: false } as any);
|
||
Alert.alert('绑定失败', (error as Error).message);
|
||
}
|
||
};
|
||
|
||
// ---- modals ----
|
||
|
||
renderBindModal = () => {
|
||
const { showPaytmPersonalBind, paytmPersonalBindType, showPhonePePersonalBind,
|
||
phonePePersonalBindType, showPaytmBusinessBind, showPhonePeBusinessBind,
|
||
showGooglePayBusinessBind, showBharatPeBusinessBind, showMobikwikPersonalBind,
|
||
showFreechargePersonalBind } = this.state;
|
||
|
||
const close = (key: keyof HomeScreenState) => () => this.setState({ [key]: false } as any);
|
||
|
||
if (showPaytmPersonalBind && paytmPersonalBindType === 'tokenMode') return (
|
||
<Modal visible transparent onRequestClose={close('showPaytmPersonalBind')}>
|
||
<PaytmPersonalBind processString="Processing..." isDebug onSuccess={this.handleBindSuccess('showPaytmPersonalBind', 'Paytm Personal 绑定成功') as any} onError={(e: string) => { Alert.alert('绑定失败', e); close('showPaytmPersonalBind')(); }} />
|
||
</Modal>
|
||
);
|
||
if (showPaytmPersonalBind) return (
|
||
<Modal visible transparent onRequestClose={close('showPaytmPersonalBind')}>
|
||
<PayTmPersonalOTPBind isDebug
|
||
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, {}); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onSuccess={(r: PaytmPersonalBindResult) => { Alert.alert('绑定成功', 'Paytm Personal OTP'); this.setState({ showPaytmPersonalBind: false }); this.fetchWallets(); }}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPaytmPersonalBind')(); }}
|
||
/>
|
||
</Modal>
|
||
);
|
||
if (showPhonePePersonalBind && phonePePersonalBindType === 'tokenMode') return (
|
||
<Modal visible transparent onRequestClose={close('showPhonePePersonalBind')}>
|
||
<PhonePePersonalBind processString="Processing..." isDebug onSuccess={this.handleBindSuccess('showPhonePePersonalBind', 'PhonePe Personal 绑定成功') as any} onError={(e: string) => { Alert.alert('绑定失败', e); close('showPhonePePersonalBind')(); }} />
|
||
</Modal>
|
||
);
|
||
if (showPhonePePersonalBind) return (
|
||
<Modal visible transparent onRequestClose={close('showPhonePePersonalBind')}>
|
||
<PhonePePersonalOTPBind isDebug
|
||
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, {}); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onSuccess={(r: PhonePePersonalBindResult) => { Alert.alert('绑定成功', 'PhonePe Personal OTP'); this.setState({ showPhonePePersonalBind: false }); this.fetchWallets(); }}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPhonePePersonalBind')(); }}
|
||
/>
|
||
</Modal>
|
||
);
|
||
if (showPaytmBusinessBind) return (
|
||
<Modal visible transparent onRequestClose={close('showPaytmBusinessBind')}>
|
||
<PaytmBusinessOTPBind isDebug
|
||
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, { password: p.password }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onSuccess={this.handleBindSuccess('showPaytmBusinessBind', 'Paytm Business 绑定成功') as any}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPaytmBusinessBind')(); }}
|
||
/>
|
||
</Modal>
|
||
);
|
||
if (showPhonePeBusinessBind) return (
|
||
<Modal visible transparent onRequestClose={close('showPhonePeBusinessBind')}>
|
||
<PhonePeBusinessBind processString="Processing..." isDebug
|
||
onSuccess={this.handleBindSuccess('showPhonePeBusinessBind', 'PhonePe Business 绑定成功') as any}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showPhonePeBusinessBind')(); }}
|
||
onRenderBottomView={({ showOtpInput, loading, formError, phone, otp, onPhoneChange, onOtpChange, onGetOtp, onSubmitOtp }) => (
|
||
<View style={s.otpBar}>
|
||
{!showOtpInput ? (<>
|
||
<TextInput style={s.otpInput} placeholder="Mobile Number" placeholderTextColor="#999" keyboardType="phone-pad" value={phone} onChangeText={onPhoneChange} editable={!loading} />
|
||
{!!formError && <Text style={s.errText}>{formError}</Text>}
|
||
<TouchableOpacity style={[s.otpBtn, { opacity: loading ? 0.5 : 1 }]} onPress={onGetOtp} disabled={loading}><Text style={s.otpBtnText}>{loading ? 'Loading...' : 'GET OTP'}</Text></TouchableOpacity>
|
||
</>) : (<>
|
||
<TextInput style={s.otpInput} placeholder="OTP" placeholderTextColor="#999" keyboardType="number-pad" value={otp} onChangeText={onOtpChange} editable={!loading} />
|
||
{!!formError && <Text style={s.errText}>{formError}</Text>}
|
||
<TouchableOpacity style={[s.otpBtn, { opacity: loading ? 0.5 : 1 }]} onPress={onSubmitOtp} disabled={loading}><Text style={s.otpBtnText}>{loading ? 'Loading...' : 'Verify OTP'}</Text></TouchableOpacity>
|
||
</>)}
|
||
</View>
|
||
)}
|
||
/>
|
||
</Modal>
|
||
);
|
||
if (showGooglePayBusinessBind) return (
|
||
<Modal visible transparent onRequestClose={close('showGooglePayBusinessBind')}>
|
||
<GooglePayBusinessBind processString="Processing..." isDebug onSuccess={this.handleBindSuccess('showGooglePayBusinessBind', 'GooglePay Business 绑定成功') as any} onError={(e: string) => { Alert.alert('绑定失败', e); close('showGooglePayBusinessBind')(); }} />
|
||
</Modal>
|
||
);
|
||
if (showBharatPeBusinessBind) return (
|
||
<Modal visible transparent onRequestClose={close('showBharatPeBusinessBind')}>
|
||
<BharatPeBusinessOTPBind isDebug
|
||
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onSuccess={this.handleBindSuccess('showBharatPeBusinessBind', 'BharatPe Business 绑定成功') as any}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showBharatPeBusinessBind')(); }}
|
||
/>
|
||
</Modal>
|
||
);
|
||
if (showMobikwikPersonalBind) return (
|
||
<Modal visible transparent onRequestClose={close('showMobikwikPersonalBind')}>
|
||
<MobikwikOTPBind isDebug deviceId={this.deviceId} tuneUserId={this.tuneUserId}
|
||
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile, { deviceId: p.deviceId, tuneUserId: p.tuneUserId }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { sessionId: p.sessionId, deviceId: p.deviceId, tuneUserId: p.tuneUserId, nid: p.nid }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onSuccess={this.handleBindSuccess('showMobikwikPersonalBind', 'Mobikwik 绑定成功') as any}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showMobikwikPersonalBind')(); }}
|
||
/>
|
||
</Modal>
|
||
);
|
||
if (showFreechargePersonalBind) return (
|
||
<Modal visible transparent onRequestClose={close('showFreechargePersonalBind')}>
|
||
<FreeChargeBind isDebug
|
||
onRequestOTP={async (wt, p) => { try { return await Api.instance.requestOTP(wt, p.mobile); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onVerifyOTP={async (wt, p) => { try { return await Api.instance.verifyOTP(wt, p.mobile, p.otp, { otpId: p.otpId, deviceId: p.deviceId, csrfId: p.csrfId, appFc: p.appFc }); } catch (e) { return { success: false, message: (e as Error).message }; } }}
|
||
onSuccess={this.handleBindSuccess('showFreechargePersonalBind', 'Freecharge 绑定成功') as any}
|
||
onError={(e: string) => { Alert.alert('绑定失败', e); close('showFreechargePersonalBind')(); }}
|
||
/>
|
||
</Modal>
|
||
);
|
||
return null;
|
||
};
|
||
|
||
openWalletBind = (key: string) => {
|
||
this.setState({ showAddWallet: false });
|
||
setTimeout(() => {
|
||
switch (key) {
|
||
case 'paytm_personal_otp': this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'otpMode' }); break;
|
||
case 'paytm_personal_token': this.setState({ showPaytmPersonalBind: true, paytmPersonalBindType: 'tokenMode' }); break;
|
||
case 'paytm_business': this.setState({ showPaytmBusinessBind: true }); break;
|
||
case 'phonepe_personal_otp': this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'otpMode' }); break;
|
||
case 'phonepe_personal_token': this.setState({ showPhonePePersonalBind: true, phonePePersonalBindType: 'tokenMode' }); break;
|
||
case 'phonepe_business': this.setState({ showPhonePeBusinessBind: true }); break;
|
||
case 'googlepay_business': this.setState({ showGooglePayBusinessBind: true }); break;
|
||
case 'bharatpe_business': this.setState({ showBharatPeBusinessBind: true }); break;
|
||
case 'mobikwik_personal': this.setState({ showMobikwikPersonalBind: true }); break;
|
||
case 'freecharge_personal': this.setState({ showFreechargePersonalBind: true }); break;
|
||
}
|
||
}, 300);
|
||
};
|
||
|
||
renderAddWalletModal() {
|
||
return (
|
||
<Modal visible={this.state.showAddWallet} transparent animationType="none" onRequestClose={() => this.setState({ showAddWallet: false })}>
|
||
<View style={s.modalOverlay}>
|
||
<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 bounces={false}>
|
||
{WALLET_TYPE_OPTIONS.map(opt => (
|
||
<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' }]} />
|
||
}
|
||
<Text style={s.walletTypeLabel}>{opt.label}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</ScrollView>
|
||
</Animatable.View>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
renderServerSettingsModal() {
|
||
const { showServerSettings, settingsHost, settingsPort } = this.state;
|
||
const presets = [
|
||
{ label: 'aa.pfgame.org', host: 'aa.pfgame.org', port: '443' },
|
||
{ label: '192.168.1.117:16000', host: '192.168.1.117', port: '16000' },
|
||
];
|
||
return (
|
||
<Modal visible={showServerSettings} transparent animationType="fade">
|
||
<View style={s.modalOverlay}>
|
||
<View style={s.settingsBox}>
|
||
<Text style={s.settingsTitle}>服务器设置</Text>
|
||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 14 }}>
|
||
{presets.map(p => (
|
||
<TouchableOpacity key={p.label} onPress={() => this.setState({ settingsHost: p.host, settingsPort: p.port })}
|
||
style={[s.presetBtn, settingsHost === p.host && settingsPort === p.port && s.presetBtnActive]}>
|
||
<Text style={{ fontSize: 12, color: settingsHost === p.host && settingsPort === p.port ? '#fff' : '#333' }}>{p.label}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
<Text style={s.inputLabel}>Host</Text>
|
||
<TextInput style={s.textInput} value={settingsHost} onChangeText={t => this.setState({ settingsHost: t })} placeholder="192.168.1.198" autoCapitalize="none" />
|
||
<Text style={s.inputLabel}>Port</Text>
|
||
<TextInput style={[s.textInput, { marginBottom: 20 }]} value={settingsPort} onChangeText={t => this.setState({ settingsPort: t })} placeholder="16000" keyboardType="number-pad" />
|
||
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
|
||
<TouchableOpacity onPress={() => this.setState({ showServerSettings: false })} style={{ paddingHorizontal: 16, paddingVertical: 8, marginRight: 10 }}>
|
||
<Text style={{ color: '#666' }}>取消</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={async () => {
|
||
const { settingsHost, settingsPort } = this.state;
|
||
const domain = settingsPort ? `${settingsHost}:${settingsPort}` : settingsHost;
|
||
await saveServerDomain(domain, settingsPort === '443');
|
||
this.setState({ showServerSettings: false });
|
||
Alert.alert('已保存', '重启 App 后生效');
|
||
}} style={s.saveBtn}>
|
||
<Text style={{ color: '#fff', fontWeight: 'bold' }}>保存</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
renderWalletItem = ({ item }: { item: WalletItem }) => {
|
||
const color = WALLET_TYPE_COLORS[item.walletType] ?? '#888';
|
||
const isActive = item.status === 'ACTIVE';
|
||
return (
|
||
<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]
|
||
? <Image source={WALLET_ICONS[item.walletType]} style={[s.walletIcon, !isActive && s.walletIconInactive]} resizeMode="contain" />
|
||
: <View style={[s.walletIconFallback, { backgroundColor: isActive ? color : '#ccc' }]}>
|
||
<Text style={s.walletBadgeText}>{item.walletType.split('_')[0]}</Text>
|
||
</View>
|
||
}
|
||
</View>
|
||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||
<Text style={s.walletPhone}>{item.phone || '—'}</Text>
|
||
<Text style={s.walletUpi} numberOfLines={1}>{item.upi || 'No UPI'}</Text>
|
||
</View>
|
||
<View style={[s.statusDot, { backgroundColor: isActive ? '#2ecc71' : '#bbb' }]} />
|
||
</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 }> = {
|
||
idle: { label: '未连接', color: '#95a5a6' },
|
||
connecting: { label: '连接中…', color: '#f39c12' },
|
||
connected: { label: '已连接', color: '#2ecc71' },
|
||
disconnected: { label: '已断开', color: '#e74c3c' },
|
||
error: { label: '连接失败', color: '#e74c3c' },
|
||
};
|
||
const { label, color } = proxyCfg[proxyStatus];
|
||
|
||
return (
|
||
<View style={s.container}>
|
||
{/* 顶栏 */}
|
||
<View style={s.topBar}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color, marginRight: 6 }} />
|
||
<Text style={{ color, fontSize: 13 }}>
|
||
Proxy {label}{proxyStatus === 'error' && proxyError ? `:${proxyError}` : ''}
|
||
</Text>
|
||
</View>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
const domain = getServerDomain();
|
||
const colonIdx = domain.lastIndexOf(':');
|
||
const host = colonIdx > 0 ? domain.substring(0, colonIdx) : domain;
|
||
const port = colonIdx > 0 ? domain.substring(colonIdx + 1) : '';
|
||
this.setState({ showServerSettings: true, settingsHost: host, settingsPort: port });
|
||
}}
|
||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||
style={{ padding: 6 }}
|
||
>
|
||
<Text style={{ fontSize: 20, color: '#3498db' }}>⚙</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={() => this.setState({ showAddWallet: true })} style={s.addBtn}>
|
||
<Text style={s.addBtnText}>+ Add</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 钱包列表 */}
|
||
<View style={s.listHeader}>
|
||
<Text style={s.listHeaderText}>已绑定钱包</Text>
|
||
<TouchableOpacity onPress={this.fetchWallets} disabled={loadingWallets}>
|
||
<Text style={{ fontSize: 13, color: '#3498db' }}>{loadingWallets ? '刷新中…' : '刷新'}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<FlatList
|
||
style={{ flex: 1, width: '100%' }}
|
||
contentContainerStyle={{ paddingHorizontal: 14, paddingBottom: 20 }}
|
||
data={wallets}
|
||
keyExtractor={item => item.id}
|
||
renderItem={this.renderWalletItem}
|
||
ListEmptyComponent={
|
||
<View style={{ alignItems: 'center', marginTop: 60 }}>
|
||
<Text style={{ color: '#aaa', fontSize: 14 }}>{loadingWallets ? '加载中…' : '暂无绑定钱包,点右上角 + Add 添加'}</Text>
|
||
</View>
|
||
}
|
||
/>
|
||
|
||
{this.renderBindModal()}
|
||
{this.renderServerSettingsModal()}
|
||
{this.renderAddWalletModal()}
|
||
{this.renderVpaModal()}
|
||
</View>
|
||
);
|
||
}
|
||
}
|
||
|
||
const s = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: '#f0f0f0' },
|
||
topBar: {
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||
paddingHorizontal: 14, paddingVertical: 8, backgroundColor: '#fff',
|
||
borderBottomWidth: 1, borderBottomColor: '#e0e0e0',
|
||
},
|
||
addBtn: {
|
||
backgroundColor: '#3498db', borderRadius: 6,
|
||
paddingHorizontal: 10, paddingVertical: 4,
|
||
},
|
||
addBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||
listHeader: {
|
||
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||
paddingHorizontal: 14, paddingVertical: 10,
|
||
},
|
||
listHeaderText: { fontSize: 14, fontWeight: '600', color: '#333' },
|
||
walletCard: {
|
||
backgroundColor: '#fff', borderRadius: 10, padding: 14,
|
||
marginBottom: 10, elevation: 1,
|
||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 4, shadowOffset: { width: 0, height: 2 },
|
||
},
|
||
walletBadge: {
|
||
width: 52, height: 52, borderRadius: 10, overflow: 'hidden',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
backgroundColor: '#f5f5f5',
|
||
},
|
||
walletIcon: { width: 52, height: 52, borderRadius: 10 },
|
||
walletIconInactive: { opacity: 0.3 },
|
||
walletBadgeInactive: { backgroundColor: '#f0f0f0' },
|
||
walletIconFallback: {
|
||
width: 52, height: 52, borderRadius: 10,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
},
|
||
walletBadgeText: { color: '#fff', fontSize: 9, fontWeight: '700', textAlign: 'center' },
|
||
walletPhone: { fontSize: 15, fontWeight: '600', color: '#222' },
|
||
walletUpi: { fontSize: 12, color: '#888', marginTop: 2 },
|
||
statusDot: { width: 8, height: 8, borderRadius: 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,
|
||
},
|
||
modalOverlay: {
|
||
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
|
||
justifyContent: 'center', alignItems: 'center',
|
||
},
|
||
addModalBox: {
|
||
backgroundColor: '#fff', borderRadius: 12, padding: 20,
|
||
width: '88%', maxHeight: '70%',
|
||
},
|
||
addModalTitle: { fontSize: 16, fontWeight: '700', color: '#222' },
|
||
walletTypeRow: {
|
||
flexDirection: 'row', alignItems: 'center',
|
||
paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#f0f0f0',
|
||
},
|
||
walletTypeIcon: { width: 32, height: 32, borderRadius: 6, marginRight: 12 },
|
||
walletTypeDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
|
||
walletTypeLabel: { fontSize: 14, color: '#333' },
|
||
settingsBox: {
|
||
backgroundColor: '#fff', borderRadius: 10, padding: 20, width: '85%',
|
||
},
|
||
settingsTitle: { fontSize: 16, fontWeight: 'bold', marginBottom: 12 },
|
||
presetBtn: {
|
||
paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: '#f0f0f0',
|
||
},
|
||
presetBtnActive: { backgroundColor: '#3498db' },
|
||
inputLabel: { fontSize: 13, color: '#666', marginBottom: 4 },
|
||
textInput: {
|
||
borderWidth: 1, borderColor: '#ddd', borderRadius: 6,
|
||
paddingHorizontal: 10, paddingVertical: 8, marginBottom: 12, fontSize: 14,
|
||
},
|
||
saveBtn: {
|
||
backgroundColor: '#3498db', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 6,
|
||
},
|
||
otpBar: {
|
||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||
backgroundColor: '#fff', padding: 16, borderTopWidth: 1,
|
||
borderTopColor: '#e0e0e0', gap: 10,
|
||
},
|
||
otpInput: {
|
||
borderWidth: 1, borderColor: '#ccc', borderRadius: 8,
|
||
paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, color: '#333',
|
||
},
|
||
otpBtn: {
|
||
backgroundColor: '#5a2d9c', borderRadius: 8, paddingVertical: 12, alignItems: 'center',
|
||
},
|
||
otpBtnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
||
errText: { color: '#e53935', fontSize: 13 },
|
||
});
|