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' },
});