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