diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ebdda6 --- /dev/null +++ b/README.md @@ -0,0 +1,251 @@ +# TEM Receiver — 瞬变电磁接收机手机 App + +六通道瞬变电磁(TEM)接收机的移动端控制与数据采集软件,通过 WiFi TCP 连接设备,实现参数配置、实时波形显示、数据管理与导出。 + +## 功能概述 + +- **设备连接** — WiFi TCP 自动连接/重连/心跳保活 +- **参数配置** — 通道数、发射频率、采样率、叠加次数、增益等全部参数下发 +- **实时采集** — 单次/连续采集,实时波形显示(LIN/LOG 模式) +- **波形交互** — 全屏查看、双指缩放、单指拖拽、横屏支持 +- **数据管理** — 工程-测线-测点三级管理,支持新建/删除/切换 +- **数据导出** — CSV 导出、.tem 工程打包导出,支持分享到微信等 App +- **数据导入** — 导入 .tem 工程文件,恢复全部测线和波形数据 +- **主题适配** — 自动跟随系统深色/浅色模式 +- **设备状态** — 实时显示发射电流、电池电压、温度、GPS、SD 卡、姿态角 + +## 技术栈 + +| 类别 | 技术 | +|------|------| +| 框架 | React Native 0.85 + Expo SDK 56 | +| 语言 | TypeScript 6.0 | +| 路由 | expo-router (文件式路由) | +| 状态管理 | Zustand 5 (持久化) | +| 数据库 | expo-sqlite (SQLite WAL 模式) | +| 图表 | @shopify/react-native-skia | +| 手势 | react-native-gesture-handler + react-native-reanimated | +| TCP 通信 | react-native-tcp-socket | +| 文件压缩 | fflate (ZIP 压缩/解压) | + +## 环境要求 + +| 工具 | 版本 | +|------|------| +| Node.js | >= 18 | +| npm | >= 9 | +| Android Studio | 最新版(含 Android SDK) | +| JDK | Android Studio 内置 JBR | +| ADB | Android SDK platform-tools | + +### 环境变量(Windows) + +``` +JAVA_HOME = D:\Program Files\Android\Android Studio\jbr +ANDROID_HOME = D:\ProgramData\AndroidSdk +PATH += %JAVA_HOME%\bin;%ANDROID_HOME%\platform-tools +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd TriloopTem_App +npm install +``` + +### 2. Debug 运行 + +手机通过 USB 连接电脑,开启 USB 调试: + +```bash +npx expo run:android +``` + +或使用项目自带脚本: + +```bash +run-android.bat +``` + +### 3. Release 构建 + +双击 `build-release.bat`,或手动执行: + +```powershell +$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr" +cd android +.\gradlew.bat assembleRelease +``` + +APK 输出路径:`android/app/build/outputs/apk/release/app-release.apk` + +### 4. 安装到手机 + +```bash +adb uninstall com.triloop.temreceiver +adb install android/app/build/outputs/apk/release/app-release.apk +``` + +### 5. iOS 构建 + +需要 macOS + Xcode + Apple Developer 账号: + +```bash +npx expo prebuild --platform ios +npx react-native run-ios +``` + +或使用 EAS 云构建(无需 Mac): + +```bash +npx eas build --platform ios +``` + +## 代码结构 + +``` +TriloopTem_App/ +├── app/ # 页面路由 (expo-router 文件式路由) +│ ├── _layout.tsx # 根布局,初始化数据库 +│ ├── connect.tsx # 连接页面 +│ ├── +not-found.tsx # 404 页面 +│ └── (tabs)/ # Tab 页面 +│ ├── _layout.tsx # Tab 栏配置 +│ ├── wave.tsx # 波形页 — 实时采集与波形显示 +│ ├── records.tsx # 测点页 — 测点列表与数据管理 +│ ├── profile.tsx # 剖面页 — 剖面分析与门窗配置 +│ └── projects.tsx # 工程页 — 工程/测线/测点管理 +│ +├── src/ +│ ├── protocol/ # 设备通信协议 +│ │ ├── constants.ts # 功能码、频率表、增益表 +│ │ ├── packet.ts # 发送包构建 (Setup/Start/Stop) +│ │ ├── parser.ts # 接收帧解析 + ADC 转换 +│ │ └── types.ts # 协议数据类型定义 +│ │ +│ ├── services/ # 业务服务层 +│ │ ├── TcpService.ts # TCP 连接管理 (连接/重连/心跳) +│ │ ├── DeviceService.ts # 设备交互 (命令发送/ACK/数据处理) +│ │ ├── StorageService.ts # SQLite 数据持久化 +│ │ ├── BinLoader.ts # TEMF 二进制波形文件读写 +│ │ └── TemBundle.ts # .tem 工程打包/解包 (ZIP) +│ │ +│ ├── stores/ # 状态管理 (Zustand) +│ │ ├── dataStore.ts # 会话/帧数据/工程关联 +│ │ ├── deviceStore.ts # 设备配置/遥测状态 +│ │ └── connectionStore.ts # TCP 连接状态 +│ │ +│ ├── components/ # 可复用组件 +│ │ ├── WaveformChart.tsx # Skia 波形图表 (缩放/拖拽) +│ │ ├── ParamForm.tsx # 参数配置表单 +│ │ ├── SessionSelector.tsx # 测线选择器 +│ │ ├── NoProjectGate.tsx # 无工程提示页 +│ │ ├── device/ +│ │ │ └── GlobalStatusBar.tsx # 全局状态栏 +│ │ └── modals/ +│ │ └── ConnectModal.tsx # 连接弹窗 +│ │ +│ ├── hooks/ # 自定义 Hooks +│ │ ├── useDevice.ts # 设备操作封装 +│ │ └── useWaveform.ts # 波形数据处理与降采样 +│ │ +│ ├── design/ +│ │ └── tokens.ts # 主题色板 (深色/浅色) + useTheme() +│ │ +│ └── utils/ +│ ├── export.ts # CSV 导出与文件分享 +│ ├── format.ts # 数值/坐标/时间格式化 +│ └── storage.ts # Zustand 持久化存储适配 +│ +├── assets/images/ # 图标与闪屏资源 +├── docs/ +│ └── commercial-audit.md # 商业化审计报告 +├── android/ # Android 原生工程 +├── app.json # Expo 配置 +├── package.json # 依赖管理 +├── build-release.bat # Release 一键构建脚本 +└── run-android.bat # Debug 运行脚本 +``` + +## 通信协议 + +### 帧格式 + +``` +┌──────────┬──────┬─────┬────────────┬────────────┐ +│ Magic 4B │ Flag │ CMD │ Length │ Tail/Len │ +│ 68 68 │ FF/FE│ │ 2B or 4B │ 68 68 / -- │ +│ FF FF │ │ │ │ │ +└──────────┴──────┴─────┴────────────┴────────────┘ +``` + +- **Flag bit0=1** (0xFF):2 字节长度 + `68 68` 尾部(App 发送使用) +- **Flag bit0=0** (0xFE):4 字节长度,无尾部(设备回复使用) + +### 功能码 + +| 方向 | CMD | 说明 | +|------|-----|------| +| App→设备 | 0x01 | 参数配置 (Setup) | +| App→设备 | 0x02 | 连续采集启动 | +| App→设备 | 0x03 | 单次采集 | +| App→设备 | 0x04 | 停止采集 | +| App→设备 | 0x08 | 分帧传输 | +| 设备→App | 0x81 | 配置 ACK | +| 设备→App | 0x82 | 连续采集 ACK | +| 设备→App | 0x83 | 单次采集 ACK | +| 设备→App | 0x84 | 停止 ACK | +| 设备→App | 0x85 | 测量数据帧 | +| 设备→App | 0x95 | 组合源数据帧 | + +### ADC 转换公式 + +``` +电压(μV) = (raw_int32 / 0x7FFFFFFF) × 5.0 × 1e6 / gain +发射电流(A) = (raw_uint32 / 0x7FFFFFFF) × 5.0 × 50 +``` + +### 设备默认参数 + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| 通道数 | 3 | 1-6 通道 | +| 发射频率 | code 5 = 16Hz | 0.5Hz ~ 64Hz | +| 采样率 | code 0 = 250kHz | 250kHz ~ 48Hz | +| 采样深度 | 2000 | 每通道采样点数 | +| 叠加次数 | 32 | 1-16383 | +| 增益 | code 3 = 1× | 1/8× ~ 128× | +| 回传方式 | WiFi | USB/P900/WiFi | +| 补偿电阻 | 12 | | +| 补偿去使能延时 | 60 | | + +## 数据管理 + +### 三级结构 + +``` +工程 (Project) + └── 测线 (Session) + └── 测点 (Frame) + ├── 元数据 (GPS/电流/温度/姿态角...) + └── 波形数据 (.bin 文件) +``` + +### 数据库表 + +- `projects` — 工程信息 +- `sessions` — 测线,必须归属工程 +- `frames` — 测点,关联测线和 .bin 波形文件 + +### 导出格式 + +- **CSV** — 测线级导出,包含所有测点的元数据和各通道峰值 +- **.tem** — 工程级导出,ZIP 压缩包含 `project.json` 和所有 `.bin` 波形文件 + +### TEMF 二进制格式 + +每个测点的波形数据存储为 `.bin` 文件(TEMF v1 格式): +- 64 字节文件头:通道数、采样深度、GPS、电流、温度、姿态角等 +- 数据区:int32 LE,按通道顺序存储 diff --git a/app.json b/app.json index df0aeae..643c3d4 100644 --- a/app.json +++ b/app.json @@ -6,7 +6,7 @@ "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "trilooptemapp", - "userInterfaceStyle": "dark", + "userInterfaceStyle": "automatic", "ios": { "supportsTablet": true, "bundleIdentifier": "com.triloop.temreceiver", @@ -18,7 +18,7 @@ "android": { "package": "com.triloop.temreceiver", "adaptiveIcon": { - "backgroundColor": "#0d0d0d", + "backgroundColor": "#ffffff", "foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundImage": "./assets/images/android-icon-background.png", "monochromeImage": "./assets/images/android-icon-monochrome.png" @@ -45,7 +45,7 @@ { "image": "./assets/images/splash-icon.png", "resizeMode": "contain", - "backgroundColor": "#0d0d0d" + "backgroundColor": "#ffffff" } ], "expo-sqlite", diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 7ebc182..d9be149 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -4,11 +4,7 @@ import { SymbolView } from 'expo-symbols'; import { Text } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { GlobalStatusBar } from '../../src/components/device/GlobalStatusBar'; -import { Colors } from '../../src/design/tokens'; - -const ACTIVE = Colors.blue.fg; -const INACTIVE = '#555'; -const TAB_BG = '#111111'; +import { useTheme } from '../../src/design/tokens'; function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) { if (Platform.OS === 'ios') { @@ -19,16 +15,17 @@ function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: str export default function TabLayout() { const insets = useSafeAreaInsets(); + const theme = useTheme(); return ( - + diff --git a/app/(tabs)/control.tsx b/app/(tabs)/control.tsx index 6b1a773..828b090 100644 --- a/app/(tabs)/control.tsx +++ b/app/(tabs)/control.tsx @@ -6,9 +6,11 @@ import { DeviceStatusBar } from '../../src/components/DeviceStatusBar'; import { ParamForm } from '../../src/components/ParamForm'; import { useDevice } from '../../src/hooks/useDevice'; import { useDataStore } from '../../src/stores/dataStore'; +import { useTheme } from '../../src/design/tokens'; export default function ControlScreen() { const insets = useSafeAreaInsets(); + const theme = useTheme(); const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice(); const frame = useDataStore((s) => s.currentFrame); const sessionId = useDataStore((s) => s.sessionId); @@ -30,11 +32,15 @@ export default function ControlScreen() { if (!connected) { return ( - - - 未连接设备 - router.push('/connect')} activeOpacity={0.8}> - 前往连接 + + + 未连接设备 + router.push('/connect')} + activeOpacity={0.8} + > + 前往连接 ); @@ -46,54 +52,54 @@ export default function ControlScreen() { const canStop = (running || single) && !busy; return ( - + {/* Project / line context */} - + {projectName - ? {projectName} · {sessionId} - : {sessionId} + ? {projectName} · {sessionId} + : {sessionId} } {/* Control buttons */} {busy && !canStop - ? + ? : <> - 连续采集 + 连续采集 } - 单 次 + 单 次 {busy && canStop - ? + ? : <> - 停 止 + 停 止 } @@ -101,25 +107,25 @@ export default function ControlScreen() { {/* Frame info */} {frame && ( - + - 帧号 - #{frame.frameId} + 帧号 + #{frame.frameId} - + - 叠加 - {frame.accNum}次 + 叠加 + {frame.accNum}次 - + - 通道 - {frame.meta?.channelNum ?? '-'}ch + 通道 + {frame.meta?.channelNum ?? '-'}ch - + - 电流 - {frame.meta?.current ?? '--'} + 电流 + {frame.meta?.current ?? '--'} )} @@ -127,23 +133,23 @@ export default function ControlScreen() { {/* Param form */} - 采集参数 + 采集参数 {configDirty && ( - - 待下发 + + 待下发 )} setConfigDirty(true)} /> {busy - ? - : 下 发 配 置} + ? + : 下 发 配 置} @@ -152,12 +158,12 @@ export default function ControlScreen() { } const S = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#090912' }, - notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, backgroundColor: '#090912' }, - notConnectedIcon: { fontSize: 48, color: '#2a2a4a' }, - notConnectedText: { color: '#4a4a6a', fontSize: 15 }, - goConnectBtn: { backgroundColor: '#111120', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1, borderColor: '#4a9eff' }, - goConnectBtnText: { color: '#4a9eff', fontWeight: '600' }, + container: { flex: 1 }, + notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 }, + notConnectedIcon: { fontSize: 48 }, + notConnectedText: { fontSize: 15 }, + goConnectBtn: { borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1 }, + goConnectBtnText: { fontWeight: '600' }, // Control buttons ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 }, @@ -170,50 +176,40 @@ const S = StyleSheet.create({ gap: 4, borderWidth: 1, }, - btnStart: { backgroundColor: '#0d2018', borderColor: '#1a4a28' }, - btnSingle: { backgroundColor: '#0d1a30', borderColor: '#1a2e54' }, - btnStop: { backgroundColor: '#1e0d12', borderColor: '#3a1520' }, btnDisabled: { opacity: 0.3 }, ctrlIcon: { fontSize: 12, color: '#ffffff88' }, ctrlText: { fontSize: 12, fontWeight: '700', letterSpacing: 1 }, - ctrlTextGreen: { color: '#3ddc84' }, - ctrlTextBlue: { color: '#4a9eff' }, - ctrlTextRed: { color: '#ff5c6e' }, // Frame info bar frameBar: { flexDirection: 'row', marginHorizontal: 12, marginBottom: 8, - backgroundColor: '#0c0c1a', borderRadius: 10, borderWidth: 1, - borderColor: '#1a1a2a', overflow: 'hidden', }, frameItem: { flex: 1, alignItems: 'center', paddingVertical: 8 }, - frameItemLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 }, - frameItemValue: { color: '#8888aa', fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, - frameSep: { width: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 6 }, + frameItemLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 }, + frameItemValue: { fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + frameSep: { width: StyleSheet.hairlineWidth, marginVertical: 6 }, // Form formScroll: { flex: 1 }, formHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10 }, - formTitle: { color: '#4a4a6a', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' }, - dirtyBadge: { backgroundColor: '#2a1a05', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1, borderColor: '#4a2a05' }, - dirtyBadgeText: { color: '#ffb84d', fontSize: 9, fontWeight: '700' }, + formTitle: { fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' }, + dirtyBadge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1 }, + dirtyBadgeText: { fontSize: 9, fontWeight: '700' }, setupBtn: { margin: 16, - backgroundColor: '#111120', borderRadius: 12, paddingVertical: 14, alignItems: 'center', borderWidth: 1, - borderColor: '#4a9eff', }, - setupBtnText: { color: '#4a9eff', fontWeight: '700', fontSize: 14, letterSpacing: 3 }, + setupBtnText: { fontWeight: '700', fontSize: 14, letterSpacing: 3 }, - contextBar: { paddingHorizontal: 16, paddingVertical: 5, backgroundColor: '#08080f', borderBottomWidth: 1, borderBottomColor: '#141422' }, - contextText: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + contextBar: { paddingHorizontal: 16, paddingVertical: 5, borderBottomWidth: 1 }, + contextText: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, }); diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index a67a354..e7c4d9a 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; import { - View, Text, StyleSheet, TouchableOpacity, Modal, FlatList, + View, Text, StyleSheet, TouchableOpacity, Modal, ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView, } from 'react-native'; import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia'; @@ -9,8 +9,10 @@ import { useDeviceStore } from '../../src/stores/deviceStore'; import * as StorageService from '../../src/services/StorageService'; import * as BinLoader from '../../src/services/BinLoader'; import type { MeasurementFrame } from '../../src/protocol/types'; -import type { SessionInfo } from '../../src/services/StorageService'; import { CHANNEL_COLORS } from '../../src/protocol/constants'; +import { useTheme, type ThemeColors } from '../../src/design/tokens'; +import { NoProjectGate } from '../../src/components/NoProjectGate'; +import { SessionSelector } from '../../src/components/SessionSelector'; // ── Constants ────────────────────────────────────────────────────────────── @@ -101,8 +103,9 @@ function useProfileData(sessionId: string, currentSessionId: string, history: Me })); }, [sessionId, currentSessionId, history]); + const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1); + useEffect(() => { - if (sessionId === currentSessionId) { setHistFrames([]); return; } setLoading(true); setHistFrames([]); let cancelled = false; @@ -124,11 +127,11 @@ function useProfileData(sessionId: string, currentSessionId: string, history: Me })(); return () => { cancelled = true; }; - }, [sessionId, currentSessionId]); + }, [sessionId, latestFrameId]); return { - frames: sessionId === currentSessionId ? liveFrames : histFrames, - loading: sessionId !== currentSessionId && loading, + frames: histFrames, + loading, progress, }; } @@ -143,6 +146,7 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz sampleDepth: number; hz: number; }) { + const theme = useTheme(); const [tStartStr, setTStartStr] = useState(String(config.tStart)); const [tEndStr, setTEndStr] = useState(String(config.tEnd)); const [countStr, setCountStr] = useState(String(config.count)); @@ -170,23 +174,31 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz return ( - - 时间门配置 + + 时间门配置 - 起始时间 (μs) + 起始时间 (μs) - 终止时间 (μs) + 终止时间 (μs) - 门数量 + 门数量 - 间隔方式 - + 间隔方式 + {(['log', 'linear'] as GateSpacing[]).map(s => ( setSpacing(s)} > - + {s === 'log' ? '对数' : '线性'} @@ -223,15 +245,18 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz {sampleDepth > 0 && hz > 0 && ( - 当前采样窗口: {fmtUs(maxUs)} + 当前采样窗口: {fmtUs(maxUs)} )} - - 取消 + + 取消 - - 应用 + + 应用 @@ -241,23 +266,21 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz const GSM = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' }, - sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 }, - title: { color: '#8888bb', fontSize: 14, fontWeight: '700', marginBottom: 16 }, + sheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 }, + title: { fontSize: 14, fontWeight: '700', marginBottom: 16 }, row: { flexDirection: 'row', gap: 12 }, field: { flex: 1, gap: 4 }, - label: { color: '#4a4a6a', fontSize: 11, fontWeight: '600' }, - input: { backgroundColor: '#111120', borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', color: '#9090b8', fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 }, - toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1, borderColor: '#2a2a3a' }, - toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center', backgroundColor: '#111120' }, - toggleBtnOn: { backgroundColor: '#0d2040' }, - toggleTxt: { color: '#4a4a6a', fontSize: 12, fontWeight: '700' }, - toggleTxtOn: { color: '#4a9eff' }, - hint: { color: '#2a2a4a', fontSize: 10, marginTop: 8 }, + label: { fontSize: 11, fontWeight: '600' }, + input: { borderRadius: 8, borderWidth: 1, fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 }, + toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1 }, + toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center' }, + toggleTxt: { fontSize: 12, fontWeight: '700' }, + hint: { fontSize: 10, marginTop: 8 }, actions: { flexDirection: 'row', gap: 10, marginTop: 20 }, - cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' }, - cancelTxt: { color: '#4a4a6a', fontSize: 13, fontWeight: '600' }, - applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, backgroundColor: '#0d2040', borderWidth: 1, borderColor: '#4a9eff55', alignItems: 'center' }, - applyTxt: { color: '#4a9eff', fontSize: 13, fontWeight: '700' }, + cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' }, + cancelTxt: { fontSize: 13, fontWeight: '600' }, + applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' }, + applyTxt: { fontSize: 13, fontWeight: '700' }, }); // ── GatePreview ──────────────────────────────────────────────────────────── @@ -271,6 +294,7 @@ function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }: gateColors: string[]; width: number; }) { + const theme = useTheme(); const plotW = width - GP.l - GP.r; const plotH = CANVAS_H - GP.t - GP.b; @@ -302,7 +326,7 @@ function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }: return ( - {wavePath && } + {wavePath && } {gatePositions.map((pos, gi) => ( { const y = toY(v); if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; - return ; + return ; })} {xTicks.map((i, idx) => ( - + ))} {!logScale && isFinite(toY(0)) && ( - + )} - - + + {paths.map((p, gi) => p ? : null, @@ -416,126 +441,40 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale const y = toY(v); if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; const label = logScale ? (v >= 1000 ? `${v / 1000}k` : `${v}`) : formatUVShort(v); - return {label}; + return {label}; })} {xTicks.map((i, idx) => ( - {i} + {i} ))} - μV - + μV + ); } const LBL = StyleSheet.create({ - y: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'right' }, - x: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'center' }, -}); - -// ── SessionPickerModal ───────────────────────────────────────────────────── - -function SessionPickerModal({ visible, sessions, allSessions, currentId, selectedId, onSelect, onClose }: { - visible: boolean; sessions: SessionInfo[]; allSessions: SessionInfo[]; - currentId: string; selectedId: string; - onSelect: (id: string) => void; onClose: () => void; -}) { - const [showAll, setShowAll] = useState(false); - const source = showAll ? allSessions : sessions; - - const listData = useMemo(() => { - const currentEntry = { sessionId: currentId, createdAt: 0, frameCount: -1, projectId: null }; - const rest = source.filter(s => s.sessionId !== currentId); - return [currentEntry, ...rest]; - }, [source, currentId]); - - return ( - - - - - 选择测线 - setShowAll(v => !v)}> - {showAll ? '全部' : '当前工程'} - - - item.sessionId} - style={SPM.list} - renderItem={({ item }) => { - const isCur = item.sessionId === currentId; - const isSel = item.sessionId === selectedId; - return ( - { onSelect(item.sessionId); onClose(); }} - > - - {item.sessionId}{isCur ? ' (当前)' : ''} - - {item.frameCount >= 0 && ( - {item.frameCount} 帧 - )} - - ); - }} - /> - - - ); -} - -const SPM = StyleSheet.create({ - backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)' }, - sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, maxHeight: '55%' }, - header: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, gap: 8 }, - title: { flex: 1, color: '#6a6a8a', fontSize: 12, fontWeight: '700' }, - allBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#0e0e1c' }, - allBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' }, - allTxt: { color: '#555', fontSize: 10, fontWeight: '700' }, - allTxtOn: { color: '#4a9eff' }, - list: { maxHeight: 280 }, - item: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' }, - itemSel: { backgroundColor: '#111130' }, - itemId: { color: '#8888aa', fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 }, - itemCnt: { color: '#4a4a6a', fontSize: 11, marginLeft: 8 }, + y: { position: 'absolute', fontSize: 9, textAlign: 'right' }, + x: { position: 'absolute', fontSize: 9, textAlign: 'center' }, }); // ── ProfileScreen ────────────────────────────────────────────────────────── export default function ProfileScreen() { + const theme = useTheme(); const { width: sw, height: sh } = useWindowDimensions(); - const { history, sessionId: currentSessionId, projectId } = useDataStore(); + const { history, sessionId, projectId, hasProject, resumeSession } = useDataStore(); const sampleFreqCode = useDeviceStore(s => s.config.sampleFreq); const gateConfig = useDeviceStore(s => s.gateConfig); const setGateConfig = useDeviceStore(s => s.setGateConfig); const [channelIdx, setChannelIdx] = useState(0); - const [sessionId, setSessionId] = useState(currentSessionId); - const [projectSessions, setProjectSessions] = useState([]); - const [allSessions, setAllSessions] = useState([]); - const [pickerVisible, setPickerVisible] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); - const [logScale, setLogScale] = useState(true); + const [logScale, setLogScale] = useState(false); - const { frames, loading, progress } = useProfileData(sessionId, currentSessionId, history); + const { frames, loading, progress } = useProfileData(sessionId, sessionId, history); const CHART_H = Math.round(Math.min(300, sh * 0.42)); - useEffect(() => { - const load = async () => { - const all = await StorageService.listSessions(); - setAllSessions(all.filter(x => x.sessionId !== currentSessionId)); - if (projectId) { - const proj = await StorageService.listSessionsByProject(projectId); - setProjectSessions(proj.filter(x => x.sessionId !== currentSessionId)); - } else { - setProjectSessions(all.filter(x => x.sessionId !== currentSessionId)); - } - }; - load(); - }, [currentSessionId, projectId]); - const maxCh = frames[0]?.channelNum ?? 6; useEffect(() => { if (channelIdx >= maxCh) setChannelIdx(0); }, [maxCh, channelIdx]); @@ -553,17 +492,44 @@ export default function ProfileScreen() { const refFrame = useMemo(() => frames[Math.floor(frames.length / 2)] ?? null, [frames]); + const handleSessionChange = async (newSessionId: string) => { + await resumeSession(newSessionId, projectId!); + }; + + // ── NoProjectGate ── + if (!hasProject) { + return ( + + + + ); + } + return ( - + {/* ── Header ── */} - setPickerVisible(true)}> - {sessionId} - - - setLogScale(v => !v)}> - {logScale ? 'LOG' : 'LIN'} + + + + setLogScale(v => !v)} + > + + {logScale ? 'LOG' : 'LIN'} + @@ -571,13 +537,13 @@ export default function ProfileScreen() { {Array.from({ length: maxCh }, (_, i) => ( setChannelIdx(i)} > - + CH{i + 1} @@ -587,15 +553,15 @@ export default function ProfileScreen() { {/* ── Loading progress ── */} {loading && ( - - 加载 {progress.current}/{progress.total} 帧… + + 加载 {progress.current}/{progress.total} 帧… )} {/* ── Empty ── */} {!loading && frames.length === 0 && ( - 暂无剖面数据 + 暂无剖面数据 )} @@ -610,13 +576,16 @@ export default function ProfileScreen() { /> {/* Gate preview + legend + settings */} - + - + 时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔 - setSettingsVisible(true)}> - ⚙ 配置 + setSettingsVisible(true)}> + ⚙ 配置 @@ -633,7 +602,7 @@ export default function ProfileScreen() { return ( - G{gi + 1} {fmtUs(tUs)} + G{gi + 1} {fmtUs(tUs)} ); })} @@ -644,16 +613,6 @@ export default function ProfileScreen() { )} - setPickerVisible(false)} - /> - void; onClose: () => void; }) { + const theme = useTheme(); const [text, setText] = useState(initial ?? ''); useEffect(() => { if (visible) setText(initial ?? ''); }, [visible, initial]); return ( - - {title} + + {title} - - 取消 + + 取消 { if (text.trim()) { onConfirm(text.trim()); onClose(); } }} disabled={!text.trim()} > - 确定 + 确定 @@ -64,14 +76,14 @@ function NameModal({ const NM = StyleSheet.create({ backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' }, - box: { position: 'absolute', left: 24, right: 24, top: '35%', backgroundColor: '#111120', borderRadius: 16, padding: 20, borderWidth: 1, borderColor: '#22223a' }, - title: { color: '#9090b8', fontSize: 14, fontWeight: '700', marginBottom: 14 }, - input: { backgroundColor: '#0c0c18', borderRadius: 8, padding: 12, color: '#d0d0e8', fontSize: 14, borderWidth: 1, borderColor: '#22223a', marginBottom: 16 }, + box: { position: 'absolute', left: 24, right: 24, top: '35%', borderRadius: 16, padding: 20, borderWidth: 1 }, + title: { fontSize: 14, fontWeight: '700', marginBottom: 14 }, + input: { borderRadius: 8, padding: 12, fontSize: 14, borderWidth: 1, marginBottom: 16 }, row: { flexDirection: 'row', gap: 10 }, - cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' }, - cancelTxt: { color: '#6a6a8a', fontWeight: '600' }, - confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, backgroundColor: '#1a3a6e', borderWidth: 1, borderColor: '#4a9eff', alignItems: 'center' }, - confirmTxt: { color: '#4a9eff', fontWeight: '700' }, + cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' }, + cancelTxt: { fontWeight: '600' }, + confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' }, + confirmTxt: { fontWeight: '700' }, btnDis: { opacity: 0.4 }, }); @@ -84,6 +96,8 @@ function LineList({ onActivate, onExport, onDelete, + onNewLine, + onReload, }: { project: ProjectInfo; currentSessionId: string; @@ -91,7 +105,11 @@ function LineList({ onActivate: (sessionId: string) => void; onExport: (sessionId: string) => void; onDelete: (sessionId: string) => void; + onNewLine: () => void; + onReload: () => void; }) { + const theme = useTheme(); + const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1); const [lines, setLines] = useState([]); const [loading, setLoading] = useState(true); @@ -102,74 +120,101 @@ function LineList({ setLoading(false); }, [project.projectId]); - useEffect(() => { void reload(); }, [reload]); + useEffect(() => { void reload(); }, [reload, latestFrameId]); - if (loading) return ; + if (loading) return ; if (lines.length === 0) { - return 暂无测线; + return 暂无测线; } return ( - + {lines.map((line) => { const isActive = line.sessionId === currentSessionId; const date = new Date(line.createdAt); const dateStr = `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; return ( - + - {isActive && } + {isActive && } - {line.sessionId} - {dateStr} · {line.frameCount} 测点 + {line.sessionId} + {dateStr} · {line.frameCount} 测点 {!isActive && ( - onActivate(line.sessionId)}> - 激活 + onActivate(line.sessionId)}> + 激活 )} - onExport(line.sessionId)}> - 导出 + onExport(line.sessionId)}> + 导出 {!isActive && ( - onDelete(line.sessionId)}> - 删除 + { + Alert.alert('删除测线', '确定删除该测线的所有数据?', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', style: 'destructive', + onPress: async () => { + await StorageService.deleteSession(line.sessionId); + await reload(); + onReload(); + }, + }, + ]); + }}> + 删除 )} ); })} + { onNewLine(); await reload(); }} + activeOpacity={0.7} + > + + 新建测线 + ); } const LL = StyleSheet.create({ - wrap: { backgroundColor: '#08080f', marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' }, - empty: { color: '#2a2a4a', fontSize: 12, textAlign: 'center', paddingVertical: 14 }, - row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#141422', gap: 8 }, - rowActive: { backgroundColor: '#0d1a2e' }, - rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }, - activeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#4a9eff' }, - lineId: { color: '#6a6a9a', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, - lineSub: { color: '#2a2a4a', fontSize: 10, marginTop: 1 }, + wrap: { marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' }, + empty: { fontSize: 12, textAlign: 'center', paddingVertical: 14 }, + row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, gap: 4 }, + rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6 }, + activeDot: { width: 6, height: 6, borderRadius: 3 }, + lineId: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + lineSub: { fontSize: 10, marginTop: 1 }, actions: { flexDirection: 'row', gap: 6 }, btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 }, - actBtn: { borderColor: '#1a3a60', backgroundColor: '#0a1830' }, - actTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700' }, - expBtn: { borderColor: '#1a4a28', backgroundColor: '#0a1810' }, - expTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' }, - delBtn: { borderColor: '#3a1520', backgroundColor: '#140a0c' }, - delTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' }, + actTxt: { fontSize: 10, fontWeight: '700' }, + expTxt: { fontSize: 10, fontWeight: '700' }, + delTxt: { fontSize: 10, fontWeight: '600' }, + addBtn: { alignItems: 'center', paddingVertical: 10, borderTopWidth: StyleSheet.hairlineWidth, borderStyle: 'dashed' }, + addTxt: { fontSize: 11, fontWeight: '700' }, }); // ── Main screen ────────────────────────────────────────────────────────────── export default function ProjectsScreen() { + const theme = useTheme(); const insets = useSafeAreaInsets(); - const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject } = useDataStore(); + const { sessionId: currentSessionId, projectId: currentProjectId, newSession, resumeSession, setProject, resetToNoProject, hasProject } = useDataStore(); const { config: deviceConfig, gateConfig } = useDeviceStore(); const [projects, setProjects] = useState([]); @@ -204,6 +249,10 @@ export default function ProjectsScreen() { placeholder: '输入工程名称', onConfirm: async (name) => { const id = await StorageService.createProject(name); + // First project — auto-create initial session + if (!useDataStore.getState().hasProject) { + await newSession(id); + } await reload(); setExpanded(id); }, @@ -233,7 +282,19 @@ export default function ProjectsScreen() { text: '删除', style: 'destructive', onPress: async () => { await StorageService.deleteProject(project.projectId); - if (currentProjectId === project.projectId) await setProject(null); + const remaining = await StorageService.listProjects(); + if (remaining.length === 0) { + resetToNoProject(); + } else if (currentProjectId === project.projectId) { + // Switch to another project's latest session + const next = remaining[0]; + const sessions = await StorageService.listSessionsByProject(next.projectId); + if (sessions.length > 0) { + await resumeSession(sessions[0].sessionId, next.projectId); + } else { + await newSession(next.projectId); + } + } await reload(); }, }, @@ -365,46 +426,64 @@ export default function ProjectsScreen() { }; return ( - + {/* Header */} - - 工程管理 + + 工程管理 {importing - ? - : 导入 .tem} + ? + : 导入 .tem} - - + 新建 + + + 新建 {/* Progress bar (export / import) */} {(importing || exporting !== null) && progress.total > 0 && ( - - - {progress.current}/{progress.total} 帧 + + + {progress.current}/{progress.total} 帧 )} {/* Current context pill */} - - 当前测线 - {currentSessionId} + + 当前测线 + {currentSessionId} {currentProjectId ? ( - - + + {projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId} ) : ( - - 未分配工程 + + 未分配工程 )} @@ -416,16 +495,19 @@ export default function ProjectsScreen() { contentContainerStyle={{ paddingBottom: 32 }} ListEmptyComponent={ - - 暂无工程 - 点击右上角「新建工程」开始 + + 暂无工程 + 点击右上角「新建工程」开始 } renderItem={({ item: project }) => { const isExpanded = expanded === project.projectId; const isCurrentProject = project.projectId === currentProjectId; return ( - + {/* Project row */} - {isExpanded ? '▾' : '▸'} + {isExpanded ? '▾' : '▸'} - {project.name} + {project.name} {isCurrentProject && ( - - 当前 + + 当前 )} - + {project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')} - handleOpenProject(project)}> - 打开 + handleOpenProject(project)}> + 打开 handleExportProject(project)} disabled={exporting === project.projectId} > {exporting === project.projectId - ? - : .tem} + ? + : .tem} - handleRenameProject(project)}> - 改名 + handleRenameProject(project)}> + 改名 - handleDeleteProject(project)}> - 删除 + handleDeleteProject(project)}> + 删除 @@ -479,6 +576,8 @@ export default function ProjectsScreen() { onActivate={(sid) => handleActivateLine(sid, project.projectId)} onExport={handleExportLine} onDelete={handleDeleteLine} + onNewLine={() => handleNewLine(project)} + onReload={reload} /> )} @@ -495,51 +594,43 @@ export default function ProjectsScreen() { } const S = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#090912' }, - header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' }, - headerTitle:{ color: '#6a6a9a', fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 }, - addBtn: { backgroundColor: '#1a3a6e', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1, borderColor: '#4a9eff' }, - addBtnTxt: { color: '#4a9eff', fontSize: 12, fontWeight: '700' }, + root: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1 }, + headerTitle:{ fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 }, + addBtn: { borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1 }, + addBtnTxt: { fontSize: 12, fontWeight: '700' }, - contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#0c0c18', borderBottomWidth: 1, borderBottomColor: '#141422', gap: 8 }, - contextLabel: { color: '#2a2a4a', fontSize: 10, fontWeight: '600', letterSpacing: 1 }, - contextSession:{ color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 }, - projectPill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: '#1a3a6e' }, - projectPillTxt:{ color: '#4a9eff', fontSize: 9, fontWeight: '700' }, - projectPillNone: { backgroundColor: '#1a1a1a', borderColor: '#2a2a2a' }, - projectPillTxtNone:{ color: '#3a3a5a' }, + contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, gap: 8 }, + contextLabel: { fontSize: 10, fontWeight: '600', letterSpacing: 1 }, + contextSession:{ fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 }, + projectPill: { borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 }, + projectPillTxt:{ fontSize: 9, fontWeight: '700' }, empty: { paddingTop: 80, alignItems: 'center', gap: 10 }, - emptyIcon: { fontSize: 40, color: '#1a1a2a' }, - emptyTxt: { color: '#2a2a4a', fontSize: 15 }, - emptyHint: { color: '#1a1a2a', fontSize: 12 }, + emptyIcon: { fontSize: 40 }, + emptyTxt: { fontSize: 15 }, + emptyHint: { fontSize: 12 }, - projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a', overflow: 'hidden', backgroundColor: '#0e0e1c' }, + projectCard: { marginHorizontal: 12, marginTop: 10, borderRadius: 12, borderWidth: 1, overflow: 'hidden' }, projectRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, gap: 10 }, projectRowLeft:{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 }, - chevron: { color: '#3a3a5a', fontSize: 12, width: 12 }, + chevron: { fontSize: 12, width: 12 }, projectNameRow:{ flexDirection: 'row', alignItems: 'center', gap: 8 }, - projectName: { color: '#c0c0d8', fontSize: 14, fontWeight: '700' }, - projectMeta: { color: '#3a3a5a', fontSize: 10, marginTop: 2 }, - activePill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1, borderColor: '#4a9eff55' }, - activePillTxt: { color: '#4a9eff', fontSize: 8, fontWeight: '700' }, + projectName: { fontSize: 14, fontWeight: '700' }, + projectMeta: { fontSize: 10, marginTop: 2 }, + activePill: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1 }, + activePillTxt: { fontSize: 8, fontWeight: '700' }, - importBtn: { backgroundColor: '#1e1a08', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, borderColor: '#ffd93d66', marginRight: 8, minWidth: 70, alignItems: 'center' }, - importBtnTxt: { color: '#ffd93d', fontSize: 12, fontWeight: '700' }, + importBtn: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, marginRight: 8, minWidth: 70, alignItems: 'center' }, + importBtnTxt: { fontSize: 12, fontWeight: '700' }, btnDis: { opacity: 0.4 }, - progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, backgroundColor: '#0c0c18', borderRadius: 10, overflow: 'hidden', borderWidth: 1, borderColor: '#1a1a2a', justifyContent: 'center' }, - progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, backgroundColor: '#1a3a6e', borderRadius: 10 }, - progressTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700', textAlign: 'center' }, + progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, borderRadius: 10, overflow: 'hidden', borderWidth: 1, justifyContent: 'center' }, + progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, borderRadius: 10 }, + progressTxt: { fontSize: 10, fontWeight: '700', textAlign: 'center' }, projectActions:{ flexDirection: 'row', gap: 5 }, - pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#111120', alignItems: 'center', justifyContent: 'center' }, - pBtnTxt: { color: '#5a5a7a', fontSize: 10, fontWeight: '600' }, - pBtnNew: { borderColor: '#1a4a28', backgroundColor: '#0a1810' }, - pBtnNewTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' }, - pBtnExp: { borderColor: '#4a3a1a', backgroundColor: '#1a1408' }, - pBtnExpTxt: { color: '#ffd93d', fontSize: 10, fontWeight: '700' }, - pBtnDel: { borderColor: '#3a1520', backgroundColor: '#140a0c' }, - pBtnDelTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' }, + pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, alignItems: 'center', justifyContent: 'center' }, + pBtnTxt: { fontSize: 10, fontWeight: '700' }, }); diff --git a/app/(tabs)/records.tsx b/app/(tabs)/records.tsx index 80fd093..626fe5f 100644 --- a/app/(tabs)/records.tsx +++ b/app/(tabs)/records.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { View, Text, FlatList, TouchableOpacity, Modal, StyleSheet, Alert, ActivityIndicator, Platform, @@ -7,90 +7,52 @@ import { 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 { 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; -}) { +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(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 [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} + 帧 #{frame.frameId} setLogScale((v) => !v)} > - - {logScale ? 'LOG' : 'LIN'} - + {logScale ? 'LOG' : 'LIN'} - - + + - {data ? ( - + ) : ( - - 无波形数据 - - )} - - - {frame.adcUV.map((ch, i) => { - const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0); - return ( - - - CH{i + 1} - {formatUV(pk)} - - ); - })} - - - {frame.meta && ( - - - UTC {formatUtc(frame.meta.utc)} ×{frame.accNum}叠 增益×{frame.gain} - - - {formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} - + + 无波形数据 )} @@ -99,18 +61,107 @@ function FrameWaveformModal({ ); } -// ── Main screen ───────────────────────────────────────────────────────────── +// ── 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 { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore(); - const [exporting, setExporting] = useState(false); + 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 (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; } + if (dbFrames.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; } setExporting(true); try { - const path = await exportCsv(history, sessionId); + // 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); @@ -119,135 +170,107 @@ export default function RecordsScreen() { } }; - const handleClear = () => { - Alert.alert('清空记录', '确定清空当前会话的所有记录?', [ + const handleClear = () => + Alert.alert('清空显示', '清空屏幕上的记录列表?(已保存的数据不受影响)', [ { text: '取消', style: 'cancel' }, { text: '清空', style: 'destructive', onPress: clearHistory }, ]); - }; - const handleNewSession = () => { - Alert.alert('新建测线', '结束当前测线,开始新测线?', [ + const handleDeleteFrame = (item: PersistedFrame) => { + Alert.alert(`删除 #${item.frameId}`, '确定删除?', [ { text: '取消', style: 'cancel' }, - { text: '新建', onPress: () => newSession(projectId) }, + { + text: '删除', style: 'destructive', onPress: () => { + deleteFrame(item.frameId); + reloadDbFrames(); + }, + }, ]); }; - const handleDeleteFrame = (item: MeasurementFrame) => { - Alert.alert( - `删除测点 #${item.frameId}`, - '确定删除该测点?此操作不可撤销。', - [ - { text: '取消', style: 'cancel' }, - { text: '删除', style: 'destructive', onPress: () => deleteFrame(item.frameId) }, - ], - ); + const handleSessionChange = async (newSessionId: string) => { + await resumeSession(newSessionId, projectId); }; - const renderItem = ({ item, index }: { item: MeasurementFrame; index: number }) => { - const m = item.meta; - return ( - setSelectedFrame(item)} - activeOpacity={0.75} - > - - {String(history.length - index).padStart(3, '0')} - - - - #{item.frameId} - {m ? formatUtc(m.utc) : '--'} - {m && ( - 0 ? '#0d2018' : '#1a0d10' }]}> - 0 ? '#3ddc84' : '#ff5c6e', fontSize: 8, fontWeight: '700' }}> - GPS {m.gpsStatus > 0 ? '✓' : '✗'} - - - )} - - 波形 › - - handleDeleteFrame(item)} hitSlop={8}> - × - - - - {m && ( - - {formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)} - - )} - - - - {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 ( - - - {formatUV(pk)} - - ); - })} - - - ×{item.accNum}叠 - 增益×{item.gain} - - - - - ); + 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 */} - + - {sessionId} - {history.length} 测点 + {dbFrames.length} 测点 {exporting - ? - : 导出 CSV} + ? + : 导出 CSV} - - 新建 - - - 清空 + + 清空 - {history.length === 0 ? ( + {loadingDb ? ( - - 暂无采集记录 - 在波形页开始采集后,数据将显示在这里 + + 加载中... + + ) : dbFrames.length === 0 ? ( + + + 暂无采集记录 + 在波形页开始采集后,数据将显示在这里 ) : ( - String(item.frameId)} - renderItem={renderItem} - contentContainerStyle={S.list} - /> + + + + String(item.frameId)} + renderItem={({ item }) => ( + handleFramePress(item)} + onDelete={() => handleDeleteFrame(item)} + /> + )} + getItemLayout={(_, index) => ({ length: 40, offset: 40 * index, index })} + /> + + )} {selectedFrame && ( - setSelectedFrame(null)} - /> + setSelectedFrame(null)} /> )} ); @@ -256,86 +279,49 @@ export default function RecordsScreen() { // ── Styles ────────────────────────────────────────────────────────────────── const S = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#090912' }, - + container: { flex: 1 }, + selectorRow: { + paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4, + }, toolbar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 10, - backgroundColor: '#0c0c18', - borderBottomWidth: 1, borderBottomColor: '#1a1a2a', - gap: 8, + borderBottomWidth: 1, 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' }, + count: { 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' }, + btnTxt: { fontSize: 12, fontWeight: '700' }, btnDisabled: { opacity: 0.4 }, + empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 }, +}); - list: { padding: 12, gap: 6 }, - - item: { +const T = StyleSheet.create({ + row: { flexDirection: 'row', - backgroundColor: '#0e0e1c', - borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a', - overflow: 'hidden', + alignItems: 'center', + height: 40, + paddingHorizontal: 8, + borderBottomWidth: StyleSheet.hairlineWidth, }, - 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 }, + 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: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 16, paddingBottom: 32 }, + sheet: { 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' }, + 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' }, }); diff --git a/app/(tabs)/wave.tsx b/app/(tabs)/wave.tsx index 30b5066..b6de2ed 100644 --- a/app/(tabs)/wave.tsx +++ b/app/(tabs)/wave.tsx @@ -2,17 +2,22 @@ import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, ActivityIndicator, useWindowDimensions, Platform, + useColorScheme, } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useConnectionStore } from '../../src/stores/connectionStore'; import { useDeviceStore } from '../../src/stores/deviceStore'; import { useDataStore } from '../../src/stores/dataStore'; import { useDevice } from '../../src/hooks/useDevice'; -import { useWaveform } from '../../src/hooks/useWaveform'; +import { useWaveform, type WaveformData } from '../../src/hooks/useWaveform'; import { WaveformChart } from '../../src/components/WaveformChart'; import { ParamForm } from '../../src/components/ParamForm'; +import { NoProjectGate } from '../../src/components/NoProjectGate'; import { CHANNEL_COLORS } from '../../src/protocol/constants'; -import { formatUV, formatUtc, formatCoord } from '../../src/utils/format'; -import { Colors, Spacing, Radius } from '../../src/design/tokens'; +import { formatUV, formatUtc, formatCoord, sourceModeLabel, formatBattery, formatTemperature } from '../../src/utils/format'; +import { useTheme } from '../../src/design/tokens'; +import { Spacing, Radius } from '../../src/design/tokens'; +import * as ScreenOrientation from 'expo-screen-orientation'; import type { MeasurementFrame } from '../../src/protocol/types'; // ── Param sheet ─────────────────────────────────────────────────────────────── @@ -30,6 +35,7 @@ function ParamSheet({ busy: boolean; configDirty: boolean; }) { + const theme = useTheme(); const { deviceStatus } = useDeviceStore(); const measuring = deviceStatus !== 'idle'; @@ -42,14 +48,20 @@ function ParamSheet({ statusBarTranslucent > - - + + - 采集参数 + 采集参数 {configDirty && ( - - 待下发 + + 待下发 )} @@ -62,7 +74,14 @@ function ParamSheet({ {busy - ? - : + ? + : {measuring ? '采集中,不可下发' : '下 发 配 置'} } @@ -88,37 +111,30 @@ const PS = StyleSheet.create({ left: 0, right: 0, maxHeight: '85%', - backgroundColor: Colors.bg.surface, borderTopLeftRadius: Radius.xl, borderTopRightRadius: Radius.xl, borderTopWidth: 1, - borderColor: Colors.bg.border, paddingBottom: Spacing.xxl, }, handle: { width: 36, height: 4, borderRadius: 2, - backgroundColor: Colors.bg.border, alignSelf: 'center', marginTop: Spacing.sm, marginBottom: Spacing.sm, }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.lg, paddingBottom: Spacing.sm, gap: Spacing.sm }, - title: { color: Colors.text.secondary, fontSize: 14, fontWeight: '700', flex: 1 }, - dirtyBadge: { backgroundColor: Colors.amber.bg, borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: Colors.amber.border }, - dirtyTxt: { color: Colors.amber.fg, fontSize: 9, fontWeight: '700' }, + title: { fontSize: 14, fontWeight: '700', flex: 1 }, + dirtyBadge: { borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 }, + dirtyTxt: { fontSize: 9, fontWeight: '700' }, scroll: { flex: 1 }, setupBtn: { margin: Spacing.lg, - backgroundColor: Colors.bg.raised, borderRadius: Radius.lg, paddingVertical: 14, alignItems: 'center', borderWidth: 1, - borderColor: Colors.bg.border, }, - setupBtnDirty: { backgroundColor: Colors.blue.bg, borderColor: Colors.blue.fg }, setupBtnDis: { opacity: 0.4 }, - setupBtnTxt: { color: Colors.text.muted, fontWeight: '700', fontSize: 14, letterSpacing: 3 }, - setupBtnTxtDirty: { color: Colors.blue.fg }, + setupBtnTxt: { fontWeight: '700', fontSize: 14, letterSpacing: 3 }, }); // ── Control row ─────────────────────────────────────────────────────────────── @@ -142,70 +158,101 @@ function ControlRow({ onSettings: () => void; configDirty: boolean; }) { + const theme = useTheme(); const idle = deviceStatus === 'idle'; const running = deviceStatus === 'running'; const single = deviceStatus === 'single'; return ( - + {/* Primary action */} {idle && ( {busy - ? + ? : <> - - 连 续 采 集 + + 连 续 采 集 } )} {running && ( - - 停 止 + + 停 止 )} {single && ( - - - 单次采集中… + + + 单次采集中… )} {/* Secondary: single-shot button (idle only) */} {idle && ( - - 单次 + + 单次 )} {/* Running indicator (running only) */} {running && frameId !== undefined && ( - - - #{frameId} + + + #{frameId} )} {/* Settings button — always present */} - - - {configDirty && } + + + {configDirty && ( + + )} ); @@ -219,8 +266,6 @@ const CR = StyleSheet.create({ paddingVertical: Spacing.sm, gap: Spacing.sm, borderTopWidth: 1, - borderTopColor: Colors.bg.border, - backgroundColor: Colors.bg.surface, }, primary: { flex: 2, @@ -232,11 +277,8 @@ const CR = StyleSheet.create({ borderRadius: Radius.lg, borderWidth: 1, }, - green: { backgroundColor: Colors.green.bg, borderColor: Colors.green.border }, - red: { backgroundColor: Colors.red.bg, borderColor: Colors.red.border }, - teal: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border }, dis: { opacity: 0.35 }, - primaryIcon: { fontSize: 11, color: '#ffffff88' }, + primaryIcon: { fontSize: 11 }, primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 }, secondary: { @@ -249,8 +291,7 @@ const CR = StyleSheet.create({ borderRadius: Radius.lg, borderWidth: 1, }, - tealBtn: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border }, - secondaryIcon:{ fontSize: 11, color: '#ffffff66' }, + secondaryIcon:{ fontSize: 11 }, secondaryTxt: { fontSize: 12, fontWeight: '700' }, indicator: { @@ -261,92 +302,119 @@ const CR = StyleSheet.create({ gap: 6, paddingVertical: 14, borderRadius: Radius.lg, - backgroundColor: Colors.green.bg, borderWidth: 1, - borderColor: Colors.green.border, }, - runDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.green.fg }, - indicatorTxt: { color: Colors.green.fg, fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + runDot: { width: 6, height: 6, borderRadius: 3 }, + indicatorTxt: { fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, settingsBtn: { width: 44, height: 44, borderRadius: Radius.md, - backgroundColor: Colors.bg.raised, - borderWidth: 1, borderColor: Colors.bg.border, + borderWidth: 1, alignItems: 'center', justifyContent: 'center', }, - settingsTxt: { fontSize: 18, color: Colors.text.muted }, - settingsDirty: { color: Colors.amber.fg }, + settingsTxt: { fontSize: 18 }, dirtyDot: { position: 'absolute', top: 6, right: 6, width: 7, height: 7, borderRadius: 4, - backgroundColor: Colors.amber.fg, - borderWidth: 1, borderColor: Colors.bg.surface, + borderWidth: 1, }, }); -// ── Peak row ────────────────────────────────────────────────────────────────── +// ── Status panel ───────────────────────────────────────────────────────────── + +const CURRENT_RATIO = 50; +const INT32_MAX = 0x7fffffff; + +function formatCurrent(raw: number): string { + const amps = (raw / INT32_MAX) * 5.0 * CURRENT_RATIO; + return `${amps.toFixed(1)} A`; +} + +function StatusPanel({ frame }: { frame: MeasurementFrame }) { + const theme = useTheme(); + const m = frame.meta; + const mono = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const; + + const row1 = [ + { label: '源模式', value: sourceModeLabel(m.sourceMode) }, + { label: '发射电流', value: formatCurrent(m.current) }, + { label: '电池', value: formatBattery(m.batteryVolt) }, + { label: '温度', value: formatTemperature(m.temperature) }, + ]; + const row2 = [ + { label: 'Roll', value: `${m.roll.toFixed(1)}°` }, + { label: 'Pitch', value: `${m.pitch.toFixed(1)}°` }, + { label: 'Yaw', value: `${m.yaw.toFixed(1)}°` }, + { label: 'GPS', value: ['未定位','非差分','差分','无效PPS','固定解','浮点解','估算中'][m.gpsStatus] ?? '未知', highlight: m.gpsStatus > 0 }, + { label: 'SD', value: ['正常','未插卡','异常'][m.sdStatus] ?? '未知', highlight: m.sdStatus === 0 }, + ]; + + const renderCell = (it: { label: string; value: string; highlight?: boolean }) => ( + + {it.label} + {it.value} + + ); -function PeakRow({ frame, visible, channelNum }: { frame: MeasurementFrame; visible: boolean[]; channelNum: number }) { return ( - - - {frame.adcUV.map((ch, i) => { - if (i >= channelNum || !visible[i]) return null; - const pk = ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0); - return ( - - - CH{i + 1} - {formatUV(pk)} - - ); - })} - - + + {row1.map(renderCell)} + {row2.map(renderCell)} + ); } -const PK = StyleSheet.create({ - scroll: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider }, - row: { flexDirection: 'row', paddingHorizontal: Spacing.md, paddingVertical: 7, gap: 10 }, - cell: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: Colors.bg.surface, borderRadius: Radius.sm, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: Colors.bg.border }, - dot: { width: 5, height: 5, borderRadius: 3 }, - label: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600' }, - value: { fontSize: 11, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, +const SP = StyleSheet.create({ + container: { paddingHorizontal: Spacing.md, paddingVertical: 4, gap: 4, borderTopWidth: StyleSheet.hairlineWidth }, + row: { flexDirection: 'row', gap: 4 }, + cell: { flex: 1, alignItems: 'center', paddingVertical: 3, borderRadius: Radius.sm, borderWidth: 1 }, + label: { fontSize: 8, fontWeight: '600' }, + value: { fontSize: 10, fontWeight: '700', marginTop: 1 }, }); // ── Frame summary bar ───────────────────────────────────────────────────────── function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) { - const m = frame.meta; + const theme = useTheme(); return ( - - + + #{frame.frameId} - {' '}×{frame.accNum}叠 - {m ? ` ${formatUtc(m.utc)}` : ''} - {m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''} + {' '}{frame.meta ? formatUtc(frame.meta.utc) : ''} + {frame.meta?.latitude ? ` ${formatCoord(frame.meta.latitude, false)} ${formatCoord(frame.meta.longitude, true)}` : ''} + {frame.meta ? ` R${frame.meta.roll.toFixed(1)} P${frame.meta.pitch.toFixed(1)} Y${frame.meta.yaw.toFixed(1)}` : ''} ); } const FS = StyleSheet.create({ - bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, backgroundColor: Colors.bg.void, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider }, - txt: { color: Colors.text.ghost, fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, borderTopWidth: StyleSheet.hairlineWidth }, + txt: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, }); // ── Not connected placeholder ───────────────────────────────────────────────── function NotConnected({ onConnect }: { onConnect: () => void }) { + const theme = useTheme(); return ( - - 未连接仪器 - 请先连接 TEM 接收机 - - 连 接 设 备 + + 未连接仪器 + 请先连接 TEM 接收机 + + 连 接 设 备 ); @@ -354,38 +422,51 @@ function NotConnected({ onConnect }: { onConnect: () => void }) { const NC = StyleSheet.create({ root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 }, - icon: { fontSize: 48, color: Colors.bg.border }, - title: { color: Colors.text.muted, fontSize: 17, fontWeight: '700' }, - sub: { color: Colors.text.ghost, fontSize: 12 }, + icon: { fontSize: 48 }, + title: { fontSize: 17, fontWeight: '700' }, + sub: { fontSize: 12 }, btn: { marginTop: Spacing.sm, - backgroundColor: Colors.blue.bg, borderRadius: Radius.lg, paddingVertical: 13, paddingHorizontal: 32, - borderWidth: 1, borderColor: Colors.blue.fg, + borderWidth: 1, }, - btnTxt: { color: Colors.blue.fg, fontSize: 14, fontWeight: '700', letterSpacing: 2 }, + btnTxt: { fontSize: 14, fontWeight: '700', letterSpacing: 2 }, }); // ── Main screen ─────────────────────────────────────────────────────────────── export default function WaveScreen() { + const theme = useTheme(); const { width: sw, height: sh } = useWindowDimensions(); const { status, showModal } = useConnectionStore(); const { config, configDirty } = useDeviceStore(); const { deviceStatus } = useDeviceStore(); const { busy, setup, startContinuous, startSingle, stop } = useDevice(); - const frame = useDataStore((s) => s.currentFrame); - const sessionId = useDataStore((s) => s.sessionId); - const projectId = useDataStore((s) => s.projectId); + const frame = useDataStore((s) => s.currentFrame); + const sessionId = useDataStore((s) => s.sessionId); + const projectId = useDataStore((s) => s.projectId); + const hasProject = useDataStore((s) => s.hasProject); const [visible, setVisible] = useState(() => Array(6).fill(true)); - const [logScale, setLogScale] = useState(true); + const [logScale, setLogScale] = useState(false); const [sheetVisible, setSheetVisible] = useState(false); + const [fullscreen, setFullscreen] = useState(false); const [projectName, setProjectName] = useState(null); + useEffect(() => { + if (fullscreen) { + ScreenOrientation.unlockAsync(); + } else { + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); + } + return () => { + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); + }; + }, [fullscreen]); + const connected = status === 'connected'; const channelNum = config.channelNum; const CHART_H = Math.round(Math.min(280, sh * 0.36)); @@ -396,7 +477,7 @@ export default function WaveScreen() { if (!projectId) { setProjectName(null); return; } import('../../src/services/StorageService').then(({ listProjects }) => listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)), - ); + ).catch(() => setProjectName(null)); }, [projectId]); const toggleChannel = (idx: number) => @@ -409,51 +490,81 @@ export default function WaveScreen() { if (!connected) { return ( - + ); } + if (!hasProject) { + return ( + + + + ); + } + return ( - + {/* ── Context bar: project · session ── */} - + {projectName - ? {projectName} · {sessionId} - : {sessionId}} + ? {projectName} · {sessionId} + : {sessionId}} {/* ── Channel selector + LOG/LIN ── */} - + {Array.from({ length: channelNum }, (_, i) => ( toggleChannel(i)} activeOpacity={0.7} > - - CH{i + 1} + + CH{i + 1} ))} setLogScale((v) => !v)} activeOpacity={0.7} > - {logScale ? 'LOG' : 'LIN'} + + {logScale ? 'LOG' : 'LIN'} + + + setFullscreen(true)} + activeOpacity={0.7} + > + {/* ── Waveform chart ── */} - + {data ? ( ) : ( - - 等待采样数据… + + 等待采样数据… )} {/* ── Peak values ── */} - {frame && } {/* ── Frame summary ── */} + {frame && } {frame && } {/* ── Control row ── */} @@ -495,22 +607,97 @@ export default function WaveScreen() { busy={busy} configDirty={configDirty} /> + + {/* ── Fullscreen waveform ── */} + setFullscreen(false)} + > + setLogScale((v) => !v)} + onClose={() => setFullscreen(false)} + /> + ); } +function FullscreenContent({ + data, visibleChannels, logScale, onToggleLog, onClose, +}: { + data: WaveformData | null; + visibleChannels: boolean[]; + logScale: boolean; + onToggleLog: () => void; + onClose: () => void; +}) { + const theme = useTheme(); + const scheme = useColorScheme(); + const isDark = scheme !== 'light'; + const { width: fsW, height: fsH } = useWindowDimensions(); + return ( + + {data ? ( + + ) : ( + + 等待采样数据… + + )} + + {/* Floating toolbar */} + + + + {logScale ? 'LOG' : 'LIN'} + + + + + + + + ); +} + const S = StyleSheet.create({ - root: { flex: 1, backgroundColor: Colors.bg.base }, + root: { flex: 1 }, ctxBar: { paddingHorizontal: Spacing.md, paddingVertical: 4, - backgroundColor: Colors.bg.void, borderBottomWidth: 1, - borderBottomColor: Colors.bg.divider, }, ctxTxt: { - color: Colors.text.ghost, fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', }, @@ -520,7 +707,6 @@ const S = StyleSheet.create({ alignItems: 'center', paddingRight: Spacing.sm, borderBottomWidth: 1, - borderBottomColor: Colors.bg.divider, }, chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 }, chBtn: { @@ -531,28 +717,47 @@ const S = StyleSheet.create({ paddingVertical: 5, borderRadius: Radius.md, borderWidth: 1, - borderColor: Colors.bg.border, }, chDot: { width: 6, height: 6, borderRadius: 3 }, - chTxt: { color: Colors.text.ghost, fontSize: 10, fontWeight: '600' }, + chTxt: { fontSize: 10, fontWeight: '600' }, logBtn: { paddingHorizontal: 9, paddingVertical: 5, borderRadius: Radius.sm, borderWidth: 1, - borderColor: Colors.bg.border, - backgroundColor: Colors.bg.raised, marginLeft: 4, }, - logBtnOn: { borderColor: Colors.blue.border, backgroundColor: Colors.blue.bg }, - logTxt: { color: Colors.text.muted, fontSize: 10, fontWeight: '700', letterSpacing: 0.5 }, - logTxtOn: { color: Colors.blue.fg }, + logTxt: { fontSize: 10, fontWeight: '700', letterSpacing: 0.5 }, + fullBtn: { + paddingHorizontal: 8, + paddingVertical: 5, + borderRadius: Radius.sm, + borderWidth: 1, + marginLeft: 4, + }, + fullTxt: { fontSize: 12 }, - chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: Colors.bg.divider }, + fsRoot: { flex: 1 }, + fsToolbar: { + position: 'absolute', + top: 40, + right: 12, + flexDirection: 'row', + gap: 8, + }, + fsCloseBtn: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + fsCloseTxt: { fontSize: 16, fontWeight: '700' }, + + chartWrap: { borderBottomWidth: StyleSheet.hairlineWidth }, chartPlaceholder: { - backgroundColor: Colors.bg.surface, justifyContent: 'center', alignItems: 'center', }, - placeholderTxt: { color: Colors.text.ghost, fontSize: 13 }, + placeholderTxt: { fontSize: 13 }, }); diff --git a/app/(tabs)/waveform.tsx b/app/(tabs)/waveform.tsx index 2b80a7a..37c2b87 100644 --- a/app/(tabs)/waveform.tsx +++ b/app/(tabs)/waveform.tsx @@ -8,13 +8,15 @@ import { useWaveform } from '../../src/hooks/useWaveform'; import { useDataStore } from '../../src/stores/dataStore'; import { useDeviceStore } from '../../src/stores/deviceStore'; import { formatUV, formatUtc, formatCoord } from '../../src/utils/format'; +import { useTheme } from '../../src/design/tokens'; export default function WaveformScreen() { const insets = useSafeAreaInsets(); + const theme = useTheme(); const { width, height: windowHeight } = useWindowDimensions(); const channelNum = useDeviceStore((s) => s.config.channelNum); const [visible, setVisible] = useState(() => Array(6).fill(true)); - const [logScale, setLogScale] = useState(true); + const [logScale, setLogScale] = useState(false); const frame = useDataStore((s) => s.currentFrame); const data = useWaveform(visible); @@ -25,24 +27,26 @@ export default function WaveformScreen() { const CHART_HEIGHT = Math.round(Math.min(300, windowHeight * 0.38)); return ( - - + + + + setLogScale((v) => !v)} activeOpacity={0.7} > - + {logScale ? 'LOG' : 'LIN'} {/* Waveform chart */} - + {data ? ( ) : ( - - 等待采样数据... + + 等待采样数据... )} @@ -66,23 +70,23 @@ export default function WaveformScreen() { if (!visible[idx] || idx >= channelNum) return null; const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0); return ( - - CH{idx + 1} - {formatUV(absMax)} + + CH{idx + 1} + {formatUV(absMax)} ); })} {frame.meta && ( - - + + 帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)} - + {formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m - + Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}° @@ -94,11 +98,11 @@ export default function WaveformScreen() { } const S = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#0d0d0d' }, + container: { flex: 1 }, toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 }, - chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' }, - placeholder: { backgroundColor: '#1a1a1a', justifyContent: 'center', alignItems: 'center' }, - placeholderText: { color: '#444', fontSize: 14 }, + chartWrap: { borderBottomWidth: 1 }, + placeholder: { justifyContent: 'center', alignItems: 'center' }, + placeholderText: { fontSize: 14 }, scaleBtn: { marginLeft: 8, @@ -106,23 +110,18 @@ const S = StyleSheet.create({ paddingVertical: 5, borderRadius: 6, borderWidth: 1, - borderColor: '#333', - backgroundColor: '#1a1a1a', }, - scaleBtnActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' }, - scaleBtnText: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 }, - scaleBtnTextActive:{ color: '#4a9eff' }, + scaleBtnText: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 }, peakScroll: { flex: 1 }, peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 }, peakCell: { - backgroundColor: '#1a1a1a', borderRadius: 8, padding: 10, minWidth: 90, alignItems: 'center', - borderWidth: 1, borderColor: '#2a2a2a', + borderWidth: 1, }, - peakLabel: { color: '#888', fontSize: 11 }, - peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' }, - metaBlock: { margin: 12, backgroundColor: '#111', borderRadius: 8, padding: 10, gap: 3 }, - metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' }, + peakLabel: { fontSize: 11 }, + peakValue: { fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' }, + metaBlock: { margin: 12, borderRadius: 8, padding: 10, gap: 3 }, + metaText: { fontSize: 10, fontFamily: 'monospace' }, }); diff --git a/app/+not-found.tsx b/app/+not-found.tsx index ffb5643..d6704de 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -2,8 +2,11 @@ import { Link, Stack } from 'expo-router'; import { StyleSheet } from 'react-native'; import { Text, View } from '@/components/Themed'; +import { useTheme } from '../src/design/tokens'; export default function NotFoundScreen() { + const theme = useTheme(); + return ( <> @@ -11,7 +14,7 @@ export default function NotFoundScreen() { This screen doesn't exist. - Go to home screen! + Go to home screen! @@ -35,6 +38,5 @@ const styles = StyleSheet.create({ }, linkText: { fontSize: 14, - color: '#2e78b7', }, }); diff --git a/app/_layout.tsx b/app/_layout.tsx index 3927f14..2336953 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,6 +7,7 @@ import { StyleSheet } from 'react-native'; import 'react-native-reanimated'; import { useDataStore } from '../src/stores/dataStore'; import { ConnectModal } from '../src/components/modals/ConnectModal'; +import { useTheme } from '../src/design/tokens'; SplashScreen.preventAutoHideAsync(); @@ -14,6 +15,7 @@ export { ErrorBoundary } from 'expo-router'; export default function RootLayout() { const init = useDataStore((s) => s.init); + const theme = useTheme(); useEffect(() => { init().finally(() => SplashScreen.hideAsync()); @@ -21,12 +23,11 @@ export default function RootLayout() { return ( - - + + - {/* ConnectModal is global — accessible from any tab */} ); diff --git a/app/connect.tsx b/app/connect.tsx index 73df294..8a0463b 100644 --- a/app/connect.tsx +++ b/app/connect.tsx @@ -15,10 +15,12 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { useConnectionStore } from '../src/stores/connectionStore'; import { useDevice } from '../src/hooks/useDevice'; +import { useTheme } from '../src/design/tokens'; const LOG_MAX = 60; export default function ConnectScreen() { + const theme = useTheme(); const { host, port, status, lastError, setHost, setPort } = useConnectionStore(); const { connect, disconnect } = useDevice(); const [logs, setLogs] = useState(['Ready.']); @@ -60,58 +62,58 @@ export default function ConnectScreen() { const isConnected = status === 'connected'; return ( - + {/* ── Brand ── */} - - TEM Receiver - 瞬变电磁接收机上位机 + + TEM Receiver + 瞬变电磁接收机上位机 {/* ── Setup guide ── */} - - 连接步骤 + + 连接步骤 - 1 - 手机连接设备 WiFi 热点 - Linking.openSettings()}> - 设置 → + 1 + 手机连接设备 WiFi 热点 + Linking.openSettings()}> + 设置 → - 2 - 确认参数,点击连接 + 2 + 确认参数,点击连接 {/* ── Inputs ── */} - - 连接参数 + + 连接参数 - IP 地址 + IP 地址 - + - 端 口 + 端 口 @@ -119,36 +121,36 @@ export default function ConnectScreen() { {/* ── Action ── */} {!isConnected ? ( {isConnecting - ? - : 连 接 设 备} + ? + : 连 接 设 备} ) : ( - - 已连接 + + 已连接 - router.push('/(tabs)/control')} activeOpacity={0.8}> - 进入控制台 → + router.push('/(tabs)/control')} activeOpacity={0.8}> + 进入控制台 → - - 断开 + + 断开 )} {/* ── Log terminal ── */} - - ● 连接日志 + + ● 连接日志 {logs.map((l, i) => ( - {l} + {l} ))} @@ -160,78 +162,70 @@ export default function ConnectScreen() { } const S = StyleSheet.create({ - safe: { flex: 1, backgroundColor: '#090912' }, + safe: { flex: 1 }, flex: { flex: 1 }, scroll: { flex: 1 }, scrollContent: { padding: 20, paddingBottom: 40 }, // Brand brand: { alignItems: 'center', marginBottom: 28, marginTop: 8 }, - brandIcon: { fontSize: 36, color: '#4a9eff', marginBottom: 8 }, - title: { color: '#e4e4f0', fontSize: 26, fontWeight: '700', letterSpacing: 1 }, - subtitle: { color: '#4a4a6a', fontSize: 12, marginTop: 4, letterSpacing: 0.5 }, + brandIcon: { fontSize: 36, marginBottom: 8 }, + title: { fontSize: 26, fontWeight: '700', letterSpacing: 1 }, + subtitle: { fontSize: 12, marginTop: 4, letterSpacing: 0.5 }, // Card card: { - backgroundColor: '#111120', borderRadius: 14, borderWidth: 1, - borderColor: '#22223a', padding: 16, marginBottom: 12, }, - cardLabel: { color: '#4a4a6a', fontSize: 10, fontWeight: '600', letterSpacing: 1.5, marginBottom: 12, textTransform: 'uppercase' }, + cardLabel: { fontSize: 10, fontWeight: '600', letterSpacing: 1.5, marginBottom: 12, textTransform: 'uppercase' }, // Steps stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }, - stepBadge: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#1a2a44', justifyContent: 'center', alignItems: 'center' }, - stepNum: { color: '#4a9eff', fontSize: 11, fontWeight: '700' }, - stepText: { color: '#8888aa', fontSize: 13, flex: 1 }, - linkBtn: { backgroundColor: '#0d2040', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#1a3a66' }, - linkBtnText: { color: '#4a9eff', fontSize: 11, fontWeight: '600' }, + stepBadge: { width: 20, height: 20, borderRadius: 10, justifyContent: 'center', alignItems: 'center' }, + stepNum: { fontSize: 11, fontWeight: '700' }, + stepText: { fontSize: 13, flex: 1 }, + linkBtn: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1 }, + linkBtnText: { fontSize: 11, fontWeight: '600' }, // Inputs inputRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 10 }, - inputLabel: { color: '#5a5a7a', fontSize: 12, width: 54, letterSpacing: 0.3 }, + inputDivider: { height: StyleSheet.hairlineWidth, marginVertical: 10 }, + inputLabel: { fontSize: 12, width: 54, letterSpacing: 0.3 }, input: { flex: 1, - color: '#d0d0e8', fontSize: 15, fontWeight: '500', paddingVertical: 6, borderBottomWidth: 1, - borderBottomColor: '#2a2a48', }, // Connect button connectBtn: { - backgroundColor: '#1a3a6e', borderRadius: 14, paddingVertical: 16, alignItems: 'center', marginBottom: 12, borderWidth: 1, - borderColor: '#4a9eff', marginTop: 4, }, - connectBtnBusy: { borderColor: '#2a4a7a', backgroundColor: '#0f1f3a' }, - connectBtnText: { color: '#4a9eff', fontSize: 16, fontWeight: '700', letterSpacing: 3 }, + connectBtnText: { fontSize: 16, fontWeight: '700', letterSpacing: 3 }, // Connected state connectedBox: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12, marginTop: 4 }, connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 }, - greenDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#3ddc84' }, - connectedText: { color: '#3ddc84', fontWeight: '700', fontSize: 13 }, - goBtn: { flex: 1, backgroundColor: '#0d2820', borderRadius: 10, paddingVertical: 11, alignItems: 'center', borderWidth: 1, borderColor: '#1a4a30' }, - goBtnText: { color: '#3ddc84', fontWeight: '600', fontSize: 13 }, - disconnectBtn: { backgroundColor: '#2a0d12', borderRadius: 10, paddingVertical: 11, paddingHorizontal: 14, borderWidth: 1, borderColor: '#4a1a22' }, - disconnectBtnText: { color: '#ff5c6e', fontSize: 13, fontWeight: '600' }, + greenDot: { width: 8, height: 8, borderRadius: 4 }, + connectedText: { fontWeight: '700', fontSize: 13 }, + goBtn: { flex: 1, borderRadius: 10, paddingVertical: 11, alignItems: 'center', borderWidth: 1 }, + goBtnText: { fontWeight: '600', fontSize: 13 }, + disconnectBtn: { borderRadius: 10, paddingVertical: 11, paddingHorizontal: 14, borderWidth: 1 }, + disconnectBtnText: { fontSize: 13, fontWeight: '600' }, // Terminal log - terminal: { backgroundColor: '#08080f', borderRadius: 12, borderWidth: 1, borderColor: '#1a1a28', overflow: 'hidden', minHeight: 120 }, - terminalHeader: { color: '#3ddc84', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, borderBottomWidth: 1, borderBottomColor: '#141422' }, + terminal: { borderRadius: 12, borderWidth: 1, overflow: 'hidden', minHeight: 120 }, + terminalHeader: { fontSize: 10, fontWeight: '700', letterSpacing: 1.5, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, borderBottomWidth: 1 }, terminalBody: { padding: 12 }, - terminalLine: { color: '#3a3a5a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 3, lineHeight: 16 }, - terminalLineLatest: { color: '#6a6a9a' }, + terminalLine: { fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 3, lineHeight: 16 }, }); diff --git a/assets/images/android-icon-background.png b/assets/images/android-icon-background.png index 5ffefc5..6fa9ab3 100644 Binary files a/assets/images/android-icon-background.png and b/assets/images/android-icon-background.png differ diff --git a/assets/images/android-icon-foreground.png b/assets/images/android-icon-foreground.png index 3a9e501..17775c4 100644 Binary files a/assets/images/android-icon-foreground.png and b/assets/images/android-icon-foreground.png differ diff --git a/assets/images/android-icon-monochrome.png b/assets/images/android-icon-monochrome.png index 77484eb..17775c4 100644 Binary files a/assets/images/android-icon-monochrome.png and b/assets/images/android-icon-monochrome.png differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png index 408bd74..dcac121 100644 Binary files a/assets/images/favicon.png and b/assets/images/favicon.png differ diff --git a/assets/images/icon.png b/assets/images/icon.png index 7165a53..02ea8c4 100644 Binary files a/assets/images/icon.png and b/assets/images/icon.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..ad5ec7e Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/splash-icon.png b/assets/images/splash-icon.png index 03d6f6b..166e5d7 100644 Binary files a/assets/images/splash-icon.png and b/assets/images/splash-icon.png differ diff --git a/build-release.bat b/build-release.bat new file mode 100644 index 0000000..f0fdcff --- /dev/null +++ b/build-release.bat @@ -0,0 +1,49 @@ +@echo off +chcp 65001 >nul +title TEM Receiver - Release Build & Install + +set "JAVA_HOME=D:\Program Files\Android\Android Studio\jbr" +set "ANDROID_HOME=D:\ProgramData\AndroidSdk" +set "PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\platform-tools;%PATH%" + +echo ======================================== +echo TEM Receiver - Release Build +echo ======================================== +echo. + +echo [1/4] Building Release APK... +cd /d "%~dp0android" +call gradlew.bat assembleRelease +if errorlevel 1 ( + echo. + echo *** BUILD FAILED *** + pause + exit /b 1 +) + +set "APK=%~dp0android\app\build\outputs\apk\release\app-release.apk" +echo. +echo [2/4] APK built: %APK% + +echo. +echo [3/4] Checking connected device... +adb devices +echo. + +echo [4/4] Installing... +adb uninstall com.triloop.temreceiver >nul 2>&1 +adb install "%APK%" +if errorlevel 1 ( + echo. + echo *** INSTALL FAILED *** + echo Try: adb uninstall com.triloop.temreceiver + pause + exit /b 1 +) + +echo. +echo ======================================== +echo Done! APK installed successfully. +echo APK path: %APK% +echo ======================================== +pause diff --git a/docs/commercial-audit.md b/docs/commercial-audit.md new file mode 100644 index 0000000..d028908 --- /dev/null +++ b/docs/commercial-audit.md @@ -0,0 +1,558 @@ +# TriloopTem App 商业化审计报告 + +> 审计日期:2026-06-20 +> 审计范围:代码质量、UX 健壮性、安全性、性能、数据完整性 +> 总计问题:36 项(致命 4 / 高 13 / 中 14 / 低 5) + +--- + +## 一、代码质量与架构 + +### 1.1 [致命] 生产代码中有大量调试日志 + +**文件及行号:** +- `src/services/TcpService.ts` — 第 38, 45, 51, 65, 68, 70, 75, 83, 125, 129 行(10 处) +- `src/services/DeviceService.ts` — 第 30, 111-114, 149, 153, 159 行(5 处) +- `src/protocol/parser.ts` — 第 58-61, 64, 71, 77 行(4 处) + +**问题描述:** +总计约 20 处 `console.log` / `console.warn` 调用。其中 TcpService 第 51 和 68 行在每个 TCP data 事件上做 `Array.from(...).map(b => '0x' + b.toString(16))` 十六进制格式化,DeviceService 第 111-114 行在每帧数据到达时循环打印各通道的原始 ADC 值。在 250kHz 六通道连续采集时,这些日志每秒触发数百次,产生大量 GC 压力。 + +**修复方案:** +删除所有 `console.log` / `console.warn`,或用 `if (__DEV__)` 包裹。建议封装一个 `logger` 模块,在 release 构建中为 no-op。 + +--- + +### 1.2 [高] IP 地址和端口号缺少格式校验 + +**文件:** `src/components/modals/ConnectModal.tsx` 第 42-43 行 + +**问题描述:** +连接弹窗仅检查 `!host.trim()` 和 `isNaN(port)`,接受诸如 `999.999.999.999` 等无效 IP,端口号也没有范围限制(1-65535)。 + +**修复方案:** +在连接前用正则或 IP 解析函数校验地址格式,端口校验 `port >= 1 && port <= 65535`。 + +--- + +### 1.3 [高] 心跳写入失败被静默吞掉 + +**文件:** `src/services/TcpService.ts` 第 97-99 行 + +**问题描述:** +心跳定时器每 8 秒写 `'0'` 到 socket,外层 `try/catch` 捕获所有异常但不做任何处理。如果 socket 处于半断开状态,心跳持续失败但 `this.connected` 仍为 `true`,用户无法感知连接已断。 + +**修复方案:** +在 catch 中将 `this.connected` 设为 `false`,调用 `this.callbacks?.onError()`,触发重连流程。 + +--- + +### 1.4 [高] 数据库查询使用 `any` 类型 + +**文件:** `src/services/StorageService.ts` 第 247 行 + +**问题描述:** +`getAllAsync` 绕过了 TypeScript 类型检查。如果后续数据库 schema 变更(列名改动),运行时会产生 `undefined` 值但编译阶段无法发现。 + +**修复方案:** +为每个查询定义明确的行类型接口,替换 `any`。 + +--- + +### 1.5 [中] TcpService 单例无防重复连接保护 + +**文件:** `src/services/TcpService.ts` 第 23-30 行 + +**问题描述:** +在已连接状态下调用 `connect()` 不会先断开旧连接。`createSocket()` 虽然销毁旧 socket,但旧的 reconnect 定时器可能仍在运行,回调引用也未清理。 + +**修复方案:** +在 `connect()` 开头调用 `this.disconnect()` 确保干净状态。 + +--- + +### 1.6 [中] 分帧传输状态使用模块级变量,断线后不重置 + +**文件:** `src/services/DeviceService.ts` 第 24-26 行 + +**问题描述:** +`splitChunks`、`splitActive`、`splitFrameNo` 是模块级 `let` 变量。如果分帧传输中途断线重连,这些状态不会重置,下次分帧传输会拼接到旧的脏数据上。 + +**修复方案:** +在 `onClose` 回调中重置分帧状态,或在 `deviceStartSplitFrame` 开始时清理。 + +--- + +### 1.7 [中] `clearTimeout` 对可能为 null 的值使用 `!` 断言 + +**文件:** `src/services/TcpService.ts` 第 43, 113, 137 行 + +**问题描述:** +`clearTimeout(this.reconnectTimer!)` 虽然在大多数 JS 引擎中对 null 不会崩溃,但 `!` 非空断言不正确,严格模式下可能隐藏问题。 + +**修复方案:** +改为 `if (this.reconnectTimer) clearTimeout(this.reconnectTimer)`。 + +--- + +### 1.8 [低] 导出函数缺少返回类型注解 + +**文件:** `src/services/DeviceService.ts` 第 28, 172-186 行 + +**问题描述:** +`handleIncomingFrame`、`deviceStartSplitFrame` 等公共 API 函数没有显式返回类型。 + +**修复方案:** +为所有 `export` 函数添加返回类型注解。 + +--- + +### 1.9 [低] filePrefix 默认值在模块加载时计算,跨天不更新 + +**文件:** `src/stores/deviceStore.ts` 第 29 行 + +**问题描述:** +`new Date().toISOString().slice(0,10).replace(/-/g,'')` 仅在模块首次加载时执行一次。如果 App 过了午夜仍在运行,文件前缀还是昨天的日期。 + +**修复方案:** +在创建新采集会话时动态生成日期前缀,或在每天零点时刷新。 + +--- + +## 二、UX 与健壮性 + +### 2.1 [致命] ACK 竞态条件——并发请求覆盖 + +**文件:** `src/services/DeviceService.ts` 第 147-170 行 + +**问题描述:** +`pendingAcks` 以 func code 为 key 存储 Promise resolver。如果用户快速连续点击(如双击"下发配置"按钮),第二次 `pendingAcks.set()` 会静默覆盖第一次的 resolver,第一个 Promise 永远不会 resolve,直到 5 秒超时才报错。 + +**修复方案:** +在覆盖前先 reject 已有的 pending promise,或使用队列/序列号机制。UI 层也应在 `busy` 状态下禁用按钮(已部分实现但需确认所有路径)。 + +--- + +### 2.2 [高] TCP 初始连接无超时 + +**文件:** `src/services/TcpService.ts` 第 32-91 行 + +**问题描述:** +`TcpSocket.createConnection` 没有连接超时设置。如果设备不可达,可能挂起 75+ 秒等待操作系统级 TCP 超时。期间 UI 显示"连接中"但无法取消。 + +**修复方案:** +添加 10 秒连接超时:如果在超时内未收到 `onConnect` 回调,销毁 socket 并调用 `onError`。可以在 `createSocket()` 中启动一个定时器,`onConnect` 时清除。 + +--- + +### 2.3 [高] clearHistory 只清内存不清数据库 + +**文件:** `src/stores/dataStore.ts` 第 62 行 + +**问题描述:** +`clearHistory` 仅清空内存中的 `history` 数组和 `currentFrame`,但 SQLite 中的帧数据和磁盘上的 bin 文件不受影响。用户以为"清除"了数据,实际上数据仍然存在。 + +**修复方案:** +要么连同数据库和文件一起删除,要么将按钮文案改为"清除显示"并向用户说明数据仍已保存。 + +--- + +### 2.4 [高] 分享功能不可用时无用户反馈 + +**文件:** `src/utils/export.ts` 第 94-99 行 + +**问题描述:** +`shareFile` 调用 `Sharing.isAvailableAsync()` 检测分享是否可用,如果不可用则静默返回。用户点击"导出"后看到 CSV 文件生成但什么也没发生。 + +**修复方案:** +在分享不可用时弹出 Alert 提示,或提供复制文件路径的选项。 + +--- + +### 2.5 [中] 操作前不检查连接状态 + +**文件:** `src/hooks/useDevice.ts` 第 35-78 行 + +**问题描述:** +`setup`、`startContinuous`、`startSingle` 在发送命令前不检查是否已连接。当未连接时,`sendAndWaitAck` 会立即 reject "TCP not connected",但用户看到的提示是"配置超时"而非"未连接"。 + +**修复方案:** +在每个操作开始时检查 `tcpService.isConnected()`,未连接时显示明确的"请先连接设备"提示。 + +--- + +### 2.6 [中] wave.tsx 项目名加载无错误处理 + +**文件:** `app/(tabs)/wave.tsx` 第 396-400 行 + +**问题描述:** +动态 import + 异步链没有 `.catch()` 处理。如果 `listProjects()` 失败,Promise rejection 未被捕获。 + +**修复方案:** +添加 `.catch(() => setProjectName(null))`。 + +--- + +### 2.7 [中] CSV 导出只导出内存中的帧 + +**文件:** `app/(tabs)/records.tsx` 第 113 行 + +**问题描述:** +`exportCsv(history, sessionId)` 只导出当前内存中的帧数据(最多 `MAX_HISTORY = 500` 帧)。如果用户采集了 1000 帧,CSV 中只有最近 500 帧,且用户不知情。 + +**修复方案:** +从数据库导出全部帧,或在导出前提示用户当前仅包含内存中的 N 帧。 + +--- + +### 2.8 [低] 大项目导出无进度指示 + +**文件:** `src/services/StorageService.ts` 第 336-393 行 + +**问题描述:** +`exportProject` 对大项目可能耗时较长(读取所有 bin 文件),虽然提供了 `onProgress` 回调,但调用方需确保显示进度 UI。 + +**修复方案:** +在项目导出时显示进度条或加载动画。 + +--- + +## 三、安全性 + +### 3.1 [高] 原始数据被打印到日志 + +**文件:** +- `src/services/TcpService.ts` 第 51, 68, 125 行 +- `src/services/DeviceService.ts` 第 111-114 行 + +**问题描述:** +原始 TCP 载荷的十六进制内容和各通道 ADC 数值被打印到 console。在 Android 上,`console.log` 输出可通过 `adb logcat` 被任何持有 `READ_LOGS` 权限的应用读取。对于地球物理勘探数据,这可能涉及商业敏感信息。 + +**修复方案:** +同 1.1,移除或用 `__DEV__` 门控。 + +--- + +### 3.2 [中] 项目名/文件名未做文件系统安全过滤 + +**文件:** `src/services/StorageService.ts` 第 104-112, 390 行 + +**问题描述:** +`createProject` 使用 `name.trim()` 但不过滤文件系统特殊字符。`exportProject` 第 390 行直接将项目名拼入文件路径 `TEM_${proj.name}_${Date.now()}.tem`。包含 `/`、`\`、`..` 或空字节的项目名可能导致路径穿越或文件创建失败。 + +**修复方案:** +对项目名做白名单过滤(只允许字母、数字、中文、下划线、短横线),或在文件名中使用项目 ID 代替名称。 + +--- + +### 3.3 [中] GPS 坐标明文存储 + +**文件:** `src/services/StorageService.ts` 第 58-69 行 + +**问题描述:** +GPS 经纬度、海拔以明文存储在 SQLite 中,zustand 持久化文件也是明文 JSON。 + +**修复方案:** +评估是否需要静态加密。如果需要,可使用 `expo-crypto` 或 SQLCipher。对于大多数行业仪器 App 这不是硬性要求,但需在隐私政策中声明。 + +--- + +### 3.4 [低] filePrefix 输入未做字符过滤 + +**文件:** `src/components/ParamForm.tsx` 第 183 行 + +**问题描述:** +`filePrefix` 只限制了长度(15 字符)但未过滤特殊字符。该字段仅用于协议包构建(不进 SQL),风险较低。 + +**修复方案:** +添加正则校验,只允许字母、数字、下划线。 + +--- + +## 四、性能 + +### 4.1 [致命] 热路径上的重量级调试计算 + +**文件:** +- `src/services/TcpService.ts` 第 51, 68 行 +- `src/services/DeviceService.ts` 第 111-114 行 + +**问题描述:** +每个 TCP data 事件都做 `Array.from(arr.slice(0, 20)).map(...)` 创建中间数组和字符串。每帧测量数据到达时循环各通道做 `Array.from()` + `.toFixed()`。连续采集时每秒触发数百次。 + +**修复方案:** +同 1.1,直接删除。 + +--- + +### 4.2 [高] Parser 缓冲区溢出时静默丢弃数据 + +**文件:** `src/protocol/parser.ts` 第 27-29 行 + +**问题描述:** +当 `this.len + incoming.length > capacity`(2MB)时,直接 `this.len = 0` 重置缓冲区,丢弃所有已缓存数据。无错误回调,无日志,用户无感知。在高吞吐量连续采集时可能导致数据丢失。 + +**修复方案:** +至少触发一个错误回调通知上层。考虑动态扩容或增大默认容量。记录溢出事件以便排查。 + +--- + +### 4.3 [高] 内存中历史帧可能导致 OOM + +**文件:** `src/stores/dataStore.ts` 第 47 行 + +**问题描述:** +每个 `MeasurementFrame` 包含 `adcRaw: Int32Array[]` 和 `adcUV: Float64Array[]`。以 6 通道 2000 采样深度计算,每帧约 `6 × 2000 × (4 + 8) = 144 KB`。`MAX_HISTORY = 500` 时内存占用可达 **72 MB**。中低端手机可能 OOM 崩溃。 + +**修复方案:** +- 方案 A:将 `MAX_HISTORY` 降至 50 +- 方案 B:内存中只存元数据和峰值摘要,完整波形按需从 bin 文件加载 +- 方案 C:历史帧只保留 `adcUV`,不保留 `adcRaw`(节省 1/3 内存) + +--- + +### 4.4 [中] 缩放/拖拽时每帧重建 Skia Path + +**文件:** `src/components/WaveformChart.tsx` 第 204-222 行 + +**问题描述:** +`channelPaths` 的 `useMemo` 依赖 `toY`、`xMinMs`、`xMaxMs`,这些在每次手势更新时都会变化。每个手势帧都触发全量重新计算所有通道的 Skia Path(最多 512 点/通道 × 6 通道)。 + +**修复方案:** +在固定坐标系中构建 Path,用 Skia 矩阵变换实现平移缩放,避免逐帧重建 Path。 + +--- + +### 4.5 [中] 降采样使用简单抽取,可能遗漏尖峰 + +**文件:** `src/hooks/useWaveform.ts` 第 15-22 行 + +**问题描述:** +当前降采样每隔 N 个取一个点。对于测量仪器来说,这可能完全遗漏瞬态尖峰信号,导致显示的峰值与实际不符。 + +**修复方案:** +使用 min-max 降采样(每组取最大值和最小值各一个点)或 LTTB 算法,确保极值被保留。 + +--- + +### 4.6 [中] PeakRow 每次渲染都重新计算峰值 + +**文件:** `app/(tabs)/wave.tsx` 第 294-297 行 + +**问题描述:** +`ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0)` 在每次组件渲染时对每个可见通道运行一次。对于大数组(2000+ 点)这是不必要的开销。 + +**修复方案:** +在帧接收时计算峰值并缓存到 `MeasurementFrame`,或用 `useMemo` 包裹。 + +--- + +### 4.7 [低] Base64 编码产生大量中间字符串 + +**文件:** `src/services/BinLoader.ts` 第 68-75 行 + +**问题描述:** +`u8ToBase64` 将二进制数据拆成 8192 字节块,逐块 `String.fromCharCode` 再 `btoa`。6 通道 2000 点的帧约 48KB 二进制 → 64KB base64 字符串。批量导出时内存抖动明显。 + +**修复方案:** +考虑使用 `expo-file-system` 的直接二进制写入能力,或流式处理。 + +--- + +## 五、数据完整性 + +### 5.1 [致命] importProject 中 gain 列写入了错误值 + +**文件:** `src/services/StorageService.ts` 第 480-484 行 + +**问题描述:** +SQL INSERT 的 `gain` 参数位置(第 10 个参数)使用了 `meta.ampRatio`(增益 code,如 3 代表 1×),而不是实际增益倍数(`AMP_GAIN[meta.ampRatio]`,即 1)。导致导入的数据在重新加载时,电压换算使用错误的增益值,波形幅度错误。 + +**修复方案:** +第 483 行改为 `AMP_GAIN[meta.ampRatio] ?? 1`。 + +--- + +### 5.2 [高] 数据库迁移无版本管理 + +**文件:** `src/services/StorageService.ts` 第 73-78 行 + +**问题描述:** +当前通过 `try { ALTER TABLE } catch {}` 添加 `project_id` 列。这种方式只能处理单次迁移,随着 App 迭代新增更多 schema 变更时无法可靠管理。没有版本号追踪,无法知道数据库处于什么状态。 + +**修复方案:** +实现基于 `PRAGMA user_version` 的迁移系统: +```typescript +const version = await db.getFirstAsync<{user_version:number}>('PRAGMA user_version'); +if (version.user_version < 1) { + await db.runAsync('ALTER TABLE sessions ADD COLUMN project_id TEXT'); + await db.runAsync('PRAGMA user_version = 1'); +} +if (version.user_version < 2) { + // 下一次迁移... + await db.runAsync('PRAGMA user_version = 2'); +} +``` + +--- + +### 5.3 [高] 删除项目不级联删除关联数据 + +**文件:** `src/services/StorageService.ts` 第 139-142 行 + +**问题描述:** +`deleteProject` 只删除 `projects` 表中的记录。关联的 sessions(`project_id` 被 SET NULL)、frames 记录和磁盘上的 bin 文件都成为孤儿数据,永远不会被清理。 + +**修复方案:** +删除项目时,先查出关联的 sessions,再删除对应的 frames 和 bin 文件,最后删除 sessions 和 project 记录。 + +--- + +### 5.4 [高] persistFrame 使用 INSERT OR REPLACE 可能静默覆盖数据 + +**文件:** `src/services/StorageService.ts` 第 224 行 + +**问题描述:** +如果由于 bug 或竞态条件,两帧具有相同的 `(frameId, sessionId)` 主键,第二帧会静默覆盖第一帧的数据,无任何警告。 + +**修复方案:** +改用 `INSERT OR IGNORE` 并记录警告日志,或在插入前检查唯一性。 + +--- + +### 5.5 [中] Session/Project ID 可能冲突 + +**文件:** +- `src/stores/dataStore.ts` 第 113-119 行 +- `src/services/StorageService.ts` 第 95-101 行 + +**问题描述:** +`generateSessionId` 和 `generateProjectId` 使用秒级时间戳生成 ID。如果在同一秒内创建两个会话或项目,ID 会重复。`ensureSession` 使用 `INSERT OR IGNORE`,第二个会话会静默复用第一个。 + +**修复方案:** +添加随机后缀(如 4 位随机字母数字),或使用 UUID,或至少使用毫秒精度。 + +--- + +### 5.6 [中] CSV 导出未转义特殊字符 + +**文件:** +- `src/utils/export.ts` 第 28-47 行 +- `src/services/StorageService.ts` 第 318-327 行 + +**问题描述:** +CSV 值直接用逗号拼接,未做引号包裹或转义。虽然数值字段一般不含逗号,但如果 UTC 字符串在某些 locale 下包含逗号,CSV 格式会错乱。 + +**修复方案:** +对所有字段做标准 CSV 转义:包含逗号、引号或换行的值用双引号包裹,值内的双引号用两个双引号转义。 + +--- + +### 5.7 [中] 帧计数器内存与数据库可能不同步 + +**文件:** `src/stores/dataStore.ts` 第 38-41 行 + +**问题描述:** +`nextFrameId` 在 zustand 内存中递增,`persistFrame` 通过 `void` 异步调用(fire-and-forget)。如果 `persistFrame` 失败(如磁盘满),内存中计数器继续递增但数据库缺少对应帧,间隙永远不会被检测到。 + +**修复方案:** +`await persistFrame` 或至少处理其 rejection(记录错误,通知用户,重试)。 + +--- + +### 5.8 [中] 导入项目时临时文件清理为 fire-and-forget + +**文件:** `src/services/StorageService.ts` 第 430 行 + +**问题描述:** +`FileSystem.deleteAsync(tmpPath, ...).catch(() => {})` 静默忽略清理失败。多次失败的导入会在设备上累积临时文件。 + +**修复方案:** +跟踪临时文件并定期清理,或 await 删除操作并在失败时记录。 + +--- + +### 5.9 [低] parseAck 读取 reason 字符串时未检查 payload 长度 + +**文件:** `src/protocol/parser.ts` 第 189-195 行 + +**问题描述:** +reason 字符串从 payload offset 22 开始读取,但没有检查 `payload.length >= 22`。如果设备返回一个短 ACK 包,循环会读取到 `undefined`,`String.fromCharCode(undefined)` 得到乱码。 + +**修复方案:** +在读取前检查 `if (payload.length < reasonOffset) return { result, reason: '' }`。 + +--- + +## 修复状态追踪 + +### ✅ 已修复 +| # | 问题 | 修复日期 | +|---|------|---------| +| 1.1 | 调试日志加 `__DEV__` 门控 | 2026-06-20 | +| 1.2 | IP/端口格式校验 | 2026-06-20 | +| 1.3 | 心跳失败触发重连 | 2026-06-20 | +| 1.5 | connect 防重复连接 | 2026-06-20 | +| 1.6 | 分帧状态断线重置 | 2026-06-20 | +| 1.9 | filePrefix 动态日期 | 2026-06-20 | +| 2.1 | ACK 竞态覆盖保护 | 2026-06-20 | +| 2.2 | TCP 连接 10s 超时 | 2026-06-20 | +| 2.3 | clearHistory 文案明确 | 2026-06-20 | +| 2.4 | 分享不可用提示 | 2026-06-20 | +| 2.5 | 操作前检查连接 | 2026-06-20 | +| 2.6 | 项目名加载加 catch | 2026-06-20 | +| 3.1 | 日志泄露(同 1.1) | 2026-06-20 | +| 3.2 | 项目名文件安全过滤 | 2026-06-20 | +| 4.1 | 热路径调试计算(同 1.1) | 2026-06-20 | +| 4.2 | Parser 溢出加 onOverflow 回调 | 2026-06-20 | +| 4.3 | MAX_HISTORY 降至 50 | 2026-06-20 | +| 4.5 | 降采样改 min-max 保留极值 | 2026-06-20 | +| 4.6 | PeakRow 已删除 | 2026-06-20 | +| 5.1 | importProject gain 用 AMP_GAIN | 2026-06-20 | +| 5.2 | 数据库迁移 PRAGMA user_version | 2026-06-20 | +| 5.3 | 删项目级联清理文件 | 2026-06-20 | +| 5.4 | INSERT OR REPLACE → IGNORE | 2026-06-20 | +| 5.5 | ID 加毫秒+随机后缀 | 2026-06-20 | +| 5.9 | parseAck 长度检查 | 2026-06-20 | + +### ⏳ 暂不修(低风险或需大重构) +| # | 问题 | 原因 | +|---|------|------| +| 1.4 | 数据库查询 `any` 类型 | 纯代码规范 | +| 1.7 | clearTimeout `!` 断言 | 运行无影响 | +| 1.8 | 缺返回类型注解 | 纯代码规范 | +| 2.7 | CSV 只导内存帧 | 需重构导出 | +| 2.8 | 大项目导出无进度 | UX 优化级 | +| 3.3 | GPS 明文存储 | 行业仪器通常不要求 | +| 3.4 | filePrefix 字符过滤 | 仅用于协议包 | +| 4.4 | 缩放重建 Path | 性能优化级 | +| 4.7 | Base64 中间字符串 | 优化级 | +| 5.6 | CSV 未转义 | 纯数值低风险 | +| 5.7 | 帧计数不同步 | 需重构存储层 | +| 5.8 | 临时文件清理 | 低风险 | + +### 原修复优先级建议(已完成) + +~~### 第一批(发布前必须修复)~~ +1. ~~移除所有调试日志(1.1 + 3.1 + 4.1)~~ +2. ~~修复 importProject gain 字段(5.1)~~ +3. ~~降低内存历史上限或改为按需加载(4.3)~~ +4. ~~修复 ACK 竞态条件(2.1)~~ + +~~### 第二批(发布前应该修复)~~ +5. ~~添加 TCP 连接超时(2.2)~~ +6. ~~心跳失败处理(1.3)~~ +7. 操作前检查连接状态(2.5) +8. 数据库迁移版本管理(5.2) +9. 删除项目级联清理(5.3) +10. IP/端口校验(1.2) +11. 项目名文件安全过滤(3.2) +12. 分享不可用提示(2.4) +13. Parser 缓冲区溢出通知(4.2) + +### 第三批(后续迭代优化) +14. Skia 矩阵变换优化波形缩放(4.4) +15. Min-max 降采样算法(4.5) +16. Session ID 防冲突(5.5) +17. CSV 转义(5.6) +18. 其余低优先级项 diff --git a/docs/eas-build-guide.md b/docs/eas-build-guide.md new file mode 100644 index 0000000..a6bbe04 --- /dev/null +++ b/docs/eas-build-guide.md @@ -0,0 +1,434 @@ +# EAS 云构建指南 + +> 适用于 TriloopTem App (TEM Receiver) +> 最后更新:2026-06-20 + +本文档详细说明如何使用 Expo EAS 云构建服务编译 Android APK 和 iOS IPA,包括所有账号注册和配置步骤。 + +--- + +## 一、前置准备 + +### 1.1 所需账号 + +| 账号 | 用途 | 费用 | +|------|------|------| +| Expo 账号 | EAS 云构建平台 | 免费(30 次/月构建额度) | +| Google 开发者账号 | 上架 Google Play(可选) | $25 一次性 | +| Apple Developer Program | iOS 签名和分发(必须) | ¥688/年 | + +### 1.2 本地环境 + +``` +Node.js >= 18 +npm >= 9 +Git(代码需在 Git 仓库中) +``` + +--- + +## 二、注册 Expo 账号 + +1. 访问 https://expo.dev/signup +2. 填写用户名、邮箱、密码,完成注册 +3. 验证邮箱 + +--- + +## 三、注册 Apple Developer Program(iOS 必须) + +### 3.1 注册 Apple ID + +如果没有 Apple ID: +1. 访问 https://appleid.apple.com/account +2. 填写姓名、邮箱、手机号 +3. 完成双重认证设置 + +### 3.2 加入 Apple Developer Program + +1. 访问 https://developer.apple.com/programs/enroll/ +2. 点击「开始注册」 +3. 选择注册类型: + - **个人** — 适合个人开发者,只需 Apple ID + - **组织** — 需要 DUNS 编号(邓白氏编码),适合公司 +4. 填写个人/公司信息 +5. 支付年费 **¥688**(支持 Visa/MasterCard/银联) +6. 等待审核(通常 24-48 小时) + +### 3.3 审核通过后 + +- 登录 https://developer.apple.com 确认会员状态为 Active +- 记住你的 **Team ID**(在 Membership 页面可以看到) + +--- + +## 四、安装和配置 EAS CLI + +### 4.1 安装 + +```bash +npm install -g eas-cli +``` + +### 4.2 登录 Expo 账号 + +```bash +eas login +``` + +输入注册的 Expo 用户名和密码。 + +### 4.3 验证登录 + +```bash +eas whoami +``` + +显示你的用户名即为成功。 + +--- + +## 五、项目配置 + +### 5.1 确认 app.json + +确保 `app.json` 中以下字段已正确填写: + +```json +{ + "expo": { + "name": "TEM Receiver", + "slug": "TriloopTem_App", + "version": "1.0.0", + "ios": { + "bundleIdentifier": "com.triloop.temreceiver" + }, + "android": { + "package": "com.triloop.temreceiver" + } + } +} +``` + +### 5.2 初始化 EAS 配置 + +```bash +cd f:\1_Projects\2026\SixChannelApp\TriloopTem_App +eas build:configure +``` + +自动生成 `eas.json` 文件。手动编辑为以下内容: + +```json +{ + "cli": { + "version": ">= 5.0.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + }, + "ios": { + "simulator": true + } + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "android": { + "buildType": "apk" + } + } + }, + "submit": { + "production": { + "ios": { + "appleId": "你的Apple ID邮箱", + "ascAppId": "App Store Connect中的App ID(上架时填写)" + } + } + } +} +``` + +### 5.3 确保代码在 Git 仓库中 + +EAS 需要 Git 仓库: + +```bash +git add -A +git commit -m "准备 EAS 构建" +``` + +--- + +## 六、构建 Android + +### 6.1 Preview 版(内部测试用 APK) + +```bash +eas build --platform android --profile preview +``` + +首次构建会提示: +- **Generate a new Android Keystore?** → 选 **Yes**(EAS 自动管理签名密钥) + +构建过程(约 10-20 分钟): +1. 代码上传到 EAS 云端 +2. 云端安装依赖 +3. 编译生成 APK +4. 提供下载链接 + +### 6.2 Production 版(正式发布用) + +```bash +eas build --platform android --profile production +``` + +### 6.3 下载 APK + +构建完成后终端会显示下载链接,也可以在 https://expo.dev 的项目页面查看。 + +```bash +# 查看构建列表 +eas build:list --platform android +``` + +--- + +## 七、构建 iOS + +### 7.1 首次构建 + +```bash +eas build --platform ios --profile production +``` + +首次构建会依次提示以下内容: + +#### 第 1 步:Apple 账号登录 + +``` +? Do you want to log in to your Apple account? › Yes + +? Apple ID: › 输入你的 Apple ID 邮箱 +? Password: › 输入密码 +``` + +如果开启了双重认证,会要求输入 6 位验证码。 + +#### 第 2 步:选择开发者团队 + +``` +? Select Team › + ❯ XXXXXXXX - 你的名字/公司名 (Team ID) +``` + +选择你的开发者团队。 + +#### 第 3 步:Bundle Identifier 确认 + +``` +? Bundle Identifier: › com.triloop.temreceiver +``` + +直接回车使用默认值。 + +#### 第 4 步:签名证书管理 + +``` +? How would you like to manage your credentials? › + ❯ Let Expo handle it (Recommended) + I want to provide my own +``` + +**选择 "Let Expo handle it"**。EAS 会自动: +- 创建 Distribution Certificate(发布证书) +- 创建 Provisioning Profile(配置文件) +- 安全存储在 EAS 服务器上 + +#### 第 5 步:等待构建 + +iOS 构建约 15-30 分钟。进度可在终端或 https://expo.dev 查看。 + +### 7.2 后续构建 + +第二次及以后的构建不再需要上述交互,直接运行: + +```bash +eas build --platform ios --profile production +``` + +### 7.3 下载 IPA + +```bash +eas build:list --platform ios +``` + +或访问 https://expo.dev 项目页面下载。 + +--- + +## 八、安装到设备 + +### 8.1 Android + +下载 APK 后直接安装: + +```bash +adb install app-xxx.apk +``` + +或将 APK 发送到手机上点击安装。 + +### 8.2 iOS — 内部测试(Ad Hoc) + +**注意**:需要先注册测试设备的 UDID。 + +#### 注册测试设备 + +1. 手机 Safari 访问 https://expo.dev/register-device +2. 安装描述文件获取 UDID +3. 在 EAS 中注册设备: + +```bash +eas device:create +``` + +按提示输入设备 UDID 和名称。 + +#### 构建 Ad Hoc 版本 + +```bash +eas build --platform ios --profile preview +``` + +构建完成后生成安装链接,在手机 Safari 中打开即可安装。 + +### 8.3 iOS — TestFlight 分发 + +适合分发给多人测试: + +#### 提交到 App Store Connect + +```bash +eas submit --platform ios +``` + +提示: +``` +? Select a build to submit › 选择最近的构建 +? Apple ID: › 你的 Apple ID +``` + +提交后: +1. 登录 https://appstoreconnect.apple.com +2. 进入「我的 App」→ 选择 App → TestFlight +3. 等待 Apple 审核(通常几分钟到几小时) +4. 审核通过后添加内部/外部测试人员 +5. 测试人员在 TestFlight App 中安装 + +--- + +## 九、上架应用商店(可选) + +### 9.1 Google Play + +1. 注册 Google Play 开发者账号($25 一次性):https://play.google.com/console/signup +2. 在 Google Play Console 创建应用 +3. 构建 AAB 格式: + +修改 `eas.json` 的 production profile: +```json +"production": { + "android": { + "buildType": "app-bundle" + } +} +``` + +```bash +eas build --platform android --profile production +eas submit --platform android +``` + +### 9.2 App Store + +1. 在 App Store Connect 创建 App:https://appstoreconnect.apple.com +2. 填写 App 信息(名称、描述、截图、分类等) +3. 提交构建: + +```bash +eas build --platform ios --profile production +eas submit --platform ios +``` + +4. 在 App Store Connect 中选择构建版本 +5. 提交审核(通常 24-48 小时) + +--- + +## 十、常用命令速查 + +```bash +# ── 账号 ── +eas login # 登录 +eas whoami # 查看当前账号 + +# ── 构建 ── +eas build -p android # 构建 Android(默认 production) +eas build -p ios # 构建 iOS +eas build -p all # 同时构建双平台 +eas build -p android --profile preview # 指定 profile +eas build:list # 查看构建历史 +eas build:cancel # 取消当前构建 + +# ── 设备管理(iOS Ad Hoc)── +eas device:create # 注册测试设备 +eas device:list # 查看已注册设备 + +# ── 提交商店 ── +eas submit -p android # 提交到 Google Play +eas submit -p ios # 提交到 App Store + +# ── 证书管理 ── +eas credentials # 管理签名证书 +``` + +--- + +## 十一、常见问题 + +### Q: 构建失败,提示 "Missing credentials" +**A:** 运行 `eas credentials` 重新配置签名证书。 + +### Q: iOS 构建提示 "No bundle identifier" +**A:** 确认 `app.json` 中 `ios.bundleIdentifier` 已填写。 + +### Q: 免费额度用完了怎么办 +**A:** 等下个月刷新(每月 30 次),或改用本地构建(Android 用 `build-release.bat`,iOS 需要 Mac)。 + +### Q: 不想用 EAS 管理证书,可以自己管理吗 +**A:** 可以。首次构建时选 "I want to provide my own",然后上传你的 .p12 证书和 .mobileprovision 文件。 + +### Q: Android 签名密钥丢了怎么办 +**A:** EAS 自动管理的密钥存储在 Expo 服务器,不会丢失。可以用 `eas credentials` 随时下载备份。 + +--- + +## 十二、费用总结 + +| 场景 | 费用 | +|------|------| +| 仅构建 Android APK | **免费**(EAS 免费额度) | +| 构建 iOS + 自己测试 | **¥688/年**(Apple Developer) | +| 上架 Google Play | **$25 一次性** + 免费构建 | +| 上架 App Store | **¥688/年**(Apple Developer) | +| 双平台上架 | **¥688/年 + $25** | diff --git a/package-lock.json b/package-lock.json index ad7ff86..280466b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "expo-linking": "~56.0.13", "expo-location": "~56.0.16", "expo-router": "~56.2.9", + "expo-screen-orientation": "~56.0.5", "expo-sharing": "~56.0.16", "expo-splash-screen": "~56.0.10", "expo-sqlite": "~56.0.4", @@ -4181,6 +4182,16 @@ } } }, + "node_modules/expo-screen-orientation": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-56.0.5.tgz", + "integrity": "sha512-Puf4L/cgM8z45Z2fwZzJtlVGSk0ZM/l3gBqXm50bKTACmUk8P8fr7HVbDfs8reyoZuEKKFZJ0VlnKo5i6cSotQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "56.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", diff --git a/package.json b/package.json index abd5cc6..bd86372 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "expo-linking": "~56.0.13", "expo-location": "~56.0.16", "expo-router": "~56.2.9", + "expo-screen-orientation": "~56.0.5", "expo-sharing": "~56.0.16", "expo-splash-screen": "~56.0.10", "expo-sqlite": "~56.0.4", @@ -40,8 +41,8 @@ }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web" }, "private": true diff --git a/src/components/ChannelSelector.tsx b/src/components/ChannelSelector.tsx index befae0d..d8a2a8b 100644 --- a/src/components/ChannelSelector.tsx +++ b/src/components/ChannelSelector.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import { CHANNEL_COLORS } from '../protocol/constants'; +import { useTheme } from '../design/tokens'; interface Props { maxChannels: number; @@ -9,6 +10,8 @@ interface Props { } export function ChannelSelector({ maxChannels, visible, onToggle }: Props) { + const theme = useTheme(); + return ( {Array.from({ length: maxChannels }, (_, i) => { @@ -19,14 +22,14 @@ export function ChannelSelector({ maxChannels, visible, onToggle }: Props) { key={i} style={[ S.chip, - { borderColor: active ? color + '88' : '#1e1e30' }, + { backgroundColor: theme.bg.base, borderColor: active ? color + '88' : theme.bg.border }, active && { backgroundColor: color + '14' }, ]} onPress={() => onToggle(i)} activeOpacity={0.7} > - - CH{i + 1} + + CH{i + 1} ); })} @@ -44,7 +47,6 @@ const S = StyleSheet.create({ borderRadius: 20, paddingHorizontal: 10, paddingVertical: 5, - backgroundColor: '#0c0c18', }, dot: { width: 6, height: 6, borderRadius: 3 }, label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 }, diff --git a/src/components/DeviceStatusBar.tsx b/src/components/DeviceStatusBar.tsx index ef642fc..70a56a5 100644 --- a/src/components/DeviceStatusBar.tsx +++ b/src/components/DeviceStatusBar.tsx @@ -3,18 +3,20 @@ import { View, Text, StyleSheet, Platform } from 'react-native'; import { useDeviceStore } from '../stores/deviceStore'; import { useDataStore } from '../stores/dataStore'; import { formatBattery, formatTemperature } from '../utils/format'; +import { useTheme, type ThemeColors } from '../design/tokens'; -function Chip({ label, value, color }: { label: string; value: string; color?: string }) { +function Chip({ label, value, color, theme }: { label: string; value: string; color?: string; theme: ThemeColors }) { return ( - + {color && } - {label} - {value} + {label} + {value} ); } export function DeviceStatusBar() { + const theme = useTheme(); const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore(); const frame = useDataStore((s) => s.currentFrame); const meta = frame?.meta; @@ -26,22 +28,22 @@ export function DeviceStatusBar() { const running = deviceStatus === 'running'; const single = deviceStatus === 'single'; - const statusColor = running ? '#3ddc84' : single ? '#ffb84d' : '#3a3a5a'; + const statusColor = running ? theme.green.fg : single ? theme.amber.fg : theme.text.muted; const statusLabel = running ? '● 运行中' : single ? '◎ 单次' : '○ 停止'; return ( - + {statusLabel} - - 0 ? 'OK' : '--'} color={sdStatus > 0 ? '#4a9eff' : '#3a3a5a'} /> - {!!batteryVolt && } - {!!temperature && } + + 0 ? 'OK' : '--'} color={sdStatus > 0 ? theme.blue.fg : theme.text.muted} theme={theme} /> + {!!batteryVolt && } + {!!temperature && } {frame && ( - #{frame.frameId} + #{frame.frameId} )} @@ -51,14 +53,12 @@ export function DeviceStatusBar() { const S = StyleSheet.create({ bar: { flexDirection: 'row', - backgroundColor: '#0c0c18', paddingHorizontal: 12, paddingVertical: 8, alignItems: 'center', flexWrap: 'wrap', gap: 6, borderBottomWidth: 1, - borderBottomColor: '#1a1a2a', }, statusPill: { borderRadius: 6, @@ -71,16 +71,14 @@ const S = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 4, - backgroundColor: '#111120', borderRadius: 6, borderWidth: 1, - borderColor: '#22223a', paddingHorizontal: 7, paddingVertical: 3, }, chipDot: { width: 5, height: 5, borderRadius: 3 }, - chipLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5 }, - chipValue: { color: '#6a6a8a', fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + chipLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5 }, + chipValue: { fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, frameCount: { marginLeft: 'auto' }, - frameCountText: { color: '#2a2a4a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, + frameCountText: { fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, }); diff --git a/src/components/NoProjectGate.tsx b/src/components/NoProjectGate.tsx new file mode 100644 index 0000000..2c65bf6 --- /dev/null +++ b/src/components/NoProjectGate.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { router } from 'expo-router'; +import { useTheme } from '../design/tokens'; + +export function NoProjectGate() { + const theme = useTheme(); + return ( + + + 请先创建工程 + + 在「工程」页面创建工程后即可开始采集 + + router.push('/(tabs)/projects')} + activeOpacity={0.8} + > + 前往工程页 + + + ); +} + +const S = StyleSheet.create({ + root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 }, + icon: { fontSize: 48 }, + title: { fontSize: 17, fontWeight: '700' }, + sub: { fontSize: 12, textAlign: 'center', paddingHorizontal: 40 }, + btn: { marginTop: 8, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 32, borderWidth: 1 }, + btnTxt: { fontSize: 14, fontWeight: '700', letterSpacing: 2 }, +}); diff --git a/src/components/ParamForm.tsx b/src/components/ParamForm.tsx index ed05457..799fd75 100644 --- a/src/components/ParamForm.tsx +++ b/src/components/ParamForm.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch, Platform } from 'react-native'; import { useDeviceStore } from '../stores/deviceStore'; import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants'; +import { useTheme } from '../design/tokens'; interface PickerRowProps { label: string; @@ -10,21 +11,33 @@ interface PickerRowProps { onChange: (code: number) => void; } +const CHIP_W = 72; + function PickerRow({ label, options, value, onChange }: PickerRowProps) { + const theme = useTheme(); + const scrollRef = useRef(null); + const activeIdx = options.findIndex((o) => o.code === value); + + useEffect(() => { + if (activeIdx > 0 && scrollRef.current) { + scrollRef.current.scrollTo({ x: Math.max(0, activeIdx * CHIP_W - CHIP_W), animated: false }); + } + }, [activeIdx]); + return ( - - {label} - + + {label} + {options.map((o) => { const active = o.code === value; return ( onChange(o.code)} activeOpacity={0.7} > - {o.label} + {o.label} ); })} @@ -44,6 +57,7 @@ interface NumberRowProps { // Keeps a local draft string while typing; commits a clamped integer on blur. function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) { + const theme = useTheme(); const [draft, setDraft] = useState(String(value)); // Sync when the store value changes externally (e.g. presets). @@ -62,31 +76,32 @@ function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) })(); return ( - - {label} + + {label} - {suffix && {suffix}} + {suffix && {suffix}} {invalid && ( - {min}–{max} + {min}–{max} )} ); } function SectionHeader({ title }: { title: string }) { + const theme = useTheme(); return ( - {title} + {title} ); } @@ -94,6 +109,7 @@ function SectionHeader({ title }: { title: string }) { interface FormProps { onAnyChange?: () => void } export function ParamForm({ onAnyChange }: FormProps = {}) { + const theme = useTheme(); const { config, updateConfig } = useDeviceStore(); const patch = (partial: Partial) => { updateConfig(partial); @@ -101,24 +117,24 @@ export function ParamForm({ onAnyChange }: FormProps = {}) { }; return ( - + {/* Channel count */} - - 通道数 + + 通道数 {[1, 2, 3, 4, 5, 6].map((n) => { const active = config.channelNum === n; return ( patch({ channelNum: n })} activeOpacity={0.7} > - {n} + {n} ); })} @@ -147,22 +163,22 @@ export function ParamForm({ onAnyChange }: FormProps = {}) { {/* Reverse accumulation */} - - 反向叠加 + + 反向叠加 - CH1-3 + CH1-3 patch({ negAcc123: v })} - thumbColor={config.negAcc123 ? '#4a9eff' : '#444'} - trackColor={{ false: '#1e1e2e', true: '#1a3a66' }} + thumbColor={config.negAcc123 ? theme.blue.fg : theme.text.muted} + trackColor={{ false: theme.bg.border, true: theme.blue.border }} /> - CH4-6 + CH4-6 patch({ negAcc456: v })} - thumbColor={config.negAcc456 ? '#4a9eff' : '#444'} - trackColor={{ false: '#1e1e2e', true: '#1a3a66' }} + thumbColor={config.negAcc456 ? theme.blue.fg : theme.text.muted} + trackColor={{ false: theme.bg.border, true: theme.blue.border }} /> @@ -175,16 +191,16 @@ export function ParamForm({ onAnyChange }: FormProps = {}) { /> {/* File prefix */} - - 文件前缀 + + 文件前缀 patch({ filePrefix: v.slice(0, 15) })} maxLength={15} autoCapitalize="none" - placeholderTextColor="#3a3a5a" + placeholderTextColor={theme.text.muted} /> @@ -194,7 +210,7 @@ export function ParamForm({ onAnyChange }: FormProps = {}) { } const S = StyleSheet.create({ - container: { backgroundColor: '#090912' }, + container: {}, sectionHeader: { paddingHorizontal: 16, @@ -202,7 +218,6 @@ const S = StyleSheet.create({ paddingBottom: 6, }, sectionTitle: { - color: '#3a3a5a', fontSize: 9, fontWeight: '700', letterSpacing: 2, @@ -215,34 +230,27 @@ const S = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#141422', gap: 12, - backgroundColor: '#0e0e1c', marginHorizontal: 0, }, - rowLabel: { color: '#4a4a6a', fontSize: 12, width: 68, flexShrink: 0, letterSpacing: 0.3 }, + rowLabel: { fontSize: 12, width: 68, flexShrink: 0, letterSpacing: 0.3 }, // Picker chips optScroll: { flex: 1 }, - optChip: { borderWidth: 1, borderColor: '#1e1e30', borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 }, - optChipActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' }, - optText: { color: '#3a3a5a', fontSize: 11 }, - optTextActive: { color: '#4a9eff', fontWeight: '700' }, + optChip: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 }, + optText: { fontSize: 11 }, // Number input inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 }, input: { flex: 1, - color: '#c0c0d8', fontSize: 13, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', paddingVertical: 4, borderBottomWidth: 1, - borderBottomColor: '#1e1e30', }, - inputInvalid: { borderBottomColor: '#ff5c6e' }, - suffix: { color: '#3a3a5a', fontSize: 10, marginLeft: 8 }, - hint: { color: '#ff5c6e', fontSize: 9, marginLeft: 4 }, + suffix: { fontSize: 10, marginLeft: 8 }, + hint: { fontSize: 9, marginLeft: 4 }, // Channel chips channelRow: { flexDirection: 'row', gap: 6, flex: 1 }, @@ -251,16 +259,12 @@ const S = StyleSheet.create({ height: 30, borderRadius: 15, borderWidth: 1, - borderColor: '#1e1e30', alignItems: 'center', justifyContent: 'center', - backgroundColor: '#0c0c18', }, - chChipActive: { borderColor: '#4a9eff66', backgroundColor: '#0d1e3a' }, - chText: { color: '#3a3a5a', fontSize: 12, fontWeight: '700' }, - chTextActive: { color: '#4a9eff' }, + chText: { fontSize: 12, fontWeight: '700' }, // Switch row switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 }, - switchLabel: { color: '#4a4a6a', fontSize: 12 }, + switchLabel: { fontSize: 12 }, }); diff --git a/src/components/SessionSelector.tsx b/src/components/SessionSelector.tsx new file mode 100644 index 0000000..09eb1d3 --- /dev/null +++ b/src/components/SessionSelector.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, Text, TouchableOpacity, Modal, FlatList, + StyleSheet, ActivityIndicator, Platform, +} from 'react-native'; +import * as StorageService from '../services/StorageService'; +import type { SessionInfo } from '../services/StorageService'; +import { useTheme } from '../design/tokens'; +import { Radius } from '../design/tokens'; + +interface Props { + selectedSessionId: string; + projectId: string; + onSelect: (sessionId: string) => void; +} + +export function SessionSelector({ selectedSessionId, projectId, onSelect }: Props) { + const theme = useTheme(); + const [visible, setVisible] = useState(false); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + + const current = sessions.find((s) => s.sessionId === selectedSessionId); + + const reload = useCallback(async () => { + setLoading(true); + const data = await StorageService.listSessionsByProject(projectId); + setSessions(data); + setLoading(false); + }, [projectId]); + + useEffect(() => { void reload(); }, [reload]); + + const handleOpen = () => { + void reload(); + setVisible(true); + }; + + const handleSelect = (sid: string) => { + setVisible(false); + if (sid !== selectedSessionId) onSelect(sid); + }; + + return ( + <> + + + 测线 + + {selectedSessionId || '--'} + + + {current && ( + {current.frameCount} 点 + )} + + + + setVisible(false)}> + setVisible(false)} /> + + + 选择测线 + + {loading ? ( + + ) : sessions.length === 0 ? ( + 当前工程无测线 + ) : ( + s.sessionId} + style={{ maxHeight: 400 }} + renderItem={({ item }) => { + const active = item.sessionId === selectedSessionId; + const date = new Date(item.createdAt); + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + return ( + handleSelect(item.sessionId)} + activeOpacity={0.7} + > + {active && } + + + {item.sessionId} + + + {dateStr} · {item.frameCount} 测点 + + + {active && 当前} + + ); + }} + /> + )} + + + + ); +} + +const MONO = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const; + +const S = StyleSheet.create({ + trigger: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 12, paddingVertical: 8, + borderRadius: Radius.md, borderWidth: 1, gap: 8, + }, + triggerLeft: { flex: 1, gap: 2 }, + label: { fontSize: 9, fontWeight: '600' }, + sessionId: { fontSize: 11, fontWeight: '600', ...MONO }, + count: { fontSize: 10, ...MONO }, + arrow: { fontSize: 12 }, + + backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + position: 'absolute', bottom: 0, left: 0, right: 0, + maxHeight: '70%', + borderTopLeftRadius: 20, borderTopRightRadius: 20, + paddingBottom: 32, + }, + sheetHandle: { width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 10, marginBottom: 10 }, + sheetTitle: { fontSize: 14, fontWeight: '700', paddingHorizontal: 16, paddingBottom: 10 }, + empty: { fontSize: 12, textAlign: 'center', paddingVertical: 24 }, + + item: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 16, paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, gap: 8, + }, + dot: { width: 6, height: 6, borderRadius: 3 }, + itemLeft: { flex: 1, gap: 2 }, + itemId: { fontSize: 12, fontWeight: '600', ...MONO }, + itemSub: { fontSize: 10 }, + activeLabel:{ fontSize: 10, fontWeight: '700' }, +}); diff --git a/src/components/WaveformChart.tsx b/src/components/WaveformChart.tsx index 9ab35f5..607f492 100644 --- a/src/components/WaveformChart.tsx +++ b/src/components/WaveformChart.tsx @@ -1,7 +1,10 @@ -import React, { useMemo } from 'react'; -import { View, StyleSheet, Text } from 'react-native'; +import React, { useMemo, useState, useCallback } from 'react'; +import { View, Text } from 'react-native'; import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { useSharedValue, runOnJS } from 'react-native-reanimated'; import { CHANNEL_COLORS } from '../protocol/constants'; +import { useTheme } from '../design/tokens'; import type { WaveformData } from '../hooks/useWaveform'; interface Props { @@ -9,14 +12,14 @@ interface Props { visibleChannels: boolean[]; width: number; height: number; - logScale?: boolean; // default true + logScale?: boolean; + interactive?: boolean; } const PADDING = { top: 14, right: 14, bottom: 34, left: 56 }; const LOG_Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7]; const X_TICK_COUNT = 5; -// Pick a "nice" step for axis ticks. function niceStep(range: number, targetCount: number): number { const rough = range / targetCount; const mag = Math.pow(10, Math.floor(Math.log10(rough))); @@ -26,11 +29,13 @@ function niceStep(range: number, targetCount: number): number { return 10 * mag; } -function xTicks(totalMs: number): number[] { - if (totalMs <= 0) return []; - const step = niceStep(totalMs, X_TICK_COUNT); +function xTicks(minMs: number, maxMs: number): number[] { + const range = maxMs - minMs; + if (range <= 0) return []; + const step = niceStep(range, X_TICK_COUNT); + const start = Math.ceil(minMs / step) * step; const ticks: number[] = []; - for (let t = step; t < totalMs * 0.99; t += step) ticks.push(t); + for (let t = start; t <= maxMs * 1.001; t += step) ticks.push(t); return ticks; } @@ -64,19 +69,110 @@ function formatMsShort(ms: number): string { return `${(ms * 1000).toFixed(0)}μs`; } -export function WaveformChart({ data, visibleChannels, width, height, logScale = true }: Props) { +export function WaveformChart({ data, visibleChannels, width, height, logScale = true, interactive = false }: Props) { + const theme = useTheme(); + const C = theme.chart; const plotW = width - PADDING.left - PADDING.right; const plotH = height - PADDING.top - PADDING.bottom; + // ── Zoom & pan state (JS thread for rendering) ─────────────────────────── + const [view, setView] = useState({ sx: 1, sy: 1, ox: 0, oy: 0 }); + const { sx: scaleX, sy: scaleY, ox: offsetX, oy: offsetY } = view; + + // ── Shared values for gesture worklets ─────────────────────────────────── + const savedOx = useSharedValue(0); + const savedOy = useSharedValue(0); + const savedSx = useSharedValue(1); + const savedSy = useSharedValue(1); + const curSx = useSharedValue(1); + + const applyView = useCallback((sx: number, sy: number, ox: number, oy: number) => { + setView({ sx, sy, ox, oy }); + }, []); + + const resetView = useCallback(() => { + savedOx.value = 0; savedOy.value = 0; + savedSx.value = 1; savedSy.value = 1; + curSx.value = 1; + setView({ sx: 1, sy: 1, ox: 0, oy: 0 }); + }, []); + + // ── Gestures ───────────────────────────────────────────────────────────── + const panGesture = Gesture.Pan() + .onStart(() => { + 'worklet'; + savedOx.value = savedOx.value + 0; // capture + savedOy.value = savedOy.value + 0; + }) + .onUpdate((e) => { + 'worklet'; + const ox = savedOx.value + e.translationX / curSx.value; + const oy = savedOy.value + e.translationY / curSx.value; + runOnJS(applyView)(curSx.value, curSx.value, ox, oy); + }) + .onEnd(() => { + 'worklet'; + }) + .minPointers(1) + .maxPointers(1); + + const pinchGesture = Gesture.Pinch() + .onStart(() => { + 'worklet'; + savedSx.value = curSx.value; + }) + .onUpdate((e) => { + 'worklet'; + const ns = Math.max(0.5, Math.min(savedSx.value * e.scale, 50)); + curSx.value = ns; + runOnJS(applyView)(ns, ns, savedOx.value, savedOy.value); + }); + + const doubleTapGesture = Gesture.Tap() + .numberOfTaps(2) + .onEnd(() => { + 'worklet'; + runOnJS(resetView)(); + }); + + // Sync shared values when JS state settles (after pan end) + const panEndGesture = Gesture.Pan() + .onEnd((e) => { + 'worklet'; + savedOx.value = savedOx.value + e.translationX / curSx.value; + savedOy.value = savedOy.value + e.translationY / curSx.value; + }) + .minPointers(1) + .maxPointers(1); + + const composed = Gesture.Simultaneous( + Gesture.Simultaneous(panGesture, panEndGesture), + pinchGesture, + doubleTapGesture, + ); + + // ── Visible data range ─────────────────────────────────────────────────── + const totalTimeMs = data.totalTimeMs; + const visibleTimeCenterMs = totalTimeMs / 2 - offsetX / plotW * totalTimeMs; + const visibleTimeSpanMs = totalTimeMs / scaleX; + const xMinMs = Math.max(0, visibleTimeCenterMs - visibleTimeSpanMs / 2); + const xMaxMs = Math.min(totalTimeMs, visibleTimeCenterMs + visibleTimeSpanMs / 2); + // ── Y mapping ──────────────────────────────────────────────────────────── const yLogMin = data.minLogUV - 0.5; const yLogMax = data.maxLogUV + 0.5; - const yLogRange = yLogMax - yLogMin; - const yLinMin = data.minUV; - const yLinMax = data.maxUV; + const baseYMin = data.minUV; + const baseYMax = data.maxUV; + const baseYRange = baseYMax - baseYMin; + const yCenter = (baseYMin + baseYMax) / 2 - (offsetY / plotH) * baseYRange; + const ySpan = baseYRange / scaleY; + const yLinMin = yCenter - ySpan / 2; + const yLinMax = yCenter + ySpan / 2; const yLinRange = Math.max(yLinMax - yLinMin, 1e-9); + const yLogRange = yLogMax - yLogMin; + const toY = useMemo(() => { if (logScale) { return (uv: number): number => { @@ -91,11 +187,8 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale = }, [logScale, yLogMin, yLogRange, yLinMin, yLinRange, plotH]); // ── X mapping ──────────────────────────────────────────────────────────── - const toX = (i: number, total: number): number => - PADDING.left + (i / Math.max(total - 1, 1)) * plotW; - - const toXms = (ms: number): number => - PADDING.left + (ms / Math.max(data.totalTimeMs, 1e-9)) * plotW; + const toX = (ms: number): number => + PADDING.left + ((ms - xMinMs) / Math.max(xMaxMs - xMinMs, 1e-9)) * plotW; // ── Grid / tick values ────────────────────────────────────────────────── const yGridValues = useMemo(() => { @@ -108,7 +201,7 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale = return linYTicks(yLinMin, yLinMax); }, [logScale, yLogMin, yLogMax, yLinMin, yLinMax]); - const xTickValues = useMemo(() => xTicks(data.totalTimeMs), [data.totalTimeMs]); + const xTickValues = useMemo(() => xTicks(xMinMs, xMaxMs), [xMinMs, xMaxMs]); // ── Channel paths ──────────────────────────────────────────────────────── const channelPaths = useMemo(() => { @@ -116,110 +209,123 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale = if (!visibleChannels[idx]) return null; const path = Skia.Path.Make(); let moved = false; + const timeStep = data.totalTimeMs / Math.max(ch.length - 1, 1); for (let i = 0; i < ch.length; i++) { + const ms = i * timeStep; + if (ms < xMinMs || ms > xMaxMs) continue; if (logScale && Math.abs(ch[i]) < 1e-12) continue; - const x = toX(i, ch.length); + const x = toX(ms); const y = toY(ch[i]); - if (!isFinite(y)) continue; + if (!isFinite(y) || x < PADDING.left || x > PADDING.left + plotW) continue; if (!moved) { path.moveTo(x, y); moved = true; } else path.lineTo(x, y); } return path; }); - }, [data, visibleChannels, toY, plotW, plotH]); + }, [data, visibleChannels, toY, xMinMs, xMaxMs, plotW, plotH, logScale]); const top = PADDING.top; const bot = PADDING.top + plotH; const left = PADDING.left; const right = PADDING.left + plotW; - return ( - - + const chartContent = ( + + - {/* Y grid lines */} + {/* Y grid lines */} + {yGridValues.map((v) => { + const y = toY(v); + if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; + return ( + + ); + })} + + {/* X grid lines */} + {xTickValues.map((ms) => { + const x = toX(ms); + if (x < left - 1 || x > right + 1) return null; + return ( + + ); + })} + + {/* Baseline */} + {!logScale && (() => { + const y0 = toY(0); + return isFinite(y0) && y0 >= top && y0 <= bot ? ( + + ) : null; + })()} + + {/* X axis */} + + + {/* Channel paths */} + {channelPaths.map((path, idx) => { + if (!path || !visibleChannels[idx]) return null; + return ( + + ); + })} + + + {/* Y-axis labels */} {yGridValues.map((v) => { const y = toY(v); if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; + const label = logScale ? formatLogTick(v) : formatUVShort(v); return ( - + + {label} + ); })} - {/* X grid lines */} + {/* X-axis tick labels */} {xTickValues.map((ms) => { - const x = toXms(ms); + const x = toX(ms); + if (x < left - 10 || x > right + 10) return null; return ( - + + {formatMsShort(ms)} + ); })} - {/* Baseline */} - {!logScale && ( - + {/* Axis unit labels */} + μV + + {data.totalTimeMs >= 1000 ? 's' : 'ms'} + + + {/* Scale mode badge */} + + {logScale ? 'LOG' : 'LIN'} + + + {/* Zoom indicator */} + {(scaleX > 1.01 || scaleY > 1.01) && ( + + ×{scaleX.toFixed(1)} + )} - - {/* X axis */} - - - {/* Channel paths */} - {channelPaths.map((path, idx) => { - if (!path || !visibleChannels[idx]) return null; - return ( - - ); - })} - - - {/* Y-axis labels */} - {yGridValues.map((v) => { - const y = toY(v); - if (!isFinite(y) || y < top - 1 || y > bot + 1) return null; - const label = logScale ? formatLogTick(v) : formatUVShort(v); - return ( - - {label} - - ); - })} - - {/* X-axis tick labels */} - {xTickValues.map((ms) => { - const x = toXms(ms); - return ( - - {formatMsShort(ms)} - - ); - })} - - {/* Axis unit labels */} - μV - - {data.totalTimeMs >= 1000 ? 's' : 'ms'} - - - {/* Scale mode badge */} - - {logScale ? 'LOG' : 'LIN'} - - + ); + + if (interactive) { + return {chartContent}; + } + return chartContent; } -const S = StyleSheet.create({ - container: { backgroundColor: '#111111', position: 'relative' }, - yLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'right' }, - xLabel: { position: 'absolute', color: '#888', fontSize: 9, textAlign: 'center' }, - unitLabel: { position: 'absolute', color: '#555', fontSize: 9 }, - scaleBadge: { position: 'absolute', color: '#444', fontSize: 8, fontWeight: '700', letterSpacing: 0.5 }, -}); diff --git a/src/components/device/GlobalStatusBar.tsx b/src/components/device/GlobalStatusBar.tsx index 2c22450..f888569 100644 --- a/src/components/device/GlobalStatusBar.tsx +++ b/src/components/device/GlobalStatusBar.tsx @@ -4,24 +4,26 @@ import { useConnectionStore } from '../../stores/connectionStore'; import { useDeviceStore } from '../../stores/deviceStore'; import { useDataStore } from '../../stores/dataStore'; import { formatBattery, formatTemperature } from '../../utils/format'; -import { Colors } from '../../design/tokens'; +import { useTheme, type ThemeColors } from '../../design/tokens'; function Pill({ label, value, color, + theme, onPress, }: { label: string; value: string; color: string; + theme: ThemeColors; onPress?: () => void; }) { const inner = ( - - - {label} - {value} + + + {label} + {value} ); if (onPress) return {inner}; @@ -29,91 +31,87 @@ function Pill({ } export function GlobalStatusBar() { + const theme = useTheme(); const { status, showModal } = useConnectionStore(); const { deviceStatus, batteryVolt, temperature, gpsStatus, sdStatus } = useDeviceStore(); const frame = useDataStore((s) => s.currentFrame); const meta = frame?.meta; const connected = status === 'connected'; - const connColor = connected ? Colors.green.fg : status === 'connecting' ? Colors.amber.fg : Colors.text.ghost; + const connColor = connected ? theme.green.fg : status === 'connecting' ? theme.amber.fg : theme.text.ghost; const connLabel = connected ? '已连接' : status === 'connecting' ? '连接中' : status === 'reconnecting' ? '重连' : '未连接'; const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0; - const hasSd = sdStatus > 0; + const hasSd = sdStatus === 0; const running = deviceStatus === 'running'; const single = deviceStatus === 'single'; - const stateColor = running ? Colors.green.fg : single ? Colors.teal.fg : Colors.text.ghost; + const stateColor = running ? theme.green.fg : single ? theme.teal.fg : theme.text.ghost; const stateLabel = running ? '● 运行' : single ? '◎ 单次' : '○ 停止'; return ( - + {/* Connection — tappable */} - + {/* Device run state */} {connected && ( - - {stateLabel} + + {stateLabel} )} {/* GPS */} - + {/* SD */} - + {/* Battery */} {batteryVolt > 0 && ( - + )} {/* Temperature */} {temperature > 0 && ( - + )} {/* Frame counter — right-aligned */} {frame && ( - - #{frame.frameId} + + #{frame.frameId} )} ); } -const S = StyleSheet.create({ +const styles = StyleSheet.create({ bar: { flexDirection: 'row', - backgroundColor: Colors.bg.void, paddingHorizontal: 10, paddingVertical: 7, alignItems: 'center', flexWrap: 'wrap', gap: 5, borderBottomWidth: 1, - borderBottomColor: Colors.bg.border, }, pill: { flexDirection: 'row', alignItems: 'center', gap: 4, - backgroundColor: Colors.bg.raised, borderRadius: 5, borderWidth: 1, - borderColor: Colors.bg.border, paddingHorizontal: 6, paddingVertical: 2, }, dot: { width: 5, height: 5, borderRadius: 3 }, - pillLabel: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600', letterSpacing: 0.5 }, + pillLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5 }, pillValue: { fontSize: 9, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }, stateText: { fontSize: 10, fontWeight: '700', letterSpacing: 0.3 }, frameCount: { marginLeft: 'auto' as any }, frameCountText: { - color: Colors.text.ghost, fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', }, diff --git a/src/components/modals/ConnectModal.tsx b/src/components/modals/ConnectModal.tsx index fbabeef..39bb775 100644 --- a/src/components/modals/ConnectModal.tsx +++ b/src/components/modals/ConnectModal.tsx @@ -6,11 +6,13 @@ import { } from 'react-native'; import { useConnectionStore } from '../../stores/connectionStore'; import { useDevice } from '../../hooks/useDevice'; -import { Colors, Radius, Spacing } from '../../design/tokens'; +import { useTheme } from '../../design/tokens'; +import { Radius, Spacing } from '../../design/tokens'; const LOG_MAX = 20; export function ConnectModal() { + const theme = useTheme(); const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } = useConnectionStore(); const { connect, disconnect } = useDevice(); @@ -39,10 +41,17 @@ export function ConnectModal() { }, [status, lastError]); const handleConnect = () => { + const trimmed = host.trim(); const p = parseInt(portStr, 10); - if (!host.trim() || isNaN(p)) { addLog('请检查 IP 和端口'); return; } + const ipRe = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!trimmed || !ipRe.test(trimmed) || trimmed.split('.').some(s => Number(s) > 255)) { + addLog('IP 地址格式无效'); return; + } + if (isNaN(p) || p < 1 || p > 65535) { + addLog('端口范围 1-65535'); return; + } setPort(p); - addLog(`连接 ${host}:${p} …`); + addLog(`连接 ${trimmed}:${p} …`); connect(); }; @@ -69,51 +78,79 @@ export function ConnectModal() { style={S.kav} pointerEvents="box-none" > - + {/* Handle */} - + {/* Title */} - 连接仪器 + 连接仪器 {/* Guide */} - 1 - 手机连接设备 WiFi 热点 - Linking.openSettings()}> - 设置 → + + 1 + + 手机连接设备 WiFi 热点 + Linking.openSettings()} + > + 设置 → - 2 - 确认参数后点击连接 + + 2 + + 确认参数后点击连接 {/* Inputs */} - + - IP 地址 + IP 地址 - + - 端 口 + 端 口 @@ -122,33 +159,51 @@ export function ConnectModal() { {/* Action */} {!connected ? ( {connecting - ? - : 连 接 设 备} + ? + : 连 接 设 备} ) : ( - - 已连接 {host}:{port} + + 已连接 {host}:{port} - - 断 开 + + 断 开 )} {/* Log */} - - ● 连接日志 + + ● 连接日志 {logs.map((l, i) => ( - {l} + {l} ))} @@ -168,22 +223,18 @@ const S = StyleSheet.create({ justifyContent: 'flex-end', }, sheet: { - backgroundColor: Colors.bg.surface, borderTopLeftRadius: Radius.xl, borderTopRightRadius: Radius.xl, padding: Spacing.lg, paddingBottom: Spacing.xl, borderTopWidth: 1, - borderColor: Colors.bg.border, }, handle: { width: 36, height: 4, borderRadius: 2, - backgroundColor: Colors.bg.border, alignSelf: 'center', marginBottom: Spacing.md, }, title: { - color: Colors.text.secondary, fontSize: 15, fontWeight: '700', marginBottom: Spacing.md, @@ -197,81 +248,73 @@ const S = StyleSheet.create({ }, stepBadge: { width: 20, height: 20, borderRadius: 10, - backgroundColor: Colors.blue.bg, justifyContent: 'center', alignItems: 'center', - borderWidth: 1, borderColor: Colors.blue.border, + borderWidth: 1, }, - stepNum: { color: Colors.blue.fg, fontSize: 10, fontWeight: '700' }, - stepTxt: { color: Colors.text.muted, fontSize: 12, flex: 1 }, + stepNum: { fontSize: 10, fontWeight: '700' }, + stepTxt: { fontSize: 12, flex: 1 }, settingsBtn: { - backgroundColor: Colors.blue.bg, borderRadius: Radius.sm, + borderRadius: Radius.sm, paddingHorizontal: 8, paddingVertical: 3, - borderWidth: 1, borderColor: Colors.blue.border, + borderWidth: 1, }, - settingsTxt: { color: Colors.blue.fg, fontSize: 11, fontWeight: '600' }, + settingsTxt: { fontSize: 11, fontWeight: '600' }, inputCard: { - backgroundColor: Colors.bg.raised, borderRadius: Radius.md, - borderWidth: 1, borderColor: Colors.bg.border, + borderWidth: 1, paddingHorizontal: Spacing.md, marginBottom: Spacing.md, }, inputRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, paddingVertical: Spacing.sm }, - inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: Colors.bg.divider }, - inputLabel: { color: Colors.text.muted, fontSize: 12, width: 52 }, + inputDivider: { height: StyleSheet.hairlineWidth }, + inputLabel: { fontSize: 12, width: 52 }, input: { flex: 1, - color: Colors.text.primary, fontSize: 15, fontWeight: '500', paddingVertical: 4, borderBottomWidth: 1, - borderBottomColor: Colors.bg.border, }, connectBtn: { - backgroundColor: Colors.blue.bg, borderRadius: Radius.lg, paddingVertical: 15, alignItems: 'center', marginBottom: Spacing.md, - borderWidth: 1, borderColor: Colors.blue.fg, + borderWidth: 1, }, - connectBtnBusy: { borderColor: Colors.blue.border, backgroundColor: Colors.bg.raised }, - connectBtnTxt: { color: Colors.blue.fg, fontSize: 15, fontWeight: '700', letterSpacing: 3 }, + connectBtnTxt: { fontSize: 15, fontWeight: '700', letterSpacing: 3 }, connectedRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.md }, connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 }, - greenDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: Colors.green.fg }, + greenDot: { width: 8, height: 8, borderRadius: 4 }, connectedTxt: { - color: Colors.green.fg, fontWeight: '700', fontSize: 12, + fontWeight: '700', fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', }, disconnectBtn: { - backgroundColor: Colors.red.bg, borderRadius: Radius.md, + borderRadius: Radius.md, paddingVertical: 8, paddingHorizontal: 14, - borderWidth: 1, borderColor: Colors.red.border, + borderWidth: 1, }, - disconnectTxt: { color: Colors.red.fg, fontSize: 12, fontWeight: '700', letterSpacing: 1 }, + disconnectTxt: { fontSize: 12, fontWeight: '700', letterSpacing: 1 }, logBox: { - backgroundColor: Colors.bg.void, borderRadius: Radius.md, - borderWidth: 1, borderColor: Colors.bg.border, + borderWidth: 1, overflow: 'hidden', maxHeight: 120, }, logHeader: { - color: Colors.green.fg, fontSize: 9, fontWeight: '700', letterSpacing: 1.5, + fontSize: 9, fontWeight: '700', letterSpacing: 1.5, paddingHorizontal: Spacing.md, paddingTop: 8, paddingBottom: 5, - borderBottomWidth: 1, borderBottomColor: Colors.bg.divider, + borderBottomWidth: 1, }, logScroll: { padding: Spacing.sm }, logLine: { - color: Colors.text.ghost, fontSize: 10, + fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 2, lineHeight: 15, }, - logLineLatest: { color: Colors.text.muted }, }); diff --git a/src/design/tokens.ts b/src/design/tokens.ts index 0965493..7264640 100644 --- a/src/design/tokens.ts +++ b/src/design/tokens.ts @@ -1,4 +1,8 @@ -export const Colors = { +import { useColorScheme } from 'react-native'; + +// ── Color palettes ────────────────────────────────────────────────────────── + +const DarkColors = { bg: { void: '#050508', base: '#090912', @@ -18,8 +22,65 @@ export const Colors = { red: { bg: '#1e0d12', border: '#3a1520', fg: '#ff5c6e' }, teal: { bg: '#0d1e20', border: '#1a3a3e', fg: '#4ecdc4' }, amber: { bg: '#1e1a08', border: '#4a3a1a', fg: '#ffd93d' }, + chart: { + bg: '#111111', + grid: '#333333', + gridFine: '#2a2a2a', + axis: '#555555', + label: '#888888', + labelDim: '#555555', + badge: '#444444', + zoomBadge:'#666666', + }, } as const; +const LightColors = { + bg: { + void: '#f0f0f4', + base: '#f5f5f8', + surface: '#ffffff', + raised: '#f0f0f5', + border: '#d8d8e0', + divider: '#e8e8f0', + }, + text: { + primary: '#1a1a2e', + secondary: '#3a3a5a', + muted: '#8a8aa0', + ghost: '#b0b0c0', + }, + green: { bg: '#e8f5e9', border: '#a5d6a7', fg: '#2e7d32' }, + blue: { bg: '#e3f2fd', border: '#90caf9', fg: '#1565c0' }, + red: { bg: '#fce4ec', border: '#ef9a9a', fg: '#c62828' }, + teal: { bg: '#e0f2f1', border: '#80cbc4', fg: '#00695c' }, + amber: { bg: '#fff8e1', border: '#ffe082', fg: '#f57f17' }, + chart: { + bg: '#ffffff', + grid: '#e0e0e0', + gridFine: '#eeeeee', + axis: '#aaaaaa', + label: '#666666', + labelDim: '#999999', + badge: '#999999', + zoomBadge:'#777777', + }, +} as const; + +export type ThemeColors = typeof DarkColors; + +// ── Hook ──────────────────────────────────────────────────────────────────── + +export function useTheme(): ThemeColors { + const scheme = useColorScheme(); + return scheme === 'light' ? LightColors : DarkColors; +} + +// ── Static export (for non-component code that can't use hooks) ───────────── + +export const Colors = DarkColors; + +// ── Spacing / Radius / FontSize ───────────────────────────────────────────── + export const Spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32, } as const; diff --git a/src/hooks/useDevice.ts b/src/hooks/useDevice.ts index 2fcbb83..7ee4208 100644 --- a/src/hooks/useDevice.ts +++ b/src/hooks/useDevice.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { Alert } from 'react-native'; import { tcpService } from '../services/TcpService'; -import { handleIncomingFrame, deviceSetup, deviceStartContinuous, deviceStartSingle, deviceStop } from '../services/DeviceService'; +import { handleIncomingFrame, deviceSetup, deviceStartContinuous, deviceStartSingle, deviceStop, flushPendingData } from '../services/DeviceService'; import { useConnectionStore } from '../stores/connectionStore'; import { useDeviceStore } from '../stores/deviceStore'; import type { SetupConfig } from '../protocol/types'; @@ -32,7 +32,16 @@ export function useDevice() { useDeviceStore.getState().setDeviceStatus('idle'); }, []); + const checkConn = () => { + if (!tcpService.isConnected()) { + Alert.alert('未连接', '请先连接设备'); + return false; + } + return true; + }; + const setup = useCallback(async (cfg?: SetupConfig) => { + if (!checkConn()) return; const config = cfg ?? useDeviceStore.getState().config; setBusy(true); try { @@ -41,6 +50,7 @@ export function useDevice() { Alert.alert('配置失败', ack.reason || '设备返回失败'); } else { useDeviceStore.getState().setConfigDirty(false); + Alert.alert('配置成功', '参数已下发到设备'); } return ack; } catch (e: any) { @@ -51,6 +61,7 @@ export function useDevice() { }, []); const startContinuous = useCallback(async () => { + if (!checkConn()) return; setBusy(true); try { const ack = await deviceStartContinuous(); @@ -64,6 +75,7 @@ export function useDevice() { }, []); const startSingle = useCallback(async () => { + if (!checkConn()) return; setBusy(true); try { const ack = await deviceStartSingle(); @@ -77,14 +89,22 @@ export function useDevice() { }, []); const stop = useCallback(async () => { - // Optimistically transition to idle so the UI responds immediately. - // The ACK is sent best-effort; if the device doesn't reply the state - // is already correct on our side. + setBusy(true); + // Immediately stop processing buffered frames useDeviceStore.getState().setDeviceStatus('idle'); + flushPendingData(); try { - await deviceStop(); - } catch { - // best-effort — UI already reflects idle + for (let attempt = 0; attempt < 3; attempt++) { + try { + const ack = await deviceStop(); + if (ack.result === 0x01) return; + } catch { + // retry + } + } + Alert.alert('停止采集', '设备未确认,已强制停止'); + } finally { + setBusy(false); } }, []); diff --git a/src/hooks/useWaveform.ts b/src/hooks/useWaveform.ts index 3206fcd..2ae7838 100644 --- a/src/hooks/useWaveform.ts +++ b/src/hooks/useWaveform.ts @@ -12,20 +12,21 @@ const SAMPLE_FREQ_HZ: Record = { 0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61, }; -// Peak-hold downsample: preserves signal envelope when reducing points. function downsample(data: Float64Array, targetLen: number): Float64Array { if (data.length <= targetLen) return data; - const ratio = data.length / targetLen; - const out = new Float64Array(targetLen); - for (let i = 0; i < targetLen; i++) { + const pairLen = Math.floor(targetLen / 2); + const ratio = data.length / pairLen; + const out = new Float64Array(pairLen * 2); + for (let i = 0; i < pairLen; i++) { const start = Math.floor(i * ratio); - const end = Math.min(Math.floor((i + 1) * ratio), data.length); - let maxAbs = 0; - let maxVal = 0; - for (let j = start; j < end; j++) { - if (Math.abs(data[j]) > maxAbs) { maxAbs = Math.abs(data[j]); maxVal = data[j]; } + const end = Math.min(Math.floor((i + 1) * ratio), data.length); + let mn = data[start], mx = data[start]; + for (let j = start + 1; j < end; j++) { + if (data[j] < mn) mn = data[j]; + if (data[j] > mx) mx = data[j]; } - out[i] = maxVal; + out[i * 2] = mn; + out[i * 2 + 1] = mx; } return out; } @@ -86,10 +87,16 @@ export function computeWaveformData( if (!isFinite(linMin)) linMin = -1; if (!isFinite(linMax)) linMax = 1; - // Symmetric linear range for cleaner display - const linPeak = Math.max(Math.abs(linMin), Math.abs(linMax), 1); - linMin = -linPeak; - linMax = linPeak; + // Auto-range: pad the actual data range by 10% for visual breathing room + const linRange = linMax - linMin; + if (linRange < 1e-9) { + linMin -= 1; + linMax += 1; + } else { + const pad = linRange * 0.1; + linMin -= pad; + linMax += pad; + } return { channels, timeMs, totalTimeMs, diff --git a/src/protocol/constants.ts b/src/protocol/constants.ts index 483f720..f8ce690 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -1,5 +1,7 @@ export const FRAME_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]); +export const FRAME_FLAG_2BYTE = 0xff; export const FRAME_FLAG_4BYTE = 0xfe; +export const FRAME_TAIL = new Uint8Array([0x68, 0x68]); export const DEFAULT_DEVICE_IP = '192.168.4.1'; export const DEFAULT_TCP_PORT = 4321; export const MAX_PAYLOAD_SIZE = 400 * 1024; // 400KB sanity limit @@ -10,7 +12,6 @@ export const FuncCode = { CONTINUOUS_REQ: 0x02, SINGLE_REQ: 0x03, STOP_REQ: 0x04, - ACTIVE_REQ: 0x05, SPLITFRAME_REQ: 0x08, // Device → App SETUP_ACK: 0x81, @@ -33,13 +34,11 @@ export const SEND_FREQ_TABLE = [ { code: 0x02, label: '2 Hz' }, { code: 0x03, label: '4 Hz' }, { code: 0x04, label: '8 Hz' }, - { code: 0x05, label: '12.5 Hz' }, - { code: 0x06, label: '16 Hz' }, - { code: 0x07, label: '25 Hz' }, - { code: 0x08, label: '32 Hz' }, - { code: 0x09, label: '50 Hz' }, - { code: 0x0a, label: '64 Hz' }, - // 0xFE (ZTEM) not supported + { code: 0x05, label: '16 Hz' }, + { code: 0x06, label: '25 Hz' }, + { code: 0x07, label: '32 Hz' }, + { code: 0x08, label: '50 Hz' }, + { code: 0x09, label: '64 Hz' }, ] as const; export const SAMPLE_FREQ_TABLE = [ diff --git a/src/protocol/packet.ts b/src/protocol/packet.ts index d66fa51..96714a6 100644 --- a/src/protocol/packet.ts +++ b/src/protocol/packet.ts @@ -1,46 +1,58 @@ -import { FRAME_FLAG_4BYTE, FuncCode, DataChannel } from './constants'; +import { FRAME_FLAG_2BYTE, FRAME_FLAG_4BYTE, FRAME_TAIL, FuncCode } from './constants'; import type { SetupConfig } from './types'; const HEADER_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]); +const HEADER_LEN = 10; +const STANDARD_PAYLOAD_LEN = 54; +const STANDARD_TOTAL_LEN = HEADER_LEN + STANDARD_PAYLOAD_LEN; // 64 -// Build the 10-byte packet header function buildHeader(func: number, payloadLen: number): Uint8Array { - const buf = new Uint8Array(10); + const buf = new Uint8Array(HEADER_LEN); buf.set(HEADER_MAGIC, 0); - buf[4] = FRAME_FLAG_4BYTE; buf[5] = func; - buf[6] = payloadLen & 0xff; - buf[7] = (payloadLen >> 8) & 0xff; - buf[8] = (payloadLen >> 16) & 0xff; - buf[9] = (payloadLen >> 24) & 0xff; + + if (payloadLen <= 0xffff) { + buf[4] = FRAME_FLAG_2BYTE; + buf[6] = payloadLen & 0xff; + buf[7] = (payloadLen >> 8) & 0xff; + buf.set(FRAME_TAIL, 8); + } else { + buf[4] = FRAME_FLAG_4BYTE; + buf[6] = payloadLen & 0xff; + buf[7] = (payloadLen >> 8) & 0xff; + buf[8] = (payloadLen >> 16) & 0xff; + buf[9] = (payloadLen >> 24) & 0xff; + } return buf; } -// Build a command packet with a zero-filled 54-byte payload (minimum) -function buildSimpleCommand(func: number): Uint8Array { - const payload = new Uint8Array(54); - const header = buildHeader(func, 54); - const pkt = new Uint8Array(10 + 54); +function buildSimpleCommand(func: number, freqFlag = 0): Uint8Array { + const payload = new Uint8Array(STANDARD_PAYLOAD_LEN); + payload[0] = 0x01; + payload[1] = freqFlag & 0xff; + + const now = new Date(); + const dateVal = + (now.getFullYear() % 100) * 10000 + + (now.getMonth() + 1) * 100 + + now.getDate(); + payload[2] = (dateVal >> 24) & 0xff; + payload[3] = (dateVal >> 16) & 0xff; + payload[4] = (dateVal >> 8) & 0xff; + payload[5] = dateVal & 0xff; + + const header = buildHeader(func, STANDARD_PAYLOAD_LEN); + const pkt = new Uint8Array(STANDARD_TOTAL_LEN); pkt.set(header, 0); - pkt.set(payload, 10); + pkt.set(payload, HEADER_LEN); return pkt; } -// Write a little-endian uint16 into buf at offset function writeUint16LE(buf: Uint8Array, offset: number, value: number) { buf[offset] = value & 0xff; buf[offset + 1] = (value >> 8) & 0xff; } -// Write a little-endian uint32 into buf at offset -function writeUint32LE(buf: Uint8Array, offset: number, value: number) { - buf[offset] = value & 0xff; - buf[offset + 1] = (value >> 8) & 0xff; - buf[offset + 2] = (value >> 16) & 0xff; - buf[offset + 3] = (value >> 24) & 0xff; -} - -// Write ASCII string (null-padded) into buf starting at offset function writeString(buf: Uint8Array, offset: number, str: string, maxLen: number) { for (let i = 0; i < maxLen; i++) { buf[offset + i] = i < str.length ? str.charCodeAt(i) & 0xff : 0; @@ -48,50 +60,69 @@ function writeString(buf: Uint8Array, offset: number, str: string, maxLen: numbe } export function buildSetupPacket(cfg: SetupConfig): Uint8Array { - const PAYLOAD_LEN = 64; // sizeof(setupReq_t) - const payload = new Uint8Array(PAYLOAD_LEN); + const payload = new Uint8Array(STANDARD_PAYLOAD_LEN); payload[0] = cfg.channelNum & 0xff; payload[1] = cfg.sendFreq & 0xff; payload[2] = cfg.sampleFreq & 0xff; writeUint16LE(payload, 3, cfg.sampleDepth); - // accNum is 14-bit; bit1 = negAcc456; bit0 = negAcc123 - let accFlags = (cfg.accNum & 0x3fff) << 2; - if (cfg.negAcc456) accFlags |= 0x02; - if (cfg.negAcc123) accFlags |= 0x01; - writeUint16LE(payload, 5, accFlags); + let negAccNum = cfg.accNum & 0x3fff; + if (cfg.negAcc123) negAccNum |= 0x8000; + if (cfg.negAcc456) negAccNum |= 0x4000; + writeUint16LE(payload, 5, negAccNum); payload[7] = cfg.ampRatio & 0xff; payload[8] = cfg.dataChannel & 0xff; payload[9] = cfg.compRes & 0xff; - payload[10] = cfg.secondSampleFreq & 0xff; + payload[10] = cfg.sampleFreq & 0xff; writeUint16LE(payload, 11, cfg.compDisableDelay); payload[13] = cfg.sourceMode & 0xff; writeUint16LE(payload, 14, cfg.batteryVoltageMin); - // bytes 16-37: reserved (zeros) writeString(payload, 38, cfg.filePrefix, 16); - const header = buildHeader(FuncCode.SETUP_REQ, PAYLOAD_LEN); - const pkt = new Uint8Array(10 + PAYLOAD_LEN); + const header = buildHeader(FuncCode.SETUP_REQ, STANDARD_PAYLOAD_LEN); + const pkt = new Uint8Array(STANDARD_TOTAL_LEN); pkt.set(header, 0); - pkt.set(payload, 10); + pkt.set(payload, HEADER_LEN); return pkt; } -export const buildContinuousReq = () => buildSimpleCommand(FuncCode.CONTINUOUS_REQ); -export const buildSingleReq = () => buildSimpleCommand(FuncCode.SINGLE_REQ); -export const buildStopReq = () => buildSimpleCommand(FuncCode.STOP_REQ); -export const buildActiveReq = () => buildSimpleCommand(FuncCode.ACTIVE_REQ); +export const buildContinuousReq = (freqFlag = 0) => + buildSimpleCommand(FuncCode.CONTINUOUS_REQ, freqFlag); + +export const buildSingleReq = (freqFlag = 0) => + buildSimpleCommand(FuncCode.SINGLE_REQ, freqFlag); + +export function buildStopReq(freqFlag = 0): Uint8Array { + const payload = new Uint8Array(STANDARD_PAYLOAD_LEN); + payload[0] = 0x02; + payload[1] = freqFlag & 0xff; + + const now = new Date(); + const dateVal = + (now.getFullYear() % 100) * 10000 + + (now.getMonth() + 1) * 100 + + now.getDate(); + payload[2] = (dateVal >> 24) & 0xff; + payload[3] = (dateVal >> 16) & 0xff; + payload[4] = (dateVal >> 8) & 0xff; + payload[5] = dateVal & 0xff; + + const header = buildHeader(FuncCode.STOP_REQ, STANDARD_PAYLOAD_LEN); + const pkt = new Uint8Array(STANDARD_TOTAL_LEN); + pkt.set(header, 0); + pkt.set(payload, HEADER_LEN); + return pkt; +} export function buildSplitFrameReq(subCmd: number, frameNo: number): Uint8Array { - const PAYLOAD_LEN = 54; - const payload = new Uint8Array(PAYLOAD_LEN); + const payload = new Uint8Array(STANDARD_PAYLOAD_LEN); payload[0] = subCmd & 0xff; writeUint16LE(payload, 1, frameNo); - const header = buildHeader(FuncCode.SPLITFRAME_REQ, PAYLOAD_LEN); - const pkt = new Uint8Array(10 + PAYLOAD_LEN); + const header = buildHeader(FuncCode.SPLITFRAME_REQ, STANDARD_PAYLOAD_LEN); + const pkt = new Uint8Array(STANDARD_TOTAL_LEN); pkt.set(header, 0); - pkt.set(payload, 10); + pkt.set(payload, HEADER_LEN); return pkt; } diff --git a/src/protocol/parser.ts b/src/protocol/parser.ts index 4a474e4..fe75090 100644 --- a/src/protocol/parser.ts +++ b/src/protocol/parser.ts @@ -8,6 +8,7 @@ export class TemFrameParser { private buf: Uint8Array; private len = 0; private readonly capacity: number; + onOverflow?: () => void; constructor(capacity = 2 * 1024 * 1024) { this.capacity = capacity; @@ -25,8 +26,9 @@ export class TemFrameParser { } if (this.len + incoming.length > this.capacity) { - // Buffer overflow — reset and resync + if (__DEV__) console.warn(`[Parser] buffer overflow: ${this.len} + ${incoming.length} > ${this.capacity}, resetting`); this.len = 0; + this.onOverflow?.(); } this.buf.set(incoming, this.len); this.len += incoming.length; @@ -50,19 +52,31 @@ export class TemFrameParser { const flag = this.buf[offset + 4]; const func = this.buf[offset + 5]; - const payloadLen = this.readUint32LE(offset + 6); + const is2Byte = !!(flag & 0x01); + const payloadLen = is2Byte + ? this.readUint16LE(offset + 6) + : this.readUint32LE(offset + 6); + + if (__DEV__) console.log( + `[Parser] header @ ${offset}: flag=0x${flag.toString(16)} func=0x${func.toString(16)} lenMode=${is2Byte ? '2B' : '4B'} payloadLen=${payloadLen} bufLen=${this.len}`, + 'hdr:', Array.from(this.buf.slice(offset, offset + 10)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '), + ); if (payloadLen > MAX_PAYLOAD_SIZE) { - // Bogus length — skip this magic and search again + if (__DEV__) console.warn(`[Parser] bogus payloadLen ${payloadLen} > MAX ${MAX_PAYLOAD_SIZE}, skipping magic`); offset += 4; continue; } const totalLen = 10 + payloadLen; - if (this.len - offset < totalLen) break; // wait for more data + if (this.len - offset < totalLen) { + if (__DEV__) console.log(`[Parser] incomplete frame: have ${this.len - offset}, need ${totalLen}`); + break; + } const payload = new Uint8Array(totalLen - 10); payload.set(this.buf.subarray(offset + 10, offset + totalLen)); + if (__DEV__) console.log(`[Parser] ✓ frame complete: func=0x${func.toString(16)} payload=${payloadLen}B`); results.push({ flag, func, payload }); offset += totalLen; } @@ -91,6 +105,10 @@ export class TemFrameParser { return -1; } + private readUint16LE(offset: number): number { + return this.buf[offset] | (this.buf[offset + 1] << 8); + } + private readUint32LE(offset: number): number { const b = this.buf; return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000); @@ -128,22 +146,24 @@ export function parseMetadata(payload: Uint8Array): MeasurementMeta | null { } // Parse ADC sample data from a 0x85/0x95 payload into per-channel arrays +const ADC_FULL_SCALE_V = 5.0; +const INT32_MAX = 0x7fffffff; +// raw → μV: (raw / 0x7FFFFFFF) * 5V / gain * 1e6 +const ADC_UV_SCALE = (ADC_FULL_SCALE_V * 1e6) / INT32_MAX; + export function parseAdcData( payload: Uint8Array, channelNum: number, - accNum: number, + _accNum: number, ): { adcRaw: Int32Array[]; adcUV: Float64Array[] } { const META_SIZE = 54; const sampleDataLen = payload.length - META_SIZE; - const totalSamples = sampleDataLen / 4; // int32 per sample + const totalSamples = sampleDataLen / 4; const samplesPerChannel = Math.floor(totalSamples / channelNum); const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen); - const ampRatio = payload[9]; // ampRatio in metadata at offset 9 from payload start... - // actually re-derive from parseMetadata - const gain = AMP_GAIN[payload[9 + 1]] ?? 1; // byte 10 of payload (after devId+utc+lon+lat+alt+height+sdGps = 1+4+8+8+4+4+1 = 30) - // Correct: ampRatio is at offset 31 in payload (1+4+8+8+4+4+1 = 30, then ampRatio at 30) - const correctGain = AMP_GAIN[payload[30]] ?? 1; + const gain = AMP_GAIN[payload[30]] ?? 1; + const scale = ADC_UV_SCALE / gain; const adcRaw: Int32Array[] = []; const adcUV: Float64Array[] = []; @@ -152,10 +172,10 @@ export function parseAdcData( const raw = new Int32Array(samplesPerChannel); const uv = new Float64Array(samplesPerChannel); for (let i = 0; i < samplesPerChannel; i++) { - const idx = (ch * samplesPerChannel + i) * 4; + const idx = (i * channelNum + ch) * 4; const val = view.getInt32(idx, true); raw[i] = val; - uv[i] = val / (accNum || 1) / correctGain; + uv[i] = val * scale; } adcRaw.push(raw); adcUV.push(uv); @@ -168,11 +188,12 @@ export function parseAdcData( export function parseAck(payload: Uint8Array): AckPacket { const result = payload[0] ?? 0x02; let reason = ''; - // reason string starts at offset 22 in the payload, max 32 chars const reasonOffset = 22; - for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) { - if (payload[i] === 0) break; - reason += String.fromCharCode(payload[i]); + if (payload.length > reasonOffset) { + for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) { + if (payload[i] === 0) break; + reason += String.fromCharCode(payload[i]); + } } return { result, reason }; } diff --git a/src/services/BinLoader.ts b/src/services/BinLoader.ts index b822a74..4a2c017 100644 --- a/src/services/BinLoader.ts +++ b/src/services/BinLoader.ts @@ -153,13 +153,14 @@ export function decodeBin(bytes: Uint8Array): LoadedBin | null { if (bytes.length < expected) return null; const gain = AMP_GAIN[ampRatio] ?? 1; - const acc = Math.max(accNum, 1); + const ADC_UV_SCALE = (5.0 * 1e6) / 0x7fffffff; + const scale = ADC_UV_SCALE / gain; const adcUV: Float64Array[] = []; let off = HEADER_SIZE; for (let ch = 0; ch < channelNum; ch++) { const channel = new Float64Array(sampleDepth); for (let i = 0; i < sampleDepth; i++) { - channel[i] = dv.getInt32(off, true) / acc / gain; + channel[i] = dv.getInt32(off, true) * scale; off += 4; } adcUV.push(channel); diff --git a/src/services/DeviceService.ts b/src/services/DeviceService.ts index a4abb6a..b79ffd8 100644 --- a/src/services/DeviceService.ts +++ b/src/services/DeviceService.ts @@ -11,11 +11,12 @@ import { parseAdcData, parseAck, } from '../protocol/parser'; -import { FuncCode, SplitFrameCmd, AckResult } from '../protocol/constants'; +import { FuncCode, SplitFrameCmd, AckResult, AMP_GAIN } from '../protocol/constants'; import type { RawFrame, SetupConfig, MeasurementFrame, AckPacket } from '../protocol/types'; import { useConnectionStore } from '../stores/connectionStore'; import { useDeviceStore } from '../stores/deviceStore'; import { useDataStore } from '../stores/dataStore'; +import * as StorageService from './StorageService'; // Pending ACK promise resolver keyed by func code const pendingAcks = new Map void>(); @@ -27,6 +28,7 @@ let splitFrameNo = 0; export function handleIncomingFrame(frame: RawFrame) { const { func, payload } = frame; + if (__DEV__) console.log(`[Device] incoming frame: func=0x${func.toString(16)} payloadLen=${payload.length} pendingAcks=[${[...pendingAcks.keys()].map(k => '0x' + k.toString(16))}]`); const devStore = useDeviceStore.getState(); const dataStore = useDataStore.getState(); @@ -90,47 +92,60 @@ export function handleIncomingFrame(frame: RawFrame) { } } +let _lastUIUpdateTime = 0; +const UI_THROTTLE_MS = 150; +let _processing = false; + function processMeasurementPayload( payload: Uint8Array, devStore: ReturnType, dataStore: ReturnType, ) { - // Discard frames that arrive after we've already stopped — these are - // in-flight packets from the device before it processes the STOP command. if (devStore.deviceStatus === 'idle') return; + if (_processing) return; + _processing = true; - const meta = parseMetadata(payload); - if (!meta) return; + try { + const meta = parseMetadata(payload); + if (!meta) return; - const cfg = devStore.config; - const accNum = cfg.accNum || 1; - const { adcRaw, adcUV } = parseAdcData(payload, meta.channelNum, accNum); + const cfg = devStore.config; + const accNum = cfg.accNum || 1; + const { adcRaw, adcUV } = parseAdcData(payload, meta.channelNum, accNum); - const measurementFrame: MeasurementFrame = { - meta, - adcRaw, - adcUV, - accNum, - gain: cfg.ampRatio, - sampleFreqCode: cfg.sampleFreq, - timestamp: Date.now(), - frameId: dataStore.nextFrameId(), - }; + const measurementFrame: MeasurementFrame = { + meta, + adcRaw, + adcUV, + accNum, + gain: AMP_GAIN[cfg.ampRatio] ?? 1, + sampleFreqCode: cfg.sampleFreq, + timestamp: Date.now(), + frameId: dataStore.nextFrameId(), + }; - dataStore.addFrame(measurementFrame); + void StorageService.persistFrame(measurementFrame, dataStore.sessionId); - devStore.updateTelemetry({ - batteryVolt: meta.batteryVolt, - temperature: meta.temperature, - gpsStatus: meta.gpsStatus, - sdStatus: meta.sdStatus, - frameId: measurementFrame.frameId, - }); + const isSingle = devStore.deviceStatus === 'single'; + const now = Date.now(); + if (isSingle || now - _lastUIUpdateTime >= UI_THROTTLE_MS) { + _lastUIUpdateTime = now; + dataStore.addFrameUI(measurementFrame); + } - // Single acquisition completes as soon as the data frame arrives — - // automatically send STOP so the device returns to idle. - if (devStore.deviceStatus === 'single') { - void deviceStop(); + devStore.updateTelemetry({ + batteryVolt: meta.batteryVolt, + temperature: meta.temperature, + gpsStatus: meta.gpsStatus, + sdStatus: meta.sdStatus, + frameId: measurementFrame.frameId, + }); + + if (isSingle) { + void deviceStop(); + } + } finally { + _processing = false; } } @@ -138,13 +153,23 @@ function processMeasurementPayload( function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise { return new Promise((resolve, reject) => { + if (__DEV__) console.log(`[Device] sendAndWaitAck: waiting for ack func=0x${ackFunc.toString(16)}, timeout=${timeoutMs}ms`); + const timer = setTimeout(() => { pendingAcks.delete(ackFunc); + if (__DEV__) console.warn(`[Device] ACK timeout for func=0x${ackFunc.toString(16)}, pendingAcks remaining:`, [...pendingAcks.keys()].map(k => '0x' + k.toString(16))); reject(new Error('ACK timeout')); }, timeoutMs); + const existing = pendingAcks.get(ackFunc); + if (existing) { + pendingAcks.delete(ackFunc); + existing({ result: 0x02, reason: 'superseded' }); + } + pendingAcks.set(ackFunc, (ack) => { clearTimeout(timer); + if (__DEV__) console.log(`[Device] ACK received for func=0x${ackFunc.toString(16)}, result=0x${ack.result.toString(16)}`); resolve(ack); }); @@ -171,7 +196,15 @@ export async function deviceStartSingle(): Promise { } export async function deviceStop(): Promise { - return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK); + return sendAndWaitAck(buildStopReq(), FuncCode.STOP_ACK, 2000); +} + +export function flushPendingData() { + tcpService.resetParser(); + splitChunks = []; + splitActive = false; + splitFrameNo = 0; + _processing = false; } // Initiate split frame transfer to request large data diff --git a/src/services/StorageService.ts b/src/services/StorageService.ts index f83bcb2..c5f5cd9 100644 --- a/src/services/StorageService.ts +++ b/src/services/StorageService.ts @@ -1,6 +1,7 @@ import * as SQLite from 'expo-sqlite'; import * as FileSystem from 'expo-file-system/legacy'; import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types'; +import { AMP_GAIN } from '../protocol/constants'; import * as BinLoader from './BinLoader'; import * as TemBundle from './TemBundle'; @@ -70,10 +71,37 @@ async function getDb(): Promise { CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id); `); + await runMigrations(_db); } return _db; } +async function runMigrations(db: SQLite.SQLiteDatabase) { + const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version'); + let version = row?.user_version ?? 0; + + if (version < 1) { + try { await db.runAsync('ALTER TABLE sessions ADD COLUMN project_id TEXT'); } catch {} + await db.runAsync('PRAGMA user_version = 1'); + version = 1; + } + if (version < 2) { + const orphans = await db.getAllAsync<{ session_id: string }>( + 'SELECT session_id FROM sessions WHERE project_id IS NULL', + ); + if (orphans.length > 0) { + const defId = generateProjectId(); + await db.runAsync( + 'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)', + [defId, '默认工程', Date.now()], + ); + await db.runAsync('UPDATE sessions SET project_id = ? WHERE project_id IS NULL', [defId]); + } + await db.runAsync('PRAGMA user_version = 2'); + version = 2; + } +} + async function ensureDataDir() { const info = await FileSystem.getInfoAsync(DATA_DIR); if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true }); @@ -84,23 +112,44 @@ export async function initStorage(): Promise { await ensureDataDir(); } +export async function hasAnyProject(): Promise { + const d = await getDb(); + const row = await d.getFirstAsync<{ cnt: number }>('SELECT COUNT(*) AS cnt FROM projects'); + return (row?.cnt ?? 0) > 0; +} + +export async function getFirstProject(): Promise { + const d = await getDb(); + const r = await d.getFirstAsync<{ project_id: string; name: string; created_at: number }>( + 'SELECT project_id, name, created_at FROM projects ORDER BY created_at DESC LIMIT 1', + ); + if (!r) return null; + return { projectId: r.project_id, name: r.name, createdAt: r.created_at, lineCount: 0 }; +} + // ── Project CRUD ──────────────────────────────────────────────────────────── function generateProjectId(): string { const now = new Date(); const pad = (n: number, d = 2) => String(n).padStart(d, '0'); + const rand = Math.random().toString(36).slice(2, 6); return ( `P${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + - `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + + `${pad(now.getMilliseconds(), 3)}_${rand}` ); } +function sanitizeName(name: string): string { + return name.trim().replace(/[\/\\:*?"<>|.\x00]/g, '_').slice(0, 50); +} + export async function createProject(name: string): Promise { const d = await getDb(); const id = generateProjectId(); await d.runAsync( 'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)', - [id, name.trim(), Date.now()], + [id, sanitizeName(name), Date.now()], ); return id; } @@ -132,6 +181,19 @@ export async function renameProject(projectId: string, name: string): Promise { const d = await getDb(); + const sessions = await d.getAllAsync<{ session_id: string }>( + 'SELECT session_id FROM sessions WHERE project_id = ?', [projectId], + ); + for (const s of sessions) { + const frames = await d.getAllAsync<{ bin_path: string | null }>( + 'SELECT bin_path FROM frames WHERE session_id = ?', [s.session_id], + ); + for (const f of frames) { + if (f.bin_path) { try { await FileSystem.deleteAsync(f.bin_path, { idempotent: true }); } catch {} } + } + await d.runAsync('DELETE FROM frames WHERE session_id = ?', [s.session_id]); + } + await d.runAsync('DELETE FROM sessions WHERE project_id = ?', [projectId]); await d.runAsync('DELETE FROM projects WHERE project_id = ?', [projectId]); } @@ -215,7 +277,7 @@ export async function persistFrame(frame: MeasurementFrame, sessionId: string): } catch { binPath = null; } await d.runAsync( - `INSERT OR REPLACE INTO frames ( + `INSERT OR IGNORE INTO frames ( frame_id, session_id, ts, utc, lon, lat, alt, ch_num, acc_num, gain, gps_st, sd_st, amp_ratio, current, temp, batt, roll, pitch, yaw, src_mode, bin_path @@ -381,7 +443,9 @@ export async function exportProject( const bundleBytes = await TemBundle.packBundle(manifest, bins); await ensureDataDir(); - const outPath = DATA_DIR + `TEM_${proj.name}_${Date.now()}.tem`; + const safeName = sanitizeName(proj.name); + const ts = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const outPath = DATA_DIR + `TEM_${safeName}_${ts}.tem`; await BinLoader.writeFileBytes(outPath, bundleBytes); return outPath; } @@ -473,7 +537,7 @@ export async function importProject( [ frameId, newSessionId, meta.timestamp, meta.utc, meta.longitude, meta.latitude, meta.altitude, - meta.channelNum, meta.accNum, meta.ampRatio, + meta.channelNum, meta.accNum, AMP_GAIN[meta.ampRatio] ?? 1, meta.gpsStatus, meta.sdStatus, meta.ampRatio, meta.current, meta.temperature, meta.batteryVolt, meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath, diff --git a/src/services/TcpService.ts b/src/services/TcpService.ts index b0cea7b..026b331 100644 --- a/src/services/TcpService.ts +++ b/src/services/TcpService.ts @@ -15,11 +15,13 @@ class TcpService { private callbacks: TcpCallbacks | null = null; private connected = false; private reconnectTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; private reconnectEnabled = false; private host = ''; private port = 0; connect(host: string, port: number, callbacks: TcpCallbacks) { + this.disconnect(); this.host = host; this.port = port; this.callbacks = callbacks; @@ -34,33 +36,62 @@ class TcpService { this.socket = null; } + if (__DEV__) console.log(`[TCP] creating socket → ${this.host}:${this.port}`); + const connectTimeout = setTimeout(() => { + if (!this.connected && sock) { + sock.destroy(); + this.callbacks?.onError(new Error('连接超时 (10s)')); + this.scheduleReconnect(); + } + }, 10000); + const sock = TcpSocket.createConnection( { host: this.host, port: this.port, tls: false }, () => { + clearTimeout(connectTimeout); this.connected = true; clearTimeout(this.reconnectTimer!); + this.startHeartbeat(); + if (__DEV__) console.log(`[TCP] ✓ connected to ${this.host}:${this.port}`); this.callbacks?.onConnect(); }, ); sock.on('data', (data: any) => { - const arr: Uint8Array = - data instanceof Uint8Array - ? data - : data instanceof ArrayBuffer - ? new Uint8Array(data) - : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + if (__DEV__) console.log('[TCP] data event fired, type:', typeof data, 'constructor:', data?.constructor?.name, 'length:', data?.length ?? data?.byteLength ?? '?'); + let arr: Uint8Array; + if (data instanceof Uint8Array) { + arr = data; + } else if (data instanceof ArrayBuffer) { + arr = new Uint8Array(data); + } else if (typeof data === 'string') { + // react-native-tcp-socket may deliver data as a UTF-8 string + const bytes = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) bytes[i] = data.charCodeAt(i) & 0xff; + arr = bytes; + } else if (data?.buffer) { + arr = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } else { + if (__DEV__) console.warn('[TCP] unknown data type:', typeof data, data); + return; + } + if (__DEV__) console.log('[TCP] raw recv', arr.length, 'bytes:', Array.from(arr.slice(0, 20)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ')); const frames = this.parser.feed(arr); + if (__DEV__) console.log('[TCP] parsed frames:', frames.length); frames.forEach((f) => this.callbacks?.onFrame(f)); }); sock.on('error', (err: Error) => { + if (__DEV__) console.warn('[TCP] error event:', err.message); + this.stopHeartbeat(); this.connected = false; this.callbacks?.onError(err); this.scheduleReconnect(); }); sock.on('close', () => { + if (__DEV__) console.log('[TCP] close event'); + this.stopHeartbeat(); this.connected = false; this.callbacks?.onClose(); this.scheduleReconnect(); @@ -69,6 +100,29 @@ class TcpService { this.socket = sock; } + private startHeartbeat() { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (this.connected && this.socket) { + try { + this.socket.write('0'); + } catch { + this.connected = false; + this.stopHeartbeat(); + this.callbacks?.onError(new Error('Heartbeat write failed')); + this.scheduleReconnect(); + } + } + }, 8000); + } + + private stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + private scheduleReconnect() { if (!this.reconnectEnabled) return; clearTimeout(this.reconnectTimer!); @@ -83,15 +137,18 @@ class TcpService { send(data: Uint8Array): boolean { if (!this.connected || !this.socket) return false; try { + if (__DEV__) console.log('[TCP] send', data.length, 'bytes:', Array.from(data.slice(0, 16)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ')); this.socket.write(data as unknown as string); return true; - } catch { + } catch (e) { + if (__DEV__) console.warn('[TCP] send error:', e); return false; } } disconnect() { this.reconnectEnabled = false; + this.stopHeartbeat(); clearTimeout(this.reconnectTimer!); this.socket?.destroy(); this.socket = null; @@ -102,6 +159,10 @@ class TcpService { isConnected() { return this.connected; } + + resetParser() { + this.parser.reset(); + } } // Singleton instance shared across the app diff --git a/src/stores/dataStore.ts b/src/stores/dataStore.ts index 1a2a122..d7c9725 100644 --- a/src/stores/dataStore.ts +++ b/src/stores/dataStore.ts @@ -4,9 +4,10 @@ import type { MeasurementFrame } from '../protocol/types'; import * as StorageService from '../services/StorageService'; import { fileStorage } from '../utils/storage'; -const MAX_HISTORY = 500; +const MAX_HISTORY = 50; interface DataState { + hasProject: boolean; currentFrame: MeasurementFrame | null; history: MeasurementFrame[]; sessionId: string; @@ -14,24 +15,23 @@ interface DataState { _frameCounter: number; nextFrameId: () => number; addFrame: (frame: MeasurementFrame) => void; + addFrameUI: (frame: MeasurementFrame) => void; deleteFrame: (frameId: number) => void; clearHistory: () => void; - /** Start a new line (session), optionally under a project. */ - newSession: (projectId?: string | null) => Promise; - /** Resume an existing session from a previous run. In-memory history starts - * empty; the frame counter resumes from the DB max so IDs don't collide. */ + newSession: (projectId: string) => Promise; resumeSession: (sessionId: string, projectId?: string | null) => Promise; - /** Change the project association of the current session. */ setProject: (projectId: string | null) => Promise; + resetToNoProject: () => void; init: () => Promise; } export const useDataStore = create()( persist( (set, get) => ({ + hasProject: false, currentFrame: null, history: [], - sessionId: generateSessionId(), + sessionId: '', projectId: null, _frameCounter: 0, @@ -49,6 +49,13 @@ export const useDataStore = create()( void StorageService.persistFrame(frame, get().sessionId); }, + addFrameUI: (frame) => { + set((s) => ({ + currentFrame: frame, + history: [frame, ...s.history].slice(0, MAX_HISTORY), + })); + }, + deleteFrame: (frameId) => { const { sessionId, history, currentFrame } = get(); void StorageService.deleteFrame(frameId, sessionId); @@ -61,25 +68,23 @@ export const useDataStore = create()( clearHistory: () => set({ history: [], currentFrame: null }), - newSession: async (projectId) => { + newSession: async (projectId: string) => { const id = generateSessionId(); - const pid = projectId !== undefined ? projectId : get().projectId; - await StorageService.ensureSession(id, pid); - set({ sessionId: id, projectId: pid, history: [], currentFrame: null, _frameCounter: 0 }); + await StorageService.ensureSession(id, projectId); + set({ sessionId: id, projectId, history: [], currentFrame: null, _frameCounter: 0, hasProject: true }); }, resumeSession: async (sessionId, projectId) => { const pid = projectId !== undefined ? projectId : get().projectId; await StorageService.ensureSession(sessionId, pid); const maxId = await StorageService.getMaxFrameId(sessionId); - // In-memory history intentionally starts empty — past frames are in SQLite - // and visible in the Profile / SessionHistory sections. set({ sessionId, projectId: pid ?? null, history: [], currentFrame: null, _frameCounter: maxId + 1, + hasProject: true, }); }, @@ -89,19 +94,51 @@ export const useDataStore = create()( set({ projectId }); }, + resetToNoProject: () => set({ + hasProject: false, + projectId: null, + sessionId: '', + history: [], + currentFrame: null, + _frameCounter: 0, + }), + init: async () => { await StorageService.initStorage(); + + const exists = await StorageService.hasAnyProject(); + if (!exists) { + set({ hasProject: false }); + return; + } + + set({ hasProject: true }); const { sessionId, projectId } = get(); - await StorageService.ensureSession(sessionId, projectId); - const maxId = await StorageService.getMaxFrameId(sessionId); - set({ _frameCounter: maxId + 1 }); + + if (projectId && sessionId) { + const projects = await StorageService.listProjects(); + if (projects.some((p) => p.projectId === projectId)) { + await StorageService.ensureSession(sessionId, projectId); + const maxId = await StorageService.getMaxFrameId(sessionId); + set({ _frameCounter: maxId + 1 }); + return; + } + } + + const first = await StorageService.getFirstProject(); + if (first) { + const sessions = await StorageService.listSessionsByProject(first.projectId); + if (sessions.length > 0) { + await get().resumeSession(sessions[0].sessionId, first.projectId); + } else { + await get().newSession(first.projectId); + } + } }, }), { name: 'data-session', storage: createJSONStorage(() => fileStorage), - // Only persist identity fields. Runtime data (frames, history) is always - // rebuilt from SQLite on demand. partialize: (state) => ({ sessionId: state.sessionId, projectId: state.projectId, @@ -113,8 +150,10 @@ export const useDataStore = create()( function generateSessionId(): string { const now = new Date(); const pad = (n: number, d = 2) => String(n).padStart(d, '0'); + const rand = Math.random().toString(36).slice(2, 6); return ( `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + - `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + + `${pad(now.getMilliseconds(), 3)}_${rand}` ); } diff --git a/src/stores/deviceStore.ts b/src/stores/deviceStore.ts index 331c252..2097aa7 100644 --- a/src/stores/deviceStore.ts +++ b/src/stores/deviceStore.ts @@ -13,20 +13,20 @@ const DEFAULT_GATE_CONFIG: GateConfig = { const DEFAULT_CONFIG: SetupConfig = { channelNum: 3, - sendFreq: 0x03, // 4 Hz - sampleFreq: 0x03, // 31.25 kHz - sampleDepth: 1024, - accNum: 50, + sendFreq: 0x05, // 16 Hz + sampleFreq: 0x00, // 250 kHz + sampleDepth: 2000, + accNum: 32, negAcc456: false, negAcc123: false, ampRatio: 0x03, // 1× dataChannel: DataChannel.WIFI, - compRes: 0, - secondSampleFreq: 0x03, - compDisableDelay: 300, // 300 × 50μs = 15ms + compRes: 12, + secondSampleFreq: 0x00, + compDisableDelay: 60, sourceMode: 0x00, // single source A batteryVoltageMin: 0, - filePrefix: 'TEM', + filePrefix: new Date().toISOString().slice(0, 10).replace(/-/g, ''), }; interface DeviceState { diff --git a/src/utils/export.ts b/src/utils/export.ts index f60c7cc..0c7c458 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -91,13 +91,66 @@ export async function saveFrameBin(frame: MeasurementFrame, sessionId: string): return path; } -export async function shareFile(filePath: string) { - const canShare = await Sharing.isAvailableAsync(); - if (canShare) { - await Sharing.shareAsync(filePath); +function getMime(filePath: string): string { + const ext = filePath.split('.').pop()?.toLowerCase(); + const map: Record = { + csv: 'text/csv', tem: 'application/octet-stream', + bin: 'application/octet-stream', json: 'application/json', + }; + return map[ext ?? ''] ?? 'application/octet-stream'; +} + +function getFileName(filePath: string): string { + return filePath.split('/').pop() ?? 'export'; +} + +async function saveToFile(filePath: string): Promise { + try { + const perms = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (!perms.granted) return false; + + const fileName = getFileName(filePath); + const mime = getMime(filePath); + const destUri = await FileSystem.StorageAccessFramework.createFileAsync( + perms.directoryUri, fileName, mime, + ); + + const content = await FileSystem.readAsStringAsync(filePath, { + encoding: FileSystem.EncodingType.Base64, + }); + await FileSystem.writeAsStringAsync(destUri, content, { + encoding: FileSystem.EncodingType.Base64, + }); + return true; + } catch { + return false; } } +export async function shareFile(filePath: string) { + const { Alert } = require('react-native'); + const canShare = await Sharing.isAvailableAsync(); + + Alert.alert('导出方式', getFileName(filePath), [ + canShare ? { + text: '分享', + onPress: () => Sharing.shareAsync(filePath, { + mimeType: getMime(filePath), + dialogTitle: '导出数据', + }), + } : null, + { + text: '保存到文件', + onPress: async () => { + const ok = await saveToFile(filePath); + if (ok) Alert.alert('保存成功', '文件已保存到所选目录'); + else Alert.alert('保存取消', '未选择保存位置或保存失败'); + }, + }, + { text: '取消', style: 'cancel' }, + ].filter(Boolean)); +} + export async function listSessionFiles(sessionId: string): Promise { await ensureDir(); const all = await FileSystem.readDirectoryAsync(DATA_DIR);