TriloopTem_App/app/(tabs)/records.tsx
2026-06-20 21:43:09 +08:00

328 lines
14 KiB
TypeScript
Raw 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 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, { 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' },
});