zhoujie 9b1ce66f4b ♻️ refactor(bin-loader): 重构二进制帧格式以匹配设备协议
- 将 TEMF v1 格式替换为设备 0x85 数据帧格式(10 字节帧头 + 54 字节元数据 + ADC 数据)
- 重写 encodeBin/decodeBin 函数以支持交织排列的 ADC 采样数据
- 更新记录页面使用设备配置中的 sampleFreqCode 替代从二进制文件解析的值
- 在导入项目时从设备配置获取 accNum 以适配新格式
2026-06-21 17:34:58 +08:00

330 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
View, Text, FlatList, TouchableOpacity, Modal,
StyleSheet, Alert, ActivityIndicator, Platform,
useWindowDimensions, ScrollView,
} from 'react-native';
import { useDataStore } from '../../src/stores/dataStore';
import { useDeviceStore } from '../../src/stores/deviceStore';
import { exportCsv, shareFile } from '../../src/utils/export';
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 }) {
const theme = useTheme();
const { width } = useWindowDimensions();
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
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, { backgroundColor: theme.bg.surface }]}>
<View style={M.header}>
<Text style={[M.title, { color: theme.text.secondary }]}> #{frame.frameId}</Text>
<TouchableOpacity
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, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>{logScale ? 'LOG' : 'LIN'}</Text>
</TouchableOpacity>
<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} />
) : (
<View style={[{ height: CHART_H }, M.noData, { backgroundColor: theme.bg.raised }]}>
<Text style={{ color: theme.text.muted }}></Text>
</View>
)}
</View>
</View>
</Modal>
);
}
// ── 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 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 (dbFrames.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
setExporting(true);
try {
// 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);
} finally {
setExporting(false);
}
};
const handleClear = () =>
Alert.alert('清空显示', '清空屏幕上的记录列表?(已保存的数据不受影响)', [
{ text: '取消', style: 'cancel' },
{ text: '清空', style: 'destructive', onPress: clearHistory },
]);
const handleDeleteFrame = (item: PersistedFrame) => {
Alert.alert(`删除 #${item.frameId}`, '确定删除?', [
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive', onPress: () => {
deleteFrame(item.frameId);
reloadDbFrames();
},
},
]);
};
const handleSessionChange = async (newSessionId: string) => {
await resumeSession(newSessionId, projectId);
};
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
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,
timestamp: pf.timestamp,
frameId: pf.frameId,
};
setSelectedFrame(frame);
};
return (
<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, { backgroundColor: theme.bg.surface, borderBottomColor: theme.bg.border }]}>
<View style={S.toolbarLeft}>
<Text style={[S.count, { color: theme.text.secondary }]}>{dbFrames.length} </Text>
</View>
<TouchableOpacity
style={[S.toolBtn, { borderColor: theme.green.border, backgroundColor: theme.green.bg }, exporting && S.btnDisabled]}
onPress={handleExportAll} disabled={exporting} activeOpacity={0.8}
>
{exporting
? <ActivityIndicator color={theme.green.fg} size="small" />
: <Text style={[S.btnTxt, { color: theme.green.fg }]}> CSV</Text>}
</TouchableOpacity>
<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>
{loadingDb ? (
<View style={S.empty}>
<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>
) : (
<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)} />
)}
</View>
);
}
// ── Styles ──────────────────────────────────────────────────────────────────
const S = StyleSheet.create({
container: { flex: 1 },
selectorRow: {
paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4,
},
toolbar: {
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 10,
borderBottomWidth: 1, gap: 8,
},
toolbarLeft: { flex: 1, gap: 2 },
count: { fontSize: 13, fontWeight: '700' },
toolBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 8, borderWidth: 1 },
btnTxt: { fontSize: 12, fontWeight: '700' },
btnDisabled: { opacity: 0.4 },
empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 },
});
const T = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
height: 40,
paddingHorizontal: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
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: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, paddingBottom: 32 },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
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' },
});