基本功能完成
This commit is contained in:
@@ -4,11 +4,7 @@ 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 = Colors.blue.fg;
|
||||
const INACTIVE = '#555';
|
||||
const TAB_BG = '#111111';
|
||||
import { useTheme } from '../../src/design/tokens';
|
||||
|
||||
function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) {
|
||||
if (Platform.OS === 'ios') {
|
||||
@@ -19,16 +15,17 @@ function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: str
|
||||
|
||||
export default function TabLayout() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: Colors.bg.base, paddingTop: insets.top }}>
|
||||
<View style={{ flex: 1, backgroundColor: theme.bg.base, paddingTop: insets.top }}>
|
||||
<GlobalStatusBar />
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: { backgroundColor: TAB_BG, borderTopColor: '#222' },
|
||||
tabBarActiveTintColor: ACTIVE,
|
||||
tabBarInactiveTintColor: INACTIVE,
|
||||
tabBarStyle: { backgroundColor: theme.bg.void, borderTopColor: theme.bg.border },
|
||||
tabBarActiveTintColor: theme.blue.fg,
|
||||
tabBarInactiveTintColor: theme.text.muted,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
}}
|
||||
>
|
||||
|
||||
+56
-60
@@ -6,9 +6,11 @@ import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
||||
import { ParamForm } from '../../src/components/ParamForm';
|
||||
import { useDevice } from '../../src/hooks/useDevice';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { useTheme } from '../../src/design/tokens';
|
||||
|
||||
export default function ControlScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const sessionId = useDataStore((s) => s.sessionId);
|
||||
@@ -30,11 +32,15 @@ export default function ControlScreen() {
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<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>
|
||||
<View style={[S.notConnected, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
|
||||
<Text style={[S.notConnectedIcon, { color: theme.text.ghost }]}>⊘</Text>
|
||||
<Text style={[S.notConnectedText, { color: theme.text.muted }]}>未连接设备</Text>
|
||||
<TouchableOpacity
|
||||
style={[S.goConnectBtn, { backgroundColor: theme.bg.raised, borderColor: theme.blue.fg }]}
|
||||
onPress={() => router.push('/connect')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[S.goConnectBtnText, { color: theme.blue.fg }]}>前往连接</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -46,54 +52,54 @@ export default function ControlScreen() {
|
||||
const canStop = (running || single) && !busy;
|
||||
|
||||
return (
|
||||
<View style={[S.container, { paddingTop: insets.top }]}>
|
||||
<View style={[S.container, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
|
||||
<DeviceStatusBar />
|
||||
{/* Project / line context */}
|
||||
<View style={S.contextBar}>
|
||||
<View style={[S.contextBar, { backgroundColor: theme.bg.void, borderBottomColor: theme.bg.divider }]}>
|
||||
{projectName
|
||||
? <Text style={S.contextText} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||
: <Text style={S.contextText} numberOfLines={1}>{sessionId}</Text>
|
||||
? <Text style={[S.contextText, { color: theme.text.ghost }]} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||
: <Text style={[S.contextText, { color: theme.text.ghost }]} numberOfLines={1}>{sessionId}</Text>
|
||||
}
|
||||
</View>
|
||||
|
||||
{/* Control buttons */}
|
||||
<View style={S.ctrlRow}>
|
||||
<TouchableOpacity
|
||||
style={[S.ctrlBtn, S.btnStart, !canStart && S.btnDisabled]}
|
||||
style={[S.ctrlBtn, { backgroundColor: theme.green.bg, borderColor: theme.green.border }, !canStart && S.btnDisabled]}
|
||||
onPress={startContinuous}
|
||||
disabled={!canStart}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy && !canStop
|
||||
? <ActivityIndicator color="#3ddc84" size="small" />
|
||||
? <ActivityIndicator color={theme.green.fg} size="small" />
|
||||
: <>
|
||||
<Text style={S.ctrlIcon}>▶</Text>
|
||||
<Text style={[S.ctrlText, S.ctrlTextGreen]}>连续采集</Text>
|
||||
<Text style={[S.ctrlText, { color: theme.green.fg }]}>连续采集</Text>
|
||||
</>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[S.ctrlBtn, S.btnSingle, !canStart && S.btnDisabled]}
|
||||
style={[S.ctrlBtn, { backgroundColor: theme.blue.bg, borderColor: theme.blue.border }, !canStart && S.btnDisabled]}
|
||||
onPress={startSingle}
|
||||
disabled={!canStart}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={S.ctrlIcon}>◎</Text>
|
||||
<Text style={[S.ctrlText, S.ctrlTextBlue]}>单 次</Text>
|
||||
<Text style={[S.ctrlText, { color: theme.blue.fg }]}>单 次</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[S.ctrlBtn, S.btnStop, !canStop && S.btnDisabled]}
|
||||
style={[S.ctrlBtn, { backgroundColor: theme.red.bg, borderColor: theme.red.border }, !canStop && S.btnDisabled]}
|
||||
onPress={stop}
|
||||
disabled={!canStop}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy && canStop
|
||||
? <ActivityIndicator color="#ff5c6e" size="small" />
|
||||
? <ActivityIndicator color={theme.red.fg} size="small" />
|
||||
: <>
|
||||
<Text style={S.ctrlIcon}>■</Text>
|
||||
<Text style={[S.ctrlText, S.ctrlTextRed]}>停 止</Text>
|
||||
<Text style={[S.ctrlText, { color: theme.red.fg }]}>停 止</Text>
|
||||
</>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
@@ -101,25 +107,25 @@ export default function ControlScreen() {
|
||||
|
||||
{/* Frame info */}
|
||||
{frame && (
|
||||
<View style={S.frameBar}>
|
||||
<View style={[S.frameBar, { backgroundColor: theme.bg.surface, borderColor: theme.bg.border }]}>
|
||||
<View style={S.frameItem}>
|
||||
<Text style={S.frameItemLabel}>帧号</Text>
|
||||
<Text style={S.frameItemValue}>#{frame.frameId}</Text>
|
||||
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>帧号</Text>
|
||||
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>#{frame.frameId}</Text>
|
||||
</View>
|
||||
<View style={S.frameSep} />
|
||||
<View style={[S.frameSep, { backgroundColor: theme.bg.border }]} />
|
||||
<View style={S.frameItem}>
|
||||
<Text style={S.frameItemLabel}>叠加</Text>
|
||||
<Text style={S.frameItemValue}>{frame.accNum}次</Text>
|
||||
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>叠加</Text>
|
||||
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>{frame.accNum}次</Text>
|
||||
</View>
|
||||
<View style={S.frameSep} />
|
||||
<View style={[S.frameSep, { backgroundColor: theme.bg.border }]} />
|
||||
<View style={S.frameItem}>
|
||||
<Text style={S.frameItemLabel}>通道</Text>
|
||||
<Text style={S.frameItemValue}>{frame.meta?.channelNum ?? '-'}ch</Text>
|
||||
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>通道</Text>
|
||||
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>{frame.meta?.channelNum ?? '-'}ch</Text>
|
||||
</View>
|
||||
<View style={S.frameSep} />
|
||||
<View style={[S.frameSep, { backgroundColor: theme.bg.border }]} />
|
||||
<View style={S.frameItem}>
|
||||
<Text style={S.frameItemLabel}>电流</Text>
|
||||
<Text style={S.frameItemValue}>{frame.meta?.current ?? '--'}</Text>
|
||||
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>电流</Text>
|
||||
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>{frame.meta?.current ?? '--'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -127,23 +133,23 @@ export default function ControlScreen() {
|
||||
{/* Param form */}
|
||||
<ScrollView style={S.formScroll} keyboardShouldPersistTaps="handled">
|
||||
<View style={S.formHeader}>
|
||||
<Text style={S.formTitle}>采集参数</Text>
|
||||
<Text style={[S.formTitle, { color: theme.text.muted }]}>采集参数</Text>
|
||||
{configDirty && (
|
||||
<View style={S.dirtyBadge}>
|
||||
<Text style={S.dirtyBadgeText}>待下发</Text>
|
||||
<View style={[S.dirtyBadge, { backgroundColor: theme.amber.bg, borderColor: theme.amber.border }]}>
|
||||
<Text style={[S.dirtyBadgeText, { color: theme.amber.fg }]}>待下发</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
||||
<TouchableOpacity
|
||||
style={[S.setupBtn, busy && S.btnDisabled]}
|
||||
style={[S.setupBtn, { backgroundColor: theme.bg.raised, borderColor: theme.blue.fg }, busy && S.btnDisabled]}
|
||||
onPress={handleSetup}
|
||||
disabled={busy}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator color="#4a9eff" />
|
||||
: <Text style={S.setupBtnText}>下 发 配 置</Text>}
|
||||
? <ActivityIndicator color={theme.blue.fg} />
|
||||
: <Text style={[S.setupBtnText, { color: theme.blue.fg }]}>下 发 配 置</Text>}
|
||||
</TouchableOpacity>
|
||||
<View style={{ height: 32 }} />
|
||||
</ScrollView>
|
||||
@@ -152,12 +158,12 @@ export default function ControlScreen() {
|
||||
}
|
||||
|
||||
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' },
|
||||
container: { flex: 1 },
|
||||
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
|
||||
notConnectedIcon: { fontSize: 48 },
|
||||
notConnectedText: { fontSize: 15 },
|
||||
goConnectBtn: { borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1 },
|
||||
goConnectBtnText: { fontWeight: '600' },
|
||||
|
||||
// Control buttons
|
||||
ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
||||
@@ -170,50 +176,40 @@ const S = StyleSheet.create({
|
||||
gap: 4,
|
||||
borderWidth: 1,
|
||||
},
|
||||
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',
|
||||
marginHorizontal: 12,
|
||||
marginBottom: 8,
|
||||
backgroundColor: '#0c0c1a',
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1a1a2a',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
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 },
|
||||
frameItemLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
||||
frameItemValue: { fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
frameSep: { width: StyleSheet.hairlineWidth, 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' },
|
||||
formTitle: { fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' },
|
||||
dirtyBadge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1 },
|
||||
dirtyBadgeText: { 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 },
|
||||
setupBtnText: { 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' },
|
||||
contextBar: { paddingHorizontal: 16, paddingVertical: 5, borderBottomWidth: 1 },
|
||||
contextText: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
});
|
||||
|
||||
+137
-183
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity, Modal, FlatList,
|
||||
View, Text, StyleSheet, TouchableOpacity, Modal,
|
||||
ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView,
|
||||
} from 'react-native';
|
||||
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
|
||||
@@ -9,8 +9,10 @@ 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';
|
||||
import { useTheme, type ThemeColors } from '../../src/design/tokens';
|
||||
import { NoProjectGate } from '../../src/components/NoProjectGate';
|
||||
import { SessionSelector } from '../../src/components/SessionSelector';
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,8 +103,9 @@ function useProfileData(sessionId: string, currentSessionId: string, history: Me
|
||||
}));
|
||||
}, [sessionId, currentSessionId, history]);
|
||||
|
||||
const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId === currentSessionId) { setHistFrames([]); return; }
|
||||
setLoading(true);
|
||||
setHistFrames([]);
|
||||
let cancelled = false;
|
||||
@@ -124,11 +127,11 @@ function useProfileData(sessionId: string, currentSessionId: string, history: Me
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId, currentSessionId]);
|
||||
}, [sessionId, latestFrameId]);
|
||||
|
||||
return {
|
||||
frames: sessionId === currentSessionId ? liveFrames : histFrames,
|
||||
loading: sessionId !== currentSessionId && loading,
|
||||
frames: histFrames,
|
||||
loading,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +146,7 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
||||
sampleDepth: number;
|
||||
hz: number;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const [tStartStr, setTStartStr] = useState(String(config.tStart));
|
||||
const [tEndStr, setTEndStr] = useState(String(config.tEnd));
|
||||
const [countStr, setCountStr] = useState(String(config.count));
|
||||
@@ -170,23 +174,31 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
||||
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.sheet, { backgroundColor: theme.bg.surface }]}>
|
||||
<Text style={[GSM.title, { color: theme.text.secondary }]}>时间门配置</Text>
|
||||
|
||||
<View style={GSM.row}>
|
||||
<View style={GSM.field}>
|
||||
<Text style={GSM.label}>起始时间 (μs)</Text>
|
||||
<Text style={[GSM.label, { color: theme.text.muted }]}>起始时间 (μs)</Text>
|
||||
<TextInput
|
||||
style={GSM.input}
|
||||
style={[GSM.input, {
|
||||
backgroundColor: theme.bg.raised,
|
||||
borderColor: theme.bg.border,
|
||||
color: theme.text.secondary,
|
||||
}]}
|
||||
keyboardType="numeric"
|
||||
value={tStartStr}
|
||||
onChangeText={setTStartStr}
|
||||
/>
|
||||
</View>
|
||||
<View style={GSM.field}>
|
||||
<Text style={GSM.label}>终止时间 (μs)</Text>
|
||||
<Text style={[GSM.label, { color: theme.text.muted }]}>终止时间 (μs)</Text>
|
||||
<TextInput
|
||||
style={GSM.input}
|
||||
style={[GSM.input, {
|
||||
backgroundColor: theme.bg.raised,
|
||||
borderColor: theme.bg.border,
|
||||
color: theme.text.secondary,
|
||||
}]}
|
||||
keyboardType="numeric"
|
||||
value={tEndStr}
|
||||
onChangeText={setTEndStr}
|
||||
@@ -196,24 +208,34 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
||||
|
||||
<View style={[GSM.row, { marginTop: 6 }]}>
|
||||
<View style={GSM.field}>
|
||||
<Text style={GSM.label}>门数量</Text>
|
||||
<Text style={[GSM.label, { color: theme.text.muted }]}>门数量</Text>
|
||||
<TextInput
|
||||
style={GSM.input}
|
||||
style={[GSM.input, {
|
||||
backgroundColor: theme.bg.raised,
|
||||
borderColor: theme.bg.border,
|
||||
color: theme.text.secondary,
|
||||
}]}
|
||||
keyboardType="numeric"
|
||||
value={countStr}
|
||||
onChangeText={setCountStr}
|
||||
/>
|
||||
</View>
|
||||
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
|
||||
<Text style={GSM.label}>间隔方式</Text>
|
||||
<View style={GSM.toggle}>
|
||||
<Text style={[GSM.label, { color: theme.text.muted }]}>间隔方式</Text>
|
||||
<View style={[GSM.toggle, { borderColor: theme.bg.border }]}>
|
||||
{(['log', 'linear'] as GateSpacing[]).map(s => (
|
||||
<TouchableOpacity
|
||||
key={s}
|
||||
style={[GSM.toggleBtn, spacing === s && GSM.toggleBtnOn]}
|
||||
style={[GSM.toggleBtn,
|
||||
{ backgroundColor: theme.bg.raised },
|
||||
spacing === s && { backgroundColor: theme.blue.bg },
|
||||
]}
|
||||
onPress={() => setSpacing(s)}
|
||||
>
|
||||
<Text style={[GSM.toggleTxt, spacing === s && GSM.toggleTxtOn]}>
|
||||
<Text style={[GSM.toggleTxt,
|
||||
{ color: theme.text.muted },
|
||||
spacing === s && { color: theme.blue.fg },
|
||||
]}>
|
||||
{s === 'log' ? '对数' : '线性'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -223,15 +245,18 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
||||
</View>
|
||||
|
||||
{sampleDepth > 0 && hz > 0 && (
|
||||
<Text style={GSM.hint}>当前采样窗口: {fmtUs(maxUs)}</Text>
|
||||
<Text style={[GSM.hint, { color: theme.text.ghost }]}>当前采样窗口: {fmtUs(maxUs)}</Text>
|
||||
)}
|
||||
|
||||
<View style={GSM.actions}>
|
||||
<TouchableOpacity style={GSM.cancelBtn} onPress={onClose}>
|
||||
<Text style={GSM.cancelTxt}>取消</Text>
|
||||
<TouchableOpacity style={[GSM.cancelBtn, { borderColor: theme.bg.border }]} onPress={onClose}>
|
||||
<Text style={[GSM.cancelTxt, { color: theme.text.muted }]}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={GSM.applyBtn} onPress={handleApply}>
|
||||
<Text style={GSM.applyTxt}>应用</Text>
|
||||
<TouchableOpacity style={[GSM.applyBtn, {
|
||||
backgroundColor: theme.blue.bg,
|
||||
borderColor: theme.blue.fg + '55',
|
||||
}]} onPress={handleApply}>
|
||||
<Text style={[GSM.applyTxt, { color: theme.blue.fg }]}>应用</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -241,23 +266,21 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
||||
|
||||
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 },
|
||||
sheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
|
||||
title: { 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 },
|
||||
label: { fontSize: 11, fontWeight: '600' },
|
||||
input: { borderRadius: 8, borderWidth: 1, fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
|
||||
toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1 },
|
||||
toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center' },
|
||||
toggleTxt: { fontSize: 12, fontWeight: '700' },
|
||||
hint: { 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' },
|
||||
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' },
|
||||
cancelTxt: { fontSize: 13, fontWeight: '600' },
|
||||
applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' },
|
||||
applyTxt: { fontSize: 13, fontWeight: '700' },
|
||||
});
|
||||
|
||||
// ── GatePreview ────────────────────────────────────────────────────────────
|
||||
@@ -271,6 +294,7 @@ function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }:
|
||||
gateColors: string[];
|
||||
width: number;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const plotW = width - GP.l - GP.r;
|
||||
const plotH = CANVAS_H - GP.t - GP.b;
|
||||
|
||||
@@ -302,7 +326,7 @@ function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }:
|
||||
|
||||
return (
|
||||
<Canvas style={{ width, height: CANVAS_H }}>
|
||||
{wavePath && <Path path={wavePath} color="#445566" style="stroke" strokeWidth={1} />}
|
||||
{wavePath && <Path path={wavePath} color={theme.chart.grid} style="stroke" strokeWidth={1} />}
|
||||
{gatePositions.map((pos, gi) => (
|
||||
<Line key={gi}
|
||||
p1={vec(GP.l + pos * plotW, GP.t)}
|
||||
@@ -321,6 +345,7 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale
|
||||
gatePositions: number[]; gateColors: string[];
|
||||
logScale: boolean; width: number; height: number;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const plotW = width - CP.left - CP.right;
|
||||
const plotH = height - CP.top - CP.bottom;
|
||||
|
||||
@@ -397,16 +422,16 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale
|
||||
{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} />;
|
||||
return <Line key={`y_${idx}`} p1={vec(left, y)} p2={vec(right, y)} color={theme.chart.gridFine} 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} />
|
||||
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color={theme.chart.gridFine} 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, toY(0))} p2={vec(right, toY(0))} color={theme.chart.axis} 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} />
|
||||
<Line p1={vec(left, bot)} p2={vec(right, bot)} color={theme.chart.axis} strokeWidth={1} />
|
||||
<Line p1={vec(left, top)} p2={vec(left, bot)} color={theme.chart.axis} 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,
|
||||
@@ -416,126 +441,40 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale
|
||||
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>;
|
||||
return <Text key={`yl_${idx}`} style={[LBL.y, { top: y - 7, left: 2, width: CP.left - 4, color: theme.chart.label }]}>{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 key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36, color: theme.chart.label }]}>{i}</Text>
|
||||
))}
|
||||
<Text style={[LBL.y, { top: 2, left: 2 }]}>μV</Text>
|
||||
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8 }]}>帧</Text>
|
||||
<Text style={[LBL.y, { top: 2, left: 2, color: theme.chart.label }]}>μV</Text>
|
||||
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8, color: theme.chart.label }]}>帧</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 },
|
||||
y: { position: 'absolute', fontSize: 9, textAlign: 'right' },
|
||||
x: { position: 'absolute', fontSize: 9, textAlign: 'center' },
|
||||
});
|
||||
|
||||
// ── ProfileScreen ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const theme = useTheme();
|
||||
const { width: sw, height: sh } = useWindowDimensions();
|
||||
const { history, sessionId: currentSessionId, projectId } = useDataStore();
|
||||
const { history, sessionId, projectId, hasProject, resumeSession } = 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 [logScale, setLogScale] = useState(false);
|
||||
|
||||
const { frames, loading, progress } = useProfileData(sessionId, currentSessionId, history);
|
||||
const { frames, loading, progress } = useProfileData(sessionId, sessionId, 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]);
|
||||
|
||||
@@ -553,17 +492,44 @@ export default function ProfileScreen() {
|
||||
|
||||
const refFrame = useMemo(() => frames[Math.floor(frames.length / 2)] ?? null, [frames]);
|
||||
|
||||
const handleSessionChange = async (newSessionId: string) => {
|
||||
await resumeSession(newSessionId, projectId!);
|
||||
};
|
||||
|
||||
// ── NoProjectGate ──
|
||||
if (!hasProject) {
|
||||
return (
|
||||
<View style={[SCR.root, { backgroundColor: theme.bg.void }]}>
|
||||
<NoProjectGate />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={SCR.root}>
|
||||
<View style={[SCR.root, { backgroundColor: theme.bg.void }]}>
|
||||
|
||||
{/* ── 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>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SessionSelector
|
||||
selectedSessionId={sessionId}
|
||||
projectId={projectId!}
|
||||
onSelect={handleSessionChange}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[SCR.scaleBtn, {
|
||||
borderColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.base,
|
||||
}, !logScale && {
|
||||
borderColor: theme.blue.fg + '55',
|
||||
backgroundColor: theme.blue.bg,
|
||||
}]}
|
||||
onPress={() => setLogScale(v => !v)}
|
||||
>
|
||||
<Text style={[SCR.scaleTxt, { color: theme.chart.axis }, !logScale && { color: theme.blue.fg }]}>
|
||||
{logScale ? 'LOG' : 'LIN'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -571,13 +537,13 @@ export default function ProfileScreen() {
|
||||
<View style={SCR.chRow}>
|
||||
{Array.from({ length: maxCh }, (_, i) => (
|
||||
<TouchableOpacity key={i}
|
||||
style={[SCR.chBtn, channelIdx === i && {
|
||||
style={[SCR.chBtn, { borderColor: theme.bg.border }, 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] }]}>
|
||||
<Text style={[SCR.chTxt, { color: theme.chart.axis }, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
|
||||
CH{i + 1}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -587,15 +553,15 @@ export default function ProfileScreen() {
|
||||
{/* ── Loading progress ── */}
|
||||
{loading && (
|
||||
<View style={SCR.loadRow}>
|
||||
<ActivityIndicator size="small" color="#4a9eff" />
|
||||
<Text style={SCR.loadTxt}>加载 {progress.current}/{progress.total} 帧…</Text>
|
||||
<ActivityIndicator size="small" color={theme.blue.fg} />
|
||||
<Text style={[SCR.loadTxt, { color: theme.text.muted }]}>加载 {progress.current}/{progress.total} 帧…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Empty ── */}
|
||||
{!loading && frames.length === 0 && (
|
||||
<View style={SCR.empty}>
|
||||
<Text style={SCR.emptyTxt}>暂无剖面数据</Text>
|
||||
<Text style={[SCR.emptyTxt, { color: theme.text.ghost }]}>暂无剖面数据</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -610,13 +576,16 @@ export default function ProfileScreen() {
|
||||
/>
|
||||
|
||||
{/* Gate preview + legend + settings */}
|
||||
<View style={SCR.gateSection}>
|
||||
<View style={[SCR.gateSection, { backgroundColor: theme.bg.base }]}>
|
||||
<View style={SCR.gateHeader}>
|
||||
<Text style={SCR.gateHdr}>
|
||||
<Text style={[SCR.gateHdr, { color: theme.chart.badge }]}>
|
||||
时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
|
||||
</Text>
|
||||
<TouchableOpacity style={SCR.settingsBtn} onPress={() => setSettingsVisible(true)}>
|
||||
<Text style={SCR.settingsTxt}>⚙ 配置</Text>
|
||||
<TouchableOpacity style={[SCR.settingsBtn, {
|
||||
borderColor: theme.blue.border,
|
||||
backgroundColor: theme.blue.bg,
|
||||
}]} onPress={() => setSettingsVisible(true)}>
|
||||
<Text style={[SCR.settingsTxt, { color: theme.blue.fg }]}>⚙ 配置</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -633,7 +602,7 @@ export default function ProfileScreen() {
|
||||
return (
|
||||
<View key={gi} style={SCR.legendItem}>
|
||||
<View style={[SCR.legendDot, { backgroundColor: gateColors[gi] }]} />
|
||||
<Text style={SCR.legendTxt}>G{gi + 1} {fmtUs(tUs)}</Text>
|
||||
<Text style={[SCR.legendTxt, { color: theme.text.muted }]}>G{gi + 1} {fmtUs(tUs)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@@ -644,16 +613,6 @@ export default function ProfileScreen() {
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<SessionPickerModal
|
||||
visible={pickerVisible}
|
||||
sessions={projectSessions}
|
||||
allSessions={allSessions}
|
||||
currentId={currentSessionId}
|
||||
selectedId={sessionId}
|
||||
onSelect={setSessionId}
|
||||
onClose={() => setPickerVisible(false)}
|
||||
/>
|
||||
|
||||
<GateSettingsModal
|
||||
visible={settingsVisible}
|
||||
config={gateConfig}
|
||||
@@ -667,32 +626,27 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
const SCR = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
root: { flex: 1 },
|
||||
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' },
|
||||
scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1 },
|
||||
scaleTxt: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
||||
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' },
|
||||
chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||
chTxt: { fontSize: 11, fontWeight: '600' },
|
||||
loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
|
||||
loadTxt: { color: '#556', fontSize: 12 },
|
||||
loadTxt: { fontSize: 12 },
|
||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
|
||||
emptyTxt: { color: '#333', fontSize: 14 },
|
||||
emptyTxt: { fontSize: 14 },
|
||||
|
||||
gateSection: { backgroundColor: '#111', marginTop: 2 },
|
||||
gateSection: { 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' },
|
||||
gateHdr: { flex: 1, fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
|
||||
settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||
settingsTxt: { 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 },
|
||||
legendTxt: { fontSize: 10 },
|
||||
});
|
||||
|
||||
+206
-115
@@ -10,6 +10,7 @@ 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';
|
||||
import { useTheme, type ThemeColors } from '../../src/design/tokens';
|
||||
|
||||
// ── Name input modal (reused for create project / rename) ────────────────────
|
||||
|
||||
@@ -28,33 +29,44 @@ function NameModal({
|
||||
onConfirm: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
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>
|
||||
<View style={[NM.box, {
|
||||
backgroundColor: theme.bg.raised,
|
||||
borderColor: theme.bg.border,
|
||||
}]}>
|
||||
<Text style={[NM.title, { color: theme.text.secondary }]}>{title}</Text>
|
||||
<TextInput
|
||||
style={NM.input}
|
||||
style={[NM.input, {
|
||||
backgroundColor: theme.bg.void,
|
||||
color: theme.text.primary,
|
||||
borderColor: theme.bg.border,
|
||||
}]}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#3a3a5a"
|
||||
placeholderTextColor={theme.text.muted}
|
||||
autoFocus
|
||||
maxLength={40}
|
||||
/>
|
||||
<View style={NM.row}>
|
||||
<TouchableOpacity style={NM.cancelBtn} onPress={onClose}>
|
||||
<Text style={NM.cancelTxt}>取消</Text>
|
||||
<TouchableOpacity style={[NM.cancelBtn, { borderColor: theme.bg.border }]} onPress={onClose}>
|
||||
<Text style={[NM.cancelTxt, { color: theme.text.muted }]}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[NM.confirmBtn, !text.trim() && NM.btnDis]}
|
||||
style={[NM.confirmBtn, {
|
||||
backgroundColor: theme.blue.border,
|
||||
borderColor: theme.blue.fg,
|
||||
}, !text.trim() && NM.btnDis]}
|
||||
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
<Text style={NM.confirmTxt}>确定</Text>
|
||||
<Text style={[NM.confirmTxt, { color: theme.blue.fg }]}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -64,14 +76,14 @@ function NameModal({
|
||||
|
||||
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 },
|
||||
box: { position: 'absolute', left: 24, right: 24, top: '35%', borderRadius: 16, padding: 20, borderWidth: 1 },
|
||||
title: { fontSize: 14, fontWeight: '700', marginBottom: 14 },
|
||||
input: { borderRadius: 8, padding: 12, fontSize: 14, borderWidth: 1, 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' },
|
||||
cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' },
|
||||
cancelTxt: { fontWeight: '600' },
|
||||
confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' },
|
||||
confirmTxt: { fontWeight: '700' },
|
||||
btnDis: { opacity: 0.4 },
|
||||
});
|
||||
|
||||
@@ -84,6 +96,8 @@ function LineList({
|
||||
onActivate,
|
||||
onExport,
|
||||
onDelete,
|
||||
onNewLine,
|
||||
onReload,
|
||||
}: {
|
||||
project: ProjectInfo;
|
||||
currentSessionId: string;
|
||||
@@ -91,7 +105,11 @@ function LineList({
|
||||
onActivate: (sessionId: string) => void;
|
||||
onExport: (sessionId: string) => void;
|
||||
onDelete: (sessionId: string) => void;
|
||||
onNewLine: () => void;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1);
|
||||
const [lines, setLines] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -102,74 +120,101 @@ function LineList({
|
||||
setLoading(false);
|
||||
}, [project.projectId]);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
useEffect(() => { void reload(); }, [reload, latestFrameId]);
|
||||
|
||||
if (loading) return <ActivityIndicator style={{ padding: 16 }} color="#4a9eff" size="small" />;
|
||||
if (loading) return <ActivityIndicator style={{ padding: 16 }} color={theme.blue.fg} size="small" />;
|
||||
if (lines.length === 0) {
|
||||
return <Text style={LL.empty}>暂无测线</Text>;
|
||||
return <Text style={[LL.empty, { color: theme.text.ghost }]}>暂无测线</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={LL.wrap}>
|
||||
<View style={[LL.wrap, { backgroundColor: theme.bg.void }]}>
|
||||
{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 key={line.sessionId} style={[LL.row, { borderBottomColor: theme.bg.divider }, isActive && { backgroundColor: theme.blue.bg }]}>
|
||||
<View style={LL.rowLeft}>
|
||||
{isActive && <View style={LL.activeDot} />}
|
||||
{isActive && <View style={[LL.activeDot, { backgroundColor: theme.blue.fg }]} />}
|
||||
<View>
|
||||
<Text style={LL.lineId} numberOfLines={1}>{line.sessionId}</Text>
|
||||
<Text style={LL.lineSub}>{dateStr} · {line.frameCount} 测点</Text>
|
||||
<Text style={[LL.lineId, { color: theme.text.muted }]} numberOfLines={1}>{line.sessionId}</Text>
|
||||
<Text style={[LL.lineSub, { color: theme.text.ghost }]}>{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 style={[LL.btn, {
|
||||
borderColor: theme.blue.border,
|
||||
backgroundColor: theme.blue.bg,
|
||||
}]} onPress={() => onActivate(line.sessionId)}>
|
||||
<Text style={[LL.actTxt, { color: theme.blue.fg }]}>激活</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={[LL.btn, LL.expBtn]} onPress={() => onExport(line.sessionId)}>
|
||||
<Text style={LL.expTxt}>导出</Text>
|
||||
<TouchableOpacity style={[LL.btn, {
|
||||
borderColor: theme.green.border,
|
||||
backgroundColor: theme.green.bg,
|
||||
}]} onPress={() => onExport(line.sessionId)}>
|
||||
<Text style={[LL.expTxt, { color: theme.green.fg }]}>导出</Text>
|
||||
</TouchableOpacity>
|
||||
{!isActive && (
|
||||
<TouchableOpacity style={[LL.btn, LL.delBtn]} onPress={() => onDelete(line.sessionId)}>
|
||||
<Text style={LL.delTxt}>删除</Text>
|
||||
<TouchableOpacity style={[LL.btn, {
|
||||
borderColor: theme.red.border,
|
||||
backgroundColor: theme.red.bg,
|
||||
}]} onPress={() => {
|
||||
Alert.alert('删除测线', '确定删除该测线的所有数据?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除', style: 'destructive',
|
||||
onPress: async () => {
|
||||
await StorageService.deleteSession(line.sessionId);
|
||||
await reload();
|
||||
onReload();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}>
|
||||
<Text style={[LL.delTxt, { color: theme.red.fg }]}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<TouchableOpacity
|
||||
style={[LL.addBtn, { borderColor: theme.blue.border }]}
|
||||
onPress={async () => { onNewLine(); await reload(); }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[LL.addTxt, { color: theme.blue.fg }]}>+ 新建测线</Text>
|
||||
</TouchableOpacity>
|
||||
</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 },
|
||||
wrap: { marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' },
|
||||
empty: { fontSize: 12, textAlign: 'center', paddingVertical: 14 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, gap: 4 },
|
||||
rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
activeDot: { width: 6, height: 6, borderRadius: 3 },
|
||||
lineId: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
lineSub: { 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' },
|
||||
actTxt: { fontSize: 10, fontWeight: '700' },
|
||||
expTxt: { fontSize: 10, fontWeight: '700' },
|
||||
delTxt: { fontSize: 10, fontWeight: '600' },
|
||||
addBtn: { alignItems: 'center', paddingVertical: 10, borderTopWidth: StyleSheet.hairlineWidth, borderStyle: 'dashed' },
|
||||
addTxt: { fontSize: 11, fontWeight: '700' },
|
||||
});
|
||||
|
||||
// ── Main screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProjectsScreen() {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject } = useDataStore();
|
||||
const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject, resetToNoProject, hasProject } = useDataStore();
|
||||
const { config: deviceConfig, gateConfig } = useDeviceStore();
|
||||
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
@@ -204,6 +249,10 @@ export default function ProjectsScreen() {
|
||||
placeholder: '输入工程名称',
|
||||
onConfirm: async (name) => {
|
||||
const id = await StorageService.createProject(name);
|
||||
// First project — auto-create initial session
|
||||
if (!useDataStore.getState().hasProject) {
|
||||
await newSession(id);
|
||||
}
|
||||
await reload();
|
||||
setExpanded(id);
|
||||
},
|
||||
@@ -233,7 +282,19 @@ export default function ProjectsScreen() {
|
||||
text: '删除', style: 'destructive',
|
||||
onPress: async () => {
|
||||
await StorageService.deleteProject(project.projectId);
|
||||
if (currentProjectId === project.projectId) await setProject(null);
|
||||
const remaining = await StorageService.listProjects();
|
||||
if (remaining.length === 0) {
|
||||
resetToNoProject();
|
||||
} else if (currentProjectId === project.projectId) {
|
||||
// Switch to another project's latest session
|
||||
const next = remaining[0];
|
||||
const sessions = await StorageService.listSessionsByProject(next.projectId);
|
||||
if (sessions.length > 0) {
|
||||
await resumeSession(sessions[0].sessionId, next.projectId);
|
||||
} else {
|
||||
await newSession(next.projectId);
|
||||
}
|
||||
}
|
||||
await reload();
|
||||
},
|
||||
},
|
||||
@@ -365,46 +426,64 @@ export default function ProjectsScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[S.root, { paddingTop: insets.top }]}>
|
||||
<View style={[S.root, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
|
||||
{/* Header */}
|
||||
<View style={S.header}>
|
||||
<Text style={S.headerTitle}>工程管理</Text>
|
||||
<View style={[S.header, { borderBottomColor: theme.bg.border }]}>
|
||||
<Text style={[S.headerTitle, { color: theme.text.muted }]}>工程管理</Text>
|
||||
<TouchableOpacity
|
||||
style={[S.importBtn, importing && S.btnDis]}
|
||||
style={[S.importBtn, {
|
||||
backgroundColor: theme.amber.bg,
|
||||
borderColor: theme.amber.fg + '66',
|
||||
}, importing && S.btnDis]}
|
||||
onPress={handleImportProject}
|
||||
disabled={importing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{importing
|
||||
? <ActivityIndicator color="#ffd93d" size="small" />
|
||||
: <Text style={S.importBtnTxt}>导入 .tem</Text>}
|
||||
? <ActivityIndicator color={theme.amber.fg} size="small" />
|
||||
: <Text style={[S.importBtnTxt, { color: theme.amber.fg }]}>导入 .tem</Text>}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={S.addBtn} onPress={handleCreateProject} activeOpacity={0.8}>
|
||||
<Text style={S.addBtnTxt}>+ 新建</Text>
|
||||
<TouchableOpacity style={[S.addBtn, {
|
||||
backgroundColor: theme.blue.border,
|
||||
borderColor: theme.blue.fg,
|
||||
}]} onPress={handleCreateProject} activeOpacity={0.8}>
|
||||
<Text style={[S.addBtnTxt, { color: theme.blue.fg }]}>+ 新建</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 style={[S.progressBar, {
|
||||
backgroundColor: theme.bg.void,
|
||||
borderColor: theme.bg.border,
|
||||
}]}>
|
||||
<View style={[S.progressFill, { backgroundColor: theme.blue.border }]} />
|
||||
<Text style={[S.progressTxt, { color: theme.blue.fg }]}>{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>
|
||||
<View style={[S.contextBar, {
|
||||
backgroundColor: theme.bg.void,
|
||||
borderBottomColor: theme.bg.divider,
|
||||
}]}>
|
||||
<Text style={[S.contextLabel, { color: theme.text.ghost }]}>当前测线</Text>
|
||||
<Text style={[S.contextSession, { color: theme.text.muted }]} numberOfLines={1}>{currentSessionId}</Text>
|
||||
{currentProjectId ? (
|
||||
<View style={S.projectPill}>
|
||||
<Text style={S.projectPillTxt}>
|
||||
<View style={[S.projectPill, {
|
||||
backgroundColor: theme.blue.bg,
|
||||
borderColor: theme.blue.border,
|
||||
}]}>
|
||||
<Text style={[S.projectPillTxt, { color: theme.blue.fg }]}>
|
||||
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[S.projectPill, S.projectPillNone]}>
|
||||
<Text style={[S.projectPillTxt, S.projectPillTxtNone]}>未分配工程</Text>
|
||||
<View style={[S.projectPill, {
|
||||
backgroundColor: theme.bg.base,
|
||||
borderColor: theme.bg.border,
|
||||
}]}>
|
||||
<Text style={[S.projectPillTxt, { color: theme.text.muted }]}>未分配工程</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -416,16 +495,19 @@ export default function ProjectsScreen() {
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
ListEmptyComponent={
|
||||
<View style={S.empty}>
|
||||
<Text style={S.emptyIcon}>◫</Text>
|
||||
<Text style={S.emptyTxt}>暂无工程</Text>
|
||||
<Text style={S.emptyHint}>点击右上角「新建工程」开始</Text>
|
||||
<Text style={[S.emptyIcon, { color: theme.bg.border }]}>◫</Text>
|
||||
<Text style={[S.emptyTxt, { color: theme.text.ghost }]}>暂无工程</Text>
|
||||
<Text style={[S.emptyHint, { color: theme.bg.border }]}>点击右上角「新建工程」开始</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: project }) => {
|
||||
const isExpanded = expanded === project.projectId;
|
||||
const isCurrentProject = project.projectId === currentProjectId;
|
||||
return (
|
||||
<View style={S.projectCard}>
|
||||
<View style={[S.projectCard, {
|
||||
borderColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.surface,
|
||||
}]}>
|
||||
{/* Project row */}
|
||||
<TouchableOpacity
|
||||
style={S.projectRow}
|
||||
@@ -433,39 +515,54 @@ export default function ProjectsScreen() {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={S.projectRowLeft}>
|
||||
<Text style={S.chevron}>{isExpanded ? '▾' : '▸'}</Text>
|
||||
<Text style={[S.chevron, { color: theme.text.muted }]}>{isExpanded ? '▾' : '▸'}</Text>
|
||||
<View>
|
||||
<View style={S.projectNameRow}>
|
||||
<Text style={S.projectName}>{project.name}</Text>
|
||||
<Text style={[S.projectName, { color: theme.text.primary }]}>{project.name}</Text>
|
||||
{isCurrentProject && (
|
||||
<View style={S.activePill}>
|
||||
<Text style={S.activePillTxt}>当前</Text>
|
||||
<View style={[S.activePill, {
|
||||
backgroundColor: theme.blue.bg,
|
||||
borderColor: theme.blue.fg + '55',
|
||||
}]}>
|
||||
<Text style={[S.activePillTxt, { color: theme.blue.fg }]}>当前</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={S.projectMeta}>
|
||||
<Text style={[S.projectMeta, { color: theme.text.muted }]}>
|
||||
{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 style={[S.pBtn, {
|
||||
borderColor: theme.green.border,
|
||||
backgroundColor: theme.green.bg,
|
||||
}]} onPress={() => handleOpenProject(project)}>
|
||||
<Text style={[S.pBtnTxt, { color: theme.green.fg }]}>打开</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[S.pBtn, S.pBtnExp, exporting === project.projectId && S.btnDis]}
|
||||
style={[S.pBtn, {
|
||||
borderColor: theme.amber.border,
|
||||
backgroundColor: theme.amber.bg,
|
||||
}, 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>}
|
||||
? <ActivityIndicator color={theme.amber.fg} size="small" style={{ width: 28 }} />
|
||||
: <Text style={[S.pBtnTxt, { color: theme.amber.fg }]}>.tem</Text>}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={S.pBtn} onPress={() => handleRenameProject(project)}>
|
||||
<Text style={S.pBtnTxt}>改名</Text>
|
||||
<TouchableOpacity style={[S.pBtn, {
|
||||
borderColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.raised,
|
||||
}]} onPress={() => handleRenameProject(project)}>
|
||||
<Text style={[S.pBtnTxt, { color: theme.text.muted }]}>改名</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[S.pBtn, S.pBtnDel]} onPress={() => handleDeleteProject(project)}>
|
||||
<Text style={S.pBtnDelTxt}>删除</Text>
|
||||
<TouchableOpacity style={[S.pBtn, {
|
||||
borderColor: theme.red.border,
|
||||
backgroundColor: theme.red.bg,
|
||||
}]} onPress={() => handleDeleteProject(project)}>
|
||||
<Text style={[S.pBtnTxt, { color: theme.red.fg }]}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -479,6 +576,8 @@ export default function ProjectsScreen() {
|
||||
onActivate={(sid) => handleActivateLine(sid, project.projectId)}
|
||||
onExport={handleExportLine}
|
||||
onDelete={handleDeleteLine}
|
||||
onNewLine={() => handleNewLine(project)}
|
||||
onReload={reload}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -495,51 +594,43 @@ export default function ProjectsScreen() {
|
||||
}
|
||||
|
||||
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' },
|
||||
root: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1 },
|
||||
headerTitle:{ fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 },
|
||||
addBtn: { borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1 },
|
||||
addBtnTxt: { 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' },
|
||||
contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, gap: 8 },
|
||||
contextLabel: { fontSize: 10, fontWeight: '600', letterSpacing: 1 },
|
||||
contextSession:{ fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
|
||||
projectPill: { borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 },
|
||||
projectPillTxt:{ fontSize: 9, fontWeight: '700' },
|
||||
|
||||
empty: { paddingTop: 80, alignItems: 'center', gap: 10 },
|
||||
emptyIcon: { fontSize: 40, color: '#1a1a2a' },
|
||||
emptyTxt: { color: '#2a2a4a', fontSize: 15 },
|
||||
emptyHint: { color: '#1a1a2a', fontSize: 12 },
|
||||
emptyIcon: { fontSize: 40 },
|
||||
emptyTxt: { fontSize: 15 },
|
||||
emptyHint: { fontSize: 12 },
|
||||
|
||||
projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a', overflow: 'hidden', backgroundColor: '#0e0e1c' },
|
||||
projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, overflow: 'hidden' },
|
||||
|
||||
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 },
|
||||
chevron: { 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' },
|
||||
projectName: { fontSize: 14, fontWeight: '700' },
|
||||
projectMeta: { fontSize: 10, marginTop: 2 },
|
||||
activePill: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1 },
|
||||
activePillTxt: { 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' },
|
||||
importBtn: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, marginRight: 8, minWidth: 70, alignItems: 'center' },
|
||||
importBtnTxt: { 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' },
|
||||
progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, borderRadius: 10, overflow: 'hidden', borderWidth: 1, justifyContent: 'center' },
|
||||
progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, borderRadius: 10 },
|
||||
progressTxt: { 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' },
|
||||
pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
pBtnTxt: { fontSize: 10, fontWeight: '700' },
|
||||
});
|
||||
|
||||
+219
-233
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
View, Text, FlatList, TouchableOpacity, Modal,
|
||||
StyleSheet, Alert, ActivityIndicator, Platform,
|
||||
@@ -7,90 +7,52 @@ import {
|
||||
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 { useTheme } from '../../src/design/tokens';
|
||||
import { NoProjectGate } from '../../src/components/NoProjectGate';
|
||||
import { SessionSelector } from '../../src/components/SessionSelector';
|
||||
import * as StorageService from '../../src/services/StorageService';
|
||||
import * as BinLoader from '../../src/services/BinLoader';
|
||||
import type { PersistedFrame } from '../../src/services/StorageService';
|
||||
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||
|
||||
const MONO = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const;
|
||||
|
||||
// ── Waveform modal ──────────────────────────────────────────────────────────
|
||||
|
||||
function FrameWaveformModal({
|
||||
frame,
|
||||
onClose,
|
||||
}: {
|
||||
frame: MeasurementFrame;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
function FrameWaveformModal({ frame, onClose }: { frame: MeasurementFrame; onClose: () => void }) {
|
||||
const theme = useTheme();
|
||||
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 [logScale, setLogScale] = useState(false);
|
||||
|
||||
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.sheet, { backgroundColor: theme.bg.surface }]}>
|
||||
<View style={M.header}>
|
||||
<Text style={M.title}>帧 #{frame.frameId}</Text>
|
||||
<Text style={[M.title, { color: theme.text.secondary }]}>帧 #{frame.frameId}</Text>
|
||||
<TouchableOpacity
|
||||
style={[M.scaleBtn, !logScale && M.scaleBtnOn]}
|
||||
style={[M.scaleBtn, { borderColor: theme.bg.border, backgroundColor: theme.bg.raised }, !logScale && { borderColor: theme.blue.fg + '55', backgroundColor: theme.blue.bg }]}
|
||||
onPress={() => setLogScale((v) => !v)}
|
||||
>
|
||||
<Text style={[M.scaleTxt, !logScale && M.scaleTxtOn]}>
|
||||
{logScale ? 'LOG' : 'LIN'}
|
||||
</Text>
|
||||
<Text style={[M.scaleTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={M.closeBtn} onPress={onClose}>
|
||||
<Text style={M.closeTxt}>✕</Text>
|
||||
<TouchableOpacity style={[M.closeBtn, { backgroundColor: theme.bg.border }]} onPress={onClose}>
|
||||
<Text style={[M.closeTxt, { color: theme.text.secondary }]}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{data ? (
|
||||
<WaveformChart
|
||||
data={data}
|
||||
visibleChannels={visible}
|
||||
width={width - 32}
|
||||
height={CHART_H}
|
||||
logScale={logScale}
|
||||
/>
|
||||
<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 style={[{ height: CHART_H }, M.noData, { backgroundColor: theme.bg.raised }]}>
|
||||
<Text style={{ color: theme.text.muted }}>无波形数据</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -99,18 +61,107 @@ function FrameWaveformModal({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main screen ─────────────────────────────────────────────────────────────
|
||||
// ── Table header ───────────────────────────────────────────────────────────
|
||||
|
||||
function TableHeader() {
|
||||
const theme = useTheme();
|
||||
const hdr = { color: theme.text.muted, fontSize: 9, fontWeight: '600' as const };
|
||||
return (
|
||||
<View style={[T.row, T.headerRow, { backgroundColor: theme.bg.raised, borderBottomColor: theme.bg.border }]}>
|
||||
<Text style={[hdr, T.colId]}>#</Text>
|
||||
<Text style={[hdr, T.colTime]}>时间</Text>
|
||||
<Text style={[hdr, T.colGps]}>GPS</Text>
|
||||
<Text style={[hdr, T.colCoord]}>经度</Text>
|
||||
<Text style={[hdr, T.colCoord]}>纬度</Text>
|
||||
<Text style={[hdr, T.colAcc]}>叠加</Text>
|
||||
<Text style={[hdr, T.colDel]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Table row ──────────────────────────────────────────────────────────────
|
||||
|
||||
function TableRow({
|
||||
item,
|
||||
onPress,
|
||||
onDelete,
|
||||
}: {
|
||||
item: PersistedFrame;
|
||||
onPress: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const m = item.meta;
|
||||
|
||||
const timeStr = m
|
||||
? `${String(Math.floor(m.utc / 3600) % 24).padStart(2, '0')}:${String(Math.floor(m.utc / 60) % 60).padStart(2, '0')}:${String(m.utc % 60).padStart(2, '0')}`
|
||||
: '--';
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={[T.row, { borderBottomColor: theme.bg.divider }]} onPress={onPress} activeOpacity={0.6}>
|
||||
<Text style={[T.cell, T.colId, MONO, { color: theme.blue.fg }]}>{item.frameId}</Text>
|
||||
<Text style={[T.cell, T.colTime, MONO, { color: theme.text.secondary }]}>{timeStr}</Text>
|
||||
<Text style={[T.cell, T.colGps, { color: m && m.gpsStatus > 0 ? theme.green.fg : theme.red.fg, fontWeight: '700' }]}>
|
||||
{m && m.gpsStatus > 0 ? '✓' : '✗'}
|
||||
</Text>
|
||||
<Text style={[T.cell, T.colCoord, MONO, { color: theme.text.secondary }]} numberOfLines={1}>
|
||||
{m ? m.longitude.toFixed(5) : '--'}
|
||||
</Text>
|
||||
<Text style={[T.cell, T.colCoord, MONO, { color: theme.text.secondary }]} numberOfLines={1}>
|
||||
{m ? m.latitude.toFixed(5) : '--'}
|
||||
</Text>
|
||||
<Text style={[T.cell, T.colAcc, MONO, { color: theme.text.muted }]}>×{item.accNum}</Text>
|
||||
<TouchableOpacity style={T.colDel} onPress={onDelete} hitSlop={8}>
|
||||
<Text style={{ color: theme.red.fg, fontSize: 14, fontWeight: '700' }}>×</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main screen ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RecordsScreen() {
|
||||
const { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const theme = useTheme();
|
||||
const {
|
||||
sessionId, projectId, hasProject, clearHistory, deleteFrame, resumeSession,
|
||||
} = useDataStore();
|
||||
const frameId = useDataStore((s) => s.currentFrame?.frameId ?? -1);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [dbFrames, setDbFrames] = useState<PersistedFrame[]>([]);
|
||||
const [loadingDb, setLoadingDb] = useState(false);
|
||||
const [selectedFrame, setSelectedFrame] = useState<MeasurementFrame | null>(null);
|
||||
|
||||
// ── Load frames from DB when sessionId changes or new frame arrives ──
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const timer = setTimeout(() => {
|
||||
setLoadingDb(true);
|
||||
StorageService.loadSessionFrames(sessionId)
|
||||
.then(setDbFrames)
|
||||
.finally(() => setLoadingDb(false));
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [sessionId, frameId]);
|
||||
|
||||
const reloadDbFrames = useCallback(() => {
|
||||
if (!sessionId) return;
|
||||
setLoadingDb(true);
|
||||
StorageService.loadSessionFrames(sessionId)
|
||||
.then(setDbFrames)
|
||||
.finally(() => setLoadingDb(false));
|
||||
}, [sessionId]);
|
||||
|
||||
// ── NoProjectGate ──
|
||||
if (!hasProject) {
|
||||
return <NoProjectGate />;
|
||||
}
|
||||
|
||||
const handleExportAll = async () => {
|
||||
if (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
|
||||
if (dbFrames.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
|
||||
setExporting(true);
|
||||
try {
|
||||
const path = await exportCsv(history, sessionId);
|
||||
// Export uses in-memory history; for DB frames we pass them through
|
||||
const path = await exportCsv(dbFrames as any, sessionId);
|
||||
await shareFile(path);
|
||||
} catch (e: any) {
|
||||
Alert.alert('导出失败', e.message);
|
||||
@@ -119,135 +170,107 @@ export default function RecordsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
Alert.alert('清空记录', '确定清空当前会话的所有记录?', [
|
||||
const handleClear = () =>
|
||||
Alert.alert('清空显示', '清空屏幕上的记录列表?(已保存的数据不受影响)', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '清空', style: 'destructive', onPress: clearHistory },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
Alert.alert('新建测线', '结束当前测线,开始新测线?', [
|
||||
const handleDeleteFrame = (item: PersistedFrame) => {
|
||||
Alert.alert(`删除 #${item.frameId}`, '确定删除?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '新建', onPress: () => newSession(projectId) },
|
||||
{
|
||||
text: '删除', style: 'destructive', onPress: () => {
|
||||
deleteFrame(item.frameId);
|
||||
reloadDbFrames();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleDeleteFrame = (item: MeasurementFrame) => {
|
||||
Alert.alert(
|
||||
`删除测点 #${item.frameId}`,
|
||||
'确定删除该测点?此操作不可撤销。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '删除', style: 'destructive', onPress: () => deleteFrame(item.frameId) },
|
||||
],
|
||||
);
|
||||
const handleSessionChange = async (newSessionId: string) => {
|
||||
await resumeSession(newSessionId, projectId);
|
||||
};
|
||||
|
||||
const renderItem = ({ item, index }: { item: MeasurementFrame; index: number }) => {
|
||||
const m = item.meta;
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
const handleFramePress = async (pf: PersistedFrame) => {
|
||||
if (!pf.binPath) return;
|
||||
const loaded = await BinLoader.loadBinFile(pf.binPath);
|
||||
if (!loaded) { Alert.alert('加载失败', '无法读取波形文件'); return; }
|
||||
const frame: MeasurementFrame = {
|
||||
meta: pf.meta as any,
|
||||
adcRaw: [],
|
||||
adcUV: loaded.adcUV,
|
||||
accNum: pf.accNum,
|
||||
gain: pf.gain,
|
||||
sampleFreqCode: loaded.sampleFreqCode,
|
||||
timestamp: pf.timestamp,
|
||||
frameId: pf.frameId,
|
||||
};
|
||||
setSelectedFrame(frame);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={S.container}>
|
||||
<View style={[S.container, { backgroundColor: theme.bg.base }]}>
|
||||
{/* Session Selector */}
|
||||
<View style={S.selectorRow}>
|
||||
<SessionSelector
|
||||
selectedSessionId={sessionId}
|
||||
projectId={projectId!}
|
||||
onSelect={handleSessionChange}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Toolbar */}
|
||||
<View style={S.toolbar}>
|
||||
<View style={[S.toolbar, { backgroundColor: theme.bg.surface, borderBottomColor: theme.bg.border }]}>
|
||||
<View style={S.toolbarLeft}>
|
||||
<Text style={S.sessionId} numberOfLines={1}>{sessionId}</Text>
|
||||
<Text style={S.count}>{history.length} 测点</Text>
|
||||
<Text style={[S.count, { color: theme.text.secondary }]}>{dbFrames.length} 测点</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[S.toolBtn, S.exportBtn, exporting && S.btnDisabled]}
|
||||
onPress={handleExportAll}
|
||||
disabled={exporting}
|
||||
activeOpacity={0.8}
|
||||
style={[S.toolBtn, { borderColor: theme.green.border, backgroundColor: theme.green.bg }, exporting && S.btnDisabled]}
|
||||
onPress={handleExportAll} disabled={exporting} activeOpacity={0.8}
|
||||
>
|
||||
{exporting
|
||||
? <ActivityIndicator color="#3ddc84" size="small" />
|
||||
: <Text style={S.exportText}>导出 CSV</Text>}
|
||||
? <ActivityIndicator color={theme.green.fg} size="small" />
|
||||
: <Text style={[S.btnTxt, { color: theme.green.fg }]}>导出 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 style={[S.toolBtn, { borderColor: theme.red.border, backgroundColor: theme.red.bg }]} onPress={handleClear} activeOpacity={0.8}>
|
||||
<Text style={[S.btnTxt, { color: theme.red.fg }]}>清空</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{history.length === 0 ? (
|
||||
{loadingDb ? (
|
||||
<View style={S.empty}>
|
||||
<Text style={S.emptyIcon}>◌</Text>
|
||||
<Text style={S.emptyText}>暂无采集记录</Text>
|
||||
<Text style={S.emptyHint}>在波形页开始采集后,数据将显示在这里</Text>
|
||||
<ActivityIndicator size="large" color={theme.blue.fg} />
|
||||
<Text style={{ color: theme.text.ghost, fontSize: 13, marginTop: 12 }}>加载中...</Text>
|
||||
</View>
|
||||
) : dbFrames.length === 0 ? (
|
||||
<View style={S.empty}>
|
||||
<Text style={{ color: theme.bg.border, fontSize: 40 }}>◌</Text>
|
||||
<Text style={{ color: theme.text.ghost, fontSize: 15 }}>暂无采集记录</Text>
|
||||
<Text style={{ color: theme.bg.border, fontSize: 12 }}>在波形页开始采集后,数据将显示在这里</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={(item) => String(item.frameId)}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={S.list}
|
||||
/>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||
<View style={{ minWidth: '100%' }}>
|
||||
<TableHeader />
|
||||
<FlatList
|
||||
data={dbFrames}
|
||||
keyExtractor={(item) => String(item.frameId)}
|
||||
renderItem={({ item }) => (
|
||||
<TableRow
|
||||
item={item}
|
||||
onPress={() => handleFramePress(item)}
|
||||
onDelete={() => handleDeleteFrame(item)}
|
||||
/>
|
||||
)}
|
||||
getItemLayout={(_, index) => ({ length: 40, offset: 40 * index, index })}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{selectedFrame && (
|
||||
<FrameWaveformModal
|
||||
frame={selectedFrame}
|
||||
onClose={() => setSelectedFrame(null)}
|
||||
/>
|
||||
<FrameWaveformModal frame={selectedFrame} onClose={() => setSelectedFrame(null)} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -256,86 +279,49 @@ export default function RecordsScreen() {
|
||||
// ── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const S = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#090912' },
|
||||
|
||||
container: { flex: 1 },
|
||||
selectorRow: {
|
||||
paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 14, paddingVertical: 10,
|
||||
backgroundColor: '#0c0c18',
|
||||
borderBottomWidth: 1, borderBottomColor: '#1a1a2a',
|
||||
gap: 8,
|
||||
borderBottomWidth: 1, gap: 8,
|
||||
},
|
||||
toolbarLeft: { flex: 1, gap: 2 },
|
||||
sessionId: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
count: { color: '#6a6a8a', fontSize: 13, fontWeight: '700' },
|
||||
count: { 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' },
|
||||
btnTxt: { fontSize: 12, fontWeight: '700' },
|
||||
btnDisabled: { opacity: 0.4 },
|
||||
empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 },
|
||||
});
|
||||
|
||||
list: { padding: 12, gap: 6 },
|
||||
|
||||
item: {
|
||||
const T = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#0e0e1c',
|
||||
borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a',
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
height: 40,
|
||||
paddingHorizontal: 8,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
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 },
|
||||
headerRow: { height: 32, borderBottomWidth: 1 },
|
||||
cell: { fontSize: 10, paddingHorizontal: 4 },
|
||||
colId: { width: 36, textAlign: 'center' },
|
||||
colTime: { width: 70 },
|
||||
colGps: { width: 30, textAlign: 'center', fontSize: 12 },
|
||||
colCoord: { width: 80, textAlign: 'right' },
|
||||
colAcc: { width: 40, textAlign: 'center' },
|
||||
colDel: { width: 30, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
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 },
|
||||
sheet: { 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' },
|
||||
title: { fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
scaleBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||
scaleTxt: { fontSize: 11, fontWeight: '700' },
|
||||
closeBtn: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
|
||||
closeTxt: { fontSize: 14 },
|
||||
noData: { borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
+342
-137
@@ -2,17 +2,22 @@ import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity, ScrollView,
|
||||
Modal, ActivityIndicator, useWindowDimensions, Platform,
|
||||
useColorScheme,
|
||||
} from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
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 { useWaveform, type WaveformData } from '../../src/hooks/useWaveform';
|
||||
import { WaveformChart } from '../../src/components/WaveformChart';
|
||||
import { ParamForm } from '../../src/components/ParamForm';
|
||||
import { NoProjectGate } from '../../src/components/NoProjectGate';
|
||||
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
||||
import { Colors, Spacing, Radius } from '../../src/design/tokens';
|
||||
import { formatUV, formatUtc, formatCoord, sourceModeLabel, formatBattery, formatTemperature } from '../../src/utils/format';
|
||||
import { useTheme } from '../../src/design/tokens';
|
||||
import { Spacing, Radius } from '../../src/design/tokens';
|
||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||
|
||||
// ── Param sheet ───────────────────────────────────────────────────────────────
|
||||
@@ -30,6 +35,7 @@ function ParamSheet({
|
||||
busy: boolean;
|
||||
configDirty: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { deviceStatus } = useDeviceStore();
|
||||
const measuring = deviceStatus !== 'idle';
|
||||
|
||||
@@ -42,14 +48,20 @@ function ParamSheet({
|
||||
statusBarTranslucent
|
||||
>
|
||||
<TouchableOpacity style={PS.backdrop} activeOpacity={1} onPress={onClose} />
|
||||
<View style={PS.sheet}>
|
||||
<View style={PS.handle} />
|
||||
<View style={[PS.sheet, {
|
||||
backgroundColor: theme.bg.surface,
|
||||
borderColor: theme.bg.border,
|
||||
}]}>
|
||||
<View style={[PS.handle, { backgroundColor: theme.bg.border }]} />
|
||||
|
||||
<View style={PS.header}>
|
||||
<Text style={PS.title}>采集参数</Text>
|
||||
<Text style={[PS.title, { color: theme.text.secondary }]}>采集参数</Text>
|
||||
{configDirty && (
|
||||
<View style={PS.dirtyBadge}>
|
||||
<Text style={PS.dirtyTxt}>待下发</Text>
|
||||
<View style={[PS.dirtyBadge, {
|
||||
backgroundColor: theme.amber.bg,
|
||||
borderColor: theme.amber.border,
|
||||
}]}>
|
||||
<Text style={[PS.dirtyTxt, { color: theme.amber.fg }]}>待下发</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -62,7 +74,14 @@ function ParamSheet({
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
PS.setupBtn,
|
||||
configDirty && PS.setupBtnDirty,
|
||||
{
|
||||
backgroundColor: theme.bg.raised,
|
||||
borderColor: theme.bg.border,
|
||||
},
|
||||
configDirty && {
|
||||
backgroundColor: theme.blue.bg,
|
||||
borderColor: theme.blue.fg,
|
||||
},
|
||||
(busy || measuring) && PS.setupBtnDis,
|
||||
]}
|
||||
onPress={onSetup}
|
||||
@@ -70,8 +89,12 @@ function ParamSheet({
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator color={Colors.blue.fg} />
|
||||
: <Text style={[PS.setupBtnTxt, configDirty && PS.setupBtnTxtDirty]}>
|
||||
? <ActivityIndicator color={theme.blue.fg} />
|
||||
: <Text style={[
|
||||
PS.setupBtnTxt,
|
||||
{ color: theme.text.muted },
|
||||
configDirty && { color: theme.blue.fg },
|
||||
]}>
|
||||
{measuring ? '采集中,不可下发' : '下 发 配 置'}
|
||||
</Text>}
|
||||
</TouchableOpacity>
|
||||
@@ -88,37 +111,30 @@ const PS = StyleSheet.create({
|
||||
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' },
|
||||
title: { fontSize: 14, fontWeight: '700', flex: 1 },
|
||||
dirtyBadge: { borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 },
|
||||
dirtyTxt: { 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 },
|
||||
setupBtnTxt: { fontWeight: '700', fontSize: 14, letterSpacing: 3 },
|
||||
});
|
||||
|
||||
// ── Control row ───────────────────────────────────────────────────────────────
|
||||
@@ -142,70 +158,101 @@ function ControlRow({
|
||||
onSettings: () => void;
|
||||
configDirty: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const idle = deviceStatus === 'idle';
|
||||
const running = deviceStatus === 'running';
|
||||
const single = deviceStatus === 'single';
|
||||
|
||||
return (
|
||||
<View style={CR.row}>
|
||||
<View style={[CR.row, {
|
||||
borderTopColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.surface,
|
||||
}]}>
|
||||
{/* Primary action */}
|
||||
{idle && (
|
||||
<TouchableOpacity
|
||||
style={[CR.primary, CR.green, !busy ? null : CR.dis]}
|
||||
style={[CR.primary, {
|
||||
backgroundColor: theme.green.bg,
|
||||
borderColor: theme.green.border,
|
||||
}, !busy ? null : CR.dis]}
|
||||
onPress={onContinuous}
|
||||
disabled={busy}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator color={Colors.green.fg} />
|
||||
? <ActivityIndicator color={theme.green.fg} />
|
||||
: <>
|
||||
<Text style={CR.primaryIcon}>▶</Text>
|
||||
<Text style={[CR.primaryTxt, { color: Colors.green.fg }]}>连 续 采 集</Text>
|
||||
<Text style={[CR.primaryIcon, { color: theme.text.ghost }]}>▶</Text>
|
||||
<Text style={[CR.primaryTxt, { color: theme.green.fg }]}>连 续 采 集</Text>
|
||||
</>}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{running && (
|
||||
<TouchableOpacity
|
||||
style={[CR.primary, CR.red]}
|
||||
style={[CR.primary, {
|
||||
backgroundColor: theme.red.bg,
|
||||
borderColor: theme.red.border,
|
||||
}]}
|
||||
onPress={onStop}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={CR.primaryIcon}>■</Text>
|
||||
<Text style={[CR.primaryTxt, { color: Colors.red.fg }]}>停 止</Text>
|
||||
<Text style={[CR.primaryIcon, { color: theme.text.ghost }]}>■</Text>
|
||||
<Text style={[CR.primaryTxt, { color: theme.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 style={[CR.primary, {
|
||||
backgroundColor: theme.teal.bg,
|
||||
borderColor: theme.teal.border,
|
||||
}, CR.dis]}>
|
||||
<ActivityIndicator color={theme.teal.fg} size="small" />
|
||||
<Text style={[CR.primaryTxt, { color: theme.teal.fg }]}>单次采集中…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Secondary: single-shot button (idle only) */}
|
||||
{idle && (
|
||||
<TouchableOpacity
|
||||
style={[CR.secondary, CR.tealBtn, !busy ? null : CR.dis]}
|
||||
style={[CR.secondary, {
|
||||
backgroundColor: theme.teal.bg,
|
||||
borderColor: theme.teal.border,
|
||||
}, !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>
|
||||
<Text style={[CR.secondaryIcon, { color: theme.text.ghost }]}>◎</Text>
|
||||
<Text style={[CR.secondaryTxt, { color: theme.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 style={[CR.indicator, {
|
||||
backgroundColor: theme.green.bg,
|
||||
borderColor: theme.green.border,
|
||||
}]}>
|
||||
<View style={[CR.runDot, { backgroundColor: theme.green.fg }]} />
|
||||
<Text style={[CR.indicatorTxt, { color: theme.green.fg }]}>#{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
|
||||
style={[CR.settingsBtn, {
|
||||
backgroundColor: theme.bg.raised,
|
||||
borderColor: theme.bg.border,
|
||||
}]}
|
||||
onPress={onSettings}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[CR.settingsTxt, { color: theme.text.muted }, configDirty && { color: theme.amber.fg }]}>⚙</Text>
|
||||
{configDirty && (
|
||||
<View style={[CR.dirtyDot, {
|
||||
backgroundColor: theme.amber.fg,
|
||||
borderColor: theme.bg.surface,
|
||||
}]} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -219,8 +266,6 @@ const CR = StyleSheet.create({
|
||||
paddingVertical: Spacing.sm,
|
||||
gap: Spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: Colors.bg.border,
|
||||
backgroundColor: Colors.bg.surface,
|
||||
},
|
||||
primary: {
|
||||
flex: 2,
|
||||
@@ -232,11 +277,8 @@ const CR = StyleSheet.create({
|
||||
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' },
|
||||
primaryIcon: { fontSize: 11 },
|
||||
primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 },
|
||||
|
||||
secondary: {
|
||||
@@ -249,8 +291,7 @@ const CR = StyleSheet.create({
|
||||
borderRadius: Radius.lg,
|
||||
borderWidth: 1,
|
||||
},
|
||||
tealBtn: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
|
||||
secondaryIcon:{ fontSize: 11, color: '#ffffff66' },
|
||||
secondaryIcon:{ fontSize: 11 },
|
||||
secondaryTxt: { fontSize: 12, fontWeight: '700' },
|
||||
|
||||
indicator: {
|
||||
@@ -261,92 +302,119 @@ const CR = StyleSheet.create({
|
||||
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' },
|
||||
runDot: { width: 6, height: 6, borderRadius: 3 },
|
||||
indicatorTxt: { 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,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
settingsTxt: { fontSize: 18, color: Colors.text.muted },
|
||||
settingsDirty: { color: Colors.amber.fg },
|
||||
settingsTxt: { fontSize: 18 },
|
||||
dirtyDot: {
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
width: 7, height: 7, borderRadius: 4,
|
||||
backgroundColor: Colors.amber.fg,
|
||||
borderWidth: 1, borderColor: Colors.bg.surface,
|
||||
borderWidth: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Peak row ──────────────────────────────────────────────────────────────────
|
||||
// ── Status panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CURRENT_RATIO = 50;
|
||||
const INT32_MAX = 0x7fffffff;
|
||||
|
||||
function formatCurrent(raw: number): string {
|
||||
const amps = (raw / INT32_MAX) * 5.0 * CURRENT_RATIO;
|
||||
return `${amps.toFixed(1)} A`;
|
||||
}
|
||||
|
||||
function StatusPanel({ frame }: { frame: MeasurementFrame }) {
|
||||
const theme = useTheme();
|
||||
const m = frame.meta;
|
||||
const mono = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const;
|
||||
|
||||
const row1 = [
|
||||
{ label: '源模式', value: sourceModeLabel(m.sourceMode) },
|
||||
{ label: '发射电流', value: formatCurrent(m.current) },
|
||||
{ label: '电池', value: formatBattery(m.batteryVolt) },
|
||||
{ label: '温度', value: formatTemperature(m.temperature) },
|
||||
];
|
||||
const row2 = [
|
||||
{ label: 'Roll', value: `${m.roll.toFixed(1)}°` },
|
||||
{ label: 'Pitch', value: `${m.pitch.toFixed(1)}°` },
|
||||
{ label: 'Yaw', value: `${m.yaw.toFixed(1)}°` },
|
||||
{ label: 'GPS', value: ['未定位','非差分','差分','无效PPS','固定解','浮点解','估算中'][m.gpsStatus] ?? '未知', highlight: m.gpsStatus > 0 },
|
||||
{ label: 'SD', value: ['正常','未插卡','异常'][m.sdStatus] ?? '未知', highlight: m.sdStatus === 0 },
|
||||
];
|
||||
|
||||
const renderCell = (it: { label: string; value: string; highlight?: boolean }) => (
|
||||
<View key={it.label} style={[SP.cell, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }]}>
|
||||
<Text style={[SP.label, { color: theme.text.muted }]}>{it.label}</Text>
|
||||
<Text style={[SP.value, mono, { color: it.highlight ? theme.green.fg : theme.text.secondary }]}>{it.value}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
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>
|
||||
<View style={[SP.container, { borderTopColor: theme.bg.divider, backgroundColor: theme.bg.void }]}>
|
||||
<View style={SP.row}>{row1.map(renderCell)}</View>
|
||||
<View style={SP.row}>{row2.map(renderCell)}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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' },
|
||||
const SP = StyleSheet.create({
|
||||
container: { paddingHorizontal: Spacing.md, paddingVertical: 4, gap: 4, borderTopWidth: StyleSheet.hairlineWidth },
|
||||
row: { flexDirection: 'row', gap: 4 },
|
||||
cell: { flex: 1, alignItems: 'center', paddingVertical: 3, borderRadius: Radius.sm, borderWidth: 1 },
|
||||
label: { fontSize: 8, fontWeight: '600' },
|
||||
value: { fontSize: 10, fontWeight: '700', marginTop: 1 },
|
||||
});
|
||||
|
||||
// ── Frame summary bar ─────────────────────────────────────────────────────────
|
||||
|
||||
function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
|
||||
const m = frame.meta;
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<View style={FS.bar}>
|
||||
<Text style={FS.txt}>
|
||||
<View style={[FS.bar, {
|
||||
backgroundColor: theme.bg.void,
|
||||
borderTopColor: theme.bg.divider,
|
||||
}]}>
|
||||
<Text style={[FS.txt, { color: theme.text.ghost }]}>
|
||||
#{frame.frameId}
|
||||
{' '}×{frame.accNum}叠
|
||||
{m ? ` ${formatUtc(m.utc)}` : ''}
|
||||
{m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''}
|
||||
{' '}{frame.meta ? formatUtc(frame.meta.utc) : ''}
|
||||
{frame.meta?.latitude ? ` ${formatCoord(frame.meta.latitude, false)} ${formatCoord(frame.meta.longitude, true)}` : ''}
|
||||
{frame.meta ? ` R${frame.meta.roll.toFixed(1)} P${frame.meta.pitch.toFixed(1)} Y${frame.meta.yaw.toFixed(1)}` : ''}
|
||||
</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' },
|
||||
bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, borderTopWidth: StyleSheet.hairlineWidth },
|
||||
txt: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||
});
|
||||
|
||||
// ── Not connected placeholder ─────────────────────────────────────────────────
|
||||
|
||||
function NotConnected({ onConnect }: { onConnect: () => void }) {
|
||||
const theme = useTheme();
|
||||
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>
|
||||
<Text style={[NC.icon, { color: theme.bg.border }]}>◈</Text>
|
||||
<Text style={[NC.title, { color: theme.text.muted }]}>未连接仪器</Text>
|
||||
<Text style={[NC.sub, { color: theme.text.ghost }]}>请先连接 TEM 接收机</Text>
|
||||
<TouchableOpacity
|
||||
style={[NC.btn, {
|
||||
backgroundColor: theme.blue.bg,
|
||||
borderColor: theme.blue.fg,
|
||||
}]}
|
||||
onPress={onConnect}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[NC.btnTxt, { color: theme.blue.fg }]}>连 接 设 备</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -354,38 +422,51 @@ function NotConnected({ onConnect }: { onConnect: () => void }) {
|
||||
|
||||
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 },
|
||||
icon: { fontSize: 48 },
|
||||
title: { fontSize: 17, fontWeight: '700' },
|
||||
sub: { fontSize: 12 },
|
||||
btn: {
|
||||
marginTop: Spacing.sm,
|
||||
backgroundColor: Colors.blue.bg,
|
||||
borderRadius: Radius.lg,
|
||||
paddingVertical: 13,
|
||||
paddingHorizontal: 32,
|
||||
borderWidth: 1, borderColor: Colors.blue.fg,
|
||||
borderWidth: 1,
|
||||
},
|
||||
btnTxt: { color: Colors.blue.fg, fontSize: 14, fontWeight: '700', letterSpacing: 2 },
|
||||
btnTxt: { fontSize: 14, fontWeight: '700', letterSpacing: 2 },
|
||||
});
|
||||
|
||||
// ── Main screen ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WaveScreen() {
|
||||
const theme = useTheme();
|
||||
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 frame = useDataStore((s) => s.currentFrame);
|
||||
const sessionId = useDataStore((s) => s.sessionId);
|
||||
const projectId = useDataStore((s) => s.projectId);
|
||||
const hasProject = useDataStore((s) => s.hasProject);
|
||||
|
||||
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
||||
const [logScale, setLogScale] = useState(true);
|
||||
const [logScale, setLogScale] = useState(false);
|
||||
const [sheetVisible, setSheetVisible] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [projectName, setProjectName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fullscreen) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
}
|
||||
return () => {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
const connected = status === 'connected';
|
||||
const channelNum = config.channelNum;
|
||||
const CHART_H = Math.round(Math.min(280, sh * 0.36));
|
||||
@@ -396,7 +477,7 @@ export default function WaveScreen() {
|
||||
if (!projectId) { setProjectName(null); return; }
|
||||
import('../../src/services/StorageService').then(({ listProjects }) =>
|
||||
listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)),
|
||||
);
|
||||
).catch(() => setProjectName(null));
|
||||
}, [projectId]);
|
||||
|
||||
const toggleChannel = (idx: number) =>
|
||||
@@ -409,51 +490,81 @@ export default function WaveScreen() {
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<View style={S.root}>
|
||||
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
||||
<NotConnected onConnect={showModal} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasProject) {
|
||||
return (
|
||||
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
||||
<NoProjectGate />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={S.root}>
|
||||
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
||||
|
||||
{/* ── Context bar: project · session ── */}
|
||||
<View style={S.ctxBar}>
|
||||
<View style={[S.ctxBar, {
|
||||
backgroundColor: theme.bg.void,
|
||||
borderBottomColor: theme.bg.divider,
|
||||
}]}>
|
||||
{projectName
|
||||
? <Text style={S.ctxTxt} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||
: <Text style={S.ctxTxt} numberOfLines={1}>{sessionId}</Text>}
|
||||
? <Text style={[S.ctxTxt, { color: theme.text.ghost }]} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||
: <Text style={[S.ctxTxt, { color: theme.text.ghost }]} numberOfLines={1}>{sessionId}</Text>}
|
||||
</View>
|
||||
|
||||
{/* ── Channel selector + LOG/LIN ── */}
|
||||
<View style={S.toolbar}>
|
||||
<View style={[S.toolbar, { borderBottomColor: theme.bg.divider }]}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
|
||||
{Array.from({ length: channelNum }, (_, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[
|
||||
S.chBtn,
|
||||
{ borderColor: theme.bg.border },
|
||||
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>
|
||||
<View style={[S.chDot, { backgroundColor: visible[i] ? CHANNEL_COLORS[i] : theme.bg.border }]} />
|
||||
<Text style={[S.chTxt, { color: theme.text.ghost }, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
style={[S.logBtn, !logScale && S.logBtnOn]}
|
||||
style={[S.logBtn, {
|
||||
borderColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.raised,
|
||||
}, !logScale && {
|
||||
borderColor: theme.blue.border,
|
||||
backgroundColor: theme.blue.bg,
|
||||
}]}
|
||||
onPress={() => setLogScale((v) => !v)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.logTxt, !logScale && S.logTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
||||
<Text style={[S.logTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
||||
{logScale ? 'LOG' : 'LIN'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[S.fullBtn, {
|
||||
borderColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.raised,
|
||||
}]}
|
||||
onPress={() => setFullscreen(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.fullTxt, { color: theme.text.muted }]}>⛶</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Waveform chart ── */}
|
||||
<View style={S.chartWrap}>
|
||||
<View style={[S.chartWrap, { borderBottomColor: theme.bg.divider }]}>
|
||||
{data ? (
|
||||
<WaveformChart
|
||||
data={data}
|
||||
@@ -461,18 +572,19 @@ export default function WaveScreen() {
|
||||
width={sw}
|
||||
height={CHART_H}
|
||||
logScale={logScale}
|
||||
interactive={false}
|
||||
/>
|
||||
) : (
|
||||
<View style={[S.chartPlaceholder, { height: CHART_H }]}>
|
||||
<Text style={S.placeholderTxt}>等待采样数据…</Text>
|
||||
<View style={[S.chartPlaceholder, { height: CHART_H, backgroundColor: theme.bg.surface }]}>
|
||||
<Text style={[S.placeholderTxt, { color: theme.text.ghost }]}>等待采样数据…</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Peak values ── */}
|
||||
{frame && <PeakRow frame={frame} visible={visible} channelNum={channelNum} />}
|
||||
|
||||
{/* ── Frame summary ── */}
|
||||
{frame && <StatusPanel frame={frame} />}
|
||||
{frame && <FrameSummaryBar frame={frame} />}
|
||||
|
||||
{/* ── Control row ── */}
|
||||
@@ -495,22 +607,97 @@ export default function WaveScreen() {
|
||||
busy={busy}
|
||||
configDirty={configDirty}
|
||||
/>
|
||||
|
||||
{/* ── Fullscreen waveform ── */}
|
||||
<Modal
|
||||
visible={fullscreen}
|
||||
animationType="fade"
|
||||
supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
|
||||
statusBarTranslucent
|
||||
onRequestClose={() => setFullscreen(false)}
|
||||
>
|
||||
<FullscreenContent
|
||||
data={data}
|
||||
visibleChannels={visible}
|
||||
logScale={logScale}
|
||||
onToggleLog={() => setLogScale((v) => !v)}
|
||||
onClose={() => setFullscreen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenContent({
|
||||
data, visibleChannels, logScale, onToggleLog, onClose,
|
||||
}: {
|
||||
data: WaveformData | null;
|
||||
visibleChannels: boolean[];
|
||||
logScale: boolean;
|
||||
onToggleLog: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const scheme = useColorScheme();
|
||||
const isDark = scheme !== 'light';
|
||||
const { width: fsW, height: fsH } = useWindowDimensions();
|
||||
return (
|
||||
<GestureHandlerRootView style={[S.fsRoot, { backgroundColor: theme.chart.bg }]}>
|
||||
{data ? (
|
||||
<WaveformChart
|
||||
data={data}
|
||||
visibleChannels={visibleChannels}
|
||||
width={fsW}
|
||||
height={fsH}
|
||||
logScale={logScale}
|
||||
interactive
|
||||
/>
|
||||
) : (
|
||||
<View style={[S.chartPlaceholder, { flex: 1, backgroundColor: theme.bg.surface }]}>
|
||||
<Text style={[S.placeholderTxt, { color: theme.text.ghost }]}>等待采样数据…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Floating toolbar */}
|
||||
<View style={S.fsToolbar}>
|
||||
<TouchableOpacity
|
||||
style={[S.logBtn, {
|
||||
borderColor: theme.bg.border,
|
||||
backgroundColor: theme.bg.raised,
|
||||
}, !logScale && {
|
||||
borderColor: theme.blue.border,
|
||||
backgroundColor: theme.blue.bg,
|
||||
}]}
|
||||
onPress={onToggleLog}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.logTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
||||
{logScale ? 'LOG' : 'LIN'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[S.fsCloseBtn, {
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)',
|
||||
}]}
|
||||
onPress={onClose}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.fsCloseTxt, { color: isDark ? '#ffffff' : '#000000' }]}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: Colors.bg.base },
|
||||
root: { flex: 1 },
|
||||
|
||||
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',
|
||||
},
|
||||
@@ -520,7 +707,6 @@ const S = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingRight: Spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: Colors.bg.divider,
|
||||
},
|
||||
chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 },
|
||||
chBtn: {
|
||||
@@ -531,28 +717,47 @@ const S = StyleSheet.create({
|
||||
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' },
|
||||
chTxt: { 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 },
|
||||
logTxt: { fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
|
||||
fullBtn: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 5,
|
||||
borderRadius: Radius.sm,
|
||||
borderWidth: 1,
|
||||
marginLeft: 4,
|
||||
},
|
||||
fullTxt: { fontSize: 12 },
|
||||
|
||||
chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.bg.divider },
|
||||
fsRoot: { flex: 1 },
|
||||
fsToolbar: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 12,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
fsCloseBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fsCloseTxt: { fontSize: 16, fontWeight: '700' },
|
||||
|
||||
chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
chartPlaceholder: {
|
||||
backgroundColor: Colors.bg.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderTxt: { color: Colors.text.ghost, fontSize: 13 },
|
||||
placeholderTxt: { fontSize: 13 },
|
||||
});
|
||||
|
||||
+29
-30
@@ -8,13 +8,15 @@ import { useWaveform } from '../../src/hooks/useWaveform';
|
||||
import { useDataStore } from '../../src/stores/dataStore';
|
||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
||||
import { useTheme } from '../../src/design/tokens';
|
||||
|
||||
export default function WaveformScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
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 [logScale, setLogScale] = useState(false);
|
||||
|
||||
const frame = useDataStore((s) => s.currentFrame);
|
||||
const data = useWaveform(visible);
|
||||
@@ -25,24 +27,26 @@ export default function WaveformScreen() {
|
||||
const CHART_HEIGHT = Math.round(Math.min(300, windowHeight * 0.38));
|
||||
|
||||
return (
|
||||
<View style={[S.container, { paddingTop: insets.top }]}>
|
||||
<DeviceStatusBar />
|
||||
<View style={[S.container, { backgroundColor: theme.bg.base }]}>
|
||||
<View style={{ paddingTop: insets.top }}>
|
||||
<DeviceStatusBar />
|
||||
</View>
|
||||
|
||||
<View style={S.toolbar}>
|
||||
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
||||
<TouchableOpacity
|
||||
style={[S.scaleBtn, !logScale && S.scaleBtnActive]}
|
||||
style={[S.scaleBtn, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }, !logScale && { borderColor: theme.blue.fg + '55', backgroundColor: theme.blue.bg }]}
|
||||
onPress={() => setLogScale((v) => !v)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[S.scaleBtnText, !logScale && S.scaleBtnTextActive]}>
|
||||
<Text style={[S.scaleBtnText, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
||||
{logScale ? 'LOG' : 'LIN'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Waveform chart */}
|
||||
<View style={S.chartWrap}>
|
||||
<View style={[S.chartWrap, { borderBottomColor: theme.bg.border }]}>
|
||||
{data ? (
|
||||
<WaveformChart
|
||||
data={data}
|
||||
@@ -52,8 +56,8 @@ export default function WaveformScreen() {
|
||||
logScale={logScale}
|
||||
/>
|
||||
) : (
|
||||
<View style={[S.placeholder, { height: CHART_HEIGHT }]}>
|
||||
<Text style={S.placeholderText}>等待采样数据...</Text>
|
||||
<View style={[S.placeholder, { height: CHART_HEIGHT, backgroundColor: theme.bg.surface }]}>
|
||||
<Text style={[S.placeholderText, { color: theme.text.muted }]}>等待采样数据...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -66,23 +70,23 @@ export default function WaveformScreen() {
|
||||
if (!visible[idx] || idx >= channelNum) return null;
|
||||
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||
return (
|
||||
<View key={idx} style={S.peakCell}>
|
||||
<Text style={S.peakLabel}>CH{idx + 1}</Text>
|
||||
<Text style={S.peakValue}>{formatUV(absMax)}</Text>
|
||||
<View key={idx} style={[S.peakCell, { backgroundColor: theme.bg.surface, borderColor: theme.bg.border }]}>
|
||||
<Text style={[S.peakLabel, { color: theme.text.secondary }]}>CH{idx + 1}</Text>
|
||||
<Text style={[S.peakValue, { color: theme.text.primary }]}>{formatUV(absMax)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{frame.meta && (
|
||||
<View style={S.metaBlock}>
|
||||
<Text style={S.metaText}>
|
||||
<View style={[S.metaBlock, { backgroundColor: theme.bg.raised }]}>
|
||||
<Text style={[S.metaText, { color: theme.text.muted }]}>
|
||||
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
||||
</Text>
|
||||
<Text style={S.metaText}>
|
||||
<Text style={[S.metaText, { color: theme.text.muted }]}>
|
||||
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
||||
</Text>
|
||||
<Text style={S.metaText}>
|
||||
<Text style={[S.metaText, { color: theme.text.muted }]}>
|
||||
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
||||
</Text>
|
||||
</View>
|
||||
@@ -94,11 +98,11 @@ export default function WaveformScreen() {
|
||||
}
|
||||
|
||||
const S = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
||||
container: { flex: 1 },
|
||||
toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 },
|
||||
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
|
||||
placeholder: { backgroundColor: '#1a1a1a', justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { color: '#444', fontSize: 14 },
|
||||
chartWrap: { borderBottomWidth: 1 },
|
||||
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { fontSize: 14 },
|
||||
|
||||
scaleBtn: {
|
||||
marginLeft: 8,
|
||||
@@ -106,23 +110,18 @@ const S = StyleSheet.create({
|
||||
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' },
|
||||
scaleBtnText: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
||||
|
||||
peakScroll: { flex: 1 },
|
||||
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
||||
peakCell: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderRadius: 8, padding: 10,
|
||||
minWidth: 90, alignItems: 'center',
|
||||
borderWidth: 1, borderColor: '#2a2a2a',
|
||||
borderWidth: 1,
|
||||
},
|
||||
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' },
|
||||
peakLabel: { fontSize: 11 },
|
||||
peakValue: { fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
||||
metaBlock: { margin: 12, borderRadius: 8, padding: 10, gap: 3 },
|
||||
metaText: { fontSize: 10, fontFamily: 'monospace' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user