TriloopTem_App/src/components/modals/ConnectModal.tsx
2026-06-20 21:43:09 +08:00

321 lines
10 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
View, Text, TextInput, TouchableOpacity, StyleSheet,
Modal, ScrollView, ActivityIndicator, KeyboardAvoidingView,
Platform, Linking,
} from 'react-native';
import { useConnectionStore } from '../../stores/connectionStore';
import { useDevice } from '../../hooks/useDevice';
import { useTheme } from '../../design/tokens';
import { Radius, Spacing } from '../../design/tokens';
const LOG_MAX = 20;
export function ConnectModal() {
const theme = useTheme();
const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } =
useConnectionStore();
const { connect, disconnect } = useDevice();
const [portStr, setPortStr] = useState(String(port));
const [logs, setLogs] = useState<string[]>(['就绪']);
const prevStatus = useRef(status);
const addLog = (msg: string) =>
setLogs((p) => [`${new Date().toLocaleTimeString('zh-CN', { hour12: false })} ${msg}`, ...p].slice(0, LOG_MAX));
// Sync portStr when store port changes (e.g. from persisted storage)
useEffect(() => { setPortStr(String(port)); }, [port]);
useEffect(() => {
if (status === prevStatus.current) return;
prevStatus.current = status;
if (status === 'connected') {
addLog('连接成功 ✓');
const t = setTimeout(() => hideModal(), 900);
return () => clearTimeout(t);
}
if (status === 'error') addLog(`错误: ${lastError}`);
if (status === 'reconnecting') addLog('断线,重连中…');
if (status === 'disconnected') addLog('已断开');
}, [status, lastError]);
const handleConnect = () => {
const trimmed = host.trim();
const p = parseInt(portStr, 10);
const ipRe = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!trimmed || !ipRe.test(trimmed) || trimmed.split('.').some(s => Number(s) > 255)) {
addLog('IP 地址格式无效'); return;
}
if (isNaN(p) || p < 1 || p > 65535) {
addLog('端口范围 1-65535'); return;
}
setPort(p);
addLog(`连接 ${trimmed}:${p}`);
connect();
};
const handleDisconnect = () => {
disconnect();
addLog('主动断开');
};
const connecting = status === 'connecting' || status === 'reconnecting';
const connected = status === 'connected';
return (
<Modal
visible={modalVisible}
transparent
animationType="slide"
onRequestClose={hideModal}
statusBarTranslucent
>
<TouchableOpacity style={S.backdrop} activeOpacity={1} onPress={hideModal} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={S.kav}
pointerEvents="box-none"
>
<View style={[S.sheet, {
backgroundColor: theme.bg.surface,
borderColor: theme.bg.border,
}]}>
{/* Handle */}
<View style={[S.handle, { backgroundColor: theme.bg.border }]} />
{/* Title */}
<Text style={[S.title, { color: theme.text.secondary }]}></Text>
{/* Guide */}
<View style={S.guideRow}>
<View style={[S.stepBadge, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}>
<Text style={[S.stepNum, { color: theme.blue.fg }]}>1</Text>
</View>
<Text style={[S.stepTxt, { color: theme.text.muted }]}> WiFi </Text>
<TouchableOpacity
style={[S.settingsBtn, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}
onPress={() => Linking.openSettings()}
>
<Text style={[S.settingsTxt, { color: theme.blue.fg }]}> </Text>
</TouchableOpacity>
</View>
<View style={[S.guideRow, { marginBottom: Spacing.md }]}>
<View style={[S.stepBadge, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.border,
}]}>
<Text style={[S.stepNum, { color: theme.blue.fg }]}>2</Text>
</View>
<Text style={[S.stepTxt, { color: theme.text.muted }]}></Text>
</View>
{/* Inputs */}
<View style={[S.inputCard, {
backgroundColor: theme.bg.raised,
borderColor: theme.bg.border,
}]}>
<View style={S.inputRow}>
<Text style={[S.inputLabel, { color: theme.text.muted }]}>IP </Text>
<TextInput
style={[S.input, {
color: theme.text.primary,
borderBottomColor: theme.bg.border,
}]}
value={host}
onChangeText={setHost}
keyboardType="numeric"
placeholder="192.168.4.1"
placeholderTextColor={theme.text.ghost}
autoCapitalize="none"
editable={!connecting}
/>
</View>
<View style={[S.inputDivider, { backgroundColor: theme.bg.divider }]} />
<View style={S.inputRow}>
<Text style={[S.inputLabel, { color: theme.text.muted }]}> </Text>
<TextInput
style={[S.input, {
color: theme.text.primary,
borderBottomColor: theme.bg.border,
}]}
value={portStr}
onChangeText={setPortStr}
keyboardType="numeric"
placeholder="4321"
placeholderTextColor={theme.text.ghost}
editable={!connecting}
/>
</View>
</View>
{/* Action */}
{!connected ? (
<TouchableOpacity
style={[S.connectBtn, {
backgroundColor: theme.blue.bg,
borderColor: theme.blue.fg,
}, connecting && {
borderColor: theme.blue.border,
backgroundColor: theme.bg.raised,
}]}
onPress={handleConnect}
disabled={connecting}
activeOpacity={0.8}
>
{connecting
? <ActivityIndicator color={theme.blue.fg} />
: <Text style={[S.connectBtnTxt, { color: theme.blue.fg }]}> </Text>}
</TouchableOpacity>
) : (
<View style={S.connectedRow}>
<View style={S.connectedLeft}>
<View style={[S.greenDot, { backgroundColor: theme.green.fg }]} />
<Text style={[S.connectedTxt, { color: theme.green.fg }]}> {host}:{port}</Text>
</View>
<TouchableOpacity
style={[S.disconnectBtn, {
backgroundColor: theme.red.bg,
borderColor: theme.red.border,
}]}
onPress={handleDisconnect}
>
<Text style={[S.disconnectTxt, { color: theme.red.fg }]}> </Text>
</TouchableOpacity>
</View>
)}
{/* Log */}
<View style={[S.logBox, {
backgroundColor: theme.bg.void,
borderColor: theme.bg.border,
}]}>
<Text style={[S.logHeader, {
color: theme.green.fg,
borderBottomColor: theme.bg.divider,
}]}> </Text>
<ScrollView style={S.logScroll} showsVerticalScrollIndicator={false}>
{logs.map((l, i) => (
<Text key={i} style={[S.logLine, { color: theme.text.ghost }, i === 0 && { color: theme.text.muted }]}>{l}</Text>
))}
</ScrollView>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const S = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0,0,0,0.6)',
},
kav: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
borderTopLeftRadius: Radius.xl,
borderTopRightRadius: Radius.xl,
padding: Spacing.lg,
paddingBottom: Spacing.xl,
borderTopWidth: 1,
},
handle: {
width: 36, height: 4, borderRadius: 2,
alignSelf: 'center',
marginBottom: Spacing.md,
},
title: {
fontSize: 15,
fontWeight: '700',
marginBottom: Spacing.md,
},
guideRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: 6,
},
stepBadge: {
width: 20, height: 20, borderRadius: 10,
justifyContent: 'center', alignItems: 'center',
borderWidth: 1,
},
stepNum: { fontSize: 10, fontWeight: '700' },
stepTxt: { fontSize: 12, flex: 1 },
settingsBtn: {
borderRadius: Radius.sm,
paddingHorizontal: 8, paddingVertical: 3,
borderWidth: 1,
},
settingsTxt: { fontSize: 11, fontWeight: '600' },
inputCard: {
borderRadius: Radius.md,
borderWidth: 1,
paddingHorizontal: Spacing.md,
marginBottom: Spacing.md,
},
inputRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, paddingVertical: Spacing.sm },
inputDivider: { height: StyleSheet.hairlineWidth },
inputLabel: { fontSize: 12, width: 52 },
input: {
flex: 1,
fontSize: 15,
fontWeight: '500',
paddingVertical: 4,
borderBottomWidth: 1,
},
connectBtn: {
borderRadius: Radius.lg,
paddingVertical: 15,
alignItems: 'center',
marginBottom: Spacing.md,
borderWidth: 1,
},
connectBtnTxt: { fontSize: 15, fontWeight: '700', letterSpacing: 3 },
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.md },
connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
greenDot: { width: 8, height: 8, borderRadius: 4 },
connectedTxt: {
fontWeight: '700', fontSize: 12,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
},
disconnectBtn: {
borderRadius: Radius.md,
paddingVertical: 8, paddingHorizontal: 14,
borderWidth: 1,
},
disconnectTxt: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
logBox: {
borderRadius: Radius.md,
borderWidth: 1,
overflow: 'hidden',
maxHeight: 120,
},
logHeader: {
fontSize: 9, fontWeight: '700', letterSpacing: 1.5,
paddingHorizontal: Spacing.md, paddingTop: 8, paddingBottom: 5,
borderBottomWidth: 1,
},
logScroll: { padding: Spacing.sm },
logLine: {
fontSize: 10,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
marginBottom: 2, lineHeight: 15,
},
});