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