This commit is contained in:
2026-06-06 22:13:00 +08:00
parent ed8682a1e2
commit 091b358705
27 changed files with 3001 additions and 102 deletions
+41 -48
View File
@@ -1,67 +1,60 @@
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { SymbolView } from 'expo-symbols';
import { Link, Tabs } from 'expo-router';
import { Platform, Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
const ACTIVE = '#4a9eff';
const INACTIVE = '#555';
const BG = '#111111';
// SymbolView tintColor is typed as string but Tabs passes ColorValue — cast to avoid TS error
function TabIcon({ name, color }: { name: string; color: any }) {
return <SymbolView name={name as any} tintColor={color} size={22} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
headerShown: false,
tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 10 },
}}
>
<Tabs.Screen
name="index"
name="control"
options={{
title: 'Tab One',
title: '控制台',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
),
headerRight: () => (
<Link href="/modal" asChild>
<Pressable style={{ marginRight: 15 }}>
{({ pressed }) => (
<SymbolView
name={{ ios: 'info.circle', android: 'info', web: 'info' }}
size={25}
tintColor={Colors[colorScheme].text}
style={{ opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
<TabIcon name={Platform.select({ ios: 'slider.horizontal.3', android: 'tune', web: 'tune' })!} color={color} />
),
}}
/>
<Tabs.Screen
name="two"
name="waveform"
options={{
title: 'Tab Two',
title: '波形',
tabBarIcon: ({ color }) => (
<SymbolView
name={{
ios: 'chevron.left.forwardslash.chevron.right',
android: 'code',
web: 'code',
}}
tintColor={color}
size={28}
/>
<TabIcon name={Platform.select({ ios: 'waveform', android: 'show_chart', web: 'show_chart' })!} color={color} />
),
}}
/>
<Tabs.Screen
name="map"
options={{
title: '地图',
tabBarIcon: ({ color }) => (
<TabIcon name={Platform.select({ ios: 'map', android: 'map', web: 'map' })!} color={color} />
),
}}
/>
<Tabs.Screen
name="records"
options={{
title: '记录',
tabBarIcon: ({ color }) => (
<TabIcon name={Platform.select({ ios: 'list.bullet', android: 'list', web: 'list' })!} color={color} />
),
}}
/>
+150
View File
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { ParamForm } from '../../src/components/ParamForm';
import { useDevice } from '../../src/hooks/useDevice';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { useDataStore } from '../../src/stores/dataStore';
export default function ControlScreen() {
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
const frame = useDataStore((s) => s.currentFrame);
const [configDirty, setConfigDirty] = useState(false);
const handleSetup = async () => {
await setup();
setConfigDirty(false);
};
if (!connected) {
return (
<View style={styles.notConnected}>
<Text style={styles.notConnectedText}>未连接设备</Text>
<TouchableOpacity style={styles.connectBtn} onPress={() => router.replace('/connect')}>
<Text style={styles.connectBtnText}>前往连接</Text>
</TouchableOpacity>
</View>
);
}
const running = deviceStatus === 'running';
const canStart = !running && !busy;
const canStop = (running || deviceStatus === 'single') && !busy;
return (
<View style={styles.container}>
<DeviceStatusBar />
{/* Control buttons */}
<View style={styles.ctrlRow}>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnStart, !canStart && styles.btnDisabled]}
onPress={startContinuous}
disabled={!canStart}
>
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.ctrlText}>连续采集</Text>}
</TouchableOpacity>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnSingle, !canStart && styles.btnDisabled]}
onPress={startSingle}
disabled={!canStart}
>
<Text style={styles.ctrlText}>单次</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnStop, !canStop && styles.btnDisabled]}
onPress={stop}
disabled={!canStop}
>
<Text style={styles.ctrlText}>停止</Text>
</TouchableOpacity>
</View>
{/* Last frame info */}
{frame && (
<View style={styles.frameInfo}>
<Text style={styles.frameText}>
帧 #{frame.frameId} 叠加 {frame.meta ? frame.accNum : '--'} 次 通道 {frame.meta?.channelNum ?? '-'}
</Text>
<Text style={styles.frameText}>
电流峰值: {frame.meta?.current ?? '--'} 温度: {frame.meta?.temperature ?? '--'}
</Text>
</View>
)}
{/* Param form */}
<ScrollView style={styles.formScroll} keyboardShouldPersistTaps="handled">
<View style={styles.formHeader}>
<Text style={styles.formTitle}>采集参数</Text>
{configDirty && <Text style={styles.dirtyBadge}>未下发</Text>}
</View>
<ParamForm onAnyChange={() => setConfigDirty(true)} />
<TouchableOpacity
style={[styles.setupBtn, busy && styles.btnDisabled]}
onPress={handleSetup}
disabled={busy}
>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.setupBtnText}>下 发 配 置</Text>}
</TouchableOpacity>
<View style={{ height: 24 }} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, backgroundColor: '#0d0d0d' },
notConnectedText: { color: '#666', fontSize: 16 },
connectBtn: { backgroundColor: '#4a9eff', borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
connectBtnText: { color: '#fff', fontWeight: '600' },
ctrlRow: { flexDirection: 'row', gap: 10, padding: 12 },
ctrlBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
btnStart: { backgroundColor: '#1a5c2a' },
btnSingle: { backgroundColor: '#1a3a5c' },
btnStop: { backgroundColor: '#5c1a1a' },
btnDisabled: { opacity: 0.4 },
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
frameInfo: {
marginHorizontal: 12,
backgroundColor: '#181818',
borderRadius: 8,
padding: 8,
marginBottom: 8,
},
frameText: { color: '#888', fontSize: 11, fontFamily: 'monospace' },
formScroll: { flex: 1 },
formHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 14,
paddingVertical: 8,
},
formTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600' },
dirtyBadge: {
backgroundColor: '#5c3a0a',
color: '#ffaa00',
fontSize: 10,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
setupBtn: {
margin: 14,
backgroundColor: '#4a9eff',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
setupBtnText: { color: '#fff', fontWeight: '700', fontSize: 15, letterSpacing: 2 },
});
+114
View File
@@ -0,0 +1,114 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
import MapView, { Marker, Polyline, type Region } from 'react-native-maps';
import { useDataStore } from '../../src/stores/dataStore';
import { formatCoord } from '../../src/utils/format';
export default function MapScreen() {
const history = useDataStore((s) => s.history);
const mapRef = useRef<MapView>(null);
const points = history
.filter((f) => f.meta && f.meta.latitude !== 0 && f.meta.longitude !== 0)
.map((f) => ({
latitude: f.meta!.latitude,
longitude: f.meta!.longitude,
frameId: f.frameId,
}))
.reverse(); // oldest first for polyline
const latest = points[points.length - 1];
const centerOnCurrent = () => {
if (!latest || !mapRef.current) return;
mapRef.current.animateToRegion(
{ latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.005, longitudeDelta: 0.005 },
600,
);
};
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={styles.map}
mapType="satellite"
showsUserLocation
initialRegion={
latest
? { latitude: latest.latitude, longitude: latest.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 }
: { latitude: 30, longitude: 115, latitudeDelta: 10, longitudeDelta: 10 }
}
>
{/* GPS track polyline */}
{points.length > 1 && (
<Polyline coordinates={points} strokeColor="#4a9eff" strokeWidth={2} />
)}
{/* Measurement point markers */}
{points.map((p) => (
<Marker
key={p.frameId}
coordinate={{ latitude: p.latitude, longitude: p.longitude }}
anchor={{ x: 0.5, y: 0.5 }}
title={`帧 #${p.frameId}`}
>
<View style={styles.markerDot} />
</Marker>
))}
</MapView>
{/* Overlay info */}
<View style={styles.overlay}>
<Text style={styles.overlayText}>测点数: {points.length}</Text>
{latest && (
<Text style={styles.overlayText}>
{formatCoord(latest.latitude, false)} {formatCoord(latest.longitude, true)}
</Text>
)}
</View>
{/* Center button */}
<TouchableOpacity style={styles.centerBtn} onPress={centerOnCurrent}>
<Text style={styles.centerBtnText}>⌖</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
markerDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#4a9eff',
borderWidth: 1,
borderColor: '#fff',
},
overlay: {
position: 'absolute',
top: 12,
left: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8,
padding: 8,
gap: 2,
},
overlayText: { color: '#ddd', fontSize: 11 },
centerBtn: {
position: 'absolute',
bottom: 24,
right: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(10,10,10,0.85)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#444',
},
centerBtnText: { color: '#4a9eff', fontSize: 22 },
});
+159
View File
@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { useDataStore } from '../../src/stores/dataStore';
import { exportCsv, shareFile } from '../../src/utils/export';
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
import type { MeasurementFrame } from '../../src/protocol/types';
export default function RecordsScreen() {
const { history, sessionId, clearHistory } = useDataStore();
const [exporting, setExporting] = useState(false);
const handleExportAll = async () => {
if (history.length === 0) {
Alert.alert('无数据', '当前会话没有采集记录');
return;
}
setExporting(true);
try {
const path = await exportCsv(history, sessionId);
await shareFile(path);
} catch (e: any) {
Alert.alert('导出失败', e.message);
} finally {
setExporting(false);
}
};
const handleClear = () => {
Alert.alert('清空记录', '确定清空当前会话的所有记录?', [
{ text: '取消', style: 'cancel' },
{ text: '清空', style: 'destructive', onPress: clearHistory },
]);
};
const renderItem = ({ item }: { item: MeasurementFrame }) => {
const m = item.meta;
const peakUV = item.adcUV[0]
? item.adcUV[0].reduce((mx, v) => Math.max(mx, Math.abs(v)), 0)
: 0;
return (
<View style={styles.item}>
<View style={styles.itemHeader}>
<Text style={styles.frameId}>#{item.frameId}</Text>
<Text style={styles.time}>{m ? formatUtc(m.utc) : '--'}</Text>
</View>
{m && (
<>
<Text style={styles.coord}>
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
</Text>
<View style={styles.itemFooter}>
<Text style={styles.meta}>叠加 {item.accNum}次</Text>
<Text style={styles.meta}>增益 ×{item.gain}</Text>
<Text style={styles.meta}>CH1峰值 {formatUV(peakUV)}</Text>
<View style={[styles.gpsIndicator, { backgroundColor: m.gpsStatus > 0 ? '#1a4a1a' : '#2a1a1a' }]}>
<Text style={{ color: m.gpsStatus > 0 ? '#44cc44' : '#884444', fontSize: 9 }}>
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
</Text>
</View>
</View>
</>
)}
</View>
);
};
return (
<View style={styles.container}>
{/* Toolbar */}
<View style={styles.toolbar}>
<Text style={styles.sessionText}>会话: {sessionId}</Text>
<Text style={styles.countText}>{history.length} 测点</Text>
<View style={styles.toolbarBtns}>
<TouchableOpacity
style={[styles.toolBtn, styles.exportBtn, exporting && styles.btnDisabled]}
onPress={handleExportAll}
disabled={exporting}
>
{exporting ? (
<ActivityIndicator color="#44cc44" size="small" />
) : (
<Text style={styles.exportText}>导出 CSV</Text>
)}
</TouchableOpacity>
<TouchableOpacity style={[styles.toolBtn, styles.clearBtn]} onPress={handleClear}>
<Text style={styles.clearText}>清空</Text>
</TouchableOpacity>
</View>
</View>
{history.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyText}>暂无采集记录</Text>
<Text style={styles.emptyHint}>在控制台开始采集后,数据将显示在这里</Text>
</View>
) : (
<FlatList
data={history}
keyExtractor={(item) => String(item.frameId)}
renderItem={renderItem}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.sep} />}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
backgroundColor: '#111',
borderBottomWidth: 1,
borderBottomColor: '#2a2a2a',
gap: 8,
flexWrap: 'wrap',
},
sessionText: { color: '#555', fontSize: 10, flex: 1 },
countText: { color: '#888', fontSize: 12, fontWeight: '600' },
toolbarBtns: { flexDirection: 'row', gap: 8 },
toolBtn: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6 },
exportBtn: { backgroundColor: '#1a3a1a', borderWidth: 1, borderColor: '#2a5a2a' },
exportText: { color: '#44cc44', fontSize: 12, fontWeight: '600' },
clearBtn: { backgroundColor: '#3a1a1a', borderWidth: 1, borderColor: '#5a2a2a' },
clearText: { color: '#ff6666', fontSize: 12 },
btnDisabled: { opacity: 0.4 },
list: { padding: 10, gap: 8 },
item: {
backgroundColor: '#181818',
borderRadius: 8,
padding: 10,
borderWidth: 1,
borderColor: '#2a2a2a',
},
itemHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: 'monospace' },
time: { color: '#666', fontSize: 10, fontFamily: 'monospace' },
coord: { color: '#888', fontSize: 10, marginBottom: 4 },
itemFooter: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
meta: { color: '#555', fontSize: 10 },
gpsIndicator: { borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 },
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#222' },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 },
emptyText: { color: '#444', fontSize: 16 },
emptyHint: { color: '#333', fontSize: 12 },
});
+114
View File
@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, useWindowDimensions, ScrollView } from 'react-native';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { WaveformChart } from '../../src/components/WaveformChart';
import { ChannelSelector } from '../../src/components/ChannelSelector';
import { useWaveform } from '../../src/hooks/useWaveform';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
export default function WaveformScreen() {
const { width } = useWindowDimensions();
const channelNum = useDeviceStore((s) => s.config.channelNum);
const [visible, setVisible] = useState(() => Array(6).fill(true));
const frame = useDataStore((s) => s.currentFrame);
const data = useWaveform(visible);
const toggleChannel = (idx: number) => {
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
};
const CHART_HEIGHT = 280;
return (
<View style={styles.container}>
<DeviceStatusBar />
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
{/* Waveform chart */}
<View style={styles.chartWrap}>
{data ? (
<WaveformChart
data={data}
visibleChannels={visible}
width={width}
height={CHART_HEIGHT}
/>
) : (
<View style={[styles.placeholder, { height: CHART_HEIGHT }]}>
<Text style={styles.placeholderText}>等待采样数据...</Text>
</View>
)}
</View>
{/* Channel peak values */}
{frame && (
<ScrollView style={styles.peakScroll}>
<View style={styles.peakRow}>
{frame.adcUV.map((ch, idx) => {
if (!visible[idx] || idx >= channelNum) return null;
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
const peak = ch[0] < 0 ? -absMax : absMax;
return (
<View key={idx} style={styles.peakCell}>
<Text style={styles.peakLabel}>CH{idx + 1}</Text>
<Text style={styles.peakValue}>{formatUV(absMax)}</Text>
</View>
);
})}
</View>
{/* Frame metadata */}
{frame.meta && (
<View style={styles.metaBlock}>
<Text style={styles.metaText}>
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
</Text>
<Text style={styles.metaText}>
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
</Text>
<Text style={styles.metaText}>
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
</Text>
</View>
)}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
placeholder: {
backgroundColor: '#1a1a1a',
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: { color: '#444', fontSize: 14 },
peakScroll: { flex: 1 },
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
peakCell: {
backgroundColor: '#1a1a1a',
borderRadius: 8,
padding: 10,
minWidth: 90,
alignItems: 'center',
borderWidth: 1,
borderColor: '#2a2a2a',
},
peakLabel: { color: '#888', fontSize: 11 },
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
metaBlock: {
margin: 12,
backgroundColor: '#111',
borderRadius: 8,
padding: 10,
gap: 3,
},
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
});
+20 -44
View File
@@ -1,56 +1,32 @@
import { useFonts } from 'expo-font';
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } from 'react-native';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export { ErrorBoundary } from 'expo-router';
export const unstable_settings = { initialRouteName: 'connect' };
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
SplashScreen.hideAsync();
}, []);
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
<GestureHandlerRootView style={styles.root}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#0d0d0d' } }}>
<Stack.Screen name="connect" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+207
View File
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
Linking,
ActivityIndicator,
} from 'react-native';
import { router } from 'expo-router';
import { useConnectionStore } from '../src/stores/connectionStore';
import { useDevice } from '../src/hooks/useDevice';
const LOG_MAX = 60;
export default function ConnectScreen() {
const { host, port, status, setHost, setPort } = useConnectionStore();
const { connect, disconnect } = useDevice();
const [logs, setLogs] = useState<string[]>(['准备连接...']);
const [portStr, setPortStr] = useState(String(port));
const addLog = (msg: string) => {
setLogs((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, LOG_MAX));
};
const handleConnect = () => {
const p = parseInt(portStr, 10);
if (!host || isNaN(p)) {
addLog('请检查 IP 和端口');
return;
}
setPort(p);
addLog(`正在连接 ${host}:${p}...`);
// Watch for status changes via the hook (side effect in useDevice)
useConnectionStore.subscribe((s) => {
if (s.status === 'connected') {
addLog('连接成功!');
router.replace('/(tabs)/control');
} else if (s.status === 'error') {
addLog(`错误: ${s.lastError}`);
} else if (s.status === 'reconnecting') {
addLog('断线,正在重连...');
}
});
connect();
};
const handleDisconnect = () => {
disconnect();
addLog('已断开连接');
};
const isConnecting = status === 'connecting' || status === 'reconnecting';
const isConnected = status === 'connected';
return (
<View style={styles.container}>
<Text style={styles.title}>TEM Receiver</Text>
<Text style={styles.subtitle}>瞬变电磁接收机上位机</Text>
{/* Connection steps */}
<View style={styles.card}>
<Text style={styles.cardTitle}>连接步骤</Text>
<Text style={styles.step}>① 在手机 WiFi 设置中连接设备热点</Text>
<TouchableOpacity style={styles.wifiBtn} onPress={() => Linking.openSettings()}>
<Text style={styles.wifiBtnText}>打开 WiFi 设置 →</Text>
</TouchableOpacity>
<Text style={styles.step}>② 确认连接参数后点击连接</Text>
</View>
{/* IP / Port input */}
<View style={styles.card}>
<View style={styles.inputRow}>
<Text style={styles.inputLabel}>设备 IP</Text>
<TextInput
style={styles.input}
value={host}
onChangeText={setHost}
keyboardType="numeric"
placeholder="192.168.4.1"
placeholderTextColor="#555"
autoCapitalize="none"
/>
</View>
<View style={styles.inputRow}>
<Text style={styles.inputLabel}>端口</Text>
<TextInput
style={styles.input}
value={portStr}
onChangeText={setPortStr}
keyboardType="numeric"
placeholder="4321"
placeholderTextColor="#555"
/>
</View>
</View>
{/* Connect / Disconnect button */}
{!isConnected ? (
<TouchableOpacity
style={[styles.connectBtn, isConnecting && styles.connectBtnBusy]}
onPress={handleConnect}
disabled={isConnecting}
>
{isConnecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.connectBtnText}>连 接 设 备</Text>
)}
</TouchableOpacity>
) : (
<View style={styles.connectedRow}>
<View style={styles.connectedBadge}>
<View style={styles.greenDot} />
<Text style={styles.connectedText}>已连接</Text>
</View>
<TouchableOpacity style={styles.goBtn} onPress={() => router.replace('/(tabs)/control')}>
<Text style={styles.goBtnText}>进入控制台 →</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
<Text style={styles.disconnectBtnText}>断开</Text>
</TouchableOpacity>
</View>
)}
{/* Connection log */}
<View style={styles.logCard}>
<Text style={styles.logTitle}>连接日志</Text>
<ScrollView style={styles.logScroll} nestedScrollEnabled>
{logs.map((l, i) => (
<Text key={i} style={styles.logLine}>{l}</Text>
))}
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d', padding: 16 },
title: { color: '#4a9eff', fontSize: 28, fontWeight: '700', textAlign: 'center', marginTop: 40 },
subtitle: { color: '#666', fontSize: 13, textAlign: 'center', marginBottom: 24 },
card: {
backgroundColor: '#1a1a1a',
borderRadius: 10,
padding: 14,
marginBottom: 12,
borderWidth: 1,
borderColor: '#2a2a2a',
},
cardTitle: { color: '#4a9eff', fontSize: 13, fontWeight: '600', marginBottom: 8 },
step: { color: '#bbb', fontSize: 13, marginBottom: 6 },
wifiBtn: {
backgroundColor: '#0d2b55',
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 12,
alignSelf: 'flex-start',
marginBottom: 10,
},
wifiBtnText: { color: '#4a9eff', fontSize: 12 },
inputRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
inputLabel: { color: '#888', fontSize: 13, width: 50 },
input: {
flex: 1,
backgroundColor: '#111',
color: '#eee',
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 6,
fontSize: 14,
borderWidth: 1,
borderColor: '#333',
},
connectBtn: {
backgroundColor: '#4a9eff',
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 12,
},
connectBtnBusy: { backgroundColor: '#2a5a99' },
connectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700', letterSpacing: 2 },
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
connectedBadge: { flexDirection: 'row', alignItems: 'center', gap: 6 },
greenDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#44cc44' },
connectedText: { color: '#44cc44', fontWeight: '600' },
goBtn: { flex: 1, backgroundColor: '#1a4a22', borderRadius: 8, padding: 10, alignItems: 'center' },
goBtnText: { color: '#44cc44', fontWeight: '600' },
disconnectBtn: { backgroundColor: '#3a1010', borderRadius: 8, padding: 10 },
disconnectBtnText: { color: '#ff6666' },
logCard: {
flex: 1,
backgroundColor: '#111',
borderRadius: 10,
padding: 10,
borderWidth: 1,
borderColor: '#222',
},
logTitle: { color: '#666', fontSize: 11, marginBottom: 4 },
logScroll: { flex: 1 },
logLine: { color: '#555', fontSize: 10, fontFamily: 'monospace', marginBottom: 2 },
});