321 lines
10 KiB
TypeScript
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,
|
|
},
|
|
});
|