This commit is contained in:
2026-06-19 22:08:10 +08:00
parent 091b358705
commit 84590d5477
37 changed files with 5844 additions and 900 deletions
+61 -51
View File
@@ -1,63 +1,73 @@
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { Platform, View } from 'react-native';
import { SymbolView } from 'expo-symbols';
import { Text } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { GlobalStatusBar } from '../../src/components/device/GlobalStatusBar';
import { Colors } from '../../src/design/tokens';
const ACTIVE = '#4a9eff';
const ACTIVE = Colors.blue.fg;
const INACTIVE = '#555';
const BG = '#111111';
const TAB_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} />;
function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) {
if (Platform.OS === 'ios') {
return <SymbolView name={ios as any} tintColor={color} size={22} />;
}
return <Text style={{ fontSize: 18, color, lineHeight: 24 }}>{emoji}</Text>;
}
export default function TabLayout() {
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { backgroundColor: BG, borderTopColor: '#222' },
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 10 },
}}
>
<Tabs.Screen
name="control"
options={{
title: '控制台',
tabBarIcon: ({ color }) => (
<TabIcon name={Platform.select({ ios: 'slider.horizontal.3', android: 'tune', web: 'tune' })!} color={color} />
),
<View style={{ flex: 1, backgroundColor: Colors.bg.base, paddingTop: insets.top }}>
<GlobalStatusBar />
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { backgroundColor: TAB_BG, borderTopColor: '#222' },
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 10 },
}}
/>
<Tabs.Screen
name="waveform"
options={{
title: '波形',
tabBarIcon: ({ color }) => (
<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} />
),
}}
/>
</Tabs>
>
{/* Hidden screens */}
<Tabs.Screen name="index" options={{ href: null }} />
<Tabs.Screen name="control" options={{ href: null }} />
<Tabs.Screen name="waveform" options={{ href: null }} />
<Tabs.Screen name="map" options={{ href: null }} />
{/* Visible tabs */}
<Tabs.Screen
name="wave"
options={{
title: '波形',
tabBarIcon: ({ color }) => <TabIcon ios="waveform" emoji="〜" color={color} />,
}}
/>
<Tabs.Screen
name="records"
options={{
title: '测点',
tabBarIcon: ({ color }) => <TabIcon ios="list.bullet" emoji="≡" color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: '剖面',
tabBarIcon: ({ color }) => <TabIcon ios="chart.line.uptrend.xyaxis" emoji="↗" color={color} />,
}}
/>
<Tabs.Screen
name="projects"
options={{
title: '工程',
tabBarIcon: ({ color }) => <TabIcon ios="folder" emoji="▤" color={color} />,
}}
/>
</Tabs>
</View>
);
}
+144 -75
View File
@@ -1,16 +1,27 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator } from 'react-native';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, ActivityIndicator, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
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 insets = useSafeAreaInsets();
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
const frame = useDataStore((s) => s.currentFrame);
const sessionId = useDataStore((s) => s.sessionId);
const projectId = useDataStore((s) => s.projectId);
const [configDirty, setConfigDirty] = useState(false);
const [projectName, setProjectName] = React.useState<string | null>(null);
React.useEffect(() => {
if (!projectId) { setProjectName(null); return; }
import('../../src/services/StorageService').then(({ listProjects }) =>
listProjects().then((ps) => setProjectName(ps.find(p => p.projectId === projectId)?.name ?? null)),
);
}, [projectId]);
const handleSetup = async () => {
await setup();
@@ -19,132 +30,190 @@ export default function ControlScreen() {
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>
<View style={[S.notConnected, { paddingTop: insets.top }]}>
<Text style={S.notConnectedIcon}>⊘</Text>
<Text style={S.notConnectedText}>未连接设备</Text>
<TouchableOpacity style={S.goConnectBtn} onPress={() => router.push('/connect')} activeOpacity={0.8}>
<Text style={S.goConnectBtnText}>前往连接</Text>
</TouchableOpacity>
</View>
);
}
const running = deviceStatus === 'running';
const canStart = !running && !busy;
const canStop = (running || deviceStatus === 'single') && !busy;
const single = deviceStatus === 'single';
const canStart = !running && !single && !busy;
const canStop = (running || single) && !busy;
return (
<View style={styles.container}>
<View style={[S.container, { paddingTop: insets.top }]}>
<DeviceStatusBar />
{/* Project / line context */}
<View style={S.contextBar}>
{projectName
? <Text style={S.contextText} numberOfLines={1}>{projectName} · {sessionId}</Text>
: <Text style={S.contextText} numberOfLines={1}>{sessionId}</Text>
}
</View>
{/* Control buttons */}
<View style={styles.ctrlRow}>
<View style={S.ctrlRow}>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnStart, !canStart && styles.btnDisabled]}
style={[S.ctrlBtn, S.btnStart, !canStart && S.btnDisabled]}
onPress={startContinuous}
disabled={!canStart}
activeOpacity={0.8}
>
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.ctrlText}>连续采集</Text>}
{busy && !canStop
? <ActivityIndicator color="#3ddc84" size="small" />
: <>
<Text style={S.ctrlIcon}>▶</Text>
<Text style={[S.ctrlText, S.ctrlTextGreen]}>连续采集</Text>
</>
}
</TouchableOpacity>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnSingle, !canStart && styles.btnDisabled]}
style={[S.ctrlBtn, S.btnSingle, !canStart && S.btnDisabled]}
onPress={startSingle}
disabled={!canStart}
activeOpacity={0.8}
>
<Text style={styles.ctrlText}>单次</Text>
<Text style={S.ctrlIcon}>◎</Text>
<Text style={[S.ctrlText, S.ctrlTextBlue]}>单 次</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.ctrlBtn, styles.btnStop, !canStop && styles.btnDisabled]}
style={[S.ctrlBtn, S.btnStop, !canStop && S.btnDisabled]}
onPress={stop}
disabled={!canStop}
activeOpacity={0.8}
>
<Text style={styles.ctrlText}>停止</Text>
{busy && canStop
? <ActivityIndicator color="#ff5c6e" size="small" />
: <>
<Text style={S.ctrlIcon}>■</Text>
<Text style={[S.ctrlText, S.ctrlTextRed]}>停 止</Text>
</>
}
</TouchableOpacity>
</View>
{/* Last frame info */}
{/* 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 style={S.frameBar}>
<View style={S.frameItem}>
<Text style={S.frameItemLabel}>帧号</Text>
<Text style={S.frameItemValue}>#{frame.frameId}</Text>
</View>
<View style={S.frameSep} />
<View style={S.frameItem}>
<Text style={S.frameItemLabel}>叠加</Text>
<Text style={S.frameItemValue}>{frame.accNum}次</Text>
</View>
<View style={S.frameSep} />
<View style={S.frameItem}>
<Text style={S.frameItemLabel}>通道</Text>
<Text style={S.frameItemValue}>{frame.meta?.channelNum ?? '-'}ch</Text>
</View>
<View style={S.frameSep} />
<View style={S.frameItem}>
<Text style={S.frameItemLabel}>电流</Text>
<Text style={S.frameItemValue}>{frame.meta?.current ?? '--'}</Text>
</View>
</View>
)}
{/* Param form */}
<ScrollView style={styles.formScroll} keyboardShouldPersistTaps="handled">
<View style={styles.formHeader}>
<Text style={styles.formTitle}>采集参数</Text>
{configDirty && <Text style={styles.dirtyBadge}>未下发</Text>}
<ScrollView style={S.formScroll} keyboardShouldPersistTaps="handled">
<View style={S.formHeader}>
<Text style={S.formTitle}>采集参数</Text>
{configDirty && (
<View style={S.dirtyBadge}>
<Text style={S.dirtyBadgeText}>待下发</Text>
</View>
)}
</View>
<ParamForm onAnyChange={() => setConfigDirty(true)} />
<TouchableOpacity
style={[styles.setupBtn, busy && styles.btnDisabled]}
style={[S.setupBtn, busy && S.btnDisabled]}
onPress={handleSetup}
disabled={busy}
activeOpacity={0.8}
>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.setupBtnText}>下 发 配 置</Text>}
{busy
? <ActivityIndicator color="#4a9eff" />
: <Text style={S.setupBtnText}>下 发 配 置</Text>}
</TouchableOpacity>
<View style={{ height: 24 }} />
<View style={{ height: 32 }} />
</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 },
const S = StyleSheet.create({
container: { flex: 1, backgroundColor: '#090912' },
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, backgroundColor: '#090912' },
notConnectedIcon: { fontSize: 48, color: '#2a2a4a' },
notConnectedText: { color: '#4a4a6a', fontSize: 15 },
goConnectBtn: { backgroundColor: '#111120', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1, borderColor: '#4a9eff' },
goConnectBtnText: { color: '#4a9eff', fontWeight: '600' },
// Control buttons
ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 },
ctrlBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
gap: 4,
borderWidth: 1,
},
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: {
btnStart: { backgroundColor: '#0d2018', borderColor: '#1a4a28' },
btnSingle: { backgroundColor: '#0d1a30', borderColor: '#1a2e54' },
btnStop: { backgroundColor: '#1e0d12', borderColor: '#3a1520' },
btnDisabled: { opacity: 0.3 },
ctrlIcon: { fontSize: 12, color: '#ffffff88' },
ctrlText: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
ctrlTextGreen: { color: '#3ddc84' },
ctrlTextBlue: { color: '#4a9eff' },
ctrlTextRed: { color: '#ff5c6e' },
// Frame info bar
frameBar: {
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',
marginHorizontal: 12,
marginBottom: 8,
backgroundColor: '#0c0c1a',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: '#1a1a2a',
overflow: 'hidden',
},
setupBtnText: { color: '#fff', fontWeight: '700', fontSize: 15, letterSpacing: 2 },
frameItem: { flex: 1, alignItems: 'center', paddingVertical: 8 },
frameItemLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
frameItemValue: { color: '#8888aa', fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
frameSep: { width: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 6 },
// Form
formScroll: { flex: 1 },
formHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10 },
formTitle: { color: '#4a4a6a', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' },
dirtyBadge: { backgroundColor: '#2a1a05', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1, borderColor: '#4a2a05' },
dirtyBadgeText: { color: '#ffb84d', fontSize: 9, fontWeight: '700' },
setupBtn: {
margin: 16,
backgroundColor: '#111120',
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: '#4a9eff',
},
setupBtnText: { color: '#4a9eff', fontWeight: '700', fontSize: 14, letterSpacing: 3 },
contextBar: { paddingHorizontal: 16, paddingVertical: 5, backgroundColor: '#08080f', borderBottomWidth: 1, borderBottomColor: '#141422' },
contextText: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
+3 -29
View File
@@ -1,31 +1,5 @@
import { StyleSheet } from 'react-native';
import { Redirect } from 'expo-router';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
export default function TabIndex() {
return <Redirect href="/(tabs)/control" />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
+6 -6
View File
@@ -1,10 +1,12 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
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 insets = useSafeAreaInsets();
const history = useDataStore((s) => s.history);
const mapRef = useRef<MapView>(null);
@@ -58,8 +60,8 @@ export default function MapScreen() {
))}
</MapView>
{/* Overlay info */}
<View style={styles.overlay}>
{/* Overlay info — offset by safe area top */}
<View style={[styles.overlay, { top: 12 + insets.top }]}>
<Text style={styles.overlayText}>测点数: {points.length}</Text>
{latest && (
<Text style={styles.overlayText}>
@@ -68,8 +70,8 @@ export default function MapScreen() {
)}
</View>
{/* Center button */}
<TouchableOpacity style={styles.centerBtn} onPress={centerOnCurrent}>
{/* Center button — offset by safe area bottom */}
<TouchableOpacity style={[styles.centerBtn, { bottom: 24 + insets.bottom }]} onPress={centerOnCurrent}>
<Text style={styles.centerBtnText}>⌖</Text>
</TouchableOpacity>
</View>
@@ -89,7 +91,6 @@ const styles = StyleSheet.create({
},
overlay: {
position: 'absolute',
top: 12,
left: 12,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8,
@@ -99,7 +100,6 @@ const styles = StyleSheet.create({
overlayText: { color: '#ddd', fontSize: 11 },
centerBtn: {
position: 'absolute',
bottom: 24,
right: 16,
width: 44,
height: 44,
+698
View File
@@ -0,0 +1,698 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, Modal, FlatList,
ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView,
} from 'react-native';
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import * as StorageService from '../../src/services/StorageService';
import * as BinLoader from '../../src/services/BinLoader';
import type { MeasurementFrame } from '../../src/protocol/types';
import type { SessionInfo } from '../../src/services/StorageService';
import { CHANNEL_COLORS } from '../../src/protocol/constants';
// ── Constants ──────────────────────────────────────────────────────────────
const CP = { top: 14, right: 14, bottom: 34, left: 52 };
const CANVAS_H = 70;
const SAMPLE_FREQ_HZ: Record<number, number> = {
0x00: 250000, 0x01: 125000, 0x02: 62500, 0x03: 31250,
0x04: 15600, 0x05: 7800, 0x06: 3900, 0x07: 1950,
0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61,
};
const JET_HEX: string[] = Array.from({ length: 256 }, (_, i) => {
const t = i / 255;
const r = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 3)) * 255)));
const g = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 2)) * 255)));
const b = Math.round(Math.max(0, Math.min(255, (1.5 - Math.abs(4 * t - 1)) * 255)));
const h = (v: number) => v.toString(16).padStart(2, '0');
return `#${h(r)}${h(g)}${h(b)}`;
});
// ── Gate Config ────────────────────────────────────────────────────────────
import type { GateConfig, GateSpacing } from '../../src/protocol/types';
function computeGatePositions(cfg: GateConfig, sampleDepth: number, hz: number): number[] {
if (sampleDepth === 0 || hz === 0 || cfg.count <= 0 || cfg.tStart >= cfg.tEnd) return [];
const totalUs = (sampleDepth / hz) * 1e6;
return Array.from({ length: cfg.count }, (_, i) => {
const t = cfg.count === 1 ? 0.5 : i / (cfg.count - 1);
const tUs = cfg.spacing === 'log'
? cfg.tStart * Math.pow(cfg.tEnd / cfg.tStart, t)
: cfg.tStart + t * (cfg.tEnd - cfg.tStart);
return Math.max(0.001, Math.min(0.999, tUs / totalUs));
});
}
function gateColor(idx: number, total: number): string {
const t = total <= 1 ? 0.5 : idx / (total - 1);
return JET_HEX[Math.round(t * 255)];
}
function fmtUs(us: number): string {
if (us >= 1000) return `${(us / 1000).toFixed(2)}ms`;
return `${us.toFixed(0)}μs`;
}
// ── Types ──────────────────────────────────────────────────────────────────
interface LoadedFrame {
frameId: number;
adcUV: Float64Array[];
sampleDepth: number;
channelNum: number;
}
// ── Utility ────────────────────────────────────────────────────────────────
function niceStep(range: number, count: number): number {
const rough = range / count;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
for (const f of [1, 2, 2.5, 5, 10]) if (f * mag >= rough) return f * mag;
return 10 * mag;
}
function formatUVShort(uv: number): string {
const a = Math.abs(uv);
if (a >= 1e6) return `${(uv / 1e6).toFixed(1)}V`;
if (a >= 1e3) return `${(uv / 1e3).toFixed(1)}mV`;
if (a >= 1) return `${uv.toFixed(0)}μV`;
return `${(uv * 1e3).toFixed(1)}nV`;
}
// ── useProfileData ─────────────────────────────────────────────────────────
function useProfileData(sessionId: string, currentSessionId: string, history: MeasurementFrame[]) {
const [histFrames, setHistFrames] = useState<LoadedFrame[]>([]);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const liveFrames = useMemo<LoadedFrame[]>(() => {
if (sessionId !== currentSessionId) return [];
return [...history].reverse().map(f => ({
frameId: f.frameId,
adcUV: f.adcUV,
sampleDepth: f.adcUV[0]?.length ?? 0,
channelNum: f.adcUV.length,
}));
}, [sessionId, currentSessionId, history]);
useEffect(() => {
if (sessionId === currentSessionId) { setHistFrames([]); return; }
setLoading(true);
setHistFrames([]);
let cancelled = false;
(async () => {
const persisted = await StorageService.loadSessionFrames(sessionId);
setProgress({ current: 0, total: persisted.length });
const result: LoadedFrame[] = [];
for (let i = 0; i < persisted.length; i++) {
if (cancelled) return;
const pf = persisted[i];
if (pf.binPath) {
const loaded = await BinLoader.loadBinFile(pf.binPath);
if (loaded) result.push({ ...loaded, frameId: pf.frameId });
}
setProgress({ current: i + 1, total: persisted.length });
}
if (!cancelled) { setHistFrames(result); setLoading(false); }
})();
return () => { cancelled = true; };
}, [sessionId, currentSessionId]);
return {
frames: sessionId === currentSessionId ? liveFrames : histFrames,
loading: sessionId !== currentSessionId && loading,
progress,
};
}
// ── GateSettingsModal ──────────────────────────────────────────────────────
function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz }: {
visible: boolean;
config: GateConfig;
onApply: (cfg: GateConfig) => void;
onClose: () => void;
sampleDepth: number;
hz: number;
}) {
const [tStartStr, setTStartStr] = useState(String(config.tStart));
const [tEndStr, setTEndStr] = useState(String(config.tEnd));
const [countStr, setCountStr] = useState(String(config.count));
const [spacing, setSpacing] = useState<GateSpacing>(config.spacing);
useEffect(() => {
if (visible) {
setTStartStr(String(config.tStart));
setTEndStr(String(config.tEnd));
setCountStr(String(config.count));
setSpacing(config.spacing);
}
}, [visible, config]);
const maxUs = sampleDepth > 0 && hz > 0 ? (sampleDepth / hz) * 1e6 : 100000;
const handleApply = () => {
const tStart = Math.max(1, parseFloat(tStartStr) || config.tStart);
const tEnd = Math.min(Math.max(tStart + 1, parseFloat(tEndStr) || config.tEnd), maxUs * 0.999);
const count = Math.max(1, Math.min(64, parseInt(countStr, 10) || config.count));
onApply({ tStart, tEnd, count, spacing });
onClose();
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableOpacity style={GSM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={GSM.sheet}>
<Text style={GSM.title}>时间门配置</Text>
<View style={GSM.row}>
<View style={GSM.field}>
<Text style={GSM.label}>起始时间 (μs)</Text>
<TextInput
style={GSM.input}
keyboardType="numeric"
value={tStartStr}
onChangeText={setTStartStr}
/>
</View>
<View style={GSM.field}>
<Text style={GSM.label}>终止时间 (μs)</Text>
<TextInput
style={GSM.input}
keyboardType="numeric"
value={tEndStr}
onChangeText={setTEndStr}
/>
</View>
</View>
<View style={[GSM.row, { marginTop: 6 }]}>
<View style={GSM.field}>
<Text style={GSM.label}>门数量</Text>
<TextInput
style={GSM.input}
keyboardType="numeric"
value={countStr}
onChangeText={setCountStr}
/>
</View>
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
<Text style={GSM.label}>间隔方式</Text>
<View style={GSM.toggle}>
{(['log', 'linear'] as GateSpacing[]).map(s => (
<TouchableOpacity
key={s}
style={[GSM.toggleBtn, spacing === s && GSM.toggleBtnOn]}
onPress={() => setSpacing(s)}
>
<Text style={[GSM.toggleTxt, spacing === s && GSM.toggleTxtOn]}>
{s === 'log' ? '对数' : '线性'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
{sampleDepth > 0 && hz > 0 && (
<Text style={GSM.hint}>当前采样窗口: {fmtUs(maxUs)}</Text>
)}
<View style={GSM.actions}>
<TouchableOpacity style={GSM.cancelBtn} onPress={onClose}>
<Text style={GSM.cancelTxt}>取消</Text>
</TouchableOpacity>
<TouchableOpacity style={GSM.applyBtn} onPress={handleApply}>
<Text style={GSM.applyTxt}>应用</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const GSM = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' },
sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
title: { color: '#8888bb', fontSize: 14, fontWeight: '700', marginBottom: 16 },
row: { flexDirection: 'row', gap: 12 },
field: { flex: 1, gap: 4 },
label: { color: '#4a4a6a', fontSize: 11, fontWeight: '600' },
input: { backgroundColor: '#111120', borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', color: '#9090b8', fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1, borderColor: '#2a2a3a' },
toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center', backgroundColor: '#111120' },
toggleBtnOn: { backgroundColor: '#0d2040' },
toggleTxt: { color: '#4a4a6a', fontSize: 12, fontWeight: '700' },
toggleTxtOn: { color: '#4a9eff' },
hint: { color: '#2a2a4a', fontSize: 10, marginTop: 8 },
actions: { flexDirection: 'row', gap: 10, marginTop: 20 },
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
cancelTxt: { color: '#4a4a6a', fontSize: 13, fontWeight: '600' },
applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, backgroundColor: '#0d2040', borderWidth: 1, borderColor: '#4a9eff55', alignItems: 'center' },
applyTxt: { color: '#4a9eff', fontSize: 13, fontWeight: '700' },
});
// ── GatePreview ────────────────────────────────────────────────────────────
const GP = { t: 6, r: 8, b: 6, l: 8 };
function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }: {
refFrame: LoadedFrame | null;
channelIdx: number;
gatePositions: number[];
gateColors: string[];
width: number;
}) {
const plotW = width - GP.l - GP.r;
const plotH = CANVAS_H - GP.t - GP.b;
const wavePath = useMemo(() => {
if (!refFrame) return null;
const ch = refFrame.adcUV[channelIdx];
if (!ch || ch.length === 0) return null;
const n = Math.min(ch.length, 256);
let logMin = Infinity, logMax = -Infinity;
for (let i = 0; i < n; i++) {
const a = Math.abs(ch[Math.floor(i * ch.length / n)]);
if (a > 1e-9) { const l = Math.log10(a); if (l < logMin) logMin = l; if (l > logMax) logMax = l; }
}
if (!isFinite(logMin)) return null;
const logR = Math.max(logMax - logMin, 0.5);
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < n; i++) {
const v = ch[Math.floor(i * ch.length / n)];
const a = Math.abs(v);
if (a < 1e-12) continue;
const x = GP.l + (i / (n - 1)) * plotW;
const y = GP.t + plotH - ((Math.log10(a) - logMin) / logR) * plotH;
if (!isFinite(y)) continue;
if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y);
}
return path;
}, [refFrame, channelIdx, plotW, plotH]);
return (
<Canvas style={{ width, height: CANVAS_H }}>
{wavePath && <Path path={wavePath} color="#445566" style="stroke" strokeWidth={1} />}
{gatePositions.map((pos, gi) => (
<Line key={gi}
p1={vec(GP.l + pos * plotW, GP.t)}
p2={vec(GP.l + pos * plotW, GP.t + plotH)}
color={gateColors[gi] ?? '#fff'} strokeWidth={1.5}
/>
))}
</Canvas>
);
}
// ── GateLineChart ──────────────────────────────────────────────────────────
function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale, width, height }: {
frames: LoadedFrame[]; channelIdx: number;
gatePositions: number[]; gateColors: string[];
logScale: boolean; width: number; height: number;
}) {
const plotW = width - CP.left - CP.right;
const plotH = height - CP.top - CP.bottom;
const derived = useMemo(() => {
const sd = frames[0]?.sampleDepth ?? 0;
const series = gatePositions.map(pos => {
if (sd === 0) return null;
const si = Math.min(sd - 1, Math.floor(pos * sd));
return frames.map(f => f.adcUV[channelIdx]?.[si] ?? 0);
});
let vMin = Infinity, vMax = -Infinity;
for (const s of series) {
if (!s) continue;
for (const v of s) {
if (Math.abs(v) < 1e-9) continue;
const val = logScale ? Math.log10(Math.abs(v)) : v;
if (val < vMin) vMin = val;
if (val > vMax) vMax = val;
}
}
if (!isFinite(vMin)) { vMin = logScale ? -3 : -1; vMax = logScale ? 6 : 1; }
const pad = (vMax - vMin) * 0.05;
const yMin = vMin - pad, yMax = vMax + pad;
const yRange = Math.max(yMax - yMin, 1e-9);
const toY = (v: number) => {
const m = logScale ? (Math.abs(v) < 1e-12 ? yMin : Math.log10(Math.abs(v))) : v;
return CP.top + plotH - ((m - yMin) / yRange) * plotH;
};
const toX = (i: number) => CP.left + (i / Math.max(frames.length - 1, 1)) * plotW;
const paths = series.map((s) => {
if (!s) return null;
const path = Skia.Path.Make();
let moved = false;
for (let i = 0; i < s.length; i++) {
if (logScale && Math.abs(s[i]) < 1e-12) continue;
const x = toX(i), y = toY(s[i]);
if (!isFinite(y)) continue;
if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y);
}
return path;
});
let yTicks: number[];
if (logScale) {
yTicks = [-1, 0, 1, 2, 3, 4, 5, 6, 7]
.filter(e => e >= yMin && e <= yMax)
.map(e => Math.pow(10, e));
} else {
const step = niceStep(yMax - yMin, 5);
yTicks = [];
for (let v = Math.ceil(yMin / step) * step; v <= yMax * 1.001; v += step) yTicks.push(v);
}
const xTicks: number[] = [];
if (frames.length > 2) {
const step = Math.max(1, Math.round(niceStep(frames.length - 1, 5)));
for (let xi = step; xi < frames.length; xi += step) xTicks.push(xi);
}
return { paths, yTicks, xTicks, toY, toX };
}, [frames, channelIdx, gatePositions, logScale, plotW, plotH]);
const { paths, yTicks, xTicks, toY, toX } = derived;
const left = CP.left, right = CP.left + plotW;
const top = CP.top, bot = CP.top + plotH;
return (
<View style={{ width, height, position: 'relative' }}>
<Canvas style={{ width, height }}>
{yTicks.map((v, idx) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
return <Line key={`y_${idx}`} p1={vec(left, y)} p2={vec(right, y)} color="#222" strokeWidth={0.5} />;
})}
{xTicks.map((i, idx) => (
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color="#1e1e1e" strokeWidth={0.5} />
))}
{!logScale && isFinite(toY(0)) && (
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))} color="#444" strokeWidth={0.8} />
)}
<Line p1={vec(left, bot)} p2={vec(right, bot)} color="#555" strokeWidth={1} />
<Line p1={vec(left, top)} p2={vec(left, bot)} color="#555" strokeWidth={1} />
{paths.map((p, gi) =>
p ? <Path key={`g${gi}`} path={p} color={gateColors[gi] ?? '#fff'} style="stroke"
strokeWidth={1.5} strokeJoin="round" strokeCap="round" /> : null,
)}
</Canvas>
{yTicks.map((v, idx) => {
const y = toY(v);
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
const label = logScale ? (v >= 1000 ? `${v / 1000}k` : `${v}`) : formatUVShort(v);
return <Text key={`yl_${idx}`} style={[LBL.y, { top: y - 7, left: 2, width: CP.left - 4 }]}>{label}</Text>;
})}
{xTicks.map((i, idx) => (
<Text key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36 }]}>{i}</Text>
))}
<Text style={[LBL.y, { top: 2, left: 2 }]}>μV</Text>
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8 }]}>帧</Text>
</View>
);
}
const LBL = StyleSheet.create({
y: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'right' },
x: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'center' },
});
// ── SessionPickerModal ─────────────────────────────────────────────────────
function SessionPickerModal({ visible, sessions, allSessions, currentId, selectedId, onSelect, onClose }: {
visible: boolean; sessions: SessionInfo[]; allSessions: SessionInfo[];
currentId: string; selectedId: string;
onSelect: (id: string) => void; onClose: () => void;
}) {
const [showAll, setShowAll] = useState(false);
const source = showAll ? allSessions : sessions;
const listData = useMemo(() => {
const currentEntry = { sessionId: currentId, createdAt: 0, frameCount: -1, projectId: null };
const rest = source.filter(s => s.sessionId !== currentId);
return [currentEntry, ...rest];
}, [source, currentId]);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<TouchableOpacity style={SPM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={SPM.sheet}>
<View style={SPM.header}>
<Text style={SPM.title}>选择测线</Text>
<TouchableOpacity style={[SPM.allBtn, showAll && SPM.allBtnOn]} onPress={() => setShowAll(v => !v)}>
<Text style={[SPM.allTxt, showAll && SPM.allTxtOn]}>{showAll ? '全部' : '当前工程'}</Text>
</TouchableOpacity>
</View>
<FlatList
data={listData}
keyExtractor={item => item.sessionId}
style={SPM.list}
renderItem={({ item }) => {
const isCur = item.sessionId === currentId;
const isSel = item.sessionId === selectedId;
return (
<TouchableOpacity
style={[SPM.item, isSel && SPM.itemSel]}
onPress={() => { onSelect(item.sessionId); onClose(); }}
>
<Text style={SPM.itemId} numberOfLines={1}>
{item.sessionId}{isCur ? ' (当前)' : ''}
</Text>
{item.frameCount >= 0 && (
<Text style={SPM.itemCnt}>{item.frameCount} 帧</Text>
)}
</TouchableOpacity>
);
}}
/>
</View>
</Modal>
);
}
const SPM = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' },
sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, maxHeight: '55%' },
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, gap: 8 },
title: { flex: 1, color: '#6a6a8a', fontSize: 12, fontWeight: '700' },
allBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#0e0e1c' },
allBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
allTxt: { color: '#555', fontSize: 10, fontWeight: '700' },
allTxtOn: { color: '#4a9eff' },
list: { maxHeight: 280 },
item: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' },
itemSel: { backgroundColor: '#111130' },
itemId: { color: '#8888aa', fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
itemCnt: { color: '#4a4a6a', fontSize: 11, marginLeft: 8 },
});
// ── ProfileScreen ──────────────────────────────────────────────────────────
export default function ProfileScreen() {
const { width: sw, height: sh } = useWindowDimensions();
const { history, sessionId: currentSessionId, projectId } = useDataStore();
const sampleFreqCode = useDeviceStore(s => s.config.sampleFreq);
const gateConfig = useDeviceStore(s => s.gateConfig);
const setGateConfig = useDeviceStore(s => s.setGateConfig);
const [channelIdx, setChannelIdx] = useState(0);
const [sessionId, setSessionId] = useState(currentSessionId);
const [projectSessions, setProjectSessions] = useState<SessionInfo[]>([]);
const [allSessions, setAllSessions] = useState<SessionInfo[]>([]);
const [pickerVisible, setPickerVisible] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [logScale, setLogScale] = useState(true);
const { frames, loading, progress } = useProfileData(sessionId, currentSessionId, history);
const CHART_H = Math.round(Math.min(300, sh * 0.42));
useEffect(() => {
const load = async () => {
const all = await StorageService.listSessions();
setAllSessions(all.filter(x => x.sessionId !== currentSessionId));
if (projectId) {
const proj = await StorageService.listSessionsByProject(projectId);
setProjectSessions(proj.filter(x => x.sessionId !== currentSessionId));
} else {
setProjectSessions(all.filter(x => x.sessionId !== currentSessionId));
}
};
load();
}, [currentSessionId, projectId]);
const maxCh = frames[0]?.channelNum ?? 6;
useEffect(() => { if (channelIdx >= maxCh) setChannelIdx(0); }, [maxCh, channelIdx]);
const hz = SAMPLE_FREQ_HZ[sampleFreqCode] ?? 31250;
const sampleDepth = frames[0]?.sampleDepth ?? 0;
const gatePositions = useMemo(
() => computeGatePositions(gateConfig, sampleDepth, hz),
[gateConfig, sampleDepth, hz],
);
const gateColors = useMemo(
() => Array.from({ length: gateConfig.count }, (_, i) => gateColor(i, gateConfig.count)),
[gateConfig.count],
);
const refFrame = useMemo(() => frames[Math.floor(frames.length / 2)] ?? null, [frames]);
return (
<View style={SCR.root}>
{/* ── Header ── */}
<View style={SCR.header}>
<TouchableOpacity style={SCR.sessBtn} onPress={() => setPickerVisible(true)}>
<Text style={SCR.sessBtnTxt} numberOfLines={1}>{sessionId}</Text>
<Text style={SCR.sessBtnArrow}>▾</Text>
</TouchableOpacity>
<TouchableOpacity style={[SCR.scaleBtn, !logScale && SCR.scaleBtnOn]} onPress={() => setLogScale(v => !v)}>
<Text style={[SCR.scaleTxt, !logScale && SCR.scaleTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
</TouchableOpacity>
</View>
{/* ── Channel selector ── */}
<View style={SCR.chRow}>
{Array.from({ length: maxCh }, (_, i) => (
<TouchableOpacity key={i}
style={[SCR.chBtn, channelIdx === i && {
borderColor: CHANNEL_COLORS[i] + '99',
backgroundColor: CHANNEL_COLORS[i] + '1a',
}]}
onPress={() => setChannelIdx(i)}
>
<Text style={[SCR.chTxt, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
CH{i + 1}
</Text>
</TouchableOpacity>
))}
</View>
{/* ── Loading progress ── */}
{loading && (
<View style={SCR.loadRow}>
<ActivityIndicator size="small" color="#4a9eff" />
<Text style={SCR.loadTxt}>加载 {progress.current}/{progress.total} 帧…</Text>
</View>
)}
{/* ── Empty ── */}
{!loading && frames.length === 0 && (
<View style={SCR.empty}>
<Text style={SCR.emptyTxt}>暂无剖面数据</Text>
</View>
)}
{/* ── Charts ── */}
{frames.length > 0 && (
<ScrollView style={{ flex: 1 }} showsVerticalScrollIndicator={false}>
<GateLineChart
frames={frames} channelIdx={channelIdx}
gatePositions={gatePositions} gateColors={gateColors}
logScale={logScale} width={sw} height={CHART_H}
/>
{/* Gate preview + legend + settings */}
<View style={SCR.gateSection}>
<View style={SCR.gateHeader}>
<Text style={SCR.gateHdr}>
时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
</Text>
<TouchableOpacity style={SCR.settingsBtn} onPress={() => setSettingsVisible(true)}>
<Text style={SCR.settingsTxt}>⚙ 配置</Text>
</TouchableOpacity>
</View>
<GatePreview
refFrame={refFrame} channelIdx={channelIdx}
gatePositions={gatePositions} gateColors={gateColors}
width={sw}
/>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={SCR.legendScroll}>
<View style={SCR.legendRow}>
{gatePositions.map((pos, gi) => {
const tUs = sampleDepth > 0 && hz > 0 ? pos * (sampleDepth / hz) * 1e6 : 0;
return (
<View key={gi} style={SCR.legendItem}>
<View style={[SCR.legendDot, { backgroundColor: gateColors[gi] }]} />
<Text style={SCR.legendTxt}>G{gi + 1} {fmtUs(tUs)}</Text>
</View>
);
})}
</View>
</ScrollView>
</View>
</ScrollView>
)}
<SessionPickerModal
visible={pickerVisible}
sessions={projectSessions}
allSessions={allSessions}
currentId={currentSessionId}
selectedId={sessionId}
onSelect={setSessionId}
onClose={() => setPickerVisible(false)}
/>
<GateSettingsModal
visible={settingsVisible}
config={gateConfig}
onApply={setGateConfig}
onClose={() => setSettingsVisible(false)}
sampleDepth={sampleDepth}
hz={hz}
/>
</View>
);
}
const SCR = StyleSheet.create({
root: { flex: 1, backgroundColor: '#0d0d0d' },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 8, gap: 8 },
sessBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: '#111', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, borderWidth: 1, borderColor: '#222' },
sessBtnTxt: { flex: 1, color: '#6a9eff', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
sessBtnArrow: { color: '#555', fontSize: 10, marginLeft: 4 },
scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a1a' },
scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
scaleTxtOn: { color: '#4a9eff' },
chRow: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 6, gap: 6 },
chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a2a' },
chTxt: { color: '#555', fontSize: 11, fontWeight: '600' },
loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
loadTxt: { color: '#556', fontSize: 12 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
emptyTxt: { color: '#333', fontSize: 14 },
gateSection: { backgroundColor: '#111', marginTop: 2 },
gateHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
gateHdr: { flex: 1, color: '#444', fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a3a5a', backgroundColor: '#0a1428' },
settingsTxt: { color: '#4a7abf', fontSize: 11, fontWeight: '600' },
legendScroll: { paddingHorizontal: 12, paddingVertical: 8 },
legendRow: { flexDirection: 'row', gap: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4 },
legendTxt: { color: '#668', fontSize: 10 },
});
+545
View File
@@ -0,0 +1,545 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
Alert, TextInput, Modal, ActivityIndicator, Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import * as StorageService from '../../src/services/StorageService';
import { shareFile } from '../../src/utils/export';
import type { ProjectInfo, SessionInfo } from '../../src/services/StorageService';
import * as DocumentPicker from 'expo-document-picker';
// ── Name input modal (reused for create project / rename) ────────────────────
function NameModal({
visible,
title,
placeholder,
initial,
onConfirm,
onClose,
}: {
visible: boolean;
title: string;
placeholder: string;
initial?: string;
onConfirm: (name: string) => void;
onClose: () => void;
}) {
const [text, setText] = useState(initial ?? '');
useEffect(() => { if (visible) setText(initial ?? ''); }, [visible, initial]);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity style={NM.backdrop} activeOpacity={1} onPress={onClose} />
<View style={NM.box}>
<Text style={NM.title}>{title}</Text>
<TextInput
style={NM.input}
value={text}
onChangeText={setText}
placeholder={placeholder}
placeholderTextColor="#3a3a5a"
autoFocus
maxLength={40}
/>
<View style={NM.row}>
<TouchableOpacity style={NM.cancelBtn} onPress={onClose}>
<Text style={NM.cancelTxt}>取消</Text>
</TouchableOpacity>
<TouchableOpacity
style={[NM.confirmBtn, !text.trim() && NM.btnDis]}
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
disabled={!text.trim()}
>
<Text style={NM.confirmTxt}>确定</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const NM = StyleSheet.create({
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' },
box: { position: 'absolute', left: 24, right: 24, top: '35%', backgroundColor: '#111120', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#22223a' },
title: { color: '#9090b8', fontSize: 14, fontWeight: '700', marginBottom: 14 },
input: { backgroundColor: '#0c0c18', borderRadius: 8, padding: 12, color: '#d0d0e8', fontSize: 14, borderWidth: 1, borderColor: '#22223a', marginBottom: 16 },
row: { flexDirection: 'row', gap: 10 },
cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
cancelTxt: { color: '#6a6a8a', fontWeight: '600' },
confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, backgroundColor: '#1a3a6e', borderWidth: 1, borderColor: '#4a9eff', alignItems: 'center' },
confirmTxt: { color: '#4a9eff', fontWeight: '700' },
btnDis: { opacity: 0.4 },
});
// ── Line list panel (shown when a project is expanded) ───────────────────────
function LineList({
project,
currentSessionId,
currentProjectId,
onActivate,
onExport,
onDelete,
}: {
project: ProjectInfo;
currentSessionId: string;
currentProjectId: string | null;
onActivate: (sessionId: string) => void;
onExport: (sessionId: string) => void;
onDelete: (sessionId: string) => void;
}) {
const [lines, setLines] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const reload = useCallback(async () => {
setLoading(true);
const data = await StorageService.listSessionsByProject(project.projectId);
setLines(data);
setLoading(false);
}, [project.projectId]);
useEffect(() => { void reload(); }, [reload]);
if (loading) return <ActivityIndicator style={{ padding: 16 }} color="#4a9eff" size="small" />;
if (lines.length === 0) {
return <Text style={LL.empty}>暂无测线</Text>;
}
return (
<View style={LL.wrap}>
{lines.map((line) => {
const isActive = line.sessionId === currentSessionId;
const date = new Date(line.createdAt);
const dateStr = `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`;
return (
<View key={line.sessionId} style={[LL.row, isActive && LL.rowActive]}>
<View style={LL.rowLeft}>
{isActive && <View style={LL.activeDot} />}
<View>
<Text style={LL.lineId} numberOfLines={1}>{line.sessionId}</Text>
<Text style={LL.lineSub}>{dateStr} · {line.frameCount} 测点</Text>
</View>
</View>
<View style={LL.actions}>
{!isActive && (
<TouchableOpacity style={[LL.btn, LL.actBtn]} onPress={() => onActivate(line.sessionId)}>
<Text style={LL.actTxt}>激活</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[LL.btn, LL.expBtn]} onPress={() => onExport(line.sessionId)}>
<Text style={LL.expTxt}>导出</Text>
</TouchableOpacity>
{!isActive && (
<TouchableOpacity style={[LL.btn, LL.delBtn]} onPress={() => onDelete(line.sessionId)}>
<Text style={LL.delTxt}>删除</Text>
</TouchableOpacity>
)}
</View>
</View>
);
})}
</View>
);
}
const LL = StyleSheet.create({
wrap: { backgroundColor: '#08080f', marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' },
empty: { color: '#2a2a4a', fontSize: 12, textAlign: 'center', paddingVertical: 14 },
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#141422', gap: 8 },
rowActive: { backgroundColor: '#0d1a2e' },
rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
activeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#4a9eff' },
lineId: { color: '#6a6a9a', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
lineSub: { color: '#2a2a4a', fontSize: 10, marginTop: 1 },
actions: { flexDirection: 'row', gap: 6 },
btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
actBtn: { borderColor: '#1a3a60', backgroundColor: '#0a1830' },
actTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700' },
expBtn: { borderColor: '#1a4a28', backgroundColor: '#0a1810' },
expTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' },
delBtn: { borderColor: '#3a1520', backgroundColor: '#140a0c' },
delTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' },
});
// ── Main screen ──────────────────────────────────────────────────────────────
export default function ProjectsScreen() {
const insets = useSafeAreaInsets();
const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject } = useDataStore();
const { config: deviceConfig, gateConfig } = useDeviceStore();
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [expanded, setExpanded] = useState<string | null>(null);
const [exporting, setExporting] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
// Name modal state
const [modal, setModal] = useState<{
visible: boolean;
title: string;
placeholder: string;
initial?: string;
onConfirm: (name: string) => void;
}>({ visible: false, title: '', placeholder: '', onConfirm: () => {} });
const reload = useCallback(async () => {
const data = await StorageService.listProjects();
setProjects(data);
}, []);
useEffect(() => { void reload(); }, [reload]);
const openModal = (opts: typeof modal) => setModal({ ...opts, visible: true });
const closeModal = () => setModal((m) => ({ ...m, visible: false }));
const handleCreateProject = () => {
openModal({
visible: true,
title: '新建工程',
placeholder: '输入工程名称',
onConfirm: async (name) => {
const id = await StorageService.createProject(name);
await reload();
setExpanded(id);
},
});
};
const handleRenameProject = (project: ProjectInfo) => {
openModal({
visible: true,
title: '重命名工程',
placeholder: '新名称',
initial: project.name,
onConfirm: async (name) => {
await StorageService.renameProject(project.projectId, name);
await reload();
},
});
};
const handleDeleteProject = (project: ProjectInfo) => {
Alert.alert(
'删除工程',
`确定删除工程「${project.name}」?\n测线数据将保留,但与工程的关联会断开。`,
[
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive',
onPress: async () => {
await StorageService.deleteProject(project.projectId);
if (currentProjectId === project.projectId) await setProject(null);
await reload();
},
},
],
);
};
const handleNewLine = async (project: ProjectInfo) => {
await newSession(project.projectId);
await reload();
};
// Open project: resume latest line or create a new one.
const handleOpenProject = async (project: ProjectInfo) => {
const lines = await StorageService.listSessionsByProject(project.projectId);
const latest = lines[0]; // sorted DESC by created_at
if (!latest) {
await handleNewLine(project);
return;
}
Alert.alert(
`打开工程「${project.name}」`,
`最近测线: ${latest.sessionId}\n${latest.frameCount} 测点`,
[
{ text: '取消', style: 'cancel' },
{
text: '新建测线',
onPress: () => handleNewLine(project),
},
{
text: '继续最近测线',
onPress: async () => {
await resumeSession(latest.sessionId, project.projectId);
await reload();
},
},
],
);
};
const handleActivateLine = (sessionId: string, projectId: string) => {
Alert.alert(
'打开测线',
'将切换到该测线继续采集,当前数据已保存。',
[
{ text: '取消', style: 'cancel' },
{
text: '打开',
onPress: async () => {
await resumeSession(sessionId, projectId);
await reload();
},
},
],
);
};
const handleExportLine = async (sessionId: string) => {
setExporting(sessionId);
try {
const path = await StorageService.exportSessionMetaCsv(sessionId);
await shareFile(path);
} catch (e: any) {
Alert.alert('导出失败', e.message);
} finally {
setExporting(null);
}
};
const handleExportProject = async (project: ProjectInfo) => {
setExporting(project.projectId);
setProgress({ current: 0, total: 0 });
try {
const path = await StorageService.exportProject(
project.projectId,
deviceConfig,
gateConfig,
(current, total) => setProgress({ current, total }),
);
await shareFile(path);
} catch (e: any) {
Alert.alert('导出失败', e.message);
} finally {
setExporting(null);
}
};
const handleImportProject = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: '*/*',
copyToCacheDirectory: true,
});
if (result.canceled) return;
const uri = result.assets[0].uri;
setImporting(true);
setProgress({ current: 0, total: 0 });
const imported = await StorageService.importProject(
uri,
(current, total) => setProgress({ current, total }),
);
await reload();
Alert.alert(
'导入成功',
`工程「${imported.projectName}」已导入\n${imported.sessionCount} 条测线 · ${imported.frameCount} 测点`,
);
} catch (e: any) {
Alert.alert('导入失败', e.message);
} finally {
setImporting(false);
}
};
const handleDeleteLine = (sessionId: string) => {
Alert.alert('删除测线', `确定删除测线 ${sessionId} 的所有数据?`, [
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive',
onPress: async () => {
await StorageService.deleteSession(sessionId);
await reload();
},
},
]);
};
return (
<View style={[S.root, { paddingTop: insets.top }]}>
{/* Header */}
<View style={S.header}>
<Text style={S.headerTitle}>工程管理</Text>
<TouchableOpacity
style={[S.importBtn, importing && S.btnDis]}
onPress={handleImportProject}
disabled={importing}
activeOpacity={0.8}
>
{importing
? <ActivityIndicator color="#ffd93d" size="small" />
: <Text style={S.importBtnTxt}>导入 .tem</Text>}
</TouchableOpacity>
<TouchableOpacity style={S.addBtn} onPress={handleCreateProject} activeOpacity={0.8}>
<Text style={S.addBtnTxt}>+ 新建</Text>
</TouchableOpacity>
</View>
{/* Progress bar (export / import) */}
{(importing || exporting !== null) && progress.total > 0 && (
<View style={S.progressBar}>
<View style={[S.progressFill, { width: `${Math.round(progress.current / progress.total * 100)}%` as any }]} />
<Text style={S.progressTxt}>{progress.current}/{progress.total} 帧</Text>
</View>
)}
{/* Current context pill */}
<View style={S.contextBar}>
<Text style={S.contextLabel}>当前测线</Text>
<Text style={S.contextSession} numberOfLines={1}>{currentSessionId}</Text>
{currentProjectId ? (
<View style={S.projectPill}>
<Text style={S.projectPillTxt}>
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
</Text>
</View>
) : (
<View style={[S.projectPill, S.projectPillNone]}>
<Text style={[S.projectPillTxt, S.projectPillTxtNone]}>未分配工程</Text>
</View>
)}
</View>
{/* Project list */}
<FlatList
data={projects}
keyExtractor={(p) => p.projectId}
contentContainerStyle={{ paddingBottom: 32 }}
ListEmptyComponent={
<View style={S.empty}>
<Text style={S.emptyIcon}>◫</Text>
<Text style={S.emptyTxt}>暂无工程</Text>
<Text style={S.emptyHint}>点击右上角「新建工程」开始</Text>
</View>
}
renderItem={({ item: project }) => {
const isExpanded = expanded === project.projectId;
const isCurrentProject = project.projectId === currentProjectId;
return (
<View style={S.projectCard}>
{/* Project row */}
<TouchableOpacity
style={S.projectRow}
onPress={() => setExpanded(isExpanded ? null : project.projectId)}
activeOpacity={0.8}
>
<View style={S.projectRowLeft}>
<Text style={S.chevron}>{isExpanded ? '▾' : '▸'}</Text>
<View>
<View style={S.projectNameRow}>
<Text style={S.projectName}>{project.name}</Text>
{isCurrentProject && (
<View style={S.activePill}>
<Text style={S.activePillTxt}>当前</Text>
</View>
)}
</View>
<Text style={S.projectMeta}>
{project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
</Text>
</View>
</View>
<View style={S.projectActions}>
<TouchableOpacity style={[S.pBtn, S.pBtnNew]} onPress={() => handleOpenProject(project)}>
<Text style={S.pBtnNewTxt}>打开</Text>
</TouchableOpacity>
<TouchableOpacity
style={[S.pBtn, S.pBtnExp, exporting === project.projectId && S.btnDis]}
onPress={() => handleExportProject(project)}
disabled={exporting === project.projectId}
>
{exporting === project.projectId
? <ActivityIndicator color="#ffd93d" size="small" style={{ width: 28 }} />
: <Text style={S.pBtnExpTxt}>.tem</Text>}
</TouchableOpacity>
<TouchableOpacity style={S.pBtn} onPress={() => handleRenameProject(project)}>
<Text style={S.pBtnTxt}>改名</Text>
</TouchableOpacity>
<TouchableOpacity style={[S.pBtn, S.pBtnDel]} onPress={() => handleDeleteProject(project)}>
<Text style={S.pBtnDelTxt}>删除</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
{/* Expanded line list */}
{isExpanded && (
<LineList
project={project}
currentSessionId={currentSessionId}
currentProjectId={currentProjectId}
onActivate={(sid) => handleActivateLine(sid, project.projectId)}
onExport={handleExportLine}
onDelete={handleDeleteLine}
/>
)}
</View>
);
}}
/>
<NameModal
{...modal}
onClose={closeModal}
/>
</View>
);
}
const S = StyleSheet.create({
root: { flex: 1, backgroundColor: '#090912' },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' },
headerTitle:{ color: '#6a6a9a', fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 },
addBtn: { backgroundColor: '#1a3a6e', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1, borderColor: '#4a9eff' },
addBtnTxt: { color: '#4a9eff', fontSize: 12, fontWeight: '700' },
contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#0c0c18', borderBottomWidth: 1, borderBottomColor: '#141422', gap: 8 },
contextLabel: { color: '#2a2a4a', fontSize: 10, fontWeight: '600', letterSpacing: 1 },
contextSession:{ color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
projectPill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: '#1a3a6e' },
projectPillTxt:{ color: '#4a9eff', fontSize: 9, fontWeight: '700' },
projectPillNone: { backgroundColor: '#1a1a1a', borderColor: '#2a2a2a' },
projectPillTxtNone:{ color: '#3a3a5a' },
empty: { paddingTop: 80, alignItems: 'center', gap: 10 },
emptyIcon: { fontSize: 40, color: '#1a1a2a' },
emptyTxt: { color: '#2a2a4a', fontSize: 15 },
emptyHint: { color: '#1a1a2a', fontSize: 12 },
projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a', overflow: 'hidden', backgroundColor: '#0e0e1c' },
projectRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, gap: 10 },
projectRowLeft:{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
chevron: { color: '#3a3a5a', fontSize: 12, width: 12 },
projectNameRow:{ flexDirection: 'row', alignItems: 'center', gap: 8 },
projectName: { color: '#c0c0d8', fontSize: 14, fontWeight: '700' },
projectMeta: { color: '#3a3a5a', fontSize: 10, marginTop: 2 },
activePill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1, borderColor: '#4a9eff55' },
activePillTxt: { color: '#4a9eff', fontSize: 8, fontWeight: '700' },
importBtn: { backgroundColor: '#1e1a08', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, borderColor: '#ffd93d66', marginRight: 8, minWidth: 70, alignItems: 'center' },
importBtnTxt: { color: '#ffd93d', fontSize: 12, fontWeight: '700' },
btnDis: { opacity: 0.4 },
progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, backgroundColor: '#0c0c18', borderRadius: 10, overflow: 'hidden', borderWidth: 1, borderColor: '#1a1a2a', justifyContent: 'center' },
progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, backgroundColor: '#1a3a6e', borderRadius: 10 },
progressTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700', textAlign: 'center' },
projectActions:{ flexDirection: 'row', gap: 5 },
pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#111120', alignItems: 'center', justifyContent: 'center' },
pBtnTxt: { color: '#5a5a7a', fontSize: 10, fontWeight: '600' },
pBtnNew: { borderColor: '#1a4a28', backgroundColor: '#0a1810' },
pBtnNewTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' },
pBtnExp: { borderColor: '#4a3a1a', backgroundColor: '#1a1408' },
pBtnExpTxt: { color: '#ffd93d', fontSize: 10, fontWeight: '700' },
pBtnDel: { borderColor: '#3a1520', backgroundColor: '#140a0c' },
pBtnDelTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' },
});
+278 -96
View File
@@ -1,28 +1,113 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
View, Text, FlatList, TouchableOpacity, Modal,
StyleSheet, Alert, ActivityIndicator, Platform,
useWindowDimensions, ScrollView,
} from 'react-native';
import { router } from 'expo-router';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { exportCsv, shareFile } from '../../src/utils/export';
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
import { CHANNEL_COLORS } from '../../src/protocol/constants';
import { WaveformChart } from '../../src/components/WaveformChart';
import { computeWaveformData } from '../../src/hooks/useWaveform';
import type { MeasurementFrame } from '../../src/protocol/types';
// ── Waveform modal ──────────────────────────────────────────────────────────
function FrameWaveformModal({
frame,
onClose,
}: {
frame: MeasurementFrame;
onClose: () => void;
}) {
const { width } = useWindowDimensions();
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
const [logScale, setLogScale] = useState(true);
const visible = useMemo(
() => Array(frame.meta?.channelNum ?? frame.adcUV.length).fill(true),
[frame],
);
const data = useMemo(
() => computeWaveformData(frame, visible, sampleFreqCode),
[frame, visible, sampleFreqCode],
);
const CHART_H = Math.min(260, Math.round(width * 0.6));
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<View style={M.backdrop}>
<View style={M.sheet}>
<View style={M.header}>
<Text style={M.title}>帧 #{frame.frameId}</Text>
<TouchableOpacity
style={[M.scaleBtn, !logScale && M.scaleBtnOn]}
onPress={() => setLogScale((v) => !v)}
>
<Text style={[M.scaleTxt, !logScale && M.scaleTxtOn]}>
{logScale ? 'LOG' : 'LIN'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={M.closeBtn} onPress={onClose}>
<Text style={M.closeTxt}>✕</Text>
</TouchableOpacity>
</View>
{data ? (
<WaveformChart
data={data}
visibleChannels={visible}
width={width - 32}
height={CHART_H}
logScale={logScale}
/>
) : (
<View style={[{ height: CHART_H }, M.noData]}>
<Text style={M.noDataTxt}>无波形数据</Text>
</View>
)}
<ScrollView horizontal style={M.peakRow} showsHorizontalScrollIndicator={false}>
{frame.adcUV.map((ch, i) => {
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
return (
<View key={i} style={M.peakCell}>
<View style={[M.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
<Text style={M.peakCh}>CH{i + 1}</Text>
<Text style={M.peakVal}>{formatUV(pk)}</Text>
</View>
);
})}
</ScrollView>
{frame.meta && (
<View style={M.meta}>
<Text style={M.metaTxt}>
UTC {formatUtc(frame.meta.utc)} ×{frame.accNum}叠 增益×{frame.gain}
</Text>
<Text style={M.metaTxt}>
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)}
</Text>
</View>
)}
</View>
</View>
</Modal>
);
}
// ── Main screen ─────────────────────────────────────────────────────────────
export default function RecordsScreen() {
const { history, sessionId, clearHistory } = useDataStore();
const [exporting, setExporting] = useState(false);
const { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore();
const [exporting, setExporting] = useState(false);
const [selectedFrame, setSelectedFrame] = useState<MeasurementFrame | null>(null);
const handleExportAll = async () => {
if (history.length === 0) {
Alert.alert('无数据', '当前会话没有采集记录');
return;
}
if (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
setExporting(true);
try {
const path = await exportCsv(history, sessionId);
@@ -41,119 +126,216 @@ export default function RecordsScreen() {
]);
};
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;
const handleNewSession = () => {
Alert.alert('新建测线', '结束当前测线,开始新测线?', [
{ text: '取消', style: 'cancel' },
{ text: '新建', onPress: () => newSession(projectId) },
]);
};
const handleDeleteFrame = (item: MeasurementFrame) => {
Alert.alert(
`删除测点 #${item.frameId}`,
'确定删除该测点?此操作不可撤销。',
[
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: () => deleteFrame(item.frameId) },
],
);
};
const renderItem = ({ item, index }: { item: MeasurementFrame; index: number }) => {
const m = item.meta;
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>
<TouchableOpacity
style={S.item}
onPress={() => setSelectedFrame(item)}
activeOpacity={0.75}
>
<View style={S.itemLeft}>
<Text style={S.itemIndex}>{String(history.length - index).padStart(3, '0')}</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 }}>
<View style={S.itemBody}>
<View style={S.itemHeader}>
<Text style={S.frameId}>#{item.frameId}</Text>
<Text style={S.time}>{m ? formatUtc(m.utc) : '--'}</Text>
{m && (
<View style={[S.gpsBadge, { backgroundColor: m.gpsStatus > 0 ? '#0d2018' : '#1a0d10' }]}>
<Text style={{ color: m.gpsStatus > 0 ? '#3ddc84' : '#ff5c6e', fontSize: 8, fontWeight: '700' }}>
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
</Text>
</View>
)}
<View style={S.waveHint}>
<Text style={S.waveHintText}>波形 ›</Text>
</View>
</>
)}
</View>
<TouchableOpacity style={S.itemDelBtn} onPress={() => handleDeleteFrame(item)} hitSlop={8}>
<Text style={S.itemDelTxt}>×</Text>
</TouchableOpacity>
</View>
{m && (
<Text style={S.coord}>
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
</Text>
)}
<View style={S.itemFooter}>
<View style={S.peakRow}>
{item.adcUV.slice(0, item.meta?.channelNum ?? 0).map((ch, i) => {
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
return (
<View key={i} style={S.peakChip}>
<View style={[S.peakDot, { backgroundColor: CHANNEL_COLORS[i] }]} />
<Text style={S.peakVal}>{formatUV(pk)}</Text>
</View>
);
})}
</View>
<View style={S.metaRow}>
<Text style={S.metaTag}>×{item.accNum}叠</Text>
<Text style={S.metaTag}>增益×{item.gain}</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
};
return (
<View style={styles.container}>
<View style={S.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 style={S.toolbar}>
<View style={S.toolbarLeft}>
<Text style={S.sessionId} numberOfLines={1}>{sessionId}</Text>
<Text style={S.count}>{history.length} 测点</Text>
</View>
<TouchableOpacity
style={[S.toolBtn, S.exportBtn, exporting && S.btnDisabled]}
onPress={handleExportAll}
disabled={exporting}
activeOpacity={0.8}
>
{exporting
? <ActivityIndicator color="#3ddc84" size="small" />
: <Text style={S.exportText}>导出 CSV</Text>}
</TouchableOpacity>
<TouchableOpacity style={[S.toolBtn, S.newBtn]} onPress={handleNewSession} activeOpacity={0.8}>
<Text style={S.newText}>新建</Text>
</TouchableOpacity>
<TouchableOpacity style={[S.toolBtn, S.clearBtn]} onPress={handleClear} activeOpacity={0.8}>
<Text style={S.clearText}>清空</Text>
</TouchableOpacity>
</View>
{history.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyText}>暂无采集记录</Text>
<Text style={styles.emptyHint}>在控制台开始采集后,数据将显示在这里</Text>
<View style={S.empty}>
<Text style={S.emptyIcon}>◌</Text>
<Text style={S.emptyText}>暂无采集记录</Text>
<Text style={S.emptyHint}>在波形页开始采集后,数据将显示在这里</Text>
</View>
) : (
<FlatList
data={history}
keyExtractor={(item) => String(item.frameId)}
renderItem={renderItem}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={styles.sep} />}
contentContainerStyle={S.list}
/>
)}
{selectedFrame && (
<FrameWaveformModal
frame={selectedFrame}
onClose={() => setSelectedFrame(null)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
// ── Styles ──────────────────────────────────────────────────────────────────
const S = StyleSheet.create({
container: { flex: 1, backgroundColor: '#090912' },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
backgroundColor: '#111',
borderBottomWidth: 1,
borderBottomColor: '#2a2a2a',
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 10,
backgroundColor: '#0c0c18',
borderBottomWidth: 1, borderBottomColor: '#1a1a2a',
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 },
toolbarLeft: { flex: 1, gap: 2 },
sessionId: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
count: { color: '#6a6a8a', fontSize: 13, fontWeight: '700' },
toolBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 8, borderWidth: 1 },
exportBtn: { borderColor: '#1a4a28', backgroundColor: '#0d2018' },
exportText: { color: '#3ddc84', fontSize: 12, fontWeight: '700' },
newBtn: { borderColor: '#2a3a1a', backgroundColor: '#141e0d' },
newText: { color: '#aad464', fontSize: 12, fontWeight: '600' },
clearBtn: { borderColor: '#3a1520', backgroundColor: '#1e0d12' },
clearText: { color: '#ff5c6e', fontSize: 12, fontWeight: '600' },
btnDisabled: { opacity: 0.4 },
list: { padding: 10, gap: 8 },
list: { padding: 12, gap: 6 },
item: {
backgroundColor: '#181818',
borderRadius: 8,
padding: 10,
borderWidth: 1,
borderColor: '#2a2a2a',
flexDirection: 'row',
backgroundColor: '#0e0e1c',
borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a',
overflow: 'hidden',
},
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 },
itemLeft: {
width: 36, backgroundColor: '#0a0a14',
alignItems: 'center', justifyContent: 'center',
borderRightWidth: 1, borderRightColor: '#141422',
},
itemIndex: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
itemBody: { flex: 1, padding: 10, gap: 5 },
itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
time: { color: '#3a3a5a', fontSize: 10, flex: 1, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
gpsBadge: { borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
waveHint: { backgroundColor: '#0d1a2e', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
waveHintText:{ color: '#2a5a9f', fontSize: 9, fontWeight: '600' },
itemDelBtn: { width: 22, height: 22, borderRadius: 11, backgroundColor: '#2a0a0e', borderWidth: 1, borderColor: '#3a1520', alignItems: 'center', justifyContent: 'center' },
itemDelTxt: { color: '#ff5c6e', fontSize: 14, lineHeight: 18, fontWeight: '700' },
coord: { color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
itemFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 6 },
peakRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
peakChip: { flexDirection: 'row', alignItems: 'center', gap: 3 },
peakDot: { width: 5, height: 5, borderRadius: 3 },
peakVal: { color: '#6a6a8a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
metaRow: { flexDirection: 'row', gap: 6 },
metaTag: { color: '#2a2a4a', fontSize: 9, backgroundColor: '#111120', borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 },
emptyIcon: { fontSize: 40, color: '#1a1a2a' },
emptyText: { color: '#2a2a4a', fontSize: 15 },
emptyHint: { color: '#1a1a2a', fontSize: 12 },
});
const M = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, paddingBottom: 32 },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
title: { color: '#9090b8', fontSize: 14, fontWeight: '700', flex: 1 },
scaleBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a2a' },
scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700' },
scaleTxtOn: { color: '#4a9eff' },
closeBtn: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#1a1a2a', alignItems: 'center', justifyContent: 'center' },
closeTxt: { color: '#6a6a8a', fontSize: 14 },
noData: { backgroundColor: '#111', borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
noDataTxt:{ color: '#444' },
peakRow: { flexDirection: 'row', marginTop: 10, gap: 8 },
peakCell: { alignItems: 'center', backgroundColor: '#111120', borderRadius: 8, padding: 8, borderWidth: 1, borderColor: '#1e1e30' },
dot: { width: 6, height: 6, borderRadius: 3, marginBottom: 3 },
peakCh: { color: '#4a4a6a', fontSize: 9 },
peakVal: { color: '#9090b8', fontSize: 11, fontWeight: '600', fontFamily: 'monospace' },
meta: { marginTop: 10, backgroundColor: '#0a0a12', borderRadius: 8, padding: 10, gap: 3 },
metaTxt: { color: '#3a3a5a', fontSize: 10, fontFamily: 'monospace' },
});
-31
View File
@@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});
+558
View File
@@ -0,0 +1,558 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View, Text, StyleSheet, TouchableOpacity, ScrollView,
Modal, ActivityIndicator, useWindowDimensions, Platform,
} from 'react-native';
import { useConnectionStore } from '../../src/stores/connectionStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { useDataStore } from '../../src/stores/dataStore';
import { useDevice } from '../../src/hooks/useDevice';
import { useWaveform } from '../../src/hooks/useWaveform';
import { WaveformChart } from '../../src/components/WaveformChart';
import { ParamForm } from '../../src/components/ParamForm';
import { CHANNEL_COLORS } from '../../src/protocol/constants';
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
import { Colors, Spacing, Radius } from '../../src/design/tokens';
import type { MeasurementFrame } from '../../src/protocol/types';
// ── Param sheet ───────────────────────────────────────────────────────────────
function ParamSheet({
visible,
onClose,
onSetup,
busy,
configDirty,
}: {
visible: boolean;
onClose: () => void;
onSetup: () => Promise<void>;
busy: boolean;
configDirty: boolean;
}) {
const { deviceStatus } = useDeviceStore();
const measuring = deviceStatus !== 'idle';
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
statusBarTranslucent
>
<TouchableOpacity style={PS.backdrop} activeOpacity={1} onPress={onClose} />
<View style={PS.sheet}>
<View style={PS.handle} />
<View style={PS.header}>
<Text style={PS.title}>采集参数</Text>
{configDirty && (
<View style={PS.dirtyBadge}>
<Text style={PS.dirtyTxt}>待下发</Text>
</View>
)}
</View>
<ScrollView style={PS.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<ParamForm />
<View style={{ height: Spacing.md }} />
</ScrollView>
<TouchableOpacity
style={[
PS.setupBtn,
configDirty && PS.setupBtnDirty,
(busy || measuring) && PS.setupBtnDis,
]}
onPress={onSetup}
disabled={busy || measuring}
activeOpacity={0.8}
>
{busy
? <ActivityIndicator color={Colors.blue.fg} />
: <Text style={[PS.setupBtnTxt, configDirty && PS.setupBtnTxtDirty]}>
{measuring ? '采集中,不可下发' : '下 发 配 置'}
</Text>}
</TouchableOpacity>
</View>
</Modal>
);
}
const PS = StyleSheet.create({
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' },
sheet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
maxHeight: '85%',
backgroundColor: Colors.bg.surface,
borderTopLeftRadius: Radius.xl,
borderTopRightRadius: Radius.xl,
borderTopWidth: 1,
borderColor: Colors.bg.border,
paddingBottom: Spacing.xxl,
},
handle: {
width: 36, height: 4, borderRadius: 2,
backgroundColor: Colors.bg.border,
alignSelf: 'center',
marginTop: Spacing.sm, marginBottom: Spacing.sm,
},
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.lg, paddingBottom: Spacing.sm, gap: Spacing.sm },
title: { color: Colors.text.secondary, fontSize: 14, fontWeight: '700', flex: 1 },
dirtyBadge: { backgroundColor: Colors.amber.bg, borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: Colors.amber.border },
dirtyTxt: { color: Colors.amber.fg, fontSize: 9, fontWeight: '700' },
scroll: { flex: 1 },
setupBtn: {
margin: Spacing.lg,
backgroundColor: Colors.bg.raised,
borderRadius: Radius.lg,
paddingVertical: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: Colors.bg.border,
},
setupBtnDirty: { backgroundColor: Colors.blue.bg, borderColor: Colors.blue.fg },
setupBtnDis: { opacity: 0.4 },
setupBtnTxt: { color: Colors.text.muted, fontWeight: '700', fontSize: 14, letterSpacing: 3 },
setupBtnTxtDirty: { color: Colors.blue.fg },
});
// ── Control row ───────────────────────────────────────────────────────────────
function ControlRow({
deviceStatus,
busy,
frameId,
onContinuous,
onSingle,
onStop,
onSettings,
configDirty,
}: {
deviceStatus: 'idle' | 'running' | 'single';
busy: boolean;
frameId: number | undefined;
onContinuous: () => void;
onSingle: () => void;
onStop: () => void;
onSettings: () => void;
configDirty: boolean;
}) {
const idle = deviceStatus === 'idle';
const running = deviceStatus === 'running';
const single = deviceStatus === 'single';
return (
<View style={CR.row}>
{/* Primary action */}
{idle && (
<TouchableOpacity
style={[CR.primary, CR.green, !busy ? null : CR.dis]}
onPress={onContinuous}
disabled={busy}
activeOpacity={0.85}
>
{busy
? <ActivityIndicator color={Colors.green.fg} />
: <>
<Text style={CR.primaryIcon}>▶</Text>
<Text style={[CR.primaryTxt, { color: Colors.green.fg }]}>连 续 采 集</Text>
</>}
</TouchableOpacity>
)}
{running && (
<TouchableOpacity
style={[CR.primary, CR.red]}
onPress={onStop}
activeOpacity={0.85}
>
<Text style={CR.primaryIcon}>■</Text>
<Text style={[CR.primaryTxt, { color: Colors.red.fg }]}>停 止</Text>
</TouchableOpacity>
)}
{single && (
<View style={[CR.primary, CR.teal, CR.dis]}>
<ActivityIndicator color={Colors.teal.fg} size="small" />
<Text style={[CR.primaryTxt, { color: Colors.teal.fg }]}>单次采集中…</Text>
</View>
)}
{/* Secondary: single-shot button (idle only) */}
{idle && (
<TouchableOpacity
style={[CR.secondary, CR.tealBtn, !busy ? null : CR.dis]}
onPress={onSingle}
disabled={busy}
activeOpacity={0.85}
>
<Text style={CR.secondaryIcon}>◎</Text>
<Text style={[CR.secondaryTxt, { color: Colors.teal.fg }]}>单次</Text>
</TouchableOpacity>
)}
{/* Running indicator (running only) */}
{running && frameId !== undefined && (
<View style={CR.indicator}>
<View style={CR.runDot} />
<Text style={CR.indicatorTxt}>#{frameId}</Text>
</View>
)}
{/* Settings button — always present */}
<TouchableOpacity style={CR.settingsBtn} onPress={onSettings} activeOpacity={0.8}>
<Text style={[CR.settingsTxt, configDirty && CR.settingsDirty]}>⚙</Text>
{configDirty && <View style={CR.dirtyDot} />}
</TouchableOpacity>
</View>
);
}
const CR = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
gap: Spacing.sm,
borderTopWidth: 1,
borderTopColor: Colors.bg.border,
backgroundColor: Colors.bg.surface,
},
primary: {
flex: 2,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 14,
borderRadius: Radius.lg,
borderWidth: 1,
},
green: { backgroundColor: Colors.green.bg, borderColor: Colors.green.border },
red: { backgroundColor: Colors.red.bg, borderColor: Colors.red.border },
teal: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
dis: { opacity: 0.35 },
primaryIcon: { fontSize: 11, color: '#ffffff88' },
primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 },
secondary: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 5,
paddingVertical: 14,
borderRadius: Radius.lg,
borderWidth: 1,
},
tealBtn: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
secondaryIcon:{ fontSize: 11, color: '#ffffff66' },
secondaryTxt: { fontSize: 12, fontWeight: '700' },
indicator: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 14,
borderRadius: Radius.lg,
backgroundColor: Colors.green.bg,
borderWidth: 1,
borderColor: Colors.green.border,
},
runDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.green.fg },
indicatorTxt: { color: Colors.green.fg, fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
settingsBtn: {
width: 44, height: 44,
borderRadius: Radius.md,
backgroundColor: Colors.bg.raised,
borderWidth: 1, borderColor: Colors.bg.border,
alignItems: 'center', justifyContent: 'center',
},
settingsTxt: { fontSize: 18, color: Colors.text.muted },
settingsDirty: { color: Colors.amber.fg },
dirtyDot: {
position: 'absolute', top: 6, right: 6,
width: 7, height: 7, borderRadius: 4,
backgroundColor: Colors.amber.fg,
borderWidth: 1, borderColor: Colors.bg.surface,
},
});
// ── Peak row ──────────────────────────────────────────────────────────────────
function PeakRow({ frame, visible, channelNum }: { frame: MeasurementFrame; visible: boolean[]; channelNum: number }) {
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={PK.scroll}>
<View style={PK.row}>
{frame.adcUV.map((ch, i) => {
if (i >= channelNum || !visible[i]) return null;
const pk = ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
return (
<View key={i} style={PK.cell}>
<View style={[PK.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
<Text style={PK.label}>CH{i + 1}</Text>
<Text style={[PK.value, { color: CHANNEL_COLORS[i] + 'cc' }]}>{formatUV(pk)}</Text>
</View>
);
})}
</View>
</ScrollView>
);
}
const PK = StyleSheet.create({
scroll: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
row: { flexDirection: 'row', paddingHorizontal: Spacing.md, paddingVertical: 7, gap: 10 },
cell: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: Colors.bg.surface, borderRadius: Radius.sm, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: Colors.bg.border },
dot: { width: 5, height: 5, borderRadius: 3 },
label: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600' },
value: { fontSize: 11, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
// ── Frame summary bar ─────────────────────────────────────────────────────────
function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
const m = frame.meta;
return (
<View style={FS.bar}>
<Text style={FS.txt}>
#{frame.frameId}
{' '}×{frame.accNum}叠
{m ? ` ${formatUtc(m.utc)}` : ''}
{m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''}
</Text>
</View>
);
}
const FS = StyleSheet.create({
bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, backgroundColor: Colors.bg.void, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
txt: { color: Colors.text.ghost, fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
});
// ── Not connected placeholder ─────────────────────────────────────────────────
function NotConnected({ onConnect }: { onConnect: () => void }) {
return (
<View style={NC.root}>
<Text style={NC.icon}>◈</Text>
<Text style={NC.title}>未连接仪器</Text>
<Text style={NC.sub}>请先连接 TEM 接收机</Text>
<TouchableOpacity style={NC.btn} onPress={onConnect} activeOpacity={0.8}>
<Text style={NC.btnTxt}>连 接 设 备</Text>
</TouchableOpacity>
</View>
);
}
const NC = StyleSheet.create({
root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
icon: { fontSize: 48, color: Colors.bg.border },
title: { color: Colors.text.muted, fontSize: 17, fontWeight: '700' },
sub: { color: Colors.text.ghost, fontSize: 12 },
btn: {
marginTop: Spacing.sm,
backgroundColor: Colors.blue.bg,
borderRadius: Radius.lg,
paddingVertical: 13,
paddingHorizontal: 32,
borderWidth: 1, borderColor: Colors.blue.fg,
},
btnTxt: { color: Colors.blue.fg, fontSize: 14, fontWeight: '700', letterSpacing: 2 },
});
// ── Main screen ───────────────────────────────────────────────────────────────
export default function WaveScreen() {
const { width: sw, height: sh } = useWindowDimensions();
const { status, showModal } = useConnectionStore();
const { config, configDirty } = useDeviceStore();
const { deviceStatus } = useDeviceStore();
const { busy, setup, startContinuous, startSingle, stop } = useDevice();
const frame = useDataStore((s) => s.currentFrame);
const sessionId = useDataStore((s) => s.sessionId);
const projectId = useDataStore((s) => s.projectId);
const [visible, setVisible] = useState(() => Array(6).fill(true));
const [logScale, setLogScale] = useState(true);
const [sheetVisible, setSheetVisible] = useState(false);
const [projectName, setProjectName] = useState<string | null>(null);
const connected = status === 'connected';
const channelNum = config.channelNum;
const CHART_H = Math.round(Math.min(280, sh * 0.36));
const data = useWaveform(visible);
useEffect(() => {
if (!projectId) { setProjectName(null); return; }
import('../../src/services/StorageService').then(({ listProjects }) =>
listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)),
);
}, [projectId]);
const toggleChannel = (idx: number) =>
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
const handleSetup = async () => {
await setup();
setSheetVisible(false);
};
if (!connected) {
return (
<View style={S.root}>
<NotConnected onConnect={showModal} />
</View>
);
}
return (
<View style={S.root}>
{/* ── Context bar: project · session ── */}
<View style={S.ctxBar}>
{projectName
? <Text style={S.ctxTxt} numberOfLines={1}>{projectName} · {sessionId}</Text>
: <Text style={S.ctxTxt} numberOfLines={1}>{sessionId}</Text>}
</View>
{/* ── Channel selector + LOG/LIN ── */}
<View style={S.toolbar}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
{Array.from({ length: channelNum }, (_, i) => (
<TouchableOpacity
key={i}
style={[
S.chBtn,
visible[i] && { borderColor: CHANNEL_COLORS[i] + '99', backgroundColor: CHANNEL_COLORS[i] + '1a' },
]}
onPress={() => toggleChannel(i)}
activeOpacity={0.7}
>
<View style={[S.chDot, { backgroundColor: visible[i] ? CHANNEL_COLORS[i] : Colors.bg.border }]} />
<Text style={[S.chTxt, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
</TouchableOpacity>
))}
</ScrollView>
<TouchableOpacity
style={[S.logBtn, !logScale && S.logBtnOn]}
onPress={() => setLogScale((v) => !v)}
activeOpacity={0.7}
>
<Text style={[S.logTxt, !logScale && S.logTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
</TouchableOpacity>
</View>
{/* ── Waveform chart ── */}
<View style={S.chartWrap}>
{data ? (
<WaveformChart
data={data}
visibleChannels={visible}
width={sw}
height={CHART_H}
logScale={logScale}
/>
) : (
<View style={[S.chartPlaceholder, { height: CHART_H }]}>
<Text style={S.placeholderTxt}>等待采样数据…</Text>
</View>
)}
</View>
{/* ── Peak values ── */}
{frame && <PeakRow frame={frame} visible={visible} channelNum={channelNum} />}
{/* ── Frame summary ── */}
{frame && <FrameSummaryBar frame={frame} />}
{/* ── Control row ── */}
<ControlRow
deviceStatus={deviceStatus}
busy={busy}
frameId={frame?.frameId}
onContinuous={startContinuous}
onSingle={startSingle}
onStop={stop}
onSettings={() => setSheetVisible(true)}
configDirty={configDirty}
/>
{/* ── Param sheet ── */}
<ParamSheet
visible={sheetVisible}
onClose={() => setSheetVisible(false)}
onSetup={handleSetup}
busy={busy}
configDirty={configDirty}
/>
</View>
);
}
const S = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg.base },
ctxBar: {
paddingHorizontal: Spacing.md,
paddingVertical: 4,
backgroundColor: Colors.bg.void,
borderBottomWidth: 1,
borderBottomColor: Colors.bg.divider,
},
ctxTxt: {
color: Colors.text.ghost,
fontSize: 9,
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
paddingRight: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: Colors.bg.divider,
},
chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 },
chBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 5,
borderRadius: Radius.md,
borderWidth: 1,
borderColor: Colors.bg.border,
},
chDot: { width: 6, height: 6, borderRadius: 3 },
chTxt: { color: Colors.text.ghost, fontSize: 10, fontWeight: '600' },
logBtn: {
paddingHorizontal: 9,
paddingVertical: 5,
borderRadius: Radius.sm,
borderWidth: 1,
borderColor: Colors.bg.border,
backgroundColor: Colors.bg.raised,
marginLeft: 4,
},
logBtnOn: { borderColor: Colors.blue.border, backgroundColor: Colors.blue.bg },
logTxt: { color: Colors.text.muted, fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
logTxtOn: { color: Colors.blue.fg },
chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.bg.divider },
chartPlaceholder: {
backgroundColor: Colors.bg.surface,
justifyContent: 'center',
alignItems: 'center',
},
placeholderTxt: { color: Colors.text.ghost, fontSize: 13 },
});
+60 -46
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, useWindowDimensions, ScrollView } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, ScrollView } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
import { WaveformChart } from '../../src/components/WaveformChart';
import { ChannelSelector } from '../../src/components/ChannelSelector';
@@ -9,68 +10,79 @@ import { useDeviceStore } from '../../src/stores/deviceStore';
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
export default function WaveformScreen() {
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const { width, height: windowHeight } = useWindowDimensions();
const channelNum = useDeviceStore((s) => s.config.channelNum);
const [visible, setVisible] = useState(() => Array(6).fill(true));
const [logScale, setLogScale] = useState(true);
const frame = useDataStore((s) => s.currentFrame);
const data = useWaveform(visible);
const data = useWaveform(visible);
const toggleChannel = (idx: number) => {
const toggleChannel = (idx: number) =>
setVisible((prev) => prev.map((v, i) => (i === idx ? !v : v)));
};
const CHART_HEIGHT = 280;
const CHART_HEIGHT = Math.round(Math.min(300, windowHeight * 0.38));
return (
<View style={styles.container}>
<View style={[S.container, { paddingTop: insets.top }]}>
<DeviceStatusBar />
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
<View style={S.toolbar}>
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
<TouchableOpacity
style={[S.scaleBtn, !logScale && S.scaleBtnActive]}
onPress={() => setLogScale((v) => !v)}
activeOpacity={0.7}
>
<Text style={[S.scaleBtnText, !logScale && S.scaleBtnTextActive]}>
{logScale ? 'LOG' : 'LIN'}
</Text>
</TouchableOpacity>
</View>
{/* Waveform chart */}
<View style={styles.chartWrap}>
<View style={S.chartWrap}>
{data ? (
<WaveformChart
data={data}
visibleChannels={visible}
width={width}
height={CHART_HEIGHT}
logScale={logScale}
/>
) : (
<View style={[styles.placeholder, { height: CHART_HEIGHT }]}>
<Text style={styles.placeholderText}>等待采样数据...</Text>
<View style={[S.placeholder, { height: CHART_HEIGHT }]}>
<Text style={S.placeholderText}>等待采样数据...</Text>
</View>
)}
</View>
{/* Channel peak values */}
{/* Per-channel peak values + metadata */}
{frame && (
<ScrollView style={styles.peakScroll}>
<View style={styles.peakRow}>
<ScrollView style={S.peakScroll}>
<View style={S.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 key={idx} style={S.peakCell}>
<Text style={S.peakLabel}>CH{idx + 1}</Text>
<Text style={S.peakValue}>{formatUV(absMax)}</Text>
</View>
);
})}
</View>
{/* Frame metadata */}
{frame.meta && (
<View style={styles.metaBlock}>
<Text style={styles.metaText}>
<View style={S.metaBlock}>
<Text style={S.metaText}>
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
</Text>
<Text style={styles.metaText}>
<Text style={S.metaText}>
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
</Text>
<Text style={styles.metaText}>
<Text style={S.metaText}>
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
</Text>
</View>
@@ -81,34 +93,36 @@ export default function WaveformScreen() {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
placeholder: {
backgroundColor: '#1a1a1a',
justifyContent: 'center',
alignItems: 'center',
},
const S = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0d0d0d' },
toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 },
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
placeholder: { backgroundColor: '#1a1a1a', justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#444', fontSize: 14 },
scaleBtn: {
marginLeft: 8,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
borderWidth: 1,
borderColor: '#333',
backgroundColor: '#1a1a1a',
},
scaleBtnActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
scaleBtnText: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
scaleBtnTextActive:{ color: '#4a9eff' },
peakScroll: { flex: 1 },
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
peakCell: {
backgroundColor: '#1a1a1a',
borderRadius: 8,
padding: 10,
minWidth: 90,
alignItems: 'center',
borderWidth: 1,
borderColor: '#2a2a2a',
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' },
metaBlock: { margin: 12, backgroundColor: '#111', borderRadius: 8, padding: 10, gap: 3 },
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
});