init
This commit is contained in:
+41
-48
@@ -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} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user