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