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 (
帧 #{frame.frameId}
setLogScale((v) => !v)}
>
{logScale ? 'LOG' : 'LIN'}
✕
{data ? (
) : (
无波形数据
)}
);
}
// ── Table header ───────────────────────────────────────────────────────────
function TableHeader() {
const theme = useTheme();
const hdr = { color: theme.text.muted, fontSize: 9, fontWeight: '600' as const };
return (
#
时间
GPS
经度
纬度
叠加
);
}
// ── 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 (
{item.frameId}
{timeStr}
0 ? theme.green.fg : theme.red.fg, fontWeight: '700' }]}>
{m && m.gpsStatus > 0 ? '✓' : '✗'}
{m ? m.longitude.toFixed(5) : '--'}
{m ? m.latitude.toFixed(5) : '--'}
×{item.accNum}
×
);
}
// ── 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([]);
const [loadingDb, setLoadingDb] = useState(false);
const [selectedFrame, setSelectedFrame] = useState(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 ;
}
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 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 (
{/* Session Selector */}
{/* Toolbar */}
{dbFrames.length} 测点
{exporting
?
: 导出 CSV}
清空
{loadingDb ? (
加载中...
) : dbFrames.length === 0 ? (
◌
暂无采集记录
在波形页开始采集后,数据将显示在这里
) : (
String(item.frameId)}
renderItem={({ item }) => (
handleFramePress(item)}
onDelete={() => handleDeleteFrame(item)}
/>
)}
getItemLayout={(_, index) => ({ length: 40, offset: 40 * index, index })}
/>
)}
{selectedFrame && (
setSelectedFrame(null)} />
)}
);
}
// ── 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' },
});