TriloopTem_App/app/(tabs)/records.tsx
2026-06-19 22:08:10 +08:00

342 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, useMemo } 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 { 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 type { MeasurementFrame } from '../../src/protocol/types';
// ── Waveform modal ──────────────────────────────────────────────────────────
function FrameWaveformModal({
frame,
onClose,
}: {
frame: MeasurementFrame;
onClose: () => void;
}) {
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 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.header}>
<Text style={M.title}> #{frame.frameId}</Text>
<TouchableOpacity
style={[M.scaleBtn, !logScale && M.scaleBtnOn]}
onPress={() => setLogScale((v) => !v)}
>
<Text style={[M.scaleTxt, !logScale && M.scaleTxtOn]}>
{logScale ? 'LOG' : 'LIN'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={M.closeBtn} onPress={onClose}>
<Text style={M.closeTxt}></Text>
</TouchableOpacity>
</View>
{data ? (
<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>
)}
</View>
</View>
</Modal>
);
}
// ── Main screen ─────────────────────────────────────────────────────────────
export default function RecordsScreen() {
const { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore();
const [exporting, setExporting] = useState(false);
const [selectedFrame, setSelectedFrame] = useState<MeasurementFrame | null>(null);
const handleExportAll = async () => {
if (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
setExporting(true);
try {
const path = await exportCsv(history, 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 handleNewSession = () => {
Alert.alert('新建测线', '结束当前测线,开始新测线?', [
{ text: '取消', style: 'cancel' },
{ text: '新建', onPress: () => newSession(projectId) },
]);
};
const handleDeleteFrame = (item: MeasurementFrame) => {
Alert.alert(
`删除测点 #${item.frameId}`,
'确定删除该测点?此操作不可撤销。',
[
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: () => deleteFrame(item.frameId) },
],
);
};
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>
);
};
return (
<View style={S.container}>
{/* Toolbar */}
<View style={S.toolbar}>
<View style={S.toolbarLeft}>
<Text style={S.sessionId} numberOfLines={1}>{sessionId}</Text>
<Text style={S.count}>{history.length} </Text>
</View>
<TouchableOpacity
style={[S.toolBtn, S.exportBtn, exporting && S.btnDisabled]}
onPress={handleExportAll}
disabled={exporting}
activeOpacity={0.8}
>
{exporting
? <ActivityIndicator color="#3ddc84" size="small" />
: <Text style={S.exportText}> 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>
</View>
{history.length === 0 ? (
<View style={S.empty}>
<Text style={S.emptyIcon}></Text>
<Text style={S.emptyText}></Text>
<Text style={S.emptyHint}></Text>
</View>
) : (
<FlatList
data={history}
keyExtractor={(item) => String(item.frameId)}
renderItem={renderItem}
contentContainerStyle={S.list}
/>
)}
{selectedFrame && (
<FrameWaveformModal
frame={selectedFrame}
onClose={() => setSelectedFrame(null)}
/>
)}
</View>
);
}
// ── Styles ──────────────────────────────────────────────────────────────────
const S = StyleSheet.create({
container: { flex: 1, backgroundColor: '#090912' },
toolbar: {
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 10,
backgroundColor: '#0c0c18',
borderBottomWidth: 1, borderBottomColor: '#1a1a2a',
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' },
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' },
btnDisabled: { opacity: 0.4 },
list: { padding: 12, gap: 6 },
item: {
flexDirection: 'row',
backgroundColor: '#0e0e1c',
borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a',
overflow: 'hidden',
},
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 },
});
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 },
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' },
});