基本功能完成
251
README.md
Normal file
@ -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,按通道顺序存储
|
||||||
6
app.json
@ -6,7 +6,7 @@
|
|||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "trilooptemapp",
|
"scheme": "trilooptemapp",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "automatic",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.triloop.temreceiver",
|
"bundleIdentifier": "com.triloop.temreceiver",
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"android": {
|
"android": {
|
||||||
"package": "com.triloop.temreceiver",
|
"package": "com.triloop.temreceiver",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#0d0d0d",
|
"backgroundColor": "#ffffff",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
@ -45,7 +45,7 @@
|
|||||||
{
|
{
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#0d0d0d"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
|
|||||||
@ -4,11 +4,7 @@ import { SymbolView } from 'expo-symbols';
|
|||||||
import { Text } from 'react-native';
|
import { Text } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { GlobalStatusBar } from '../../src/components/device/GlobalStatusBar';
|
import { GlobalStatusBar } from '../../src/components/device/GlobalStatusBar';
|
||||||
import { Colors } from '../../src/design/tokens';
|
import { useTheme } from '../../src/design/tokens';
|
||||||
|
|
||||||
const ACTIVE = Colors.blue.fg;
|
|
||||||
const INACTIVE = '#555';
|
|
||||||
const TAB_BG = '#111111';
|
|
||||||
|
|
||||||
function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) {
|
function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: string | any }) {
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
@ -19,16 +15,17 @@ function TabIcon({ ios, emoji, color }: { ios: string; emoji: string; color: str
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: Colors.bg.base, paddingTop: insets.top }}>
|
<View style={{ flex: 1, backgroundColor: theme.bg.base, paddingTop: insets.top }}>
|
||||||
<GlobalStatusBar />
|
<GlobalStatusBar />
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarStyle: { backgroundColor: TAB_BG, borderTopColor: '#222' },
|
tabBarStyle: { backgroundColor: theme.bg.void, borderTopColor: theme.bg.border },
|
||||||
tabBarActiveTintColor: ACTIVE,
|
tabBarActiveTintColor: theme.blue.fg,
|
||||||
tabBarInactiveTintColor: INACTIVE,
|
tabBarInactiveTintColor: theme.text.muted,
|
||||||
tabBarLabelStyle: { fontSize: 10 },
|
tabBarLabelStyle: { fontSize: 10 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,9 +6,11 @@ import { DeviceStatusBar } from '../../src/components/DeviceStatusBar';
|
|||||||
import { ParamForm } from '../../src/components/ParamForm';
|
import { ParamForm } from '../../src/components/ParamForm';
|
||||||
import { useDevice } from '../../src/hooks/useDevice';
|
import { useDevice } from '../../src/hooks/useDevice';
|
||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
|
import { useTheme } from '../../src/design/tokens';
|
||||||
|
|
||||||
export default function ControlScreen() {
|
export default function ControlScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = useTheme();
|
||||||
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
const { busy, connected, deviceStatus, setup, startContinuous, startSingle, stop } = useDevice();
|
||||||
const frame = useDataStore((s) => s.currentFrame);
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
const sessionId = useDataStore((s) => s.sessionId);
|
const sessionId = useDataStore((s) => s.sessionId);
|
||||||
@ -30,11 +32,15 @@ export default function ControlScreen() {
|
|||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return (
|
return (
|
||||||
<View style={[S.notConnected, { paddingTop: insets.top }]}>
|
<View style={[S.notConnected, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
|
||||||
<Text style={S.notConnectedIcon}>⊘</Text>
|
<Text style={[S.notConnectedIcon, { color: theme.text.ghost }]}>⊘</Text>
|
||||||
<Text style={S.notConnectedText}>未连接设备</Text>
|
<Text style={[S.notConnectedText, { color: theme.text.muted }]}>未连接设备</Text>
|
||||||
<TouchableOpacity style={S.goConnectBtn} onPress={() => router.push('/connect')} activeOpacity={0.8}>
|
<TouchableOpacity
|
||||||
<Text style={S.goConnectBtnText}>前往连接</Text>
|
style={[S.goConnectBtn, { backgroundColor: theme.bg.raised, borderColor: theme.blue.fg }]}
|
||||||
|
onPress={() => router.push('/connect')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[S.goConnectBtnText, { color: theme.blue.fg }]}>前往连接</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -46,54 +52,54 @@ export default function ControlScreen() {
|
|||||||
const canStop = (running || single) && !busy;
|
const canStop = (running || single) && !busy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[S.container, { paddingTop: insets.top }]}>
|
<View style={[S.container, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
|
||||||
<DeviceStatusBar />
|
<DeviceStatusBar />
|
||||||
{/* Project / line context */}
|
{/* Project / line context */}
|
||||||
<View style={S.contextBar}>
|
<View style={[S.contextBar, { backgroundColor: theme.bg.void, borderBottomColor: theme.bg.divider }]}>
|
||||||
{projectName
|
{projectName
|
||||||
? <Text style={S.contextText} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
? <Text style={[S.contextText, { color: theme.text.ghost }]} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||||
: <Text style={S.contextText} numberOfLines={1}>{sessionId}</Text>
|
: <Text style={[S.contextText, { color: theme.text.ghost }]} numberOfLines={1}>{sessionId}</Text>
|
||||||
}
|
}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Control buttons */}
|
{/* Control buttons */}
|
||||||
<View style={S.ctrlRow}>
|
<View style={S.ctrlRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.ctrlBtn, S.btnStart, !canStart && S.btnDisabled]}
|
style={[S.ctrlBtn, { backgroundColor: theme.green.bg, borderColor: theme.green.border }, !canStart && S.btnDisabled]}
|
||||||
onPress={startContinuous}
|
onPress={startContinuous}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy && !canStop
|
{busy && !canStop
|
||||||
? <ActivityIndicator color="#3ddc84" size="small" />
|
? <ActivityIndicator color={theme.green.fg} size="small" />
|
||||||
: <>
|
: <>
|
||||||
<Text style={S.ctrlIcon}>▶</Text>
|
<Text style={S.ctrlIcon}>▶</Text>
|
||||||
<Text style={[S.ctrlText, S.ctrlTextGreen]}>连续采集</Text>
|
<Text style={[S.ctrlText, { color: theme.green.fg }]}>连续采集</Text>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.ctrlBtn, S.btnSingle, !canStart && S.btnDisabled]}
|
style={[S.ctrlBtn, { backgroundColor: theme.blue.bg, borderColor: theme.blue.border }, !canStart && S.btnDisabled]}
|
||||||
onPress={startSingle}
|
onPress={startSingle}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={S.ctrlIcon}>◎</Text>
|
<Text style={S.ctrlIcon}>◎</Text>
|
||||||
<Text style={[S.ctrlText, S.ctrlTextBlue]}>单 次</Text>
|
<Text style={[S.ctrlText, { color: theme.blue.fg }]}>单 次</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.ctrlBtn, S.btnStop, !canStop && S.btnDisabled]}
|
style={[S.ctrlBtn, { backgroundColor: theme.red.bg, borderColor: theme.red.border }, !canStop && S.btnDisabled]}
|
||||||
onPress={stop}
|
onPress={stop}
|
||||||
disabled={!canStop}
|
disabled={!canStop}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy && canStop
|
{busy && canStop
|
||||||
? <ActivityIndicator color="#ff5c6e" size="small" />
|
? <ActivityIndicator color={theme.red.fg} size="small" />
|
||||||
: <>
|
: <>
|
||||||
<Text style={S.ctrlIcon}>■</Text>
|
<Text style={S.ctrlIcon}>■</Text>
|
||||||
<Text style={[S.ctrlText, S.ctrlTextRed]}>停 止</Text>
|
<Text style={[S.ctrlText, { color: theme.red.fg }]}>停 止</Text>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -101,25 +107,25 @@ export default function ControlScreen() {
|
|||||||
|
|
||||||
{/* Frame info */}
|
{/* Frame info */}
|
||||||
{frame && (
|
{frame && (
|
||||||
<View style={S.frameBar}>
|
<View style={[S.frameBar, { backgroundColor: theme.bg.surface, borderColor: theme.bg.border }]}>
|
||||||
<View style={S.frameItem}>
|
<View style={S.frameItem}>
|
||||||
<Text style={S.frameItemLabel}>帧号</Text>
|
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>帧号</Text>
|
||||||
<Text style={S.frameItemValue}>#{frame.frameId}</Text>
|
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>#{frame.frameId}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.frameSep} />
|
<View style={[S.frameSep, { backgroundColor: theme.bg.border }]} />
|
||||||
<View style={S.frameItem}>
|
<View style={S.frameItem}>
|
||||||
<Text style={S.frameItemLabel}>叠加</Text>
|
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>叠加</Text>
|
||||||
<Text style={S.frameItemValue}>{frame.accNum}次</Text>
|
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>{frame.accNum}次</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.frameSep} />
|
<View style={[S.frameSep, { backgroundColor: theme.bg.border }]} />
|
||||||
<View style={S.frameItem}>
|
<View style={S.frameItem}>
|
||||||
<Text style={S.frameItemLabel}>通道</Text>
|
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>通道</Text>
|
||||||
<Text style={S.frameItemValue}>{frame.meta?.channelNum ?? '-'}ch</Text>
|
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>{frame.meta?.channelNum ?? '-'}ch</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.frameSep} />
|
<View style={[S.frameSep, { backgroundColor: theme.bg.border }]} />
|
||||||
<View style={S.frameItem}>
|
<View style={S.frameItem}>
|
||||||
<Text style={S.frameItemLabel}>电流</Text>
|
<Text style={[S.frameItemLabel, { color: theme.text.muted }]}>电流</Text>
|
||||||
<Text style={S.frameItemValue}>{frame.meta?.current ?? '--'}</Text>
|
<Text style={[S.frameItemValue, { color: theme.text.secondary }]}>{frame.meta?.current ?? '--'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@ -127,23 +133,23 @@ export default function ControlScreen() {
|
|||||||
{/* Param form */}
|
{/* Param form */}
|
||||||
<ScrollView style={S.formScroll} keyboardShouldPersistTaps="handled">
|
<ScrollView style={S.formScroll} keyboardShouldPersistTaps="handled">
|
||||||
<View style={S.formHeader}>
|
<View style={S.formHeader}>
|
||||||
<Text style={S.formTitle}>采集参数</Text>
|
<Text style={[S.formTitle, { color: theme.text.muted }]}>采集参数</Text>
|
||||||
{configDirty && (
|
{configDirty && (
|
||||||
<View style={S.dirtyBadge}>
|
<View style={[S.dirtyBadge, { backgroundColor: theme.amber.bg, borderColor: theme.amber.border }]}>
|
||||||
<Text style={S.dirtyBadgeText}>待下发</Text>
|
<Text style={[S.dirtyBadgeText, { color: theme.amber.fg }]}>待下发</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
<ParamForm onAnyChange={() => setConfigDirty(true)} />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.setupBtn, busy && S.btnDisabled]}
|
style={[S.setupBtn, { backgroundColor: theme.bg.raised, borderColor: theme.blue.fg }, busy && S.btnDisabled]}
|
||||||
onPress={handleSetup}
|
onPress={handleSetup}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy
|
{busy
|
||||||
? <ActivityIndicator color="#4a9eff" />
|
? <ActivityIndicator color={theme.blue.fg} />
|
||||||
: <Text style={S.setupBtnText}>下 发 配 置</Text>}
|
: <Text style={[S.setupBtnText, { color: theme.blue.fg }]}>下 发 配 置</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={{ height: 32 }} />
|
<View style={{ height: 32 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@ -152,12 +158,12 @@ export default function ControlScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#090912' },
|
container: { flex: 1 },
|
||||||
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, backgroundColor: '#090912' },
|
notConnected: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
|
||||||
notConnectedIcon: { fontSize: 48, color: '#2a2a4a' },
|
notConnectedIcon: { fontSize: 48 },
|
||||||
notConnectedText: { color: '#4a4a6a', fontSize: 15 },
|
notConnectedText: { fontSize: 15 },
|
||||||
goConnectBtn: { backgroundColor: '#111120', borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1, borderColor: '#4a9eff' },
|
goConnectBtn: { borderRadius: 10, paddingVertical: 10, paddingHorizontal: 28, borderWidth: 1 },
|
||||||
goConnectBtnText: { color: '#4a9eff', fontWeight: '600' },
|
goConnectBtnText: { fontWeight: '600' },
|
||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
ctrlRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
||||||
@ -170,50 +176,40 @@ const S = StyleSheet.create({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
btnStart: { backgroundColor: '#0d2018', borderColor: '#1a4a28' },
|
|
||||||
btnSingle: { backgroundColor: '#0d1a30', borderColor: '#1a2e54' },
|
|
||||||
btnStop: { backgroundColor: '#1e0d12', borderColor: '#3a1520' },
|
|
||||||
btnDisabled: { opacity: 0.3 },
|
btnDisabled: { opacity: 0.3 },
|
||||||
ctrlIcon: { fontSize: 12, color: '#ffffff88' },
|
ctrlIcon: { fontSize: 12, color: '#ffffff88' },
|
||||||
ctrlText: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
|
ctrlText: { fontSize: 12, fontWeight: '700', letterSpacing: 1 },
|
||||||
ctrlTextGreen: { color: '#3ddc84' },
|
|
||||||
ctrlTextBlue: { color: '#4a9eff' },
|
|
||||||
ctrlTextRed: { color: '#ff5c6e' },
|
|
||||||
|
|
||||||
// Frame info bar
|
// Frame info bar
|
||||||
frameBar: {
|
frameBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginHorizontal: 12,
|
marginHorizontal: 12,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
backgroundColor: '#0c0c1a',
|
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#1a1a2a',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
frameItem: { flex: 1, alignItems: 'center', paddingVertical: 8 },
|
frameItem: { flex: 1, alignItems: 'center', paddingVertical: 8 },
|
||||||
frameItemLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
frameItemLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5, marginBottom: 2 },
|
||||||
frameItemValue: { color: '#8888aa', fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
frameItemValue: { fontSize: 12, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
frameSep: { width: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 6 },
|
frameSep: { width: StyleSheet.hairlineWidth, marginVertical: 6 },
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
formScroll: { flex: 1 },
|
formScroll: { flex: 1 },
|
||||||
formHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10 },
|
formHeader: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10 },
|
||||||
formTitle: { color: '#4a4a6a', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' },
|
formTitle: { fontSize: 10, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase' },
|
||||||
dirtyBadge: { backgroundColor: '#2a1a05', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1, borderColor: '#4a2a05' },
|
dirtyBadge: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2, borderWidth: 1 },
|
||||||
dirtyBadgeText: { color: '#ffb84d', fontSize: 9, fontWeight: '700' },
|
dirtyBadgeText: { fontSize: 9, fontWeight: '700' },
|
||||||
|
|
||||||
setupBtn: {
|
setupBtn: {
|
||||||
margin: 16,
|
margin: 16,
|
||||||
backgroundColor: '#111120',
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 1,
|
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' },
|
contextBar: { paddingHorizontal: 16, paddingVertical: 5, borderBottomWidth: 1 },
|
||||||
contextText: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
contextText: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, TouchableOpacity, Modal, FlatList,
|
View, Text, StyleSheet, TouchableOpacity, Modal,
|
||||||
ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView,
|
ActivityIndicator, TextInput, useWindowDimensions, Platform, ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
|
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 StorageService from '../../src/services/StorageService';
|
||||||
import * as BinLoader from '../../src/services/BinLoader';
|
import * as BinLoader from '../../src/services/BinLoader';
|
||||||
import type { MeasurementFrame } from '../../src/protocol/types';
|
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||||
import type { SessionInfo } from '../../src/services/StorageService';
|
|
||||||
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
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 ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -101,8 +103,9 @@ function useProfileData(sessionId: string, currentSessionId: string, history: Me
|
|||||||
}));
|
}));
|
||||||
}, [sessionId, currentSessionId, history]);
|
}, [sessionId, currentSessionId, history]);
|
||||||
|
|
||||||
|
const latestFrameId = useDataStore((s) => s.currentFrame?.frameId ?? -1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId === currentSessionId) { setHistFrames([]); return; }
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setHistFrames([]);
|
setHistFrames([]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -124,11 +127,11 @@ function useProfileData(sessionId: string, currentSessionId: string, history: Me
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [sessionId, currentSessionId]);
|
}, [sessionId, latestFrameId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
frames: sessionId === currentSessionId ? liveFrames : histFrames,
|
frames: histFrames,
|
||||||
loading: sessionId !== currentSessionId && loading,
|
loading,
|
||||||
progress,
|
progress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -143,6 +146,7 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
|||||||
sampleDepth: number;
|
sampleDepth: number;
|
||||||
hz: number;
|
hz: number;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
const [tStartStr, setTStartStr] = useState(String(config.tStart));
|
const [tStartStr, setTStartStr] = useState(String(config.tStart));
|
||||||
const [tEndStr, setTEndStr] = useState(String(config.tEnd));
|
const [tEndStr, setTEndStr] = useState(String(config.tEnd));
|
||||||
const [countStr, setCountStr] = useState(String(config.count));
|
const [countStr, setCountStr] = useState(String(config.count));
|
||||||
@ -170,23 +174,31 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
|||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<TouchableOpacity style={GSM.backdrop} activeOpacity={1} onPress={onClose} />
|
<TouchableOpacity style={GSM.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
<View style={GSM.sheet}>
|
<View style={[GSM.sheet, { backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={GSM.title}>时间门配置</Text>
|
<Text style={[GSM.title, { color: theme.text.secondary }]}>时间门配置</Text>
|
||||||
|
|
||||||
<View style={GSM.row}>
|
<View style={GSM.row}>
|
||||||
<View style={GSM.field}>
|
<View style={GSM.field}>
|
||||||
<Text style={GSM.label}>起始时间 (μs)</Text>
|
<Text style={[GSM.label, { color: theme.text.muted }]}>起始时间 (μs)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={GSM.input}
|
style={[GSM.input, {
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
color: theme.text.secondary,
|
||||||
|
}]}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={tStartStr}
|
value={tStartStr}
|
||||||
onChangeText={setTStartStr}
|
onChangeText={setTStartStr}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={GSM.field}>
|
<View style={GSM.field}>
|
||||||
<Text style={GSM.label}>终止时间 (μs)</Text>
|
<Text style={[GSM.label, { color: theme.text.muted }]}>终止时间 (μs)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={GSM.input}
|
style={[GSM.input, {
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
color: theme.text.secondary,
|
||||||
|
}]}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={tEndStr}
|
value={tEndStr}
|
||||||
onChangeText={setTEndStr}
|
onChangeText={setTEndStr}
|
||||||
@ -196,24 +208,34 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
|||||||
|
|
||||||
<View style={[GSM.row, { marginTop: 6 }]}>
|
<View style={[GSM.row, { marginTop: 6 }]}>
|
||||||
<View style={GSM.field}>
|
<View style={GSM.field}>
|
||||||
<Text style={GSM.label}>门数量</Text>
|
<Text style={[GSM.label, { color: theme.text.muted }]}>门数量</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={GSM.input}
|
style={[GSM.input, {
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
color: theme.text.secondary,
|
||||||
|
}]}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={countStr}
|
value={countStr}
|
||||||
onChangeText={setCountStr}
|
onChangeText={setCountStr}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
|
<View style={[GSM.field, { justifyContent: 'flex-end' }]}>
|
||||||
<Text style={GSM.label}>间隔方式</Text>
|
<Text style={[GSM.label, { color: theme.text.muted }]}>间隔方式</Text>
|
||||||
<View style={GSM.toggle}>
|
<View style={[GSM.toggle, { borderColor: theme.bg.border }]}>
|
||||||
{(['log', 'linear'] as GateSpacing[]).map(s => (
|
{(['log', 'linear'] as GateSpacing[]).map(s => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={s}
|
key={s}
|
||||||
style={[GSM.toggleBtn, spacing === s && GSM.toggleBtnOn]}
|
style={[GSM.toggleBtn,
|
||||||
|
{ backgroundColor: theme.bg.raised },
|
||||||
|
spacing === s && { backgroundColor: theme.blue.bg },
|
||||||
|
]}
|
||||||
onPress={() => setSpacing(s)}
|
onPress={() => setSpacing(s)}
|
||||||
>
|
>
|
||||||
<Text style={[GSM.toggleTxt, spacing === s && GSM.toggleTxtOn]}>
|
<Text style={[GSM.toggleTxt,
|
||||||
|
{ color: theme.text.muted },
|
||||||
|
spacing === s && { color: theme.blue.fg },
|
||||||
|
]}>
|
||||||
{s === 'log' ? '对数' : '线性'}
|
{s === 'log' ? '对数' : '线性'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -223,15 +245,18 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{sampleDepth > 0 && hz > 0 && (
|
{sampleDepth > 0 && hz > 0 && (
|
||||||
<Text style={GSM.hint}>当前采样窗口: {fmtUs(maxUs)}</Text>
|
<Text style={[GSM.hint, { color: theme.text.ghost }]}>当前采样窗口: {fmtUs(maxUs)}</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={GSM.actions}>
|
<View style={GSM.actions}>
|
||||||
<TouchableOpacity style={GSM.cancelBtn} onPress={onClose}>
|
<TouchableOpacity style={[GSM.cancelBtn, { borderColor: theme.bg.border }]} onPress={onClose}>
|
||||||
<Text style={GSM.cancelTxt}>取消</Text>
|
<Text style={[GSM.cancelTxt, { color: theme.text.muted }]}>取消</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={GSM.applyBtn} onPress={handleApply}>
|
<TouchableOpacity style={[GSM.applyBtn, {
|
||||||
<Text style={GSM.applyTxt}>应用</Text>
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.fg + '55',
|
||||||
|
}]} onPress={handleApply}>
|
||||||
|
<Text style={[GSM.applyTxt, { color: theme.blue.fg }]}>应用</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -241,23 +266,21 @@ function GateSettingsModal({ visible, config, onApply, onClose, sampleDepth, hz
|
|||||||
|
|
||||||
const GSM = StyleSheet.create({
|
const GSM = StyleSheet.create({
|
||||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' },
|
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||||
sheet: { backgroundColor: '#0e0e1c', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
|
sheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, paddingBottom: 36 },
|
||||||
title: { color: '#8888bb', fontSize: 14, fontWeight: '700', marginBottom: 16 },
|
title: { fontSize: 14, fontWeight: '700', marginBottom: 16 },
|
||||||
row: { flexDirection: 'row', gap: 12 },
|
row: { flexDirection: 'row', gap: 12 },
|
||||||
field: { flex: 1, gap: 4 },
|
field: { flex: 1, gap: 4 },
|
||||||
label: { color: '#4a4a6a', fontSize: 11, fontWeight: '600' },
|
label: { fontSize: 11, fontWeight: '600' },
|
||||||
input: { backgroundColor: '#111120', borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', color: '#9090b8', fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
|
input: { borderRadius: 8, borderWidth: 1, fontSize: 13, paddingHorizontal: 10, paddingVertical: 8 },
|
||||||
toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1, borderColor: '#2a2a3a' },
|
toggle: { flexDirection: 'row', borderRadius: 8, overflow: 'hidden', borderWidth: 1 },
|
||||||
toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center', backgroundColor: '#111120' },
|
toggleBtn: { flex: 1, paddingVertical: 9, alignItems: 'center' },
|
||||||
toggleBtnOn: { backgroundColor: '#0d2040' },
|
toggleTxt: { fontSize: 12, fontWeight: '700' },
|
||||||
toggleTxt: { color: '#4a4a6a', fontSize: 12, fontWeight: '700' },
|
hint: { fontSize: 10, marginTop: 8 },
|
||||||
toggleTxtOn: { color: '#4a9eff' },
|
|
||||||
hint: { color: '#2a2a4a', fontSize: 10, marginTop: 8 },
|
|
||||||
actions: { flexDirection: 'row', gap: 10, marginTop: 20 },
|
actions: { flexDirection: 'row', gap: 10, marginTop: 20 },
|
||||||
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
|
cancelBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' },
|
||||||
cancelTxt: { color: '#4a4a6a', fontSize: 13, fontWeight: '600' },
|
cancelTxt: { fontSize: 13, fontWeight: '600' },
|
||||||
applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, backgroundColor: '#0d2040', borderWidth: 1, borderColor: '#4a9eff55', alignItems: 'center' },
|
applyBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: 'center' },
|
||||||
applyTxt: { color: '#4a9eff', fontSize: 13, fontWeight: '700' },
|
applyTxt: { fontSize: 13, fontWeight: '700' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── GatePreview ────────────────────────────────────────────────────────────
|
// ── GatePreview ────────────────────────────────────────────────────────────
|
||||||
@ -271,6 +294,7 @@ function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }:
|
|||||||
gateColors: string[];
|
gateColors: string[];
|
||||||
width: number;
|
width: number;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
const plotW = width - GP.l - GP.r;
|
const plotW = width - GP.l - GP.r;
|
||||||
const plotH = CANVAS_H - GP.t - GP.b;
|
const plotH = CANVAS_H - GP.t - GP.b;
|
||||||
|
|
||||||
@ -302,7 +326,7 @@ function GatePreview({ refFrame, channelIdx, gatePositions, gateColors, width }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas style={{ width, height: CANVAS_H }}>
|
<Canvas style={{ width, height: CANVAS_H }}>
|
||||||
{wavePath && <Path path={wavePath} color="#445566" style="stroke" strokeWidth={1} />}
|
{wavePath && <Path path={wavePath} color={theme.chart.grid} style="stroke" strokeWidth={1} />}
|
||||||
{gatePositions.map((pos, gi) => (
|
{gatePositions.map((pos, gi) => (
|
||||||
<Line key={gi}
|
<Line key={gi}
|
||||||
p1={vec(GP.l + pos * plotW, GP.t)}
|
p1={vec(GP.l + pos * plotW, GP.t)}
|
||||||
@ -321,6 +345,7 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale
|
|||||||
gatePositions: number[]; gateColors: string[];
|
gatePositions: number[]; gateColors: string[];
|
||||||
logScale: boolean; width: number; height: number;
|
logScale: boolean; width: number; height: number;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
const plotW = width - CP.left - CP.right;
|
const plotW = width - CP.left - CP.right;
|
||||||
const plotH = height - CP.top - CP.bottom;
|
const plotH = height - CP.top - CP.bottom;
|
||||||
|
|
||||||
@ -397,16 +422,16 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale
|
|||||||
{yTicks.map((v, idx) => {
|
{yTicks.map((v, idx) => {
|
||||||
const y = toY(v);
|
const y = toY(v);
|
||||||
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
||||||
return <Line key={`y_${idx}`} p1={vec(left, y)} p2={vec(right, y)} color="#222" strokeWidth={0.5} />;
|
return <Line key={`y_${idx}`} p1={vec(left, y)} p2={vec(right, y)} color={theme.chart.gridFine} strokeWidth={0.5} />;
|
||||||
})}
|
})}
|
||||||
{xTicks.map((i, idx) => (
|
{xTicks.map((i, idx) => (
|
||||||
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color="#1e1e1e" strokeWidth={0.5} />
|
<Line key={`x_${idx}`} p1={vec(toX(i), top)} p2={vec(toX(i), bot)} color={theme.chart.gridFine} strokeWidth={0.5} />
|
||||||
))}
|
))}
|
||||||
{!logScale && isFinite(toY(0)) && (
|
{!logScale && isFinite(toY(0)) && (
|
||||||
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))} color="#444" strokeWidth={0.8} />
|
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))} color={theme.chart.axis} strokeWidth={0.8} />
|
||||||
)}
|
)}
|
||||||
<Line p1={vec(left, bot)} p2={vec(right, bot)} color="#555" strokeWidth={1} />
|
<Line p1={vec(left, bot)} p2={vec(right, bot)} color={theme.chart.axis} strokeWidth={1} />
|
||||||
<Line p1={vec(left, top)} p2={vec(left, bot)} color="#555" strokeWidth={1} />
|
<Line p1={vec(left, top)} p2={vec(left, bot)} color={theme.chart.axis} strokeWidth={1} />
|
||||||
{paths.map((p, gi) =>
|
{paths.map((p, gi) =>
|
||||||
p ? <Path key={`g${gi}`} path={p} color={gateColors[gi] ?? '#fff'} style="stroke"
|
p ? <Path key={`g${gi}`} path={p} color={gateColors[gi] ?? '#fff'} style="stroke"
|
||||||
strokeWidth={1.5} strokeJoin="round" strokeCap="round" /> : null,
|
strokeWidth={1.5} strokeJoin="round" strokeCap="round" /> : null,
|
||||||
@ -416,126 +441,40 @@ function GateLineChart({ frames, channelIdx, gatePositions, gateColors, logScale
|
|||||||
const y = toY(v);
|
const y = toY(v);
|
||||||
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
||||||
const label = logScale ? (v >= 1000 ? `${v / 1000}k` : `${v}`) : formatUVShort(v);
|
const label = logScale ? (v >= 1000 ? `${v / 1000}k` : `${v}`) : formatUVShort(v);
|
||||||
return <Text key={`yl_${idx}`} style={[LBL.y, { top: y - 7, left: 2, width: CP.left - 4 }]}>{label}</Text>;
|
return <Text key={`yl_${idx}`} style={[LBL.y, { top: y - 7, left: 2, width: CP.left - 4, color: theme.chart.label }]}>{label}</Text>;
|
||||||
})}
|
})}
|
||||||
{xTicks.map((i, idx) => (
|
{xTicks.map((i, idx) => (
|
||||||
<Text key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36 }]}>{i}</Text>
|
<Text key={`xl_${idx}`} style={[LBL.x, { top: height - 20, left: toX(i) - 18, width: 36, color: theme.chart.label }]}>{i}</Text>
|
||||||
))}
|
))}
|
||||||
<Text style={[LBL.y, { top: 2, left: 2 }]}>μV</Text>
|
<Text style={[LBL.y, { top: 2, left: 2, color: theme.chart.label }]}>μV</Text>
|
||||||
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8 }]}>帧</Text>
|
<Text style={[LBL.x, { position: 'absolute', bottom: 2, right: 8, color: theme.chart.label }]}>帧</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LBL = StyleSheet.create({
|
const LBL = StyleSheet.create({
|
||||||
y: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'right' },
|
y: { position: 'absolute', fontSize: 9, textAlign: 'right' },
|
||||||
x: { position: 'absolute', color: '#666', fontSize: 9, textAlign: 'center' },
|
x: { position: 'absolute', 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 (
|
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
||||||
<TouchableOpacity style={SPM.backdrop} activeOpacity={1} onPress={onClose} />
|
|
||||||
<View style={SPM.sheet}>
|
|
||||||
<View style={SPM.header}>
|
|
||||||
<Text style={SPM.title}>选择测线</Text>
|
|
||||||
<TouchableOpacity style={[SPM.allBtn, showAll && SPM.allBtnOn]} onPress={() => setShowAll(v => !v)}>
|
|
||||||
<Text style={[SPM.allTxt, showAll && SPM.allTxtOn]}>{showAll ? '全部' : '当前工程'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
|
||||||
data={listData}
|
|
||||||
keyExtractor={item => item.sessionId}
|
|
||||||
style={SPM.list}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const isCur = item.sessionId === currentId;
|
|
||||||
const isSel = item.sessionId === selectedId;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[SPM.item, isSel && SPM.itemSel]}
|
|
||||||
onPress={() => { onSelect(item.sessionId); onClose(); }}
|
|
||||||
>
|
|
||||||
<Text style={SPM.itemId} numberOfLines={1}>
|
|
||||||
{item.sessionId}{isCur ? ' (当前)' : ''}
|
|
||||||
</Text>
|
|
||||||
{item.frameCount >= 0 && (
|
|
||||||
<Text style={SPM.itemCnt}>{item.frameCount} 帧</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ProfileScreen ──────────────────────────────────────────────────────────
|
// ── ProfileScreen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
|
const theme = useTheme();
|
||||||
const { width: sw, height: sh } = useWindowDimensions();
|
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 sampleFreqCode = useDeviceStore(s => s.config.sampleFreq);
|
||||||
|
|
||||||
const gateConfig = useDeviceStore(s => s.gateConfig);
|
const gateConfig = useDeviceStore(s => s.gateConfig);
|
||||||
const setGateConfig = useDeviceStore(s => s.setGateConfig);
|
const setGateConfig = useDeviceStore(s => s.setGateConfig);
|
||||||
|
|
||||||
const [channelIdx, setChannelIdx] = useState(0);
|
const [channelIdx, setChannelIdx] = useState(0);
|
||||||
const [sessionId, setSessionId] = useState(currentSessionId);
|
|
||||||
const [projectSessions, setProjectSessions] = useState<SessionInfo[]>([]);
|
|
||||||
const [allSessions, setAllSessions] = useState<SessionInfo[]>([]);
|
|
||||||
const [pickerVisible, setPickerVisible] = useState(false);
|
|
||||||
const [settingsVisible, setSettingsVisible] = 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));
|
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;
|
const maxCh = frames[0]?.channelNum ?? 6;
|
||||||
useEffect(() => { if (channelIdx >= maxCh) setChannelIdx(0); }, [maxCh, channelIdx]);
|
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 refFrame = useMemo(() => frames[Math.floor(frames.length / 2)] ?? null, [frames]);
|
||||||
|
|
||||||
|
const handleSessionChange = async (newSessionId: string) => {
|
||||||
|
await resumeSession(newSessionId, projectId!);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── NoProjectGate ──
|
||||||
|
if (!hasProject) {
|
||||||
return (
|
return (
|
||||||
<View style={SCR.root}>
|
<View style={[SCR.root, { backgroundColor: theme.bg.void }]}>
|
||||||
|
<NoProjectGate />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[SCR.root, { backgroundColor: theme.bg.void }]}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<View style={SCR.header}>
|
<View style={SCR.header}>
|
||||||
<TouchableOpacity style={SCR.sessBtn} onPress={() => setPickerVisible(true)}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={SCR.sessBtnTxt} numberOfLines={1}>{sessionId}</Text>
|
<SessionSelector
|
||||||
<Text style={SCR.sessBtnArrow}>▾</Text>
|
selectedSessionId={sessionId}
|
||||||
</TouchableOpacity>
|
projectId={projectId!}
|
||||||
<TouchableOpacity style={[SCR.scaleBtn, !logScale && SCR.scaleBtnOn]} onPress={() => setLogScale(v => !v)}>
|
onSelect={handleSessionChange}
|
||||||
<Text style={[SCR.scaleTxt, !logScale && SCR.scaleTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[SCR.scaleBtn, {
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.base,
|
||||||
|
}, !logScale && {
|
||||||
|
borderColor: theme.blue.fg + '55',
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
}]}
|
||||||
|
onPress={() => setLogScale(v => !v)}
|
||||||
|
>
|
||||||
|
<Text style={[SCR.scaleTxt, { color: theme.chart.axis }, !logScale && { color: theme.blue.fg }]}>
|
||||||
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -571,13 +537,13 @@ export default function ProfileScreen() {
|
|||||||
<View style={SCR.chRow}>
|
<View style={SCR.chRow}>
|
||||||
{Array.from({ length: maxCh }, (_, i) => (
|
{Array.from({ length: maxCh }, (_, i) => (
|
||||||
<TouchableOpacity key={i}
|
<TouchableOpacity key={i}
|
||||||
style={[SCR.chBtn, channelIdx === i && {
|
style={[SCR.chBtn, { borderColor: theme.bg.border }, channelIdx === i && {
|
||||||
borderColor: CHANNEL_COLORS[i] + '99',
|
borderColor: CHANNEL_COLORS[i] + '99',
|
||||||
backgroundColor: CHANNEL_COLORS[i] + '1a',
|
backgroundColor: CHANNEL_COLORS[i] + '1a',
|
||||||
}]}
|
}]}
|
||||||
onPress={() => setChannelIdx(i)}
|
onPress={() => setChannelIdx(i)}
|
||||||
>
|
>
|
||||||
<Text style={[SCR.chTxt, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
|
<Text style={[SCR.chTxt, { color: theme.chart.axis }, channelIdx === i && { color: CHANNEL_COLORS[i] }]}>
|
||||||
CH{i + 1}
|
CH{i + 1}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -587,15 +553,15 @@ export default function ProfileScreen() {
|
|||||||
{/* ── Loading progress ── */}
|
{/* ── Loading progress ── */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<View style={SCR.loadRow}>
|
<View style={SCR.loadRow}>
|
||||||
<ActivityIndicator size="small" color="#4a9eff" />
|
<ActivityIndicator size="small" color={theme.blue.fg} />
|
||||||
<Text style={SCR.loadTxt}>加载 {progress.current}/{progress.total} 帧…</Text>
|
<Text style={[SCR.loadTxt, { color: theme.text.muted }]}>加载 {progress.current}/{progress.total} 帧…</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Empty ── */}
|
{/* ── Empty ── */}
|
||||||
{!loading && frames.length === 0 && (
|
{!loading && frames.length === 0 && (
|
||||||
<View style={SCR.empty}>
|
<View style={SCR.empty}>
|
||||||
<Text style={SCR.emptyTxt}>暂无剖面数据</Text>
|
<Text style={[SCR.emptyTxt, { color: theme.text.ghost }]}>暂无剖面数据</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -610,13 +576,16 @@ export default function ProfileScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gate preview + legend + settings */}
|
{/* Gate preview + legend + settings */}
|
||||||
<View style={SCR.gateSection}>
|
<View style={[SCR.gateSection, { backgroundColor: theme.bg.base }]}>
|
||||||
<View style={SCR.gateHeader}>
|
<View style={SCR.gateHeader}>
|
||||||
<Text style={SCR.gateHdr}>
|
<Text style={[SCR.gateHdr, { color: theme.chart.badge }]}>
|
||||||
时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
|
时间门预览 · {gateConfig.count} 门 · {gateConfig.spacing === 'log' ? '对数' : '线性'}间隔
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity style={SCR.settingsBtn} onPress={() => setSettingsVisible(true)}>
|
<TouchableOpacity style={[SCR.settingsBtn, {
|
||||||
<Text style={SCR.settingsTxt}>⚙ 配置</Text>
|
borderColor: theme.blue.border,
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
}]} onPress={() => setSettingsVisible(true)}>
|
||||||
|
<Text style={[SCR.settingsTxt, { color: theme.blue.fg }]}>⚙ 配置</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -633,7 +602,7 @@ export default function ProfileScreen() {
|
|||||||
return (
|
return (
|
||||||
<View key={gi} style={SCR.legendItem}>
|
<View key={gi} style={SCR.legendItem}>
|
||||||
<View style={[SCR.legendDot, { backgroundColor: gateColors[gi] }]} />
|
<View style={[SCR.legendDot, { backgroundColor: gateColors[gi] }]} />
|
||||||
<Text style={SCR.legendTxt}>G{gi + 1} {fmtUs(tUs)}</Text>
|
<Text style={[SCR.legendTxt, { color: theme.text.muted }]}>G{gi + 1} {fmtUs(tUs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -644,16 +613,6 @@ export default function ProfileScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SessionPickerModal
|
|
||||||
visible={pickerVisible}
|
|
||||||
sessions={projectSessions}
|
|
||||||
allSessions={allSessions}
|
|
||||||
currentId={currentSessionId}
|
|
||||||
selectedId={sessionId}
|
|
||||||
onSelect={setSessionId}
|
|
||||||
onClose={() => setPickerVisible(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GateSettingsModal
|
<GateSettingsModal
|
||||||
visible={settingsVisible}
|
visible={settingsVisible}
|
||||||
config={gateConfig}
|
config={gateConfig}
|
||||||
@ -667,32 +626,27 @@ export default function ProfileScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SCR = StyleSheet.create({
|
const SCR = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#0d0d0d' },
|
root: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 8, gap: 8 },
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 8, gap: 8 },
|
||||||
sessBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', backgroundColor: '#111', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, borderWidth: 1, borderColor: '#222' },
|
scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1 },
|
||||||
sessBtnTxt: { flex: 1, color: '#6a9eff', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
scaleTxt: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
sessBtnArrow: { color: '#555', fontSize: 10, marginLeft: 4 },
|
|
||||||
scaleBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a1a' },
|
|
||||||
scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
|
|
||||||
scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
|
||||||
scaleTxtOn: { color: '#4a9eff' },
|
|
||||||
chRow: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 6, gap: 6 },
|
chRow: { flexDirection: 'row', paddingHorizontal: 10, paddingBottom: 6, gap: 6 },
|
||||||
chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a2a' },
|
chBtn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||||
chTxt: { color: '#555', fontSize: 11, fontWeight: '600' },
|
chTxt: { fontSize: 11, fontWeight: '600' },
|
||||||
loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
|
loadRow: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12 },
|
||||||
loadTxt: { color: '#556', fontSize: 12 },
|
loadTxt: { fontSize: 12 },
|
||||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
|
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
|
||||||
emptyTxt: { color: '#333', fontSize: 14 },
|
emptyTxt: { fontSize: 14 },
|
||||||
|
|
||||||
gateSection: { backgroundColor: '#111', marginTop: 2 },
|
gateSection: { marginTop: 2 },
|
||||||
gateHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
|
gateHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
|
||||||
gateHdr: { flex: 1, color: '#444', fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
|
gateHdr: { flex: 1, fontSize: 10, fontWeight: '600', letterSpacing: 0.3 },
|
||||||
settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#2a3a5a', backgroundColor: '#0a1428' },
|
settingsBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||||
settingsTxt: { color: '#4a7abf', fontSize: 11, fontWeight: '600' },
|
settingsTxt: { fontSize: 11, fontWeight: '600' },
|
||||||
|
|
||||||
legendScroll: { paddingHorizontal: 12, paddingVertical: 8 },
|
legendScroll: { paddingHorizontal: 12, paddingVertical: 8 },
|
||||||
legendRow: { flexDirection: 'row', gap: 10 },
|
legendRow: { flexDirection: 'row', gap: 10 },
|
||||||
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||||
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
legendTxt: { color: '#668', fontSize: 10 },
|
legendTxt: { fontSize: 10 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import * as StorageService from '../../src/services/StorageService';
|
|||||||
import { shareFile } from '../../src/utils/export';
|
import { shareFile } from '../../src/utils/export';
|
||||||
import type { ProjectInfo, SessionInfo } from '../../src/services/StorageService';
|
import type { ProjectInfo, SessionInfo } from '../../src/services/StorageService';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import { useTheme, type ThemeColors } from '../../src/design/tokens';
|
||||||
|
|
||||||
// ── Name input modal (reused for create project / rename) ────────────────────
|
// ── Name input modal (reused for create project / rename) ────────────────────
|
||||||
|
|
||||||
@ -28,33 +29,44 @@ function NameModal({
|
|||||||
onConfirm: (name: string) => void;
|
onConfirm: (name: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
const [text, setText] = useState(initial ?? '');
|
const [text, setText] = useState(initial ?? '');
|
||||||
useEffect(() => { if (visible) setText(initial ?? ''); }, [visible, initial]);
|
useEffect(() => { if (visible) setText(initial ?? ''); }, [visible, initial]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
<TouchableOpacity style={NM.backdrop} activeOpacity={1} onPress={onClose} />
|
<TouchableOpacity style={NM.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
<View style={NM.box}>
|
<View style={[NM.box, {
|
||||||
<Text style={NM.title}>{title}</Text>
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
|
<Text style={[NM.title, { color: theme.text.secondary }]}>{title}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={NM.input}
|
style={[NM.input, {
|
||||||
|
backgroundColor: theme.bg.void,
|
||||||
|
color: theme.text.primary,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}
|
||||||
value={text}
|
value={text}
|
||||||
onChangeText={setText}
|
onChangeText={setText}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor="#3a3a5a"
|
placeholderTextColor={theme.text.muted}
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
/>
|
/>
|
||||||
<View style={NM.row}>
|
<View style={NM.row}>
|
||||||
<TouchableOpacity style={NM.cancelBtn} onPress={onClose}>
|
<TouchableOpacity style={[NM.cancelBtn, { borderColor: theme.bg.border }]} onPress={onClose}>
|
||||||
<Text style={NM.cancelTxt}>取消</Text>
|
<Text style={[NM.cancelTxt, { color: theme.text.muted }]}>取消</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[NM.confirmBtn, !text.trim() && NM.btnDis]}
|
style={[NM.confirmBtn, {
|
||||||
|
backgroundColor: theme.blue.border,
|
||||||
|
borderColor: theme.blue.fg,
|
||||||
|
}, !text.trim() && NM.btnDis]}
|
||||||
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
|
onPress={() => { if (text.trim()) { onConfirm(text.trim()); onClose(); } }}
|
||||||
disabled={!text.trim()}
|
disabled={!text.trim()}
|
||||||
>
|
>
|
||||||
<Text style={NM.confirmTxt}>确定</Text>
|
<Text style={[NM.confirmTxt, { color: theme.blue.fg }]}>确定</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -64,14 +76,14 @@ function NameModal({
|
|||||||
|
|
||||||
const NM = StyleSheet.create({
|
const NM = StyleSheet.create({
|
||||||
backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0,0,0,0.6)' },
|
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' },
|
box: { position: 'absolute', left: 24, right: 24, top: '35%', borderRadius: 16, padding: 20, borderWidth: 1 },
|
||||||
title: { color: '#9090b8', fontSize: 14, fontWeight: '700', marginBottom: 14 },
|
title: { fontSize: 14, fontWeight: '700', marginBottom: 14 },
|
||||||
input: { backgroundColor: '#0c0c18', borderRadius: 8, padding: 12, color: '#d0d0e8', fontSize: 14, borderWidth: 1, borderColor: '#22223a', marginBottom: 16 },
|
input: { borderRadius: 8, padding: 12, fontSize: 14, borderWidth: 1, marginBottom: 16 },
|
||||||
row: { flexDirection: 'row', gap: 10 },
|
row: { flexDirection: 'row', gap: 10 },
|
||||||
cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: '#2a2a3a', alignItems: 'center' },
|
cancelBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' },
|
||||||
cancelTxt: { color: '#6a6a8a', fontWeight: '600' },
|
cancelTxt: { fontWeight: '600' },
|
||||||
confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, backgroundColor: '#1a3a6e', borderWidth: 1, borderColor: '#4a9eff', alignItems: 'center' },
|
confirmBtn: { flex: 1, paddingVertical: 10, borderRadius: 8, borderWidth: 1, alignItems: 'center' },
|
||||||
confirmTxt: { color: '#4a9eff', fontWeight: '700' },
|
confirmTxt: { fontWeight: '700' },
|
||||||
btnDis: { opacity: 0.4 },
|
btnDis: { opacity: 0.4 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -84,6 +96,8 @@ function LineList({
|
|||||||
onActivate,
|
onActivate,
|
||||||
onExport,
|
onExport,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onNewLine,
|
||||||
|
onReload,
|
||||||
}: {
|
}: {
|
||||||
project: ProjectInfo;
|
project: ProjectInfo;
|
||||||
currentSessionId: string;
|
currentSessionId: string;
|
||||||
@ -91,7 +105,11 @@ function LineList({
|
|||||||
onActivate: (sessionId: string) => void;
|
onActivate: (sessionId: string) => void;
|
||||||
onExport: (sessionId: string) => void;
|
onExport: (sessionId: string) => void;
|
||||||
onDelete: (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<SessionInfo[]>([]);
|
const [lines, setLines] = useState<SessionInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@ -102,74 +120,101 @@ function LineList({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [project.projectId]);
|
}, [project.projectId]);
|
||||||
|
|
||||||
useEffect(() => { void reload(); }, [reload]);
|
useEffect(() => { void reload(); }, [reload, latestFrameId]);
|
||||||
|
|
||||||
if (loading) return <ActivityIndicator style={{ padding: 16 }} color="#4a9eff" size="small" />;
|
if (loading) return <ActivityIndicator style={{ padding: 16 }} color={theme.blue.fg} size="small" />;
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
return <Text style={LL.empty}>暂无测线</Text>;
|
return <Text style={[LL.empty, { color: theme.text.ghost }]}>暂无测线</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={LL.wrap}>
|
<View style={[LL.wrap, { backgroundColor: theme.bg.void }]}>
|
||||||
{lines.map((line) => {
|
{lines.map((line) => {
|
||||||
const isActive = line.sessionId === currentSessionId;
|
const isActive = line.sessionId === currentSessionId;
|
||||||
const date = new Date(line.createdAt);
|
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')}`;
|
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 (
|
return (
|
||||||
<View key={line.sessionId} style={[LL.row, isActive && LL.rowActive]}>
|
<View key={line.sessionId} style={[LL.row, { borderBottomColor: theme.bg.divider }, isActive && { backgroundColor: theme.blue.bg }]}>
|
||||||
<View style={LL.rowLeft}>
|
<View style={LL.rowLeft}>
|
||||||
{isActive && <View style={LL.activeDot} />}
|
{isActive && <View style={[LL.activeDot, { backgroundColor: theme.blue.fg }]} />}
|
||||||
<View>
|
<View>
|
||||||
<Text style={LL.lineId} numberOfLines={1}>{line.sessionId}</Text>
|
<Text style={[LL.lineId, { color: theme.text.muted }]} numberOfLines={1}>{line.sessionId}</Text>
|
||||||
<Text style={LL.lineSub}>{dateStr} · {line.frameCount} 测点</Text>
|
<Text style={[LL.lineSub, { color: theme.text.ghost }]}>{dateStr} · {line.frameCount} 测点</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={LL.actions}>
|
<View style={LL.actions}>
|
||||||
{!isActive && (
|
{!isActive && (
|
||||||
<TouchableOpacity style={[LL.btn, LL.actBtn]} onPress={() => onActivate(line.sessionId)}>
|
<TouchableOpacity style={[LL.btn, {
|
||||||
<Text style={LL.actTxt}>激活</Text>
|
borderColor: theme.blue.border,
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
}]} onPress={() => onActivate(line.sessionId)}>
|
||||||
|
<Text style={[LL.actTxt, { color: theme.blue.fg }]}>激活</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<TouchableOpacity style={[LL.btn, LL.expBtn]} onPress={() => onExport(line.sessionId)}>
|
<TouchableOpacity style={[LL.btn, {
|
||||||
<Text style={LL.expTxt}>导出</Text>
|
borderColor: theme.green.border,
|
||||||
|
backgroundColor: theme.green.bg,
|
||||||
|
}]} onPress={() => onExport(line.sessionId)}>
|
||||||
|
<Text style={[LL.expTxt, { color: theme.green.fg }]}>导出</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{!isActive && (
|
{!isActive && (
|
||||||
<TouchableOpacity style={[LL.btn, LL.delBtn]} onPress={() => onDelete(line.sessionId)}>
|
<TouchableOpacity style={[LL.btn, {
|
||||||
<Text style={LL.delTxt}>删除</Text>
|
borderColor: theme.red.border,
|
||||||
|
backgroundColor: theme.red.bg,
|
||||||
|
}]} onPress={() => {
|
||||||
|
Alert.alert('删除测线', '确定删除该测线的所有数据?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除', style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await StorageService.deleteSession(line.sessionId);
|
||||||
|
await reload();
|
||||||
|
onReload();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}>
|
||||||
|
<Text style={[LL.delTxt, { color: theme.red.fg }]}>删除</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[LL.addBtn, { borderColor: theme.blue.border }]}
|
||||||
|
onPress={async () => { onNewLine(); await reload(); }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[LL.addTxt, { color: theme.blue.fg }]}>+ 新建测线</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LL = StyleSheet.create({
|
const LL = StyleSheet.create({
|
||||||
wrap: { backgroundColor: '#08080f', marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' },
|
wrap: { marginHorizontal: 12, marginBottom: 8, borderRadius: 10, overflow: 'hidden' },
|
||||||
empty: { color: '#2a2a4a', fontSize: 12, textAlign: 'center', paddingVertical: 14 },
|
empty: { fontSize: 12, textAlign: 'center', paddingVertical: 14 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#141422', gap: 8 },
|
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, gap: 4 },
|
||||||
rowActive: { backgroundColor: '#0d1a2e' },
|
rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
rowLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
|
activeDot: { width: 6, height: 6, borderRadius: 3 },
|
||||||
activeDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: '#4a9eff' },
|
lineId: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
lineId: { color: '#6a6a9a', fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
lineSub: { fontSize: 10, marginTop: 1 },
|
||||||
lineSub: { color: '#2a2a4a', fontSize: 10, marginTop: 1 },
|
|
||||||
actions: { flexDirection: 'row', gap: 6 },
|
actions: { flexDirection: 'row', gap: 6 },
|
||||||
btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
btn: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||||
actBtn: { borderColor: '#1a3a60', backgroundColor: '#0a1830' },
|
actTxt: { fontSize: 10, fontWeight: '700' },
|
||||||
actTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700' },
|
expTxt: { fontSize: 10, fontWeight: '700' },
|
||||||
expBtn: { borderColor: '#1a4a28', backgroundColor: '#0a1810' },
|
delTxt: { fontSize: 10, fontWeight: '600' },
|
||||||
expTxt: { color: '#3ddc84', fontSize: 10, fontWeight: '700' },
|
addBtn: { alignItems: 'center', paddingVertical: 10, borderTopWidth: StyleSheet.hairlineWidth, borderStyle: 'dashed' },
|
||||||
delBtn: { borderColor: '#3a1520', backgroundColor: '#140a0c' },
|
addTxt: { fontSize: 11, fontWeight: '700' },
|
||||||
delTxt: { color: '#ff5c6e', fontSize: 10, fontWeight: '600' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Main screen ──────────────────────────────────────────────────────────────
|
// ── Main screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ProjectsScreen() {
|
export default function ProjectsScreen() {
|
||||||
|
const theme = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
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 { config: deviceConfig, gateConfig } = useDeviceStore();
|
||||||
|
|
||||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||||
@ -204,6 +249,10 @@ export default function ProjectsScreen() {
|
|||||||
placeholder: '输入工程名称',
|
placeholder: '输入工程名称',
|
||||||
onConfirm: async (name) => {
|
onConfirm: async (name) => {
|
||||||
const id = await StorageService.createProject(name);
|
const id = await StorageService.createProject(name);
|
||||||
|
// First project — auto-create initial session
|
||||||
|
if (!useDataStore.getState().hasProject) {
|
||||||
|
await newSession(id);
|
||||||
|
}
|
||||||
await reload();
|
await reload();
|
||||||
setExpanded(id);
|
setExpanded(id);
|
||||||
},
|
},
|
||||||
@ -233,7 +282,19 @@ export default function ProjectsScreen() {
|
|||||||
text: '删除', style: 'destructive',
|
text: '删除', style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await StorageService.deleteProject(project.projectId);
|
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();
|
await reload();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -365,46 +426,64 @@ export default function ProjectsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[S.root, { paddingTop: insets.top }]}>
|
<View style={[S.root, { paddingTop: insets.top, backgroundColor: theme.bg.base }]}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={S.header}>
|
<View style={[S.header, { borderBottomColor: theme.bg.border }]}>
|
||||||
<Text style={S.headerTitle}>工程管理</Text>
|
<Text style={[S.headerTitle, { color: theme.text.muted }]}>工程管理</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.importBtn, importing && S.btnDis]}
|
style={[S.importBtn, {
|
||||||
|
backgroundColor: theme.amber.bg,
|
||||||
|
borderColor: theme.amber.fg + '66',
|
||||||
|
}, importing && S.btnDis]}
|
||||||
onPress={handleImportProject}
|
onPress={handleImportProject}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{importing
|
{importing
|
||||||
? <ActivityIndicator color="#ffd93d" size="small" />
|
? <ActivityIndicator color={theme.amber.fg} size="small" />
|
||||||
: <Text style={S.importBtnTxt}>导入 .tem</Text>}
|
: <Text style={[S.importBtnTxt, { color: theme.amber.fg }]}>导入 .tem</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={S.addBtn} onPress={handleCreateProject} activeOpacity={0.8}>
|
<TouchableOpacity style={[S.addBtn, {
|
||||||
<Text style={S.addBtnTxt}>+ 新建</Text>
|
backgroundColor: theme.blue.border,
|
||||||
|
borderColor: theme.blue.fg,
|
||||||
|
}]} onPress={handleCreateProject} activeOpacity={0.8}>
|
||||||
|
<Text style={[S.addBtnTxt, { color: theme.blue.fg }]}>+ 新建</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Progress bar (export / import) */}
|
{/* Progress bar (export / import) */}
|
||||||
{(importing || exporting !== null) && progress.total > 0 && (
|
{(importing || exporting !== null) && progress.total > 0 && (
|
||||||
<View style={S.progressBar}>
|
<View style={[S.progressBar, {
|
||||||
<View style={[S.progressFill, { width: `${Math.round(progress.current / progress.total * 100)}%` as any }]} />
|
backgroundColor: theme.bg.void,
|
||||||
<Text style={S.progressTxt}>{progress.current}/{progress.total} 帧</Text>
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
|
<View style={[S.progressFill, { backgroundColor: theme.blue.border }]} />
|
||||||
|
<Text style={[S.progressTxt, { color: theme.blue.fg }]}>{progress.current}/{progress.total} 帧</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current context pill */}
|
{/* Current context pill */}
|
||||||
<View style={S.contextBar}>
|
<View style={[S.contextBar, {
|
||||||
<Text style={S.contextLabel}>当前测线</Text>
|
backgroundColor: theme.bg.void,
|
||||||
<Text style={S.contextSession} numberOfLines={1}>{currentSessionId}</Text>
|
borderBottomColor: theme.bg.divider,
|
||||||
|
}]}>
|
||||||
|
<Text style={[S.contextLabel, { color: theme.text.ghost }]}>当前测线</Text>
|
||||||
|
<Text style={[S.contextSession, { color: theme.text.muted }]} numberOfLines={1}>{currentSessionId}</Text>
|
||||||
{currentProjectId ? (
|
{currentProjectId ? (
|
||||||
<View style={S.projectPill}>
|
<View style={[S.projectPill, {
|
||||||
<Text style={S.projectPillTxt}>
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.border,
|
||||||
|
}]}>
|
||||||
|
<Text style={[S.projectPillTxt, { color: theme.blue.fg }]}>
|
||||||
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
|
{projects.find(p => p.projectId === currentProjectId)?.name ?? currentProjectId}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[S.projectPill, S.projectPillNone]}>
|
<View style={[S.projectPill, {
|
||||||
<Text style={[S.projectPillTxt, S.projectPillTxtNone]}>未分配工程</Text>
|
backgroundColor: theme.bg.base,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
|
<Text style={[S.projectPillTxt, { color: theme.text.muted }]}>未分配工程</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -416,16 +495,19 @@ export default function ProjectsScreen() {
|
|||||||
contentContainerStyle={{ paddingBottom: 32 }}
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={S.empty}>
|
<View style={S.empty}>
|
||||||
<Text style={S.emptyIcon}>◫</Text>
|
<Text style={[S.emptyIcon, { color: theme.bg.border }]}>◫</Text>
|
||||||
<Text style={S.emptyTxt}>暂无工程</Text>
|
<Text style={[S.emptyTxt, { color: theme.text.ghost }]}>暂无工程</Text>
|
||||||
<Text style={S.emptyHint}>点击右上角「新建工程」开始</Text>
|
<Text style={[S.emptyHint, { color: theme.bg.border }]}>点击右上角「新建工程」开始</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
renderItem={({ item: project }) => {
|
renderItem={({ item: project }) => {
|
||||||
const isExpanded = expanded === project.projectId;
|
const isExpanded = expanded === project.projectId;
|
||||||
const isCurrentProject = project.projectId === currentProjectId;
|
const isCurrentProject = project.projectId === currentProjectId;
|
||||||
return (
|
return (
|
||||||
<View style={S.projectCard}>
|
<View style={[S.projectCard, {
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.surface,
|
||||||
|
}]}>
|
||||||
{/* Project row */}
|
{/* Project row */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={S.projectRow}
|
style={S.projectRow}
|
||||||
@ -433,39 +515,54 @@ export default function ProjectsScreen() {
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<View style={S.projectRowLeft}>
|
<View style={S.projectRowLeft}>
|
||||||
<Text style={S.chevron}>{isExpanded ? '▾' : '▸'}</Text>
|
<Text style={[S.chevron, { color: theme.text.muted }]}>{isExpanded ? '▾' : '▸'}</Text>
|
||||||
<View>
|
<View>
|
||||||
<View style={S.projectNameRow}>
|
<View style={S.projectNameRow}>
|
||||||
<Text style={S.projectName}>{project.name}</Text>
|
<Text style={[S.projectName, { color: theme.text.primary }]}>{project.name}</Text>
|
||||||
{isCurrentProject && (
|
{isCurrentProject && (
|
||||||
<View style={S.activePill}>
|
<View style={[S.activePill, {
|
||||||
<Text style={S.activePillTxt}>当前</Text>
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.fg + '55',
|
||||||
|
}]}>
|
||||||
|
<Text style={[S.activePillTxt, { color: theme.blue.fg }]}>当前</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text style={S.projectMeta}>
|
<Text style={[S.projectMeta, { color: theme.text.muted }]}>
|
||||||
{project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
|
{project.lineCount} 条测线 · {new Date(project.createdAt).toLocaleDateString('zh-CN')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.projectActions}>
|
<View style={S.projectActions}>
|
||||||
<TouchableOpacity style={[S.pBtn, S.pBtnNew]} onPress={() => handleOpenProject(project)}>
|
<TouchableOpacity style={[S.pBtn, {
|
||||||
<Text style={S.pBtnNewTxt}>打开</Text>
|
borderColor: theme.green.border,
|
||||||
|
backgroundColor: theme.green.bg,
|
||||||
|
}]} onPress={() => handleOpenProject(project)}>
|
||||||
|
<Text style={[S.pBtnTxt, { color: theme.green.fg }]}>打开</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.pBtn, S.pBtnExp, exporting === project.projectId && S.btnDis]}
|
style={[S.pBtn, {
|
||||||
|
borderColor: theme.amber.border,
|
||||||
|
backgroundColor: theme.amber.bg,
|
||||||
|
}, exporting === project.projectId && S.btnDis]}
|
||||||
onPress={() => handleExportProject(project)}
|
onPress={() => handleExportProject(project)}
|
||||||
disabled={exporting === project.projectId}
|
disabled={exporting === project.projectId}
|
||||||
>
|
>
|
||||||
{exporting === project.projectId
|
{exporting === project.projectId
|
||||||
? <ActivityIndicator color="#ffd93d" size="small" style={{ width: 28 }} />
|
? <ActivityIndicator color={theme.amber.fg} size="small" style={{ width: 28 }} />
|
||||||
: <Text style={S.pBtnExpTxt}>.tem</Text>}
|
: <Text style={[S.pBtnTxt, { color: theme.amber.fg }]}>.tem</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={S.pBtn} onPress={() => handleRenameProject(project)}>
|
<TouchableOpacity style={[S.pBtn, {
|
||||||
<Text style={S.pBtnTxt}>改名</Text>
|
borderColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
}]} onPress={() => handleRenameProject(project)}>
|
||||||
|
<Text style={[S.pBtnTxt, { color: theme.text.muted }]}>改名</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[S.pBtn, S.pBtnDel]} onPress={() => handleDeleteProject(project)}>
|
<TouchableOpacity style={[S.pBtn, {
|
||||||
<Text style={S.pBtnDelTxt}>删除</Text>
|
borderColor: theme.red.border,
|
||||||
|
backgroundColor: theme.red.bg,
|
||||||
|
}]} onPress={() => handleDeleteProject(project)}>
|
||||||
|
<Text style={[S.pBtnTxt, { color: theme.red.fg }]}>删除</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -479,6 +576,8 @@ export default function ProjectsScreen() {
|
|||||||
onActivate={(sid) => handleActivateLine(sid, project.projectId)}
|
onActivate={(sid) => handleActivateLine(sid, project.projectId)}
|
||||||
onExport={handleExportLine}
|
onExport={handleExportLine}
|
||||||
onDelete={handleDeleteLine}
|
onDelete={handleDeleteLine}
|
||||||
|
onNewLine={() => handleNewLine(project)}
|
||||||
|
onReload={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -495,51 +594,43 @@ export default function ProjectsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#090912' },
|
root: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1a1a2a' },
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1 },
|
||||||
headerTitle:{ color: '#6a6a9a', fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 },
|
headerTitle:{ fontSize: 14, fontWeight: '700', flex: 1, letterSpacing: 0.5 },
|
||||||
addBtn: { backgroundColor: '#1a3a6e', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1, borderColor: '#4a9eff' },
|
addBtn: { borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, borderWidth: 1 },
|
||||||
addBtnTxt: { color: '#4a9eff', fontSize: 12, fontWeight: '700' },
|
addBtnTxt: { fontSize: 12, fontWeight: '700' },
|
||||||
|
|
||||||
contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#0c0c18', borderBottomWidth: 1, borderBottomColor: '#141422', gap: 8 },
|
contextBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, gap: 8 },
|
||||||
contextLabel: { color: '#2a2a4a', fontSize: 10, fontWeight: '600', letterSpacing: 1 },
|
contextLabel: { fontSize: 10, fontWeight: '600', letterSpacing: 1 },
|
||||||
contextSession:{ color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
|
contextSession:{ fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', flex: 1 },
|
||||||
projectPill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: '#1a3a6e' },
|
projectPill: { borderRadius: 4, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 },
|
||||||
projectPillTxt:{ color: '#4a9eff', fontSize: 9, fontWeight: '700' },
|
projectPillTxt:{ fontSize: 9, fontWeight: '700' },
|
||||||
projectPillNone: { backgroundColor: '#1a1a1a', borderColor: '#2a2a2a' },
|
|
||||||
projectPillTxtNone:{ color: '#3a3a5a' },
|
|
||||||
|
|
||||||
empty: { paddingTop: 80, alignItems: 'center', gap: 10 },
|
empty: { paddingTop: 80, alignItems: 'center', gap: 10 },
|
||||||
emptyIcon: { fontSize: 40, color: '#1a1a2a' },
|
emptyIcon: { fontSize: 40 },
|
||||||
emptyTxt: { color: '#2a2a4a', fontSize: 15 },
|
emptyTxt: { fontSize: 15 },
|
||||||
emptyHint: { color: '#1a1a2a', fontSize: 12 },
|
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 },
|
projectRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, gap: 10 },
|
||||||
projectRowLeft:{ flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8 },
|
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 },
|
projectNameRow:{ flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
projectName: { color: '#c0c0d8', fontSize: 14, fontWeight: '700' },
|
projectName: { fontSize: 14, fontWeight: '700' },
|
||||||
projectMeta: { color: '#3a3a5a', fontSize: 10, marginTop: 2 },
|
projectMeta: { fontSize: 10, marginTop: 2 },
|
||||||
activePill: { backgroundColor: '#0d2040', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1, borderColor: '#4a9eff55' },
|
activePill: { borderRadius: 4, paddingHorizontal: 6, paddingVertical: 1, borderWidth: 1 },
|
||||||
activePillTxt: { color: '#4a9eff', fontSize: 8, fontWeight: '700' },
|
activePillTxt: { fontSize: 8, fontWeight: '700' },
|
||||||
|
|
||||||
importBtn: { backgroundColor: '#1e1a08', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, borderColor: '#ffd93d66', marginRight: 8, minWidth: 70, alignItems: 'center' },
|
importBtn: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, marginRight: 8, minWidth: 70, alignItems: 'center' },
|
||||||
importBtnTxt: { color: '#ffd93d', fontSize: 12, fontWeight: '700' },
|
importBtnTxt: { fontSize: 12, fontWeight: '700' },
|
||||||
btnDis: { opacity: 0.4 },
|
btnDis: { opacity: 0.4 },
|
||||||
|
|
||||||
progressBar: { marginHorizontal: 16, marginVertical: 6, height: 20, backgroundColor: '#0c0c18', borderRadius: 10, overflow: 'hidden', borderWidth: 1, borderColor: '#1a1a2a', justifyContent: '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, backgroundColor: '#1a3a6e', borderRadius: 10 },
|
progressFill: { position: 'absolute', left: 0, top: 0, bottom: 0, borderRadius: 10 },
|
||||||
progressTxt: { color: '#4a9eff', fontSize: 10, fontWeight: '700', textAlign: 'center' },
|
progressTxt: { fontSize: 10, fontWeight: '700', textAlign: 'center' },
|
||||||
|
|
||||||
projectActions:{ flexDirection: 'row', gap: 5 },
|
projectActions:{ flexDirection: 'row', gap: 5 },
|
||||||
pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, borderColor: '#2a2a3a', backgroundColor: '#111120', alignItems: 'center', justifyContent: 'center' },
|
pBtn: { paddingHorizontal: 8, paddingVertical: 5, borderRadius: 6, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
pBtnTxt: { color: '#5a5a7a', fontSize: 10, fontWeight: '600' },
|
pBtnTxt: { fontSize: 10, fontWeight: '700' },
|
||||||
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' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, TouchableOpacity, Modal,
|
View, Text, FlatList, TouchableOpacity, Modal,
|
||||||
StyleSheet, Alert, ActivityIndicator, Platform,
|
StyleSheet, Alert, ActivityIndicator, Platform,
|
||||||
@ -7,90 +7,52 @@ import {
|
|||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||||
import { exportCsv, shareFile } from '../../src/utils/export';
|
import { exportCsv, shareFile } from '../../src/utils/export';
|
||||||
import { formatUtc, formatCoord, formatUV } from '../../src/utils/format';
|
|
||||||
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
||||||
import { WaveformChart } from '../../src/components/WaveformChart';
|
import { WaveformChart } from '../../src/components/WaveformChart';
|
||||||
import { computeWaveformData } from '../../src/hooks/useWaveform';
|
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';
|
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||||
|
|
||||||
|
const MONO = { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' } as const;
|
||||||
|
|
||||||
// ── Waveform modal ──────────────────────────────────────────────────────────
|
// ── Waveform modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FrameWaveformModal({
|
function FrameWaveformModal({ frame, onClose }: { frame: MeasurementFrame; onClose: () => void }) {
|
||||||
frame,
|
const theme = useTheme();
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
frame: MeasurementFrame;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
|
const sampleFreqCode = useDeviceStore((s) => s.config.sampleFreq);
|
||||||
const [logScale, setLogScale] = useState(true);
|
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 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));
|
const CHART_H = Math.min(260, Math.round(width * 0.6));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={M.backdrop}>
|
<View style={M.backdrop}>
|
||||||
<View style={M.sheet}>
|
<View style={[M.sheet, { backgroundColor: theme.bg.surface }]}>
|
||||||
<View style={M.header}>
|
<View style={M.header}>
|
||||||
<Text style={M.title}>帧 #{frame.frameId}</Text>
|
<Text style={[M.title, { color: theme.text.secondary }]}>帧 #{frame.frameId}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[M.scaleBtn, !logScale && M.scaleBtnOn]}
|
style={[M.scaleBtn, { borderColor: theme.bg.border, backgroundColor: theme.bg.raised }, !logScale && { borderColor: theme.blue.fg + '55', backgroundColor: theme.blue.bg }]}
|
||||||
onPress={() => setLogScale((v) => !v)}
|
onPress={() => setLogScale((v) => !v)}
|
||||||
>
|
>
|
||||||
<Text style={[M.scaleTxt, !logScale && M.scaleTxtOn]}>
|
<Text style={[M.scaleTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
||||||
{logScale ? 'LOG' : 'LIN'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={M.closeBtn} onPress={onClose}>
|
<TouchableOpacity style={[M.closeBtn, { backgroundColor: theme.bg.border }]} onPress={onClose}>
|
||||||
<Text style={M.closeTxt}>✕</Text>
|
<Text style={[M.closeTxt, { color: theme.text.secondary }]}>✕</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<WaveformChart
|
<WaveformChart data={data} visibleChannels={visible} width={width - 32} height={CHART_H} logScale={logScale} />
|
||||||
data={data}
|
|
||||||
visibleChannels={visible}
|
|
||||||
width={width - 32}
|
|
||||||
height={CHART_H}
|
|
||||||
logScale={logScale}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={[{ height: CHART_H }, M.noData]}>
|
<View style={[{ height: CHART_H }, M.noData, { backgroundColor: theme.bg.raised }]}>
|
||||||
<Text style={M.noDataTxt}>无波形数据</Text>
|
<Text style={{ color: theme.text.muted }}>无波形数据</Text>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollView horizontal style={M.peakRow} showsHorizontalScrollIndicator={false}>
|
|
||||||
{frame.adcUV.map((ch, i) => {
|
|
||||||
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
|
||||||
return (
|
|
||||||
<View key={i} style={M.peakCell}>
|
|
||||||
<View style={[M.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
|
||||||
<Text style={M.peakCh}>CH{i + 1}</Text>
|
|
||||||
<Text style={M.peakVal}>{formatUV(pk)}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{frame.meta && (
|
|
||||||
<View style={M.meta}>
|
|
||||||
<Text style={M.metaTxt}>
|
|
||||||
UTC {formatUtc(frame.meta.utc)} ×{frame.accNum}叠 增益×{frame.gain}
|
|
||||||
</Text>
|
|
||||||
<Text style={M.metaTxt}>
|
|
||||||
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -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 (
|
||||||
|
<View style={[T.row, T.headerRow, { backgroundColor: theme.bg.raised, borderBottomColor: theme.bg.border }]}>
|
||||||
|
<Text style={[hdr, T.colId]}>#</Text>
|
||||||
|
<Text style={[hdr, T.colTime]}>时间</Text>
|
||||||
|
<Text style={[hdr, T.colGps]}>GPS</Text>
|
||||||
|
<Text style={[hdr, T.colCoord]}>经度</Text>
|
||||||
|
<Text style={[hdr, T.colCoord]}>纬度</Text>
|
||||||
|
<Text style={[hdr, T.colAcc]}>叠加</Text>
|
||||||
|
<Text style={[hdr, T.colDel]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TableRow({
|
||||||
|
item,
|
||||||
|
onPress,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
item: PersistedFrame;
|
||||||
|
onPress: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const m = item.meta;
|
||||||
|
|
||||||
|
const timeStr = m
|
||||||
|
? `${String(Math.floor(m.utc / 3600) % 24).padStart(2, '0')}:${String(Math.floor(m.utc / 60) % 60).padStart(2, '0')}:${String(m.utc % 60).padStart(2, '0')}`
|
||||||
|
: '--';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={[T.row, { borderBottomColor: theme.bg.divider }]} onPress={onPress} activeOpacity={0.6}>
|
||||||
|
<Text style={[T.cell, T.colId, MONO, { color: theme.blue.fg }]}>{item.frameId}</Text>
|
||||||
|
<Text style={[T.cell, T.colTime, MONO, { color: theme.text.secondary }]}>{timeStr}</Text>
|
||||||
|
<Text style={[T.cell, T.colGps, { color: m && m.gpsStatus > 0 ? theme.green.fg : theme.red.fg, fontWeight: '700' }]}>
|
||||||
|
{m && m.gpsStatus > 0 ? '✓' : '✗'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[T.cell, T.colCoord, MONO, { color: theme.text.secondary }]} numberOfLines={1}>
|
||||||
|
{m ? m.longitude.toFixed(5) : '--'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[T.cell, T.colCoord, MONO, { color: theme.text.secondary }]} numberOfLines={1}>
|
||||||
|
{m ? m.latitude.toFixed(5) : '--'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[T.cell, T.colAcc, MONO, { color: theme.text.muted }]}>×{item.accNum}</Text>
|
||||||
|
<TouchableOpacity style={T.colDel} onPress={onDelete} hitSlop={8}>
|
||||||
|
<Text style={{ color: theme.red.fg, fontSize: 14, fontWeight: '700' }}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main screen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RecordsScreen() {
|
export default function RecordsScreen() {
|
||||||
const { history, sessionId, projectId, clearHistory, newSession, deleteFrame } = useDataStore();
|
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 [exporting, setExporting] = useState(false);
|
||||||
|
const [dbFrames, setDbFrames] = useState<PersistedFrame[]>([]);
|
||||||
|
const [loadingDb, setLoadingDb] = useState(false);
|
||||||
const [selectedFrame, setSelectedFrame] = useState<MeasurementFrame | null>(null);
|
const [selectedFrame, setSelectedFrame] = useState<MeasurementFrame | null>(null);
|
||||||
|
|
||||||
|
// ── Load frames from DB when sessionId changes or new frame arrives ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLoadingDb(true);
|
||||||
|
StorageService.loadSessionFrames(sessionId)
|
||||||
|
.then(setDbFrames)
|
||||||
|
.finally(() => setLoadingDb(false));
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [sessionId, frameId]);
|
||||||
|
|
||||||
|
const reloadDbFrames = useCallback(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
setLoadingDb(true);
|
||||||
|
StorageService.loadSessionFrames(sessionId)
|
||||||
|
.then(setDbFrames)
|
||||||
|
.finally(() => setLoadingDb(false));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// ── NoProjectGate ──
|
||||||
|
if (!hasProject) {
|
||||||
|
return <NoProjectGate />;
|
||||||
|
}
|
||||||
|
|
||||||
const handleExportAll = async () => {
|
const handleExportAll = async () => {
|
||||||
if (history.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
|
if (dbFrames.length === 0) { Alert.alert('无数据', '当前会话没有采集记录'); return; }
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
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);
|
await shareFile(path);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('导出失败', e.message);
|
Alert.alert('导出失败', e.message);
|
||||||
@ -119,135 +170,107 @@ export default function RecordsScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () =>
|
||||||
Alert.alert('清空记录', '确定清空当前会话的所有记录?', [
|
Alert.alert('清空显示', '清空屏幕上的记录列表?(已保存的数据不受影响)', [
|
||||||
{ text: '取消', style: 'cancel' },
|
{ text: '取消', style: 'cancel' },
|
||||||
{ text: '清空', style: 'destructive', onPress: clearHistory },
|
{ text: '清空', style: 'destructive', onPress: clearHistory },
|
||||||
]);
|
]);
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewSession = () => {
|
const handleDeleteFrame = (item: PersistedFrame) => {
|
||||||
Alert.alert('新建测线', '结束当前测线,开始新测线?', [
|
Alert.alert(`删除 #${item.frameId}`, '确定删除?', [
|
||||||
{ text: '取消', style: 'cancel' },
|
{ text: '取消', style: 'cancel' },
|
||||||
{ text: '新建', onPress: () => newSession(projectId) },
|
{
|
||||||
|
text: '删除', style: 'destructive', onPress: () => {
|
||||||
|
deleteFrame(item.frameId);
|
||||||
|
reloadDbFrames();
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFrame = (item: MeasurementFrame) => {
|
const handleSessionChange = async (newSessionId: string) => {
|
||||||
Alert.alert(
|
await resumeSession(newSessionId, projectId);
|
||||||
`删除测点 #${item.frameId}`,
|
|
||||||
'确定删除该测点?此操作不可撤销。',
|
|
||||||
[
|
|
||||||
{ text: '取消', style: 'cancel' },
|
|
||||||
{ text: '删除', style: 'destructive', onPress: () => deleteFrame(item.frameId) },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem = ({ item, index }: { item: MeasurementFrame; index: number }) => {
|
const handleFramePress = async (pf: PersistedFrame) => {
|
||||||
const m = item.meta;
|
if (!pf.binPath) return;
|
||||||
return (
|
const loaded = await BinLoader.loadBinFile(pf.binPath);
|
||||||
<TouchableOpacity
|
if (!loaded) { Alert.alert('加载失败', '无法读取波形文件'); return; }
|
||||||
style={S.item}
|
const frame: MeasurementFrame = {
|
||||||
onPress={() => setSelectedFrame(item)}
|
meta: pf.meta as any,
|
||||||
activeOpacity={0.75}
|
adcRaw: [],
|
||||||
>
|
adcUV: loaded.adcUV,
|
||||||
<View style={S.itemLeft}>
|
accNum: pf.accNum,
|
||||||
<Text style={S.itemIndex}>{String(history.length - index).padStart(3, '0')}</Text>
|
gain: pf.gain,
|
||||||
</View>
|
sampleFreqCode: loaded.sampleFreqCode,
|
||||||
<View style={S.itemBody}>
|
timestamp: pf.timestamp,
|
||||||
<View style={S.itemHeader}>
|
frameId: pf.frameId,
|
||||||
<Text style={S.frameId}>#{item.frameId}</Text>
|
};
|
||||||
<Text style={S.time}>{m ? formatUtc(m.utc) : '--'}</Text>
|
setSelectedFrame(frame);
|
||||||
{m && (
|
|
||||||
<View style={[S.gpsBadge, { backgroundColor: m.gpsStatus > 0 ? '#0d2018' : '#1a0d10' }]}>
|
|
||||||
<Text style={{ color: m.gpsStatus > 0 ? '#3ddc84' : '#ff5c6e', fontSize: 8, fontWeight: '700' }}>
|
|
||||||
GPS {m.gpsStatus > 0 ? '✓' : '✗'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={S.waveHint}>
|
|
||||||
<Text style={S.waveHintText}>波形 ›</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity style={S.itemDelBtn} onPress={() => handleDeleteFrame(item)} hitSlop={8}>
|
|
||||||
<Text style={S.itemDelTxt}>×</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{m && (
|
|
||||||
<Text style={S.coord}>
|
|
||||||
{formatCoord(m.latitude, false)} {formatCoord(m.longitude, true)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={S.itemFooter}>
|
|
||||||
<View style={S.peakRow}>
|
|
||||||
{item.adcUV.slice(0, item.meta?.channelNum ?? 0).map((ch, i) => {
|
|
||||||
const pk = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
|
||||||
return (
|
|
||||||
<View key={i} style={S.peakChip}>
|
|
||||||
<View style={[S.peakDot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
|
||||||
<Text style={S.peakVal}>{formatUV(pk)}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
<View style={S.metaRow}>
|
|
||||||
<Text style={S.metaTag}>×{item.accNum}叠</Text>
|
|
||||||
<Text style={S.metaTag}>增益×{item.gain}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={S.container}>
|
<View style={[S.container, { backgroundColor: theme.bg.base }]}>
|
||||||
|
{/* Session Selector */}
|
||||||
|
<View style={S.selectorRow}>
|
||||||
|
<SessionSelector
|
||||||
|
selectedSessionId={sessionId}
|
||||||
|
projectId={projectId!}
|
||||||
|
onSelect={handleSessionChange}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<View style={S.toolbar}>
|
<View style={[S.toolbar, { backgroundColor: theme.bg.surface, borderBottomColor: theme.bg.border }]}>
|
||||||
<View style={S.toolbarLeft}>
|
<View style={S.toolbarLeft}>
|
||||||
<Text style={S.sessionId} numberOfLines={1}>{sessionId}</Text>
|
<Text style={[S.count, { color: theme.text.secondary }]}>{dbFrames.length} 测点</Text>
|
||||||
<Text style={S.count}>{history.length} 测点</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.toolBtn, S.exportBtn, exporting && S.btnDisabled]}
|
style={[S.toolBtn, { borderColor: theme.green.border, backgroundColor: theme.green.bg }, exporting && S.btnDisabled]}
|
||||||
onPress={handleExportAll}
|
onPress={handleExportAll} disabled={exporting} activeOpacity={0.8}
|
||||||
disabled={exporting}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
>
|
||||||
{exporting
|
{exporting
|
||||||
? <ActivityIndicator color="#3ddc84" size="small" />
|
? <ActivityIndicator color={theme.green.fg} size="small" />
|
||||||
: <Text style={S.exportText}>导出 CSV</Text>}
|
: <Text style={[S.btnTxt, { color: theme.green.fg }]}>导出 CSV</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[S.toolBtn, S.newBtn]} onPress={handleNewSession} activeOpacity={0.8}>
|
<TouchableOpacity style={[S.toolBtn, { borderColor: theme.red.border, backgroundColor: theme.red.bg }]} onPress={handleClear} activeOpacity={0.8}>
|
||||||
<Text style={S.newText}>新建</Text>
|
<Text style={[S.btnTxt, { color: theme.red.fg }]}>清空</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={[S.toolBtn, S.clearBtn]} onPress={handleClear} activeOpacity={0.8}>
|
|
||||||
<Text style={S.clearText}>清空</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{history.length === 0 ? (
|
{loadingDb ? (
|
||||||
<View style={S.empty}>
|
<View style={S.empty}>
|
||||||
<Text style={S.emptyIcon}>◌</Text>
|
<ActivityIndicator size="large" color={theme.blue.fg} />
|
||||||
<Text style={S.emptyText}>暂无采集记录</Text>
|
<Text style={{ color: theme.text.ghost, fontSize: 13, marginTop: 12 }}>加载中...</Text>
|
||||||
<Text style={S.emptyHint}>在波形页开始采集后,数据将显示在这里</Text>
|
</View>
|
||||||
|
) : dbFrames.length === 0 ? (
|
||||||
|
<View style={S.empty}>
|
||||||
|
<Text style={{ color: theme.bg.border, fontSize: 40 }}>◌</Text>
|
||||||
|
<Text style={{ color: theme.text.ghost, fontSize: 15 }}>暂无采集记录</Text>
|
||||||
|
<Text style={{ color: theme.bg.border, fontSize: 12 }}>在波形页开始采集后,数据将显示在这里</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||||
|
<View style={{ minWidth: '100%' }}>
|
||||||
|
<TableHeader />
|
||||||
<FlatList
|
<FlatList
|
||||||
data={history}
|
data={dbFrames}
|
||||||
keyExtractor={(item) => String(item.frameId)}
|
keyExtractor={(item) => String(item.frameId)}
|
||||||
renderItem={renderItem}
|
renderItem={({ item }) => (
|
||||||
contentContainerStyle={S.list}
|
<TableRow
|
||||||
|
item={item}
|
||||||
|
onPress={() => handleFramePress(item)}
|
||||||
|
onDelete={() => handleDeleteFrame(item)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
getItemLayout={(_, index) => ({ length: 40, offset: 40 * index, index })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedFrame && (
|
{selectedFrame && (
|
||||||
<FrameWaveformModal
|
<FrameWaveformModal frame={selectedFrame} onClose={() => setSelectedFrame(null)} />
|
||||||
frame={selectedFrame}
|
|
||||||
onClose={() => setSelectedFrame(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -256,86 +279,49 @@ export default function RecordsScreen() {
|
|||||||
// ── Styles ──────────────────────────────────────────────────────────────────
|
// ── Styles ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#090912' },
|
container: { flex: 1 },
|
||||||
|
selectorRow: {
|
||||||
|
paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4,
|
||||||
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
flexDirection: 'row', alignItems: 'center',
|
flexDirection: 'row', alignItems: 'center',
|
||||||
paddingHorizontal: 14, paddingVertical: 10,
|
paddingHorizontal: 14, paddingVertical: 10,
|
||||||
backgroundColor: '#0c0c18',
|
borderBottomWidth: 1, gap: 8,
|
||||||
borderBottomWidth: 1, borderBottomColor: '#1a1a2a',
|
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
toolbarLeft: { flex: 1, gap: 2 },
|
toolbarLeft: { flex: 1, gap: 2 },
|
||||||
sessionId: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
count: { fontSize: 13, fontWeight: '700' },
|
||||||
count: { color: '#6a6a8a', fontSize: 13, fontWeight: '700' },
|
|
||||||
toolBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 8, borderWidth: 1 },
|
toolBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 8, borderWidth: 1 },
|
||||||
exportBtn: { borderColor: '#1a4a28', backgroundColor: '#0d2018' },
|
btnTxt: { fontSize: 12, fontWeight: '700' },
|
||||||
exportText: { color: '#3ddc84', fontSize: 12, fontWeight: '700' },
|
|
||||||
newBtn: { borderColor: '#2a3a1a', backgroundColor: '#141e0d' },
|
|
||||||
newText: { color: '#aad464', fontSize: 12, fontWeight: '600' },
|
|
||||||
clearBtn: { borderColor: '#3a1520', backgroundColor: '#1e0d12' },
|
|
||||||
clearText: { color: '#ff5c6e', fontSize: 12, fontWeight: '600' },
|
|
||||||
btnDisabled: { opacity: 0.4 },
|
btnDisabled: { opacity: 0.4 },
|
||||||
|
|
||||||
list: { padding: 12, gap: 6 },
|
|
||||||
|
|
||||||
item: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
backgroundColor: '#0e0e1c',
|
|
||||||
borderRadius: 12, borderWidth: 1, borderColor: '#1a1a2a',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
itemLeft: {
|
|
||||||
width: 36, backgroundColor: '#0a0a14',
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
borderRightWidth: 1, borderRightColor: '#141422',
|
|
||||||
},
|
|
||||||
itemIndex: { color: '#2a2a4a', fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
||||||
itemBody: { flex: 1, padding: 10, gap: 5 },
|
|
||||||
|
|
||||||
itemHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
||||||
frameId: { color: '#4a9eff', fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
||||||
time: { color: '#3a3a5a', fontSize: 10, flex: 1, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
||||||
gpsBadge: { borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
|
|
||||||
waveHint: { backgroundColor: '#0d1a2e', borderRadius: 4, paddingHorizontal: 6, paddingVertical: 2 },
|
|
||||||
waveHintText:{ color: '#2a5a9f', fontSize: 9, fontWeight: '600' },
|
|
||||||
itemDelBtn: { width: 22, height: 22, borderRadius: 11, backgroundColor: '#2a0a0e', borderWidth: 1, borderColor: '#3a1520', alignItems: 'center', justifyContent: 'center' },
|
|
||||||
itemDelTxt: { color: '#ff5c6e', fontSize: 14, lineHeight: 18, fontWeight: '700' },
|
|
||||||
|
|
||||||
coord: { color: '#4a4a6a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
||||||
|
|
||||||
itemFooter: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 6 },
|
|
||||||
peakRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
|
|
||||||
peakChip: { flexDirection: 'row', alignItems: 'center', gap: 3 },
|
|
||||||
peakDot: { width: 5, height: 5, borderRadius: 3 },
|
|
||||||
peakVal: { color: '#6a6a8a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
||||||
metaRow: { flexDirection: 'row', gap: 6 },
|
|
||||||
metaTag: { color: '#2a2a4a', fontSize: 9, backgroundColor: '#111120', borderRadius: 4, paddingHorizontal: 5, paddingVertical: 2 },
|
|
||||||
|
|
||||||
empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 },
|
empty: { flex: 1, paddingVertical: 60, justifyContent: 'center', alignItems: 'center', gap: 10 },
|
||||||
emptyIcon: { fontSize: 40, color: '#1a1a2a' },
|
});
|
||||||
emptyText: { color: '#2a2a4a', fontSize: 15 },
|
|
||||||
emptyHint: { color: '#1a1a2a', fontSize: 12 },
|
const T = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: 40,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
headerRow: { height: 32, borderBottomWidth: 1 },
|
||||||
|
cell: { fontSize: 10, paddingHorizontal: 4 },
|
||||||
|
colId: { width: 36, textAlign: 'center' },
|
||||||
|
colTime: { width: 70 },
|
||||||
|
colGps: { width: 30, textAlign: 'center', fontSize: 12 },
|
||||||
|
colCoord: { width: 80, textAlign: 'right' },
|
||||||
|
colAcc: { width: 40, textAlign: 'center' },
|
||||||
|
colDel: { width: 30, alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const M = StyleSheet.create({
|
const M = StyleSheet.create({
|
||||||
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
|
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 },
|
header: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 },
|
||||||
title: { color: '#9090b8', fontSize: 14, fontWeight: '700', flex: 1 },
|
title: { fontSize: 14, fontWeight: '700', flex: 1 },
|
||||||
scaleBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1, borderColor: '#333', backgroundColor: '#1a1a2a' },
|
scaleBtn: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 6, borderWidth: 1 },
|
||||||
scaleBtnOn: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
|
scaleTxt: { fontSize: 11, fontWeight: '700' },
|
||||||
scaleTxt: { color: '#555', fontSize: 11, fontWeight: '700' },
|
closeBtn: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
|
||||||
scaleTxtOn: { color: '#4a9eff' },
|
closeTxt: { fontSize: 14 },
|
||||||
closeBtn: { width: 28, height: 28, borderRadius: 14, backgroundColor: '#1a1a2a', alignItems: 'center', justifyContent: 'center' },
|
noData: { borderRadius: 8, 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' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,17 +2,22 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, TouchableOpacity, ScrollView,
|
View, Text, StyleSheet, TouchableOpacity, ScrollView,
|
||||||
Modal, ActivityIndicator, useWindowDimensions, Platform,
|
Modal, ActivityIndicator, useWindowDimensions, Platform,
|
||||||
|
useColorScheme,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { useConnectionStore } from '../../src/stores/connectionStore';
|
import { useConnectionStore } from '../../src/stores/connectionStore';
|
||||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
import { useDevice } from '../../src/hooks/useDevice';
|
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 { WaveformChart } from '../../src/components/WaveformChart';
|
||||||
import { ParamForm } from '../../src/components/ParamForm';
|
import { ParamForm } from '../../src/components/ParamForm';
|
||||||
|
import { NoProjectGate } from '../../src/components/NoProjectGate';
|
||||||
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
import { CHANNEL_COLORS } from '../../src/protocol/constants';
|
||||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
import { formatUV, formatUtc, formatCoord, sourceModeLabel, formatBattery, formatTemperature } from '../../src/utils/format';
|
||||||
import { Colors, Spacing, Radius } from '../../src/design/tokens';
|
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';
|
import type { MeasurementFrame } from '../../src/protocol/types';
|
||||||
|
|
||||||
// ── Param sheet ───────────────────────────────────────────────────────────────
|
// ── Param sheet ───────────────────────────────────────────────────────────────
|
||||||
@ -30,6 +35,7 @@ function ParamSheet({
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
configDirty: boolean;
|
configDirty: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
const { deviceStatus } = useDeviceStore();
|
const { deviceStatus } = useDeviceStore();
|
||||||
const measuring = deviceStatus !== 'idle';
|
const measuring = deviceStatus !== 'idle';
|
||||||
|
|
||||||
@ -42,14 +48,20 @@ function ParamSheet({
|
|||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
<TouchableOpacity style={PS.backdrop} activeOpacity={1} onPress={onClose} />
|
<TouchableOpacity style={PS.backdrop} activeOpacity={1} onPress={onClose} />
|
||||||
<View style={PS.sheet}>
|
<View style={[PS.sheet, {
|
||||||
<View style={PS.handle} />
|
backgroundColor: theme.bg.surface,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
|
<View style={[PS.handle, { backgroundColor: theme.bg.border }]} />
|
||||||
|
|
||||||
<View style={PS.header}>
|
<View style={PS.header}>
|
||||||
<Text style={PS.title}>采集参数</Text>
|
<Text style={[PS.title, { color: theme.text.secondary }]}>采集参数</Text>
|
||||||
{configDirty && (
|
{configDirty && (
|
||||||
<View style={PS.dirtyBadge}>
|
<View style={[PS.dirtyBadge, {
|
||||||
<Text style={PS.dirtyTxt}>待下发</Text>
|
backgroundColor: theme.amber.bg,
|
||||||
|
borderColor: theme.amber.border,
|
||||||
|
}]}>
|
||||||
|
<Text style={[PS.dirtyTxt, { color: theme.amber.fg }]}>待下发</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -62,7 +74,14 @@ function ParamSheet({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
PS.setupBtn,
|
PS.setupBtn,
|
||||||
configDirty && PS.setupBtnDirty,
|
{
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
},
|
||||||
|
configDirty && {
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.fg,
|
||||||
|
},
|
||||||
(busy || measuring) && PS.setupBtnDis,
|
(busy || measuring) && PS.setupBtnDis,
|
||||||
]}
|
]}
|
||||||
onPress={onSetup}
|
onPress={onSetup}
|
||||||
@ -70,8 +89,12 @@ function ParamSheet({
|
|||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{busy
|
{busy
|
||||||
? <ActivityIndicator color={Colors.blue.fg} />
|
? <ActivityIndicator color={theme.blue.fg} />
|
||||||
: <Text style={[PS.setupBtnTxt, configDirty && PS.setupBtnTxtDirty]}>
|
: <Text style={[
|
||||||
|
PS.setupBtnTxt,
|
||||||
|
{ color: theme.text.muted },
|
||||||
|
configDirty && { color: theme.blue.fg },
|
||||||
|
]}>
|
||||||
{measuring ? '采集中,不可下发' : '下 发 配 置'}
|
{measuring ? '采集中,不可下发' : '下 发 配 置'}
|
||||||
</Text>}
|
</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -88,37 +111,30 @@ const PS = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
maxHeight: '85%',
|
maxHeight: '85%',
|
||||||
backgroundColor: Colors.bg.surface,
|
|
||||||
borderTopLeftRadius: Radius.xl,
|
borderTopLeftRadius: Radius.xl,
|
||||||
borderTopRightRadius: Radius.xl,
|
borderTopRightRadius: Radius.xl,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderColor: Colors.bg.border,
|
|
||||||
paddingBottom: Spacing.xxl,
|
paddingBottom: Spacing.xxl,
|
||||||
},
|
},
|
||||||
handle: {
|
handle: {
|
||||||
width: 36, height: 4, borderRadius: 2,
|
width: 36, height: 4, borderRadius: 2,
|
||||||
backgroundColor: Colors.bg.border,
|
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
marginTop: Spacing.sm, marginBottom: Spacing.sm,
|
marginTop: Spacing.sm, marginBottom: Spacing.sm,
|
||||||
},
|
},
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.lg, paddingBottom: Spacing.sm, gap: 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 },
|
title: { fontSize: 14, fontWeight: '700', flex: 1 },
|
||||||
dirtyBadge: { backgroundColor: Colors.amber.bg, borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1, borderColor: Colors.amber.border },
|
dirtyBadge: { borderRadius: Radius.sm, paddingHorizontal: 7, paddingVertical: 2, borderWidth: 1 },
|
||||||
dirtyTxt: { color: Colors.amber.fg, fontSize: 9, fontWeight: '700' },
|
dirtyTxt: { fontSize: 9, fontWeight: '700' },
|
||||||
scroll: { flex: 1 },
|
scroll: { flex: 1 },
|
||||||
setupBtn: {
|
setupBtn: {
|
||||||
margin: Spacing.lg,
|
margin: Spacing.lg,
|
||||||
backgroundColor: Colors.bg.raised,
|
|
||||||
borderRadius: Radius.lg,
|
borderRadius: Radius.lg,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Colors.bg.border,
|
|
||||||
},
|
},
|
||||||
setupBtnDirty: { backgroundColor: Colors.blue.bg, borderColor: Colors.blue.fg },
|
|
||||||
setupBtnDis: { opacity: 0.4 },
|
setupBtnDis: { opacity: 0.4 },
|
||||||
setupBtnTxt: { color: Colors.text.muted, fontWeight: '700', fontSize: 14, letterSpacing: 3 },
|
setupBtnTxt: { fontWeight: '700', fontSize: 14, letterSpacing: 3 },
|
||||||
setupBtnTxtDirty: { color: Colors.blue.fg },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Control row ───────────────────────────────────────────────────────────────
|
// ── Control row ───────────────────────────────────────────────────────────────
|
||||||
@ -142,70 +158,101 @@ function ControlRow({
|
|||||||
onSettings: () => void;
|
onSettings: () => void;
|
||||||
configDirty: boolean;
|
configDirty: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
const idle = deviceStatus === 'idle';
|
const idle = deviceStatus === 'idle';
|
||||||
const running = deviceStatus === 'running';
|
const running = deviceStatus === 'running';
|
||||||
const single = deviceStatus === 'single';
|
const single = deviceStatus === 'single';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={CR.row}>
|
<View style={[CR.row, {
|
||||||
|
borderTopColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.surface,
|
||||||
|
}]}>
|
||||||
{/* Primary action */}
|
{/* Primary action */}
|
||||||
{idle && (
|
{idle && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[CR.primary, CR.green, !busy ? null : CR.dis]}
|
style={[CR.primary, {
|
||||||
|
backgroundColor: theme.green.bg,
|
||||||
|
borderColor: theme.green.border,
|
||||||
|
}, !busy ? null : CR.dis]}
|
||||||
onPress={onContinuous}
|
onPress={onContinuous}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
{busy
|
{busy
|
||||||
? <ActivityIndicator color={Colors.green.fg} />
|
? <ActivityIndicator color={theme.green.fg} />
|
||||||
: <>
|
: <>
|
||||||
<Text style={CR.primaryIcon}>▶</Text>
|
<Text style={[CR.primaryIcon, { color: theme.text.ghost }]}>▶</Text>
|
||||||
<Text style={[CR.primaryTxt, { color: Colors.green.fg }]}>连 续 采 集</Text>
|
<Text style={[CR.primaryTxt, { color: theme.green.fg }]}>连 续 采 集</Text>
|
||||||
</>}
|
</>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{running && (
|
{running && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[CR.primary, CR.red]}
|
style={[CR.primary, {
|
||||||
|
backgroundColor: theme.red.bg,
|
||||||
|
borderColor: theme.red.border,
|
||||||
|
}]}
|
||||||
onPress={onStop}
|
onPress={onStop}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<Text style={CR.primaryIcon}>■</Text>
|
<Text style={[CR.primaryIcon, { color: theme.text.ghost }]}>■</Text>
|
||||||
<Text style={[CR.primaryTxt, { color: Colors.red.fg }]}>停 止</Text>
|
<Text style={[CR.primaryTxt, { color: theme.red.fg }]}>停 止</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{single && (
|
{single && (
|
||||||
<View style={[CR.primary, CR.teal, CR.dis]}>
|
<View style={[CR.primary, {
|
||||||
<ActivityIndicator color={Colors.teal.fg} size="small" />
|
backgroundColor: theme.teal.bg,
|
||||||
<Text style={[CR.primaryTxt, { color: Colors.teal.fg }]}>单次采集中…</Text>
|
borderColor: theme.teal.border,
|
||||||
|
}, CR.dis]}>
|
||||||
|
<ActivityIndicator color={theme.teal.fg} size="small" />
|
||||||
|
<Text style={[CR.primaryTxt, { color: theme.teal.fg }]}>单次采集中…</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secondary: single-shot button (idle only) */}
|
{/* Secondary: single-shot button (idle only) */}
|
||||||
{idle && (
|
{idle && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[CR.secondary, CR.tealBtn, !busy ? null : CR.dis]}
|
style={[CR.secondary, {
|
||||||
|
backgroundColor: theme.teal.bg,
|
||||||
|
borderColor: theme.teal.border,
|
||||||
|
}, !busy ? null : CR.dis]}
|
||||||
onPress={onSingle}
|
onPress={onSingle}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<Text style={CR.secondaryIcon}>◎</Text>
|
<Text style={[CR.secondaryIcon, { color: theme.text.ghost }]}>◎</Text>
|
||||||
<Text style={[CR.secondaryTxt, { color: Colors.teal.fg }]}>单次</Text>
|
<Text style={[CR.secondaryTxt, { color: theme.teal.fg }]}>单次</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Running indicator (running only) */}
|
{/* Running indicator (running only) */}
|
||||||
{running && frameId !== undefined && (
|
{running && frameId !== undefined && (
|
||||||
<View style={CR.indicator}>
|
<View style={[CR.indicator, {
|
||||||
<View style={CR.runDot} />
|
backgroundColor: theme.green.bg,
|
||||||
<Text style={CR.indicatorTxt}>#{frameId}</Text>
|
borderColor: theme.green.border,
|
||||||
|
}]}>
|
||||||
|
<View style={[CR.runDot, { backgroundColor: theme.green.fg }]} />
|
||||||
|
<Text style={[CR.indicatorTxt, { color: theme.green.fg }]}>#{frameId}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings button — always present */}
|
{/* Settings button — always present */}
|
||||||
<TouchableOpacity style={CR.settingsBtn} onPress={onSettings} activeOpacity={0.8}>
|
<TouchableOpacity
|
||||||
<Text style={[CR.settingsTxt, configDirty && CR.settingsDirty]}>⚙</Text>
|
style={[CR.settingsBtn, {
|
||||||
{configDirty && <View style={CR.dirtyDot} />}
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}
|
||||||
|
onPress={onSettings}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[CR.settingsTxt, { color: theme.text.muted }, configDirty && { color: theme.amber.fg }]}>⚙</Text>
|
||||||
|
{configDirty && (
|
||||||
|
<View style={[CR.dirtyDot, {
|
||||||
|
backgroundColor: theme.amber.fg,
|
||||||
|
borderColor: theme.bg.surface,
|
||||||
|
}]} />
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -219,8 +266,6 @@ const CR = StyleSheet.create({
|
|||||||
paddingVertical: Spacing.sm,
|
paddingVertical: Spacing.sm,
|
||||||
gap: Spacing.sm,
|
gap: Spacing.sm,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: Colors.bg.border,
|
|
||||||
backgroundColor: Colors.bg.surface,
|
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
flex: 2,
|
flex: 2,
|
||||||
@ -232,11 +277,8 @@ const CR = StyleSheet.create({
|
|||||||
borderRadius: Radius.lg,
|
borderRadius: Radius.lg,
|
||||||
borderWidth: 1,
|
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 },
|
dis: { opacity: 0.35 },
|
||||||
primaryIcon: { fontSize: 11, color: '#ffffff88' },
|
primaryIcon: { fontSize: 11 },
|
||||||
primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 },
|
primaryTxt: { fontSize: 13, fontWeight: '700', letterSpacing: 1.5 },
|
||||||
|
|
||||||
secondary: {
|
secondary: {
|
||||||
@ -249,8 +291,7 @@ const CR = StyleSheet.create({
|
|||||||
borderRadius: Radius.lg,
|
borderRadius: Radius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
tealBtn: { backgroundColor: Colors.teal.bg, borderColor: Colors.teal.border },
|
secondaryIcon:{ fontSize: 11 },
|
||||||
secondaryIcon:{ fontSize: 11, color: '#ffffff66' },
|
|
||||||
secondaryTxt: { fontSize: 12, fontWeight: '700' },
|
secondaryTxt: { fontSize: 12, fontWeight: '700' },
|
||||||
|
|
||||||
indicator: {
|
indicator: {
|
||||||
@ -261,92 +302,119 @@ const CR = StyleSheet.create({
|
|||||||
gap: 6,
|
gap: 6,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: Radius.lg,
|
borderRadius: Radius.lg,
|
||||||
backgroundColor: Colors.green.bg,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Colors.green.border,
|
|
||||||
},
|
},
|
||||||
runDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.green.fg },
|
runDot: { width: 6, height: 6, borderRadius: 3 },
|
||||||
indicatorTxt: { color: Colors.green.fg, fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
indicatorTxt: { fontSize: 12, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
|
|
||||||
settingsBtn: {
|
settingsBtn: {
|
||||||
width: 44, height: 44,
|
width: 44, height: 44,
|
||||||
borderRadius: Radius.md,
|
borderRadius: Radius.md,
|
||||||
backgroundColor: Colors.bg.raised,
|
borderWidth: 1,
|
||||||
borderWidth: 1, borderColor: Colors.bg.border,
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
},
|
},
|
||||||
settingsTxt: { fontSize: 18, color: Colors.text.muted },
|
settingsTxt: { fontSize: 18 },
|
||||||
settingsDirty: { color: Colors.amber.fg },
|
|
||||||
dirtyDot: {
|
dirtyDot: {
|
||||||
position: 'absolute', top: 6, right: 6,
|
position: 'absolute', top: 6, right: 6,
|
||||||
width: 7, height: 7, borderRadius: 4,
|
width: 7, height: 7, borderRadius: 4,
|
||||||
backgroundColor: Colors.amber.fg,
|
borderWidth: 1,
|
||||||
borderWidth: 1, borderColor: Colors.bg.surface,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Peak row ──────────────────────────────────────────────────────────────────
|
// ── Status panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PeakRow({ frame, visible, channelNum }: { frame: MeasurementFrame; visible: boolean[]; channelNum: number }) {
|
const CURRENT_RATIO = 50;
|
||||||
return (
|
const INT32_MAX = 0x7fffffff;
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={PK.scroll}>
|
|
||||||
<View style={PK.row}>
|
function formatCurrent(raw: number): string {
|
||||||
{frame.adcUV.map((ch, i) => {
|
const amps = (raw / INT32_MAX) * 5.0 * CURRENT_RATIO;
|
||||||
if (i >= channelNum || !visible[i]) return null;
|
return `${amps.toFixed(1)} A`;
|
||||||
const pk = ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
}
|
||||||
return (
|
|
||||||
<View key={i} style={PK.cell}>
|
function StatusPanel({ frame }: { frame: MeasurementFrame }) {
|
||||||
<View style={[PK.dot, { backgroundColor: CHANNEL_COLORS[i] }]} />
|
const theme = useTheme();
|
||||||
<Text style={PK.label}>CH{i + 1}</Text>
|
const m = frame.meta;
|
||||||
<Text style={[PK.value, { color: CHANNEL_COLORS[i] + 'cc' }]}>{formatUV(pk)}</Text>
|
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 }) => (
|
||||||
|
<View key={it.label} style={[SP.cell, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }]}>
|
||||||
|
<Text style={[SP.label, { color: theme.text.muted }]}>{it.label}</Text>
|
||||||
|
<Text style={[SP.value, mono, { color: it.highlight ? theme.green.fg : theme.text.secondary }]}>{it.value}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
|
return (
|
||||||
|
<View style={[SP.container, { borderTopColor: theme.bg.divider, backgroundColor: theme.bg.void }]}>
|
||||||
|
<View style={SP.row}>{row1.map(renderCell)}</View>
|
||||||
|
<View style={SP.row}>{row2.map(renderCell)}</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PK = StyleSheet.create({
|
const SP = StyleSheet.create({
|
||||||
scroll: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
|
container: { paddingHorizontal: Spacing.md, paddingVertical: 4, gap: 4, borderTopWidth: StyleSheet.hairlineWidth },
|
||||||
row: { flexDirection: 'row', paddingHorizontal: Spacing.md, paddingVertical: 7, gap: 10 },
|
row: { flexDirection: 'row', gap: 4 },
|
||||||
cell: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: Colors.bg.surface, borderRadius: Radius.sm, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: Colors.bg.border },
|
cell: { flex: 1, alignItems: 'center', paddingVertical: 3, borderRadius: Radius.sm, borderWidth: 1 },
|
||||||
dot: { width: 5, height: 5, borderRadius: 3 },
|
label: { fontSize: 8, fontWeight: '600' },
|
||||||
label: { color: Colors.text.ghost, fontSize: 9, fontWeight: '600' },
|
value: { fontSize: 10, fontWeight: '700', marginTop: 1 },
|
||||||
value: { fontSize: 11, fontWeight: '600', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Frame summary bar ─────────────────────────────────────────────────────────
|
// ── Frame summary bar ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
|
function FrameSummaryBar({ frame }: { frame: MeasurementFrame }) {
|
||||||
const m = frame.meta;
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<View style={FS.bar}>
|
<View style={[FS.bar, {
|
||||||
<Text style={FS.txt}>
|
backgroundColor: theme.bg.void,
|
||||||
|
borderTopColor: theme.bg.divider,
|
||||||
|
}]}>
|
||||||
|
<Text style={[FS.txt, { color: theme.text.ghost }]}>
|
||||||
#{frame.frameId}
|
#{frame.frameId}
|
||||||
{' '}×{frame.accNum}叠
|
{' '}{frame.meta ? formatUtc(frame.meta.utc) : ''}
|
||||||
{m ? ` ${formatUtc(m.utc)}` : ''}
|
{frame.meta?.latitude ? ` ${formatCoord(frame.meta.latitude, false)} ${formatCoord(frame.meta.longitude, true)}` : ''}
|
||||||
{m && m.latitude ? ` ${formatCoord(m.latitude, false)} ${formatCoord(m.longitude, true)}` : ''}
|
{frame.meta ? ` R${frame.meta.roll.toFixed(1)} P${frame.meta.pitch.toFixed(1)} Y${frame.meta.yaw.toFixed(1)}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FS = StyleSheet.create({
|
const FS = StyleSheet.create({
|
||||||
bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, backgroundColor: Colors.bg.void, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: Colors.bg.divider },
|
bar: { paddingHorizontal: Spacing.md, paddingVertical: 5, borderTopWidth: StyleSheet.hairlineWidth },
|
||||||
txt: { color: Colors.text.ghost, fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
txt: { fontSize: 9, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Not connected placeholder ─────────────────────────────────────────────────
|
// ── Not connected placeholder ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function NotConnected({ onConnect }: { onConnect: () => void }) {
|
function NotConnected({ onConnect }: { onConnect: () => void }) {
|
||||||
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<View style={NC.root}>
|
<View style={NC.root}>
|
||||||
<Text style={NC.icon}>◈</Text>
|
<Text style={[NC.icon, { color: theme.bg.border }]}>◈</Text>
|
||||||
<Text style={NC.title}>未连接仪器</Text>
|
<Text style={[NC.title, { color: theme.text.muted }]}>未连接仪器</Text>
|
||||||
<Text style={NC.sub}>请先连接 TEM 接收机</Text>
|
<Text style={[NC.sub, { color: theme.text.ghost }]}>请先连接 TEM 接收机</Text>
|
||||||
<TouchableOpacity style={NC.btn} onPress={onConnect} activeOpacity={0.8}>
|
<TouchableOpacity
|
||||||
<Text style={NC.btnTxt}>连 接 设 备</Text>
|
style={[NC.btn, {
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.fg,
|
||||||
|
}]}
|
||||||
|
onPress={onConnect}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[NC.btnTxt, { color: theme.blue.fg }]}>连 接 设 备</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -354,23 +422,23 @@ function NotConnected({ onConnect }: { onConnect: () => void }) {
|
|||||||
|
|
||||||
const NC = StyleSheet.create({
|
const NC = StyleSheet.create({
|
||||||
root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
|
root: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
|
||||||
icon: { fontSize: 48, color: Colors.bg.border },
|
icon: { fontSize: 48 },
|
||||||
title: { color: Colors.text.muted, fontSize: 17, fontWeight: '700' },
|
title: { fontSize: 17, fontWeight: '700' },
|
||||||
sub: { color: Colors.text.ghost, fontSize: 12 },
|
sub: { fontSize: 12 },
|
||||||
btn: {
|
btn: {
|
||||||
marginTop: Spacing.sm,
|
marginTop: Spacing.sm,
|
||||||
backgroundColor: Colors.blue.bg,
|
|
||||||
borderRadius: Radius.lg,
|
borderRadius: Radius.lg,
|
||||||
paddingVertical: 13,
|
paddingVertical: 13,
|
||||||
paddingHorizontal: 32,
|
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 ───────────────────────────────────────────────────────────────
|
// ── Main screen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function WaveScreen() {
|
export default function WaveScreen() {
|
||||||
|
const theme = useTheme();
|
||||||
const { width: sw, height: sh } = useWindowDimensions();
|
const { width: sw, height: sh } = useWindowDimensions();
|
||||||
const { status, showModal } = useConnectionStore();
|
const { status, showModal } = useConnectionStore();
|
||||||
const { config, configDirty } = useDeviceStore();
|
const { config, configDirty } = useDeviceStore();
|
||||||
@ -380,12 +448,25 @@ export default function WaveScreen() {
|
|||||||
const frame = useDataStore((s) => s.currentFrame);
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
const sessionId = useDataStore((s) => s.sessionId);
|
const sessionId = useDataStore((s) => s.sessionId);
|
||||||
const projectId = useDataStore((s) => s.projectId);
|
const projectId = useDataStore((s) => s.projectId);
|
||||||
|
const hasProject = useDataStore((s) => s.hasProject);
|
||||||
|
|
||||||
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
||||||
const [logScale, setLogScale] = useState(true);
|
const [logScale, setLogScale] = useState(false);
|
||||||
const [sheetVisible, setSheetVisible] = useState(false);
|
const [sheetVisible, setSheetVisible] = useState(false);
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [projectName, setProjectName] = useState<string | null>(null);
|
const [projectName, setProjectName] = useState<string | null>(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 connected = status === 'connected';
|
||||||
const channelNum = config.channelNum;
|
const channelNum = config.channelNum;
|
||||||
const CHART_H = Math.round(Math.min(280, sh * 0.36));
|
const CHART_H = Math.round(Math.min(280, sh * 0.36));
|
||||||
@ -396,7 +477,7 @@ export default function WaveScreen() {
|
|||||||
if (!projectId) { setProjectName(null); return; }
|
if (!projectId) { setProjectName(null); return; }
|
||||||
import('../../src/services/StorageService').then(({ listProjects }) =>
|
import('../../src/services/StorageService').then(({ listProjects }) =>
|
||||||
listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)),
|
listProjects().then((ps) => setProjectName(ps.find((p) => p.projectId === projectId)?.name ?? null)),
|
||||||
);
|
).catch(() => setProjectName(null));
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const toggleChannel = (idx: number) =>
|
const toggleChannel = (idx: number) =>
|
||||||
@ -409,51 +490,81 @@ export default function WaveScreen() {
|
|||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return (
|
return (
|
||||||
<View style={S.root}>
|
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
||||||
<NotConnected onConnect={showModal} />
|
<NotConnected onConnect={showModal} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasProject) {
|
||||||
return (
|
return (
|
||||||
<View style={S.root}>
|
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
||||||
|
<NoProjectGate />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[S.root, { backgroundColor: theme.bg.base }]}>
|
||||||
|
|
||||||
{/* ── Context bar: project · session ── */}
|
{/* ── Context bar: project · session ── */}
|
||||||
<View style={S.ctxBar}>
|
<View style={[S.ctxBar, {
|
||||||
|
backgroundColor: theme.bg.void,
|
||||||
|
borderBottomColor: theme.bg.divider,
|
||||||
|
}]}>
|
||||||
{projectName
|
{projectName
|
||||||
? <Text style={S.ctxTxt} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
? <Text style={[S.ctxTxt, { color: theme.text.ghost }]} numberOfLines={1}>{projectName} · {sessionId}</Text>
|
||||||
: <Text style={S.ctxTxt} numberOfLines={1}>{sessionId}</Text>}
|
: <Text style={[S.ctxTxt, { color: theme.text.ghost }]} numberOfLines={1}>{sessionId}</Text>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Channel selector + LOG/LIN ── */}
|
{/* ── Channel selector + LOG/LIN ── */}
|
||||||
<View style={S.toolbar}>
|
<View style={[S.toolbar, { borderBottomColor: theme.bg.divider }]}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={S.chRow}>
|
||||||
{Array.from({ length: channelNum }, (_, i) => (
|
{Array.from({ length: channelNum }, (_, i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={i}
|
key={i}
|
||||||
style={[
|
style={[
|
||||||
S.chBtn,
|
S.chBtn,
|
||||||
|
{ borderColor: theme.bg.border },
|
||||||
visible[i] && { borderColor: CHANNEL_COLORS[i] + '99', backgroundColor: CHANNEL_COLORS[i] + '1a' },
|
visible[i] && { borderColor: CHANNEL_COLORS[i] + '99', backgroundColor: CHANNEL_COLORS[i] + '1a' },
|
||||||
]}
|
]}
|
||||||
onPress={() => toggleChannel(i)}
|
onPress={() => toggleChannel(i)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[S.chDot, { backgroundColor: visible[i] ? CHANNEL_COLORS[i] : Colors.bg.border }]} />
|
<View style={[S.chDot, { backgroundColor: visible[i] ? CHANNEL_COLORS[i] : theme.bg.border }]} />
|
||||||
<Text style={[S.chTxt, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
|
<Text style={[S.chTxt, { color: theme.text.ghost }, visible[i] && { color: CHANNEL_COLORS[i] }]}>CH{i + 1}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.logBtn, !logScale && S.logBtnOn]}
|
style={[S.logBtn, {
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
}, !logScale && {
|
||||||
|
borderColor: theme.blue.border,
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
}]}
|
||||||
onPress={() => setLogScale((v) => !v)}
|
onPress={() => setLogScale((v) => !v)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[S.logTxt, !logScale && S.logTxtOn]}>{logScale ? 'LOG' : 'LIN'}</Text>
|
<Text style={[S.logTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
||||||
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.fullBtn, {
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
}]}
|
||||||
|
onPress={() => setFullscreen(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[S.fullTxt, { color: theme.text.muted }]}>⛶</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Waveform chart ── */}
|
{/* ── Waveform chart ── */}
|
||||||
<View style={S.chartWrap}>
|
<View style={[S.chartWrap, { borderBottomColor: theme.bg.divider }]}>
|
||||||
{data ? (
|
{data ? (
|
||||||
<WaveformChart
|
<WaveformChart
|
||||||
data={data}
|
data={data}
|
||||||
@ -461,18 +572,19 @@ export default function WaveScreen() {
|
|||||||
width={sw}
|
width={sw}
|
||||||
height={CHART_H}
|
height={CHART_H}
|
||||||
logScale={logScale}
|
logScale={logScale}
|
||||||
|
interactive={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[S.chartPlaceholder, { height: CHART_H }]}>
|
<View style={[S.chartPlaceholder, { height: CHART_H, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.placeholderTxt}>等待采样数据…</Text>
|
<Text style={[S.placeholderTxt, { color: theme.text.ghost }]}>等待采样数据…</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Peak values ── */}
|
{/* ── Peak values ── */}
|
||||||
{frame && <PeakRow frame={frame} visible={visible} channelNum={channelNum} />}
|
|
||||||
|
|
||||||
{/* ── Frame summary ── */}
|
{/* ── Frame summary ── */}
|
||||||
|
{frame && <StatusPanel frame={frame} />}
|
||||||
{frame && <FrameSummaryBar frame={frame} />}
|
{frame && <FrameSummaryBar frame={frame} />}
|
||||||
|
|
||||||
{/* ── Control row ── */}
|
{/* ── Control row ── */}
|
||||||
@ -495,22 +607,97 @@ export default function WaveScreen() {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
configDirty={configDirty}
|
configDirty={configDirty}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Fullscreen waveform ── */}
|
||||||
|
<Modal
|
||||||
|
visible={fullscreen}
|
||||||
|
animationType="fade"
|
||||||
|
supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={() => setFullscreen(false)}
|
||||||
|
>
|
||||||
|
<FullscreenContent
|
||||||
|
data={data}
|
||||||
|
visibleChannels={visible}
|
||||||
|
logScale={logScale}
|
||||||
|
onToggleLog={() => setLogScale((v) => !v)}
|
||||||
|
onClose={() => setFullscreen(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<GestureHandlerRootView style={[S.fsRoot, { backgroundColor: theme.chart.bg }]}>
|
||||||
|
{data ? (
|
||||||
|
<WaveformChart
|
||||||
|
data={data}
|
||||||
|
visibleChannels={visibleChannels}
|
||||||
|
width={fsW}
|
||||||
|
height={fsH}
|
||||||
|
logScale={logScale}
|
||||||
|
interactive
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[S.chartPlaceholder, { flex: 1, backgroundColor: theme.bg.surface }]}>
|
||||||
|
<Text style={[S.placeholderTxt, { color: theme.text.ghost }]}>等待采样数据…</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating toolbar */}
|
||||||
|
<View style={S.fsToolbar}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.logBtn, {
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
}, !logScale && {
|
||||||
|
borderColor: theme.blue.border,
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
}]}
|
||||||
|
onPress={onToggleLog}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[S.logTxt, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
||||||
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.fsCloseBtn, {
|
||||||
|
backgroundColor: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)',
|
||||||
|
}]}
|
||||||
|
onPress={onClose}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[S.fsCloseTxt, { color: isDark ? '#ffffff' : '#000000' }]}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: Colors.bg.base },
|
root: { flex: 1 },
|
||||||
|
|
||||||
ctxBar: {
|
ctxBar: {
|
||||||
paddingHorizontal: Spacing.md,
|
paddingHorizontal: Spacing.md,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
backgroundColor: Colors.bg.void,
|
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: Colors.bg.divider,
|
|
||||||
},
|
},
|
||||||
ctxTxt: {
|
ctxTxt: {
|
||||||
color: Colors.text.ghost,
|
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||||
},
|
},
|
||||||
@ -520,7 +707,6 @@ const S = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingRight: Spacing.sm,
|
paddingRight: Spacing.sm,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: Colors.bg.divider,
|
|
||||||
},
|
},
|
||||||
chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 },
|
chRow: { flexDirection: 'row', paddingHorizontal: Spacing.sm, paddingVertical: 6, gap: 5 },
|
||||||
chBtn: {
|
chBtn: {
|
||||||
@ -531,28 +717,47 @@ const S = StyleSheet.create({
|
|||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
borderRadius: Radius.md,
|
borderRadius: Radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Colors.bg.border,
|
|
||||||
},
|
},
|
||||||
chDot: { width: 6, height: 6, borderRadius: 3 },
|
chDot: { width: 6, height: 6, borderRadius: 3 },
|
||||||
chTxt: { color: Colors.text.ghost, fontSize: 10, fontWeight: '600' },
|
chTxt: { fontSize: 10, fontWeight: '600' },
|
||||||
logBtn: {
|
logBtn: {
|
||||||
paddingHorizontal: 9,
|
paddingHorizontal: 9,
|
||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
borderRadius: Radius.sm,
|
borderRadius: Radius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Colors.bg.border,
|
|
||||||
backgroundColor: Colors.bg.raised,
|
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
logBtnOn: { borderColor: Colors.blue.border, backgroundColor: Colors.blue.bg },
|
logTxt: { fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
logTxt: { color: Colors.text.muted, fontSize: 10, fontWeight: '700', letterSpacing: 0.5 },
|
fullBtn: {
|
||||||
logTxtOn: { color: Colors.blue.fg },
|
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: {
|
chartPlaceholder: {
|
||||||
backgroundColor: Colors.bg.surface,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
placeholderTxt: { color: Colors.text.ghost, fontSize: 13 },
|
placeholderTxt: { fontSize: 13 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,13 +8,15 @@ import { useWaveform } from '../../src/hooks/useWaveform';
|
|||||||
import { useDataStore } from '../../src/stores/dataStore';
|
import { useDataStore } from '../../src/stores/dataStore';
|
||||||
import { useDeviceStore } from '../../src/stores/deviceStore';
|
import { useDeviceStore } from '../../src/stores/deviceStore';
|
||||||
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
import { formatUV, formatUtc, formatCoord } from '../../src/utils/format';
|
||||||
|
import { useTheme } from '../../src/design/tokens';
|
||||||
|
|
||||||
export default function WaveformScreen() {
|
export default function WaveformScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = useTheme();
|
||||||
const { width, height: windowHeight } = useWindowDimensions();
|
const { width, height: windowHeight } = useWindowDimensions();
|
||||||
const channelNum = useDeviceStore((s) => s.config.channelNum);
|
const channelNum = useDeviceStore((s) => s.config.channelNum);
|
||||||
const [visible, setVisible] = useState(() => Array(6).fill(true));
|
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 frame = useDataStore((s) => s.currentFrame);
|
||||||
const data = useWaveform(visible);
|
const data = useWaveform(visible);
|
||||||
@ -25,24 +27,26 @@ export default function WaveformScreen() {
|
|||||||
const CHART_HEIGHT = Math.round(Math.min(300, windowHeight * 0.38));
|
const CHART_HEIGHT = Math.round(Math.min(300, windowHeight * 0.38));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[S.container, { paddingTop: insets.top }]}>
|
<View style={[S.container, { backgroundColor: theme.bg.base }]}>
|
||||||
|
<View style={{ paddingTop: insets.top }}>
|
||||||
<DeviceStatusBar />
|
<DeviceStatusBar />
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={S.toolbar}>
|
<View style={S.toolbar}>
|
||||||
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
<ChannelSelector maxChannels={channelNum} visible={visible} onToggle={toggleChannel} />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.scaleBtn, !logScale && S.scaleBtnActive]}
|
style={[S.scaleBtn, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }, !logScale && { borderColor: theme.blue.fg + '55', backgroundColor: theme.blue.bg }]}
|
||||||
onPress={() => setLogScale((v) => !v)}
|
onPress={() => setLogScale((v) => !v)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[S.scaleBtnText, !logScale && S.scaleBtnTextActive]}>
|
<Text style={[S.scaleBtnText, { color: theme.text.muted }, !logScale && { color: theme.blue.fg }]}>
|
||||||
{logScale ? 'LOG' : 'LIN'}
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Waveform chart */}
|
{/* Waveform chart */}
|
||||||
<View style={S.chartWrap}>
|
<View style={[S.chartWrap, { borderBottomColor: theme.bg.border }]}>
|
||||||
{data ? (
|
{data ? (
|
||||||
<WaveformChart
|
<WaveformChart
|
||||||
data={data}
|
data={data}
|
||||||
@ -52,8 +56,8 @@ export default function WaveformScreen() {
|
|||||||
logScale={logScale}
|
logScale={logScale}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[S.placeholder, { height: CHART_HEIGHT }]}>
|
<View style={[S.placeholder, { height: CHART_HEIGHT, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.placeholderText}>等待采样数据...</Text>
|
<Text style={[S.placeholderText, { color: theme.text.muted }]}>等待采样数据...</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -66,23 +70,23 @@ export default function WaveformScreen() {
|
|||||||
if (!visible[idx] || idx >= channelNum) return null;
|
if (!visible[idx] || idx >= channelNum) return null;
|
||||||
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
const absMax = ch.reduce((mx, v) => Math.max(mx, Math.abs(v)), 0);
|
||||||
return (
|
return (
|
||||||
<View key={idx} style={S.peakCell}>
|
<View key={idx} style={[S.peakCell, { backgroundColor: theme.bg.surface, borderColor: theme.bg.border }]}>
|
||||||
<Text style={S.peakLabel}>CH{idx + 1}</Text>
|
<Text style={[S.peakLabel, { color: theme.text.secondary }]}>CH{idx + 1}</Text>
|
||||||
<Text style={S.peakValue}>{formatUV(absMax)}</Text>
|
<Text style={[S.peakValue, { color: theme.text.primary }]}>{formatUV(absMax)}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{frame.meta && (
|
{frame.meta && (
|
||||||
<View style={S.metaBlock}>
|
<View style={[S.metaBlock, { backgroundColor: theme.bg.raised }]}>
|
||||||
<Text style={S.metaText}>
|
<Text style={[S.metaText, { color: theme.text.muted }]}>
|
||||||
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
帧 #{frame.frameId} UTC: {formatUtc(frame.meta.utc)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={S.metaText}>
|
<Text style={[S.metaText, { color: theme.text.muted }]}>
|
||||||
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
{formatCoord(frame.meta.latitude, false)} {formatCoord(frame.meta.longitude, true)} 海拔 {frame.meta.altitude.toFixed(1)} m
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={S.metaText}>
|
<Text style={[S.metaText, { color: theme.text.muted }]}>
|
||||||
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
Roll {frame.meta.roll.toFixed(1)}° Pitch {frame.meta.pitch.toFixed(1)}° Yaw {frame.meta.yaw.toFixed(1)}°
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -94,11 +98,11 @@ export default function WaveformScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#0d0d0d' },
|
container: { flex: 1 },
|
||||||
toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 },
|
toolbar: { flexDirection: 'row', alignItems: 'center', paddingRight: 10 },
|
||||||
chartWrap: { borderBottomWidth: 1, borderBottomColor: '#222' },
|
chartWrap: { borderBottomWidth: 1 },
|
||||||
placeholder: { backgroundColor: '#1a1a1a', justifyContent: 'center', alignItems: 'center' },
|
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||||
placeholderText: { color: '#444', fontSize: 14 },
|
placeholderText: { fontSize: 14 },
|
||||||
|
|
||||||
scaleBtn: {
|
scaleBtn: {
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
@ -106,23 +110,18 @@ const S = StyleSheet.create({
|
|||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#333',
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
},
|
},
|
||||||
scaleBtnActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
|
scaleBtnText: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
scaleBtnText: { color: '#555', fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
|
|
||||||
scaleBtnTextActive:{ color: '#4a9eff' },
|
|
||||||
|
|
||||||
peakScroll: { flex: 1 },
|
peakScroll: { flex: 1 },
|
||||||
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
peakRow: { flexDirection: 'row', flexWrap: 'wrap', padding: 12, gap: 10 },
|
||||||
peakCell: {
|
peakCell: {
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
borderRadius: 8, padding: 10,
|
borderRadius: 8, padding: 10,
|
||||||
minWidth: 90, alignItems: 'center',
|
minWidth: 90, alignItems: 'center',
|
||||||
borderWidth: 1, borderColor: '#2a2a2a',
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
peakLabel: { color: '#888', fontSize: 11 },
|
peakLabel: { fontSize: 11 },
|
||||||
peakValue: { color: '#eee', fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
peakValue: { fontSize: 13, fontWeight: '600', marginTop: 2, fontFamily: 'monospace' },
|
||||||
metaBlock: { margin: 12, backgroundColor: '#111', borderRadius: 8, padding: 10, gap: 3 },
|
metaBlock: { margin: 12, borderRadius: 8, padding: 10, gap: 3 },
|
||||||
metaText: { color: '#555', fontSize: 10, fontFamily: 'monospace' },
|
metaText: { fontSize: 10, fontFamily: 'monospace' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import { Link, Stack } from 'expo-router';
|
|||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
import { Text, View } from '@/components/Themed';
|
import { Text, View } from '@/components/Themed';
|
||||||
|
import { useTheme } from '../src/design/tokens';
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
@ -11,7 +14,7 @@ export default function NotFoundScreen() {
|
|||||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||||
|
|
||||||
<Link href="/" style={styles.link}>
|
<Link href="/" style={styles.link}>
|
||||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
<Text style={[styles.linkText, { color: theme.blue.fg }]}>Go to home screen!</Text>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@ -35,6 +38,5 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
linkText: {
|
linkText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#2e78b7',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { StyleSheet } from 'react-native';
|
|||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
import { useDataStore } from '../src/stores/dataStore';
|
import { useDataStore } from '../src/stores/dataStore';
|
||||||
import { ConnectModal } from '../src/components/modals/ConnectModal';
|
import { ConnectModal } from '../src/components/modals/ConnectModal';
|
||||||
|
import { useTheme } from '../src/design/tokens';
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ export { ErrorBoundary } from 'expo-router';
|
|||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const init = useDataStore((s) => s.init);
|
const init = useDataStore((s) => s.init);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
init().finally(() => SplashScreen.hideAsync());
|
init().finally(() => SplashScreen.hideAsync());
|
||||||
@ -21,12 +23,11 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={styles.root}>
|
<GestureHandlerRootView style={styles.root}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="auto" />
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: '#090912' } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.bg.base } }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
{/* ConnectModal is global — accessible from any tab */}
|
|
||||||
<ConnectModal />
|
<ConnectModal />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
120
app/connect.tsx
@ -15,10 +15,12 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useConnectionStore } from '../src/stores/connectionStore';
|
import { useConnectionStore } from '../src/stores/connectionStore';
|
||||||
import { useDevice } from '../src/hooks/useDevice';
|
import { useDevice } from '../src/hooks/useDevice';
|
||||||
|
import { useTheme } from '../src/design/tokens';
|
||||||
|
|
||||||
const LOG_MAX = 60;
|
const LOG_MAX = 60;
|
||||||
|
|
||||||
export default function ConnectScreen() {
|
export default function ConnectScreen() {
|
||||||
|
const theme = useTheme();
|
||||||
const { host, port, status, lastError, setHost, setPort } = useConnectionStore();
|
const { host, port, status, lastError, setHost, setPort } = useConnectionStore();
|
||||||
const { connect, disconnect } = useDevice();
|
const { connect, disconnect } = useDevice();
|
||||||
const [logs, setLogs] = useState<string[]>(['Ready.']);
|
const [logs, setLogs] = useState<string[]>(['Ready.']);
|
||||||
@ -60,58 +62,58 @@ export default function ConnectScreen() {
|
|||||||
const isConnected = status === 'connected';
|
const isConnected = status === 'connected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={S.safe}>
|
<SafeAreaView style={[S.safe, { backgroundColor: theme.bg.base }]}>
|
||||||
<KeyboardAvoidingView style={S.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
<KeyboardAvoidingView style={S.flex} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<ScrollView style={S.scroll} contentContainerStyle={S.scrollContent} keyboardShouldPersistTaps="handled">
|
<ScrollView style={S.scroll} contentContainerStyle={S.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
|
|
||||||
{/* ── Brand ── */}
|
{/* ── Brand ── */}
|
||||||
<View style={S.brand}>
|
<View style={S.brand}>
|
||||||
<Text style={S.brandIcon}>◈</Text>
|
<Text style={[S.brandIcon, { color: theme.blue.fg }]}>◈</Text>
|
||||||
<Text style={S.title}>TEM Receiver</Text>
|
<Text style={[S.title, { color: theme.text.primary }]}>TEM Receiver</Text>
|
||||||
<Text style={S.subtitle}>瞬变电磁接收机上位机</Text>
|
<Text style={[S.subtitle, { color: theme.text.muted }]}>瞬变电磁接收机上位机</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Setup guide ── */}
|
{/* ── Setup guide ── */}
|
||||||
<View style={S.card}>
|
<View style={[S.card, { backgroundColor: theme.bg.raised, borderColor: theme.bg.border }]}>
|
||||||
<Text style={S.cardLabel}>连接步骤</Text>
|
<Text style={[S.cardLabel, { color: theme.text.muted }]}>连接步骤</Text>
|
||||||
<View style={S.stepRow}>
|
<View style={S.stepRow}>
|
||||||
<View style={S.stepBadge}><Text style={S.stepNum}>1</Text></View>
|
<View style={[S.stepBadge, { backgroundColor: theme.blue.bg }]}><Text style={[S.stepNum, { color: theme.blue.fg }]}>1</Text></View>
|
||||||
<Text style={S.stepText}>手机连接设备 WiFi 热点</Text>
|
<Text style={[S.stepText, { color: theme.text.secondary }]}>手机连接设备 WiFi 热点</Text>
|
||||||
<TouchableOpacity style={S.linkBtn} onPress={() => Linking.openSettings()}>
|
<TouchableOpacity style={[S.linkBtn, { backgroundColor: theme.blue.bg, borderColor: theme.blue.border }]} onPress={() => Linking.openSettings()}>
|
||||||
<Text style={S.linkBtnText}>设置 →</Text>
|
<Text style={[S.linkBtnText, { color: theme.blue.fg }]}>设置 →</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.stepRow}>
|
<View style={S.stepRow}>
|
||||||
<View style={S.stepBadge}><Text style={S.stepNum}>2</Text></View>
|
<View style={[S.stepBadge, { backgroundColor: theme.blue.bg }]}><Text style={[S.stepNum, { color: theme.blue.fg }]}>2</Text></View>
|
||||||
<Text style={S.stepText}>确认参数,点击连接</Text>
|
<Text style={[S.stepText, { color: theme.text.secondary }]}>确认参数,点击连接</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Inputs ── */}
|
{/* ── Inputs ── */}
|
||||||
<View style={S.card}>
|
<View style={[S.card, { backgroundColor: theme.bg.raised, borderColor: theme.bg.border }]}>
|
||||||
<Text style={S.cardLabel}>连接参数</Text>
|
<Text style={[S.cardLabel, { color: theme.text.muted }]}>连接参数</Text>
|
||||||
<View style={S.inputRow}>
|
<View style={S.inputRow}>
|
||||||
<Text style={S.inputLabel}>IP 地址</Text>
|
<Text style={[S.inputLabel, { color: theme.text.secondary }]}>IP 地址</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={S.input}
|
style={[S.input, { color: theme.text.primary, borderBottomColor: theme.bg.border }]}
|
||||||
value={host}
|
value={host}
|
||||||
onChangeText={setHost}
|
onChangeText={setHost}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
placeholder="192.168.4.1"
|
placeholder="192.168.4.1"
|
||||||
placeholderTextColor="#3a3a5a"
|
placeholderTextColor={theme.text.muted}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.inputDivider} />
|
<View style={[S.inputDivider, { backgroundColor: theme.bg.border }]} />
|
||||||
<View style={S.inputRow}>
|
<View style={S.inputRow}>
|
||||||
<Text style={S.inputLabel}>端 口</Text>
|
<Text style={[S.inputLabel, { color: theme.text.secondary }]}>端 口</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={S.input}
|
style={[S.input, { color: theme.text.primary, borderBottomColor: theme.bg.border }]}
|
||||||
value={portStr}
|
value={portStr}
|
||||||
onChangeText={setPortStr}
|
onChangeText={setPortStr}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
placeholder="4321"
|
placeholder="4321"
|
||||||
placeholderTextColor="#3a3a5a"
|
placeholderTextColor={theme.text.muted}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -119,36 +121,36 @@ export default function ConnectScreen() {
|
|||||||
{/* ── Action ── */}
|
{/* ── Action ── */}
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.connectBtn, isConnecting && S.connectBtnBusy]}
|
style={[S.connectBtn, { backgroundColor: theme.blue.border, borderColor: theme.blue.fg }, isConnecting && { borderColor: theme.text.ghost, backgroundColor: theme.blue.bg }]}
|
||||||
onPress={handleConnect}
|
onPress={handleConnect}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{isConnecting
|
{isConnecting
|
||||||
? <ActivityIndicator color="#fff" />
|
? <ActivityIndicator color={theme.text.primary} />
|
||||||
: <Text style={S.connectBtnText}>连 接 设 备</Text>}
|
: <Text style={[S.connectBtnText, { color: theme.blue.fg }]}>连 接 设 备</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<View style={S.connectedBox}>
|
<View style={S.connectedBox}>
|
||||||
<View style={S.connectedLeft}>
|
<View style={S.connectedLeft}>
|
||||||
<View style={S.greenDot} />
|
<View style={[S.greenDot, { backgroundColor: theme.green.fg }]} />
|
||||||
<Text style={S.connectedText}>已连接</Text>
|
<Text style={[S.connectedText, { color: theme.green.fg }]}>已连接</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={S.goBtn} onPress={() => router.push('/(tabs)/control')} activeOpacity={0.8}>
|
<TouchableOpacity style={[S.goBtn, { backgroundColor: theme.green.bg, borderColor: theme.green.border }]} onPress={() => router.push('/(tabs)/control')} activeOpacity={0.8}>
|
||||||
<Text style={S.goBtnText}>进入控制台 →</Text>
|
<Text style={[S.goBtnText, { color: theme.green.fg }]}>进入控制台 →</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={S.disconnectBtn} onPress={handleDisconnect} activeOpacity={0.8}>
|
<TouchableOpacity style={[S.disconnectBtn, { backgroundColor: theme.red.bg, borderColor: theme.red.border }]} onPress={handleDisconnect} activeOpacity={0.8}>
|
||||||
<Text style={S.disconnectBtnText}>断开</Text>
|
<Text style={[S.disconnectBtnText, { color: theme.red.fg }]}>断开</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Log terminal ── */}
|
{/* ── Log terminal ── */}
|
||||||
<View style={S.terminal}>
|
<View style={[S.terminal, { backgroundColor: theme.bg.void, borderColor: theme.bg.border }]}>
|
||||||
<Text style={S.terminalHeader}>● 连接日志</Text>
|
<Text style={[S.terminalHeader, { color: theme.green.fg, borderBottomColor: theme.bg.divider }]}>● 连接日志</Text>
|
||||||
<View style={S.terminalBody}>
|
<View style={S.terminalBody}>
|
||||||
{logs.map((l, i) => (
|
{logs.map((l, i) => (
|
||||||
<Text key={i} style={[S.terminalLine, i === 0 && S.terminalLineLatest]}>{l}</Text>
|
<Text key={i} style={[S.terminalLine, { color: theme.text.muted }, i === 0 && { color: theme.text.secondary }]}>{l}</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -160,78 +162,70 @@ export default function ConnectScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
safe: { flex: 1, backgroundColor: '#090912' },
|
safe: { flex: 1 },
|
||||||
flex: { flex: 1 },
|
flex: { flex: 1 },
|
||||||
scroll: { flex: 1 },
|
scroll: { flex: 1 },
|
||||||
scrollContent: { padding: 20, paddingBottom: 40 },
|
scrollContent: { padding: 20, paddingBottom: 40 },
|
||||||
|
|
||||||
// Brand
|
// Brand
|
||||||
brand: { alignItems: 'center', marginBottom: 28, marginTop: 8 },
|
brand: { alignItems: 'center', marginBottom: 28, marginTop: 8 },
|
||||||
brandIcon: { fontSize: 36, color: '#4a9eff', marginBottom: 8 },
|
brandIcon: { fontSize: 36, marginBottom: 8 },
|
||||||
title: { color: '#e4e4f0', fontSize: 26, fontWeight: '700', letterSpacing: 1 },
|
title: { fontSize: 26, fontWeight: '700', letterSpacing: 1 },
|
||||||
subtitle: { color: '#4a4a6a', fontSize: 12, marginTop: 4, letterSpacing: 0.5 },
|
subtitle: { fontSize: 12, marginTop: 4, letterSpacing: 0.5 },
|
||||||
|
|
||||||
// Card
|
// Card
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#111120',
|
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#22223a',
|
|
||||||
padding: 16,
|
padding: 16,
|
||||||
marginBottom: 12,
|
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
|
// Steps
|
||||||
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 },
|
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 },
|
||||||
stepBadge: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#1a2a44', justifyContent: 'center', alignItems: 'center' },
|
stepBadge: { width: 20, height: 20, borderRadius: 10, justifyContent: 'center', alignItems: 'center' },
|
||||||
stepNum: { color: '#4a9eff', fontSize: 11, fontWeight: '700' },
|
stepNum: { fontSize: 11, fontWeight: '700' },
|
||||||
stepText: { color: '#8888aa', fontSize: 13, flex: 1 },
|
stepText: { fontSize: 13, flex: 1 },
|
||||||
linkBtn: { backgroundColor: '#0d2040', borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#1a3a66' },
|
linkBtn: { borderRadius: 6, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1 },
|
||||||
linkBtnText: { color: '#4a9eff', fontSize: 11, fontWeight: '600' },
|
linkBtnText: { fontSize: 11, fontWeight: '600' },
|
||||||
|
|
||||||
// Inputs
|
// Inputs
|
||||||
inputRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
inputRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||||
inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: '#1e1e30', marginVertical: 10 },
|
inputDivider: { height: StyleSheet.hairlineWidth, marginVertical: 10 },
|
||||||
inputLabel: { color: '#5a5a7a', fontSize: 12, width: 54, letterSpacing: 0.3 },
|
inputLabel: { fontSize: 12, width: 54, letterSpacing: 0.3 },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: '#d0d0e8',
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#2a2a48',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connect button
|
// Connect button
|
||||||
connectBtn: {
|
connectBtn: {
|
||||||
backgroundColor: '#1a3a6e',
|
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#4a9eff',
|
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
connectBtnBusy: { borderColor: '#2a4a7a', backgroundColor: '#0f1f3a' },
|
connectBtnText: { fontSize: 16, fontWeight: '700', letterSpacing: 3 },
|
||||||
connectBtnText: { color: '#4a9eff', fontSize: 16, fontWeight: '700', letterSpacing: 3 },
|
|
||||||
|
|
||||||
// Connected state
|
// Connected state
|
||||||
connectedBox: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12, marginTop: 4 },
|
connectedBox: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12, marginTop: 4 },
|
||||||
connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
greenDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#3ddc84' },
|
greenDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
connectedText: { color: '#3ddc84', fontWeight: '700', fontSize: 13 },
|
connectedText: { fontWeight: '700', fontSize: 13 },
|
||||||
goBtn: { flex: 1, backgroundColor: '#0d2820', borderRadius: 10, paddingVertical: 11, alignItems: 'center', borderWidth: 1, borderColor: '#1a4a30' },
|
goBtn: { flex: 1, borderRadius: 10, paddingVertical: 11, alignItems: 'center', borderWidth: 1 },
|
||||||
goBtnText: { color: '#3ddc84', fontWeight: '600', fontSize: 13 },
|
goBtnText: { fontWeight: '600', fontSize: 13 },
|
||||||
disconnectBtn: { backgroundColor: '#2a0d12', borderRadius: 10, paddingVertical: 11, paddingHorizontal: 14, borderWidth: 1, borderColor: '#4a1a22' },
|
disconnectBtn: { borderRadius: 10, paddingVertical: 11, paddingHorizontal: 14, borderWidth: 1 },
|
||||||
disconnectBtnText: { color: '#ff5c6e', fontSize: 13, fontWeight: '600' },
|
disconnectBtnText: { fontSize: 13, fontWeight: '600' },
|
||||||
|
|
||||||
// Terminal log
|
// Terminal log
|
||||||
terminal: { backgroundColor: '#08080f', borderRadius: 12, borderWidth: 1, borderColor: '#1a1a28', overflow: 'hidden', minHeight: 120 },
|
terminal: { borderRadius: 12, borderWidth: 1, overflow: 'hidden', minHeight: 120 },
|
||||||
terminalHeader: { color: '#3ddc84', fontSize: 10, fontWeight: '700', letterSpacing: 1.5, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, borderBottomWidth: 1, borderBottomColor: '#141422' },
|
terminalHeader: { fontSize: 10, fontWeight: '700', letterSpacing: 1.5, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6, borderBottomWidth: 1 },
|
||||||
terminalBody: { padding: 12 },
|
terminalBody: { padding: 12 },
|
||||||
terminalLine: { color: '#3a3a5a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 3, lineHeight: 16 },
|
terminalLine: { fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', marginBottom: 3, lineHeight: 16 },
|
||||||
terminalLineLatest: { color: '#6a6a9a' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 669 KiB |
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 222 KiB |
49
build-release.bat
Normal file
@ -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
|
||||||
558
docs/commercial-audit.md
Normal file
@ -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<any>` 绕过了 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. 其余低优先级项
|
||||||
434
docs/eas-build-guide.md
Normal file
@ -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** |
|
||||||
11
package-lock.json
generated
@ -18,6 +18,7 @@
|
|||||||
"expo-linking": "~56.0.13",
|
"expo-linking": "~56.0.13",
|
||||||
"expo-location": "~56.0.16",
|
"expo-location": "~56.0.16",
|
||||||
"expo-router": "~56.2.9",
|
"expo-router": "~56.2.9",
|
||||||
|
"expo-screen-orientation": "~56.0.5",
|
||||||
"expo-sharing": "~56.0.16",
|
"expo-sharing": "~56.0.16",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-sqlite": "~56.0.4",
|
"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": {
|
"node_modules/expo-server": {
|
||||||
"version": "56.0.5",
|
"version": "56.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz",
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"expo-linking": "~56.0.13",
|
"expo-linking": "~56.0.13",
|
||||||
"expo-location": "~56.0.16",
|
"expo-location": "~56.0.16",
|
||||||
"expo-router": "~56.2.9",
|
"expo-router": "~56.2.9",
|
||||||
|
"expo-screen-orientation": "~56.0.5",
|
||||||
"expo-sharing": "~56.0.16",
|
"expo-sharing": "~56.0.16",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~56.0.10",
|
||||||
"expo-sqlite": "~56.0.4",
|
"expo-sqlite": "~56.0.4",
|
||||||
@ -40,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
import { CHANNEL_COLORS } from '../protocol/constants';
|
import { CHANNEL_COLORS } from '../protocol/constants';
|
||||||
|
import { useTheme } from '../design/tokens';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
maxChannels: number;
|
maxChannels: number;
|
||||||
@ -9,6 +10,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={S.row}>
|
<View style={S.row}>
|
||||||
{Array.from({ length: maxChannels }, (_, i) => {
|
{Array.from({ length: maxChannels }, (_, i) => {
|
||||||
@ -19,14 +22,14 @@ export function ChannelSelector({ maxChannels, visible, onToggle }: Props) {
|
|||||||
key={i}
|
key={i}
|
||||||
style={[
|
style={[
|
||||||
S.chip,
|
S.chip,
|
||||||
{ borderColor: active ? color + '88' : '#1e1e30' },
|
{ backgroundColor: theme.bg.base, borderColor: active ? color + '88' : theme.bg.border },
|
||||||
active && { backgroundColor: color + '14' },
|
active && { backgroundColor: color + '14' },
|
||||||
]}
|
]}
|
||||||
onPress={() => onToggle(i)}
|
onPress={() => onToggle(i)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[S.dot, { backgroundColor: active ? color : '#2a2a3a' }]} />
|
<View style={[S.dot, { backgroundColor: active ? color : theme.text.ghost }]} />
|
||||||
<Text style={[S.label, { color: active ? color : '#3a3a5a' }]}>CH{i + 1}</Text>
|
<Text style={[S.label, { color: active ? color : theme.text.muted }]}>CH{i + 1}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -44,7 +47,6 @@ const S = StyleSheet.create({
|
|||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 5,
|
paddingVertical: 5,
|
||||||
backgroundColor: '#0c0c18',
|
|
||||||
},
|
},
|
||||||
dot: { width: 6, height: 6, borderRadius: 3 },
|
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||||
label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 },
|
label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.3 },
|
||||||
|
|||||||
@ -3,18 +3,20 @@ import { View, Text, StyleSheet, Platform } from 'react-native';
|
|||||||
import { useDeviceStore } from '../stores/deviceStore';
|
import { useDeviceStore } from '../stores/deviceStore';
|
||||||
import { useDataStore } from '../stores/dataStore';
|
import { useDataStore } from '../stores/dataStore';
|
||||||
import { formatBattery, formatTemperature } from '../utils/format';
|
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 (
|
return (
|
||||||
<View style={[S.chip, color ? { borderColor: color + '44' } : null]}>
|
<View style={[S.chip, { backgroundColor: theme.bg.raised, borderColor: theme.bg.border }, color ? { borderColor: color + '44' } : null]}>
|
||||||
{color && <View style={[S.chipDot, { backgroundColor: color }]} />}
|
{color && <View style={[S.chipDot, { backgroundColor: color }]} />}
|
||||||
<Text style={S.chipLabel}>{label}</Text>
|
<Text style={[S.chipLabel, { color: theme.text.muted }]}>{label}</Text>
|
||||||
<Text style={[S.chipValue, color ? { color } : null]}>{value}</Text>
|
<Text style={[S.chipValue, { color: color ?? theme.text.secondary }]}>{value}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceStatusBar() {
|
export function DeviceStatusBar() {
|
||||||
|
const theme = useTheme();
|
||||||
const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore();
|
const { batteryVolt, temperature, gpsStatus, sdStatus, deviceStatus } = useDeviceStore();
|
||||||
const frame = useDataStore((s) => s.currentFrame);
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
const meta = frame?.meta;
|
const meta = frame?.meta;
|
||||||
@ -26,22 +28,22 @@ export function DeviceStatusBar() {
|
|||||||
|
|
||||||
const running = deviceStatus === 'running';
|
const running = deviceStatus === 'running';
|
||||||
const single = deviceStatus === 'single';
|
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 ? '◎ 单次' : '○ 停止';
|
const statusLabel = running ? '● 运行中' : single ? '◎ 单次' : '○ 停止';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={S.bar}>
|
<View style={[S.bar, { backgroundColor: theme.bg.surface, borderBottomColor: theme.bg.border }]}>
|
||||||
<View style={[S.statusPill, { borderColor: statusColor + '55', backgroundColor: statusColor + '18' }]}>
|
<View style={[S.statusPill, { borderColor: statusColor + '55', backgroundColor: statusColor + '18' }]}>
|
||||||
<Text style={[S.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
<Text style={[S.statusText, { color: statusColor }]}>{statusLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Chip label="GPS" value={coordStr} color={hasGps ? '#3ddc84' : '#3a3a5a'} />
|
<Chip label="GPS" value={coordStr} color={hasGps ? theme.green.fg : theme.text.muted} theme={theme} />
|
||||||
<Chip label="SD" value={sdStatus > 0 ? 'OK' : '--'} color={sdStatus > 0 ? '#4a9eff' : '#3a3a5a'} />
|
<Chip label="SD" value={sdStatus > 0 ? 'OK' : '--'} color={sdStatus > 0 ? theme.blue.fg : theme.text.muted} theme={theme} />
|
||||||
{!!batteryVolt && <Chip label="电池" value={formatBattery(batteryVolt)} />}
|
{!!batteryVolt && <Chip label="电池" value={formatBattery(batteryVolt)} theme={theme} />}
|
||||||
{!!temperature && <Chip label="温度" value={formatTemperature(temperature)} />}
|
{!!temperature && <Chip label="温度" value={formatTemperature(temperature)} theme={theme} />}
|
||||||
{frame && (
|
{frame && (
|
||||||
<View style={S.frameCount}>
|
<View style={S.frameCount}>
|
||||||
<Text style={S.frameCountText}>#{frame.frameId}</Text>
|
<Text style={[S.frameCountText, { color: theme.text.ghost }]}>#{frame.frameId}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@ -51,14 +53,12 @@ export function DeviceStatusBar() {
|
|||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
bar: {
|
bar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: '#0c0c18',
|
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#1a1a2a',
|
|
||||||
},
|
},
|
||||||
statusPill: {
|
statusPill: {
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
@ -71,16 +71,14 @@ const S = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
backgroundColor: '#111120',
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#22223a',
|
|
||||||
paddingHorizontal: 7,
|
paddingHorizontal: 7,
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
},
|
},
|
||||||
chipDot: { width: 5, height: 5, borderRadius: 3 },
|
chipDot: { width: 5, height: 5, borderRadius: 3 },
|
||||||
chipLabel: { color: '#3a3a5a', fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
|
chipLabel: { fontSize: 9, fontWeight: '600', letterSpacing: 0.5 },
|
||||||
chipValue: { color: '#6a6a8a', fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
chipValue: { fontSize: 10, fontWeight: '500', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
frameCount: { marginLeft: 'auto' },
|
frameCount: { marginLeft: 'auto' },
|
||||||
frameCountText: { color: '#2a2a4a', fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
frameCountText: { fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
});
|
});
|
||||||
|
|||||||
33
src/components/NoProjectGate.tsx
Normal file
@ -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 (
|
||||||
|
<View style={S.root}>
|
||||||
|
<Text style={[S.icon, { color: theme.bg.border }]}>◫</Text>
|
||||||
|
<Text style={[S.title, { color: theme.text.muted }]}>请先创建工程</Text>
|
||||||
|
<Text style={[S.sub, { color: theme.text.ghost }]}>
|
||||||
|
在「工程」页面创建工程后即可开始采集
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.btn, { backgroundColor: theme.blue.bg, borderColor: theme.blue.fg }]}
|
||||||
|
onPress={() => router.push('/(tabs)/projects')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[S.btnTxt, { color: theme.blue.fg }]}>前往工程页</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@ -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 { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Switch, Platform } from 'react-native';
|
||||||
import { useDeviceStore } from '../stores/deviceStore';
|
import { useDeviceStore } from '../stores/deviceStore';
|
||||||
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
|
import { SEND_FREQ_TABLE, SAMPLE_FREQ_TABLE, AMP_RATIO_TABLE, SOURCE_MODE_TABLE } from '../protocol/constants';
|
||||||
|
import { useTheme } from '../design/tokens';
|
||||||
|
|
||||||
interface PickerRowProps {
|
interface PickerRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@ -10,21 +11,33 @@ interface PickerRowProps {
|
|||||||
onChange: (code: number) => void;
|
onChange: (code: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHIP_W = 72;
|
||||||
|
|
||||||
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
function PickerRow({ label, options, value, onChange }: PickerRowProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const scrollRef = useRef<ScrollView>(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 (
|
return (
|
||||||
<View style={S.row}>
|
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.rowLabel}>{label}</Text>
|
<Text style={[S.rowLabel, { color: theme.text.muted }]}>{label}</Text>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={S.optScroll}>
|
<ScrollView ref={scrollRef} horizontal showsHorizontalScrollIndicator={false} style={S.optScroll}>
|
||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const active = o.code === value;
|
const active = o.code === value;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={o.code}
|
key={o.code}
|
||||||
style={[S.optChip, active && S.optChipActive]}
|
style={[S.optChip, { borderColor: theme.bg.border }, active && { borderColor: theme.blue.fg + '55', backgroundColor: theme.blue.bg }]}
|
||||||
onPress={() => onChange(o.code)}
|
onPress={() => onChange(o.code)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[S.optText, active && S.optTextActive]}>{o.label}</Text>
|
<Text style={[S.optText, { color: theme.text.muted }, active && { color: theme.blue.fg, fontWeight: '700' }]}>{o.label}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -44,6 +57,7 @@ interface NumberRowProps {
|
|||||||
|
|
||||||
// Keeps a local draft string while typing; commits a clamped integer on blur.
|
// Keeps a local draft string while typing; commits a clamped integer on blur.
|
||||||
function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) {
|
function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps) {
|
||||||
|
const theme = useTheme();
|
||||||
const [draft, setDraft] = useState(String(value));
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
|
||||||
// Sync when the store value changes externally (e.g. presets).
|
// Sync when the store value changes externally (e.g. presets).
|
||||||
@ -62,31 +76,32 @@ function NumberRow({ label, value, min, max, onCommit, suffix }: NumberRowProps)
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={S.row}>
|
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.rowLabel}>{label}</Text>
|
<Text style={[S.rowLabel, { color: theme.text.muted }]}>{label}</Text>
|
||||||
<View style={S.inputWrap}>
|
<View style={S.inputWrap}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[S.input, invalid && S.inputInvalid]}
|
style={[S.input, { color: theme.text.primary, borderBottomColor: theme.bg.border }, invalid && { borderBottomColor: theme.red.fg }]}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChangeText={setDraft}
|
onChangeText={setDraft}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
selectTextOnFocus
|
selectTextOnFocus
|
||||||
placeholderTextColor="#3a3a5a"
|
placeholderTextColor={theme.text.muted}
|
||||||
/>
|
/>
|
||||||
{suffix && <Text style={S.suffix}>{suffix}</Text>}
|
{suffix && <Text style={[S.suffix, { color: theme.text.muted }]}>{suffix}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{invalid && (
|
{invalid && (
|
||||||
<Text style={S.hint}>{min}–{max}</Text>
|
<Text style={[S.hint, { color: theme.red.fg }]}>{min}–{max}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({ title }: { title: string }) {
|
function SectionHeader({ title }: { title: string }) {
|
||||||
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<View style={S.sectionHeader}>
|
<View style={S.sectionHeader}>
|
||||||
<Text style={S.sectionTitle}>{title}</Text>
|
<Text style={[S.sectionTitle, { color: theme.text.muted }]}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -94,6 +109,7 @@ function SectionHeader({ title }: { title: string }) {
|
|||||||
interface FormProps { onAnyChange?: () => void }
|
interface FormProps { onAnyChange?: () => void }
|
||||||
|
|
||||||
export function ParamForm({ onAnyChange }: FormProps = {}) {
|
export function ParamForm({ onAnyChange }: FormProps = {}) {
|
||||||
|
const theme = useTheme();
|
||||||
const { config, updateConfig } = useDeviceStore();
|
const { config, updateConfig } = useDeviceStore();
|
||||||
const patch = (partial: Partial<typeof config>) => {
|
const patch = (partial: Partial<typeof config>) => {
|
||||||
updateConfig(partial);
|
updateConfig(partial);
|
||||||
@ -101,24 +117,24 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={S.container}>
|
<View style={[S.container, { backgroundColor: theme.bg.base }]}>
|
||||||
|
|
||||||
<SectionHeader title="采集配置" />
|
<SectionHeader title="采集配置" />
|
||||||
|
|
||||||
{/* Channel count */}
|
{/* Channel count */}
|
||||||
<View style={S.row}>
|
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.rowLabel}>通道数</Text>
|
<Text style={[S.rowLabel, { color: theme.text.muted }]}>通道数</Text>
|
||||||
<View style={S.channelRow}>
|
<View style={S.channelRow}>
|
||||||
{[1, 2, 3, 4, 5, 6].map((n) => {
|
{[1, 2, 3, 4, 5, 6].map((n) => {
|
||||||
const active = config.channelNum === n;
|
const active = config.channelNum === n;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={n}
|
key={n}
|
||||||
style={[S.chChip, active && S.chChipActive]}
|
style={[S.chChip, { borderColor: theme.bg.border, backgroundColor: theme.bg.surface }, active && { borderColor: theme.blue.fg + '66', backgroundColor: theme.blue.bg }]}
|
||||||
onPress={() => patch({ channelNum: n })}
|
onPress={() => patch({ channelNum: n })}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[S.chText, active && S.chTextActive]}>{n}</Text>
|
<Text style={[S.chText, { color: theme.text.muted }, active && { color: theme.blue.fg }]}>{n}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -147,22 +163,22 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
|
|||||||
<SectionHeader title="高级设置" />
|
<SectionHeader title="高级设置" />
|
||||||
|
|
||||||
{/* Reverse accumulation */}
|
{/* Reverse accumulation */}
|
||||||
<View style={S.row}>
|
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.rowLabel}>反向叠加</Text>
|
<Text style={[S.rowLabel, { color: theme.text.muted }]}>反向叠加</Text>
|
||||||
<View style={S.switchRow}>
|
<View style={S.switchRow}>
|
||||||
<Text style={S.switchLabel}>CH1-3</Text>
|
<Text style={[S.switchLabel, { color: theme.text.muted }]}>CH1-3</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={config.negAcc123}
|
value={config.negAcc123}
|
||||||
onValueChange={(v) => patch({ negAcc123: v })}
|
onValueChange={(v) => patch({ negAcc123: v })}
|
||||||
thumbColor={config.negAcc123 ? '#4a9eff' : '#444'}
|
thumbColor={config.negAcc123 ? theme.blue.fg : theme.text.muted}
|
||||||
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
|
trackColor={{ false: theme.bg.border, true: theme.blue.border }}
|
||||||
/>
|
/>
|
||||||
<Text style={S.switchLabel}>CH4-6</Text>
|
<Text style={[S.switchLabel, { color: theme.text.muted }]}>CH4-6</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={config.negAcc456}
|
value={config.negAcc456}
|
||||||
onValueChange={(v) => patch({ negAcc456: v })}
|
onValueChange={(v) => patch({ negAcc456: v })}
|
||||||
thumbColor={config.negAcc456 ? '#4a9eff' : '#444'}
|
thumbColor={config.negAcc456 ? theme.blue.fg : theme.text.muted}
|
||||||
trackColor={{ false: '#1e1e2e', true: '#1a3a66' }}
|
trackColor={{ false: theme.bg.border, true: theme.blue.border }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -175,16 +191,16 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* File prefix */}
|
{/* File prefix */}
|
||||||
<View style={S.row}>
|
<View style={[S.row, { borderBottomColor: theme.bg.divider, backgroundColor: theme.bg.surface }]}>
|
||||||
<Text style={S.rowLabel}>文件前缀</Text>
|
<Text style={[S.rowLabel, { color: theme.text.muted }]}>文件前缀</Text>
|
||||||
<View style={S.inputWrap}>
|
<View style={S.inputWrap}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={S.input}
|
style={[S.input, { color: theme.text.primary, borderBottomColor: theme.bg.border }]}
|
||||||
value={config.filePrefix}
|
value={config.filePrefix}
|
||||||
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
|
onChangeText={(v) => patch({ filePrefix: v.slice(0, 15) })}
|
||||||
maxLength={15}
|
maxLength={15}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
placeholderTextColor="#3a3a5a"
|
placeholderTextColor={theme.text.muted}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -194,7 +210,7 @@ export function ParamForm({ onAnyChange }: FormProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
container: { backgroundColor: '#090912' },
|
container: {},
|
||||||
|
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@ -202,7 +218,6 @@ const S = StyleSheet.create({
|
|||||||
paddingBottom: 6,
|
paddingBottom: 6,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
color: '#3a3a5a',
|
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
@ -215,34 +230,27 @@ const S = StyleSheet.create({
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomColor: '#141422',
|
|
||||||
gap: 12,
|
gap: 12,
|
||||||
backgroundColor: '#0e0e1c',
|
|
||||||
marginHorizontal: 0,
|
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
|
// Picker chips
|
||||||
optScroll: { flex: 1 },
|
optScroll: { flex: 1 },
|
||||||
optChip: { borderWidth: 1, borderColor: '#1e1e30', borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 },
|
optChip: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 9, paddingVertical: 4, marginRight: 5 },
|
||||||
optChipActive: { borderColor: '#4a9eff55', backgroundColor: '#0d2040' },
|
optText: { fontSize: 11 },
|
||||||
optText: { color: '#3a3a5a', fontSize: 11 },
|
|
||||||
optTextActive: { color: '#4a9eff', fontWeight: '700' },
|
|
||||||
|
|
||||||
// Number input
|
// Number input
|
||||||
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
inputWrap: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: '#c0c0d8',
|
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: '#1e1e30',
|
|
||||||
},
|
},
|
||||||
inputInvalid: { borderBottomColor: '#ff5c6e' },
|
suffix: { fontSize: 10, marginLeft: 8 },
|
||||||
suffix: { color: '#3a3a5a', fontSize: 10, marginLeft: 8 },
|
hint: { fontSize: 9, marginLeft: 4 },
|
||||||
hint: { color: '#ff5c6e', fontSize: 9, marginLeft: 4 },
|
|
||||||
|
|
||||||
// Channel chips
|
// Channel chips
|
||||||
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
|
channelRow: { flexDirection: 'row', gap: 6, flex: 1 },
|
||||||
@ -251,16 +259,12 @@ const S = StyleSheet.create({
|
|||||||
height: 30,
|
height: 30,
|
||||||
borderRadius: 15,
|
borderRadius: 15,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#1e1e30',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#0c0c18',
|
|
||||||
},
|
},
|
||||||
chChipActive: { borderColor: '#4a9eff66', backgroundColor: '#0d1e3a' },
|
chText: { fontSize: 12, fontWeight: '700' },
|
||||||
chText: { color: '#3a3a5a', fontSize: 12, fontWeight: '700' },
|
|
||||||
chTextActive: { color: '#4a9eff' },
|
|
||||||
|
|
||||||
// Switch row
|
// Switch row
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
|
switchRow: { flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 },
|
||||||
switchLabel: { color: '#4a4a6a', fontSize: 12 },
|
switchLabel: { fontSize: 12 },
|
||||||
});
|
});
|
||||||
|
|||||||
144
src/components/SessionSelector.tsx
Normal file
@ -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<SessionInfo[]>([]);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.trigger, { backgroundColor: theme.bg.raised, borderColor: theme.bg.border }]}
|
||||||
|
onPress={handleOpen}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={S.triggerLeft}>
|
||||||
|
<Text style={[S.label, { color: theme.text.muted }]}>测线</Text>
|
||||||
|
<Text style={[S.sessionId, { color: theme.text.primary }]} numberOfLines={1}>
|
||||||
|
{selectedSessionId || '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{current && (
|
||||||
|
<Text style={[S.count, { color: theme.text.ghost }]}>{current.frameCount} 点</Text>
|
||||||
|
)}
|
||||||
|
<Text style={[S.arrow, { color: theme.text.muted }]}>▾</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={() => setVisible(false)}>
|
||||||
|
<TouchableOpacity style={S.backdrop} activeOpacity={1} onPress={() => setVisible(false)} />
|
||||||
|
<View style={[S.sheet, { backgroundColor: theme.bg.surface }]}>
|
||||||
|
<View style={[S.sheetHandle, { backgroundColor: theme.bg.border }]} />
|
||||||
|
<Text style={[S.sheetTitle, { color: theme.text.secondary }]}>选择测线</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator style={{ padding: 24 }} color={theme.blue.fg} />
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<Text style={[S.empty, { color: theme.text.ghost }]}>当前工程无测线</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={sessions}
|
||||||
|
keyExtractor={(s) => 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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.item, { borderBottomColor: theme.bg.divider }, active && { backgroundColor: theme.blue.bg }]}
|
||||||
|
onPress={() => handleSelect(item.sessionId)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{active && <View style={[S.dot, { backgroundColor: theme.blue.fg }]} />}
|
||||||
|
<View style={S.itemLeft}>
|
||||||
|
<Text style={[S.itemId, { color: active ? theme.blue.fg : theme.text.primary }]} numberOfLines={1}>
|
||||||
|
{item.sessionId}
|
||||||
|
</Text>
|
||||||
|
<Text style={[S.itemSub, { color: theme.text.muted }]}>
|
||||||
|
{dateStr} · {item.frameCount} 测点
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{active && <Text style={[S.activeLabel, { color: theme.blue.fg }]}>当前</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState, useCallback } from 'react';
|
||||||
import { View, StyleSheet, Text } from 'react-native';
|
import { View, Text } from 'react-native';
|
||||||
import { Canvas, Path, Skia, Line, vec } from '@shopify/react-native-skia';
|
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 { CHANNEL_COLORS } from '../protocol/constants';
|
||||||
|
import { useTheme } from '../design/tokens';
|
||||||
import type { WaveformData } from '../hooks/useWaveform';
|
import type { WaveformData } from '../hooks/useWaveform';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -9,14 +12,14 @@ interface Props {
|
|||||||
visibleChannels: boolean[];
|
visibleChannels: boolean[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
logScale?: boolean; // default true
|
logScale?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING = { top: 14, right: 14, bottom: 34, left: 56 };
|
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 LOG_Y_TICKS = [1e-1, 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7];
|
||||||
const X_TICK_COUNT = 5;
|
const X_TICK_COUNT = 5;
|
||||||
|
|
||||||
// Pick a "nice" step for axis ticks.
|
|
||||||
function niceStep(range: number, targetCount: number): number {
|
function niceStep(range: number, targetCount: number): number {
|
||||||
const rough = range / targetCount;
|
const rough = range / targetCount;
|
||||||
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
|
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||||
@ -26,11 +29,13 @@ function niceStep(range: number, targetCount: number): number {
|
|||||||
return 10 * mag;
|
return 10 * mag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function xTicks(totalMs: number): number[] {
|
function xTicks(minMs: number, maxMs: number): number[] {
|
||||||
if (totalMs <= 0) return [];
|
const range = maxMs - minMs;
|
||||||
const step = niceStep(totalMs, X_TICK_COUNT);
|
if (range <= 0) return [];
|
||||||
|
const step = niceStep(range, X_TICK_COUNT);
|
||||||
|
const start = Math.ceil(minMs / step) * step;
|
||||||
const ticks: number[] = [];
|
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;
|
return ticks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,19 +69,110 @@ function formatMsShort(ms: number): string {
|
|||||||
return `${(ms * 1000).toFixed(0)}μs`;
|
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 plotW = width - PADDING.left - PADDING.right;
|
||||||
const plotH = height - PADDING.top - PADDING.bottom;
|
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 ────────────────────────────────────────────────────────────
|
// ── Y mapping ────────────────────────────────────────────────────────────
|
||||||
const yLogMin = data.minLogUV - 0.5;
|
const yLogMin = data.minLogUV - 0.5;
|
||||||
const yLogMax = data.maxLogUV + 0.5;
|
const yLogMax = data.maxLogUV + 0.5;
|
||||||
const yLogRange = yLogMax - yLogMin;
|
|
||||||
|
|
||||||
const yLinMin = data.minUV;
|
const baseYMin = data.minUV;
|
||||||
const yLinMax = data.maxUV;
|
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 yLinRange = Math.max(yLinMax - yLinMin, 1e-9);
|
||||||
|
|
||||||
|
const yLogRange = yLogMax - yLogMin;
|
||||||
|
|
||||||
const toY = useMemo(() => {
|
const toY = useMemo(() => {
|
||||||
if (logScale) {
|
if (logScale) {
|
||||||
return (uv: number): number => {
|
return (uv: number): number => {
|
||||||
@ -91,11 +187,8 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
}, [logScale, yLogMin, yLogRange, yLinMin, yLinRange, plotH]);
|
}, [logScale, yLogMin, yLogRange, yLinMin, yLinRange, plotH]);
|
||||||
|
|
||||||
// ── X mapping ────────────────────────────────────────────────────────────
|
// ── X mapping ────────────────────────────────────────────────────────────
|
||||||
const toX = (i: number, total: number): number =>
|
const toX = (ms: number): number =>
|
||||||
PADDING.left + (i / Math.max(total - 1, 1)) * plotW;
|
PADDING.left + ((ms - xMinMs) / Math.max(xMaxMs - xMinMs, 1e-9)) * plotW;
|
||||||
|
|
||||||
const toXms = (ms: number): number =>
|
|
||||||
PADDING.left + (ms / Math.max(data.totalTimeMs, 1e-9)) * plotW;
|
|
||||||
|
|
||||||
// ── Grid / tick values ──────────────────────────────────────────────────
|
// ── Grid / tick values ──────────────────────────────────────────────────
|
||||||
const yGridValues = useMemo(() => {
|
const yGridValues = useMemo(() => {
|
||||||
@ -108,7 +201,7 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
return linYTicks(yLinMin, yLinMax);
|
return linYTicks(yLinMin, yLinMax);
|
||||||
}, [logScale, yLogMin, yLogMax, 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 ────────────────────────────────────────────────────────
|
// ── Channel paths ────────────────────────────────────────────────────────
|
||||||
const channelPaths = useMemo(() => {
|
const channelPaths = useMemo(() => {
|
||||||
@ -116,25 +209,28 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
if (!visibleChannels[idx]) return null;
|
if (!visibleChannels[idx]) return null;
|
||||||
const path = Skia.Path.Make();
|
const path = Skia.Path.Make();
|
||||||
let moved = false;
|
let moved = false;
|
||||||
|
const timeStep = data.totalTimeMs / Math.max(ch.length - 1, 1);
|
||||||
for (let i = 0; i < ch.length; i++) {
|
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;
|
if (logScale && Math.abs(ch[i]) < 1e-12) continue;
|
||||||
const x = toX(i, ch.length);
|
const x = toX(ms);
|
||||||
const y = toY(ch[i]);
|
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; }
|
if (!moved) { path.moveTo(x, y); moved = true; }
|
||||||
else path.lineTo(x, y);
|
else path.lineTo(x, y);
|
||||||
}
|
}
|
||||||
return path;
|
return path;
|
||||||
});
|
});
|
||||||
}, [data, visibleChannels, toY, plotW, plotH]);
|
}, [data, visibleChannels, toY, xMinMs, xMaxMs, plotW, plotH, logScale]);
|
||||||
|
|
||||||
const top = PADDING.top;
|
const top = PADDING.top;
|
||||||
const bot = PADDING.top + plotH;
|
const bot = PADDING.top + plotH;
|
||||||
const left = PADDING.left;
|
const left = PADDING.left;
|
||||||
const right = PADDING.left + plotW;
|
const right = PADDING.left + plotW;
|
||||||
|
|
||||||
return (
|
const chartContent = (
|
||||||
<View style={[S.container, { width, height }]}>
|
<View style={[{ backgroundColor: C.bg, width, height, position: 'relative' as const }]}>
|
||||||
<Canvas style={{ width, height }}>
|
<Canvas style={{ width, height }}>
|
||||||
|
|
||||||
{/* Y grid lines */}
|
{/* Y grid lines */}
|
||||||
@ -143,28 +239,32 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
if (!isFinite(y) || y < top - 1 || y > bot + 1) return null;
|
||||||
return (
|
return (
|
||||||
<Line key={v} p1={vec(left, y)} p2={vec(right, y)}
|
<Line key={v} p1={vec(left, y)} p2={vec(right, y)}
|
||||||
color="#333333" strokeWidth={0.5} />
|
color={C.grid} strokeWidth={0.5} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* X grid lines */}
|
{/* X grid lines */}
|
||||||
{xTickValues.map((ms) => {
|
{xTickValues.map((ms) => {
|
||||||
const x = toXms(ms);
|
const x = toX(ms);
|
||||||
|
if (x < left - 1 || x > right + 1) return null;
|
||||||
return (
|
return (
|
||||||
<Line key={ms} p1={vec(x, top)} p2={vec(x, bot)}
|
<Line key={ms} p1={vec(x, top)} p2={vec(x, bot)}
|
||||||
color="#2a2a2a" strokeWidth={0.5} />
|
color={C.gridFine} strokeWidth={0.5} />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Baseline */}
|
{/* Baseline */}
|
||||||
{!logScale && (
|
{!logScale && (() => {
|
||||||
<Line p1={vec(left, toY(0))} p2={vec(right, toY(0))}
|
const y0 = toY(0);
|
||||||
color="#555555" strokeWidth={0.8} />
|
return isFinite(y0) && y0 >= top && y0 <= bot ? (
|
||||||
)}
|
<Line p1={vec(left, y0)} p2={vec(right, y0)}
|
||||||
|
color={C.axis} strokeWidth={0.8} />
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* X axis */}
|
{/* X axis */}
|
||||||
<Line p1={vec(left, bot)} p2={vec(right, bot)}
|
<Line p1={vec(left, bot)} p2={vec(right, bot)}
|
||||||
color="#555555" strokeWidth={1} />
|
color={C.axis} strokeWidth={1} />
|
||||||
|
|
||||||
{/* Channel paths */}
|
{/* Channel paths */}
|
||||||
{channelPaths.map((path, idx) => {
|
{channelPaths.map((path, idx) => {
|
||||||
@ -172,7 +272,7 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
return (
|
return (
|
||||||
<Path key={idx} path={path}
|
<Path key={idx} path={path}
|
||||||
color={CHANNEL_COLORS[idx]}
|
color={CHANNEL_COLORS[idx]}
|
||||||
style="stroke" strokeWidth={1.5}
|
style="stroke" strokeWidth={interactive ? 2.5 : 1.5}
|
||||||
strokeJoin="round" strokeCap="round" />
|
strokeJoin="round" strokeCap="round" />
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -185,7 +285,7 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
const label = logScale ? formatLogTick(v) : formatUVShort(v);
|
const label = logScale ? formatLogTick(v) : formatUVShort(v);
|
||||||
return (
|
return (
|
||||||
<Text key={v}
|
<Text key={v}
|
||||||
style={[S.yLabel, { top: y - 7, left: 2, width: PADDING.left - 4 }]}>
|
style={{ position: 'absolute', top: y - 7, left: 2, width: PADDING.left - 4, color: C.label, fontSize: 9, textAlign: 'right' }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@ -193,33 +293,39 @@ export function WaveformChart({ data, visibleChannels, width, height, logScale =
|
|||||||
|
|
||||||
{/* X-axis tick labels */}
|
{/* X-axis tick labels */}
|
||||||
{xTickValues.map((ms) => {
|
{xTickValues.map((ms) => {
|
||||||
const x = toXms(ms);
|
const x = toX(ms);
|
||||||
|
if (x < left - 10 || x > right + 10) return null;
|
||||||
return (
|
return (
|
||||||
<Text key={ms}
|
<Text key={ms}
|
||||||
style={[S.xLabel, { top: height - 20, left: x - 18, width: 36 }]}>
|
style={{ position: 'absolute', top: height - 20, left: x - 18, width: 36, color: C.label, fontSize: 9, textAlign: 'center' }}>
|
||||||
{formatMsShort(ms)}
|
{formatMsShort(ms)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Axis unit labels */}
|
{/* Axis unit labels */}
|
||||||
<Text style={[S.unitLabel, { top: 2, left: 2 }]}>μV</Text>
|
<Text style={{ position: 'absolute', top: 2, left: 2, color: C.labelDim, fontSize: 9 }}>μV</Text>
|
||||||
<Text style={[S.unitLabel, { top: height - 14, left: PADDING.left + plotW - 12 }]}>
|
<Text style={{ position: 'absolute', top: height - 14, left: PADDING.left + plotW - 12, color: C.labelDim, fontSize: 9 }}>
|
||||||
{data.totalTimeMs >= 1000 ? 's' : 'ms'}
|
{data.totalTimeMs >= 1000 ? 's' : 'ms'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Scale mode badge */}
|
{/* Scale mode badge */}
|
||||||
<Text style={[S.scaleBadge, { top: 2, right: 4 }]}>
|
<Text style={{ position: 'absolute', top: 2, right: 4, color: C.badge, fontSize: 8, fontWeight: '700', letterSpacing: 0.5 }}>
|
||||||
{logScale ? 'LOG' : 'LIN'}
|
{logScale ? 'LOG' : 'LIN'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{/* Zoom indicator */}
|
||||||
|
{(scaleX > 1.01 || scaleY > 1.01) && (
|
||||||
|
<Text style={{ position: 'absolute', top: 2, right: 36, color: C.zoomBadge, fontSize: 8, fontWeight: '700' }}>
|
||||||
|
×{scaleX.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
return <GestureDetector gesture={composed}>{chartContent}</GestureDetector>;
|
||||||
|
}
|
||||||
|
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 },
|
|
||||||
});
|
|
||||||
|
|||||||
@ -4,24 +4,26 @@ import { useConnectionStore } from '../../stores/connectionStore';
|
|||||||
import { useDeviceStore } from '../../stores/deviceStore';
|
import { useDeviceStore } from '../../stores/deviceStore';
|
||||||
import { useDataStore } from '../../stores/dataStore';
|
import { useDataStore } from '../../stores/dataStore';
|
||||||
import { formatBattery, formatTemperature } from '../../utils/format';
|
import { formatBattery, formatTemperature } from '../../utils/format';
|
||||||
import { Colors } from '../../design/tokens';
|
import { useTheme, type ThemeColors } from '../../design/tokens';
|
||||||
|
|
||||||
function Pill({
|
function Pill({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
color,
|
color,
|
||||||
|
theme,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
theme: ThemeColors;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const inner = (
|
const inner = (
|
||||||
<View style={[S.pill, { borderColor: color + '44' }]}>
|
<View style={[styles.pill, { backgroundColor: theme.bg.raised, borderColor: color + '44' }]}>
|
||||||
<View style={[S.dot, { backgroundColor: color }]} />
|
<View style={[styles.dot, { backgroundColor: color }]} />
|
||||||
<Text style={S.pillLabel}>{label}</Text>
|
<Text style={[styles.pillLabel, { color: theme.text.ghost }]}>{label}</Text>
|
||||||
<Text style={[S.pillValue, { color }]}>{value}</Text>
|
<Text style={[styles.pillValue, { color }]}>{value}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
if (onPress) return <TouchableOpacity onPress={onPress} activeOpacity={0.7}>{inner}</TouchableOpacity>;
|
if (onPress) return <TouchableOpacity onPress={onPress} activeOpacity={0.7}>{inner}</TouchableOpacity>;
|
||||||
@ -29,91 +31,87 @@ function Pill({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GlobalStatusBar() {
|
export function GlobalStatusBar() {
|
||||||
|
const theme = useTheme();
|
||||||
const { status, showModal } = useConnectionStore();
|
const { status, showModal } = useConnectionStore();
|
||||||
const { deviceStatus, batteryVolt, temperature, gpsStatus, sdStatus } = useDeviceStore();
|
const { deviceStatus, batteryVolt, temperature, gpsStatus, sdStatus } = useDeviceStore();
|
||||||
const frame = useDataStore((s) => s.currentFrame);
|
const frame = useDataStore((s) => s.currentFrame);
|
||||||
const meta = frame?.meta;
|
const meta = frame?.meta;
|
||||||
|
|
||||||
const connected = status === 'connected';
|
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 connLabel = connected ? '已连接' : status === 'connecting' ? '连接中' : status === 'reconnecting' ? '重连' : '未连接';
|
||||||
|
|
||||||
const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
|
const hasGps = gpsStatus > 0 || (meta?.gpsStatus ?? 0) > 0;
|
||||||
const hasSd = sdStatus > 0;
|
const hasSd = sdStatus === 0;
|
||||||
|
|
||||||
const running = deviceStatus === 'running';
|
const running = deviceStatus === 'running';
|
||||||
const single = deviceStatus === 'single';
|
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 ? '◎ 单次' : '○ 停止';
|
const stateLabel = running ? '● 运行' : single ? '◎ 单次' : '○ 停止';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={S.bar}>
|
<View style={[styles.bar, { backgroundColor: theme.bg.void, borderBottomColor: theme.bg.border }]}>
|
||||||
{/* Connection — tappable */}
|
{/* Connection — tappable */}
|
||||||
<Pill label="TCP" value={connLabel} color={connColor} onPress={showModal} />
|
<Pill label="TCP" value={connLabel} color={connColor} theme={theme} onPress={showModal} />
|
||||||
|
|
||||||
{/* Device run state */}
|
{/* Device run state */}
|
||||||
{connected && (
|
{connected && (
|
||||||
<View style={[S.pill, { borderColor: stateColor + '44' }]}>
|
<View style={[styles.pill, { backgroundColor: theme.bg.raised, borderColor: stateColor + '44' }]}>
|
||||||
<Text style={[S.stateText, { color: stateColor }]}>{stateLabel}</Text>
|
<Text style={[styles.stateText, { color: stateColor }]}>{stateLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GPS */}
|
{/* GPS */}
|
||||||
<Pill label="GPS" value={hasGps ? '定位' : '--'} color={hasGps ? Colors.green.fg : Colors.text.ghost} />
|
<Pill label="GPS" value={hasGps ? '定位' : '--'} color={hasGps ? theme.green.fg : theme.text.ghost} theme={theme} />
|
||||||
|
|
||||||
{/* SD */}
|
{/* SD */}
|
||||||
<Pill label="SD" value={hasSd ? 'OK' : '--'} color={hasSd ? Colors.blue.fg : Colors.text.ghost} />
|
<Pill label="SD" value={hasSd ? 'OK' : '--'} color={hasSd ? theme.blue.fg : theme.text.ghost} theme={theme} />
|
||||||
|
|
||||||
{/* Battery */}
|
{/* Battery */}
|
||||||
{batteryVolt > 0 && (
|
{batteryVolt > 0 && (
|
||||||
<Pill label="电量" value={formatBattery(batteryVolt)} color={Colors.text.muted} />
|
<Pill label="电量" value={formatBattery(batteryVolt)} color={theme.text.muted} theme={theme} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Temperature */}
|
{/* Temperature */}
|
||||||
{temperature > 0 && (
|
{temperature > 0 && (
|
||||||
<Pill label="温度" value={formatTemperature(temperature)} color={Colors.text.muted} />
|
<Pill label="温度" value={formatTemperature(temperature)} color={theme.text.muted} theme={theme} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Frame counter — right-aligned */}
|
{/* Frame counter — right-aligned */}
|
||||||
{frame && (
|
{frame && (
|
||||||
<View style={S.frameCount}>
|
<View style={styles.frameCount}>
|
||||||
<Text style={S.frameCountText}>#{frame.frameId}</Text>
|
<Text style={[styles.frameCountText, { color: theme.text.ghost }]}>#{frame.frameId}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
bar: {
|
bar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: Colors.bg.void,
|
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 7,
|
paddingVertical: 7,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: 5,
|
gap: 5,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: Colors.bg.border,
|
|
||||||
},
|
},
|
||||||
pill: {
|
pill: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
backgroundColor: Colors.bg.raised,
|
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: Colors.bg.border,
|
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
},
|
},
|
||||||
dot: { width: 5, height: 5, borderRadius: 3 },
|
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' },
|
pillValue: { fontSize: 9, fontWeight: '700', fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
stateText: { fontSize: 10, fontWeight: '700', letterSpacing: 0.3 },
|
stateText: { fontSize: 10, fontWeight: '700', letterSpacing: 0.3 },
|
||||||
frameCount: { marginLeft: 'auto' as any },
|
frameCount: { marginLeft: 'auto' as any },
|
||||||
frameCountText: {
|
frameCountText: {
|
||||||
color: Colors.text.ghost,
|
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,11 +6,13 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useConnectionStore } from '../../stores/connectionStore';
|
import { useConnectionStore } from '../../stores/connectionStore';
|
||||||
import { useDevice } from '../../hooks/useDevice';
|
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;
|
const LOG_MAX = 20;
|
||||||
|
|
||||||
export function ConnectModal() {
|
export function ConnectModal() {
|
||||||
|
const theme = useTheme();
|
||||||
const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } =
|
const { status, host, port, lastError, modalVisible, setHost, setPort, hideModal } =
|
||||||
useConnectionStore();
|
useConnectionStore();
|
||||||
const { connect, disconnect } = useDevice();
|
const { connect, disconnect } = useDevice();
|
||||||
@ -39,10 +41,17 @@ export function ConnectModal() {
|
|||||||
}, [status, lastError]);
|
}, [status, lastError]);
|
||||||
|
|
||||||
const handleConnect = () => {
|
const handleConnect = () => {
|
||||||
|
const trimmed = host.trim();
|
||||||
const p = parseInt(portStr, 10);
|
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);
|
setPort(p);
|
||||||
addLog(`连接 ${host}:${p} …`);
|
addLog(`连接 ${trimmed}:${p} …`);
|
||||||
connect();
|
connect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -69,51 +78,79 @@ export function ConnectModal() {
|
|||||||
style={S.kav}
|
style={S.kav}
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
>
|
>
|
||||||
<View style={S.sheet}>
|
<View style={[S.sheet, {
|
||||||
|
backgroundColor: theme.bg.surface,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
{/* Handle */}
|
{/* Handle */}
|
||||||
<View style={S.handle} />
|
<View style={[S.handle, { backgroundColor: theme.bg.border }]} />
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text style={S.title}>连接仪器</Text>
|
<Text style={[S.title, { color: theme.text.secondary }]}>连接仪器</Text>
|
||||||
|
|
||||||
{/* Guide */}
|
{/* Guide */}
|
||||||
<View style={S.guideRow}>
|
<View style={S.guideRow}>
|
||||||
<View style={S.stepBadge}><Text style={S.stepNum}>1</Text></View>
|
<View style={[S.stepBadge, {
|
||||||
<Text style={S.stepTxt}>手机连接设备 WiFi 热点</Text>
|
backgroundColor: theme.blue.bg,
|
||||||
<TouchableOpacity style={S.settingsBtn} onPress={() => Linking.openSettings()}>
|
borderColor: theme.blue.border,
|
||||||
<Text style={S.settingsTxt}>设置 →</Text>
|
}]}>
|
||||||
|
<Text style={[S.stepNum, { color: theme.blue.fg }]}>1</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[S.stepTxt, { color: theme.text.muted }]}>手机连接设备 WiFi 热点</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[S.settingsBtn, {
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.border,
|
||||||
|
}]}
|
||||||
|
onPress={() => Linking.openSettings()}
|
||||||
|
>
|
||||||
|
<Text style={[S.settingsTxt, { color: theme.blue.fg }]}>设置 →</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={[S.guideRow, { marginBottom: Spacing.md }]}>
|
<View style={[S.guideRow, { marginBottom: Spacing.md }]}>
|
||||||
<View style={S.stepBadge}><Text style={S.stepNum}>2</Text></View>
|
<View style={[S.stepBadge, {
|
||||||
<Text style={S.stepTxt}>确认参数后点击连接</Text>
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.border,
|
||||||
|
}]}>
|
||||||
|
<Text style={[S.stepNum, { color: theme.blue.fg }]}>2</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[S.stepTxt, { color: theme.text.muted }]}>确认参数后点击连接</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Inputs */}
|
{/* Inputs */}
|
||||||
<View style={S.inputCard}>
|
<View style={[S.inputCard, {
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
<View style={S.inputRow}>
|
<View style={S.inputRow}>
|
||||||
<Text style={S.inputLabel}>IP 地址</Text>
|
<Text style={[S.inputLabel, { color: theme.text.muted }]}>IP 地址</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={S.input}
|
style={[S.input, {
|
||||||
|
color: theme.text.primary,
|
||||||
|
borderBottomColor: theme.bg.border,
|
||||||
|
}]}
|
||||||
value={host}
|
value={host}
|
||||||
onChangeText={setHost}
|
onChangeText={setHost}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
placeholder="192.168.4.1"
|
placeholder="192.168.4.1"
|
||||||
placeholderTextColor={Colors.text.ghost}
|
placeholderTextColor={theme.text.ghost}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
editable={!connecting}
|
editable={!connecting}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={S.inputDivider} />
|
<View style={[S.inputDivider, { backgroundColor: theme.bg.divider }]} />
|
||||||
<View style={S.inputRow}>
|
<View style={S.inputRow}>
|
||||||
<Text style={S.inputLabel}>端 口</Text>
|
<Text style={[S.inputLabel, { color: theme.text.muted }]}>端 口</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={S.input}
|
style={[S.input, {
|
||||||
|
color: theme.text.primary,
|
||||||
|
borderBottomColor: theme.bg.border,
|
||||||
|
}]}
|
||||||
value={portStr}
|
value={portStr}
|
||||||
onChangeText={setPortStr}
|
onChangeText={setPortStr}
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
placeholder="4321"
|
placeholder="4321"
|
||||||
placeholderTextColor={Colors.text.ghost}
|
placeholderTextColor={theme.text.ghost}
|
||||||
editable={!connecting}
|
editable={!connecting}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@ -122,33 +159,51 @@ export function ConnectModal() {
|
|||||||
{/* Action */}
|
{/* Action */}
|
||||||
{!connected ? (
|
{!connected ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[S.connectBtn, connecting && S.connectBtnBusy]}
|
style={[S.connectBtn, {
|
||||||
|
backgroundColor: theme.blue.bg,
|
||||||
|
borderColor: theme.blue.fg,
|
||||||
|
}, connecting && {
|
||||||
|
borderColor: theme.blue.border,
|
||||||
|
backgroundColor: theme.bg.raised,
|
||||||
|
}]}
|
||||||
onPress={handleConnect}
|
onPress={handleConnect}
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{connecting
|
{connecting
|
||||||
? <ActivityIndicator color={Colors.blue.fg} />
|
? <ActivityIndicator color={theme.blue.fg} />
|
||||||
: <Text style={S.connectBtnTxt}>连 接 设 备</Text>}
|
: <Text style={[S.connectBtnTxt, { color: theme.blue.fg }]}>连 接 设 备</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<View style={S.connectedRow}>
|
<View style={S.connectedRow}>
|
||||||
<View style={S.connectedLeft}>
|
<View style={S.connectedLeft}>
|
||||||
<View style={S.greenDot} />
|
<View style={[S.greenDot, { backgroundColor: theme.green.fg }]} />
|
||||||
<Text style={S.connectedTxt}>已连接 {host}:{port}</Text>
|
<Text style={[S.connectedTxt, { color: theme.green.fg }]}>已连接 {host}:{port}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={S.disconnectBtn} onPress={handleDisconnect}>
|
<TouchableOpacity
|
||||||
<Text style={S.disconnectTxt}>断 开</Text>
|
style={[S.disconnectBtn, {
|
||||||
|
backgroundColor: theme.red.bg,
|
||||||
|
borderColor: theme.red.border,
|
||||||
|
}]}
|
||||||
|
onPress={handleDisconnect}
|
||||||
|
>
|
||||||
|
<Text style={[S.disconnectTxt, { color: theme.red.fg }]}>断 开</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Log */}
|
{/* Log */}
|
||||||
<View style={S.logBox}>
|
<View style={[S.logBox, {
|
||||||
<Text style={S.logHeader}>● 连接日志</Text>
|
backgroundColor: theme.bg.void,
|
||||||
|
borderColor: theme.bg.border,
|
||||||
|
}]}>
|
||||||
|
<Text style={[S.logHeader, {
|
||||||
|
color: theme.green.fg,
|
||||||
|
borderBottomColor: theme.bg.divider,
|
||||||
|
}]}>● 连接日志</Text>
|
||||||
<ScrollView style={S.logScroll} showsVerticalScrollIndicator={false}>
|
<ScrollView style={S.logScroll} showsVerticalScrollIndicator={false}>
|
||||||
{logs.map((l, i) => (
|
{logs.map((l, i) => (
|
||||||
<Text key={i} style={[S.logLine, i === 0 && S.logLineLatest]}>{l}</Text>
|
<Text key={i} style={[S.logLine, { color: theme.text.ghost }, i === 0 && { color: theme.text.muted }]}>{l}</Text>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
@ -168,22 +223,18 @@ const S = StyleSheet.create({
|
|||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: Colors.bg.surface,
|
|
||||||
borderTopLeftRadius: Radius.xl,
|
borderTopLeftRadius: Radius.xl,
|
||||||
borderTopRightRadius: Radius.xl,
|
borderTopRightRadius: Radius.xl,
|
||||||
padding: Spacing.lg,
|
padding: Spacing.lg,
|
||||||
paddingBottom: Spacing.xl,
|
paddingBottom: Spacing.xl,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderColor: Colors.bg.border,
|
|
||||||
},
|
},
|
||||||
handle: {
|
handle: {
|
||||||
width: 36, height: 4, borderRadius: 2,
|
width: 36, height: 4, borderRadius: 2,
|
||||||
backgroundColor: Colors.bg.border,
|
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
marginBottom: Spacing.md,
|
marginBottom: Spacing.md,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: Colors.text.secondary,
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginBottom: Spacing.md,
|
marginBottom: Spacing.md,
|
||||||
@ -197,81 +248,73 @@ const S = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
stepBadge: {
|
stepBadge: {
|
||||||
width: 20, height: 20, borderRadius: 10,
|
width: 20, height: 20, borderRadius: 10,
|
||||||
backgroundColor: Colors.blue.bg,
|
|
||||||
justifyContent: 'center', alignItems: 'center',
|
justifyContent: 'center', alignItems: 'center',
|
||||||
borderWidth: 1, borderColor: Colors.blue.border,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
stepNum: { color: Colors.blue.fg, fontSize: 10, fontWeight: '700' },
|
stepNum: { fontSize: 10, fontWeight: '700' },
|
||||||
stepTxt: { color: Colors.text.muted, fontSize: 12, flex: 1 },
|
stepTxt: { fontSize: 12, flex: 1 },
|
||||||
settingsBtn: {
|
settingsBtn: {
|
||||||
backgroundColor: Colors.blue.bg, borderRadius: Radius.sm,
|
borderRadius: Radius.sm,
|
||||||
paddingHorizontal: 8, paddingVertical: 3,
|
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: {
|
inputCard: {
|
||||||
backgroundColor: Colors.bg.raised,
|
|
||||||
borderRadius: Radius.md,
|
borderRadius: Radius.md,
|
||||||
borderWidth: 1, borderColor: Colors.bg.border,
|
borderWidth: 1,
|
||||||
paddingHorizontal: Spacing.md,
|
paddingHorizontal: Spacing.md,
|
||||||
marginBottom: Spacing.md,
|
marginBottom: Spacing.md,
|
||||||
},
|
},
|
||||||
inputRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, paddingVertical: Spacing.sm },
|
inputRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, paddingVertical: Spacing.sm },
|
||||||
inputDivider: { height: StyleSheet.hairlineWidth, backgroundColor: Colors.bg.divider },
|
inputDivider: { height: StyleSheet.hairlineWidth },
|
||||||
inputLabel: { color: Colors.text.muted, fontSize: 12, width: 52 },
|
inputLabel: { fontSize: 12, width: 52 },
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: Colors.text.primary,
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: Colors.bg.border,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
connectBtn: {
|
connectBtn: {
|
||||||
backgroundColor: Colors.blue.bg,
|
|
||||||
borderRadius: Radius.lg,
|
borderRadius: Radius.lg,
|
||||||
paddingVertical: 15,
|
paddingVertical: 15,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: Spacing.md,
|
marginBottom: Spacing.md,
|
||||||
borderWidth: 1, borderColor: Colors.blue.fg,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
connectBtnBusy: { borderColor: Colors.blue.border, backgroundColor: Colors.bg.raised },
|
connectBtnTxt: { fontSize: 15, fontWeight: '700', letterSpacing: 3 },
|
||||||
connectBtnTxt: { color: Colors.blue.fg, fontSize: 15, fontWeight: '700', letterSpacing: 3 },
|
|
||||||
|
|
||||||
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.md },
|
connectedRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, marginBottom: Spacing.md },
|
||||||
connectedLeft: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 },
|
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: {
|
connectedTxt: {
|
||||||
color: Colors.green.fg, fontWeight: '700', fontSize: 12,
|
fontWeight: '700', fontSize: 12,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||||
},
|
},
|
||||||
disconnectBtn: {
|
disconnectBtn: {
|
||||||
backgroundColor: Colors.red.bg, borderRadius: Radius.md,
|
borderRadius: Radius.md,
|
||||||
paddingVertical: 8, paddingHorizontal: 14,
|
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: {
|
logBox: {
|
||||||
backgroundColor: Colors.bg.void,
|
|
||||||
borderRadius: Radius.md,
|
borderRadius: Radius.md,
|
||||||
borderWidth: 1, borderColor: Colors.bg.border,
|
borderWidth: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
},
|
},
|
||||||
logHeader: {
|
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,
|
paddingHorizontal: Spacing.md, paddingTop: 8, paddingBottom: 5,
|
||||||
borderBottomWidth: 1, borderBottomColor: Colors.bg.divider,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
logScroll: { padding: Spacing.sm },
|
logScroll: { padding: Spacing.sm },
|
||||||
logLine: {
|
logLine: {
|
||||||
color: Colors.text.ghost, fontSize: 10,
|
fontSize: 10,
|
||||||
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
||||||
marginBottom: 2, lineHeight: 15,
|
marginBottom: 2, lineHeight: 15,
|
||||||
},
|
},
|
||||||
logLineLatest: { color: Colors.text.muted },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
export const Colors = {
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
// ── Color palettes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DarkColors = {
|
||||||
bg: {
|
bg: {
|
||||||
void: '#050508',
|
void: '#050508',
|
||||||
base: '#090912',
|
base: '#090912',
|
||||||
@ -18,8 +22,65 @@ export const Colors = {
|
|||||||
red: { bg: '#1e0d12', border: '#3a1520', fg: '#ff5c6e' },
|
red: { bg: '#1e0d12', border: '#3a1520', fg: '#ff5c6e' },
|
||||||
teal: { bg: '#0d1e20', border: '#1a3a3e', fg: '#4ecdc4' },
|
teal: { bg: '#0d1e20', border: '#1a3a3e', fg: '#4ecdc4' },
|
||||||
amber: { bg: '#1e1a08', border: '#4a3a1a', fg: '#ffd93d' },
|
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;
|
} 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 = {
|
export const Spacing = {
|
||||||
xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32,
|
xs: 4, sm: 8, md: 12, lg: 16, xl: 24, xxl: 32,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Alert } from 'react-native';
|
import { Alert } from 'react-native';
|
||||||
import { tcpService } from '../services/TcpService';
|
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 { useConnectionStore } from '../stores/connectionStore';
|
||||||
import { useDeviceStore } from '../stores/deviceStore';
|
import { useDeviceStore } from '../stores/deviceStore';
|
||||||
import type { SetupConfig } from '../protocol/types';
|
import type { SetupConfig } from '../protocol/types';
|
||||||
@ -32,7 +32,16 @@ export function useDevice() {
|
|||||||
useDeviceStore.getState().setDeviceStatus('idle');
|
useDeviceStore.getState().setDeviceStatus('idle');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const checkConn = () => {
|
||||||
|
if (!tcpService.isConnected()) {
|
||||||
|
Alert.alert('未连接', '请先连接设备');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const setup = useCallback(async (cfg?: SetupConfig) => {
|
const setup = useCallback(async (cfg?: SetupConfig) => {
|
||||||
|
if (!checkConn()) return;
|
||||||
const config = cfg ?? useDeviceStore.getState().config;
|
const config = cfg ?? useDeviceStore.getState().config;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@ -41,6 +50,7 @@ export function useDevice() {
|
|||||||
Alert.alert('配置失败', ack.reason || '设备返回失败');
|
Alert.alert('配置失败', ack.reason || '设备返回失败');
|
||||||
} else {
|
} else {
|
||||||
useDeviceStore.getState().setConfigDirty(false);
|
useDeviceStore.getState().setConfigDirty(false);
|
||||||
|
Alert.alert('配置成功', '参数已下发到设备');
|
||||||
}
|
}
|
||||||
return ack;
|
return ack;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -51,6 +61,7 @@ export function useDevice() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startContinuous = useCallback(async () => {
|
const startContinuous = useCallback(async () => {
|
||||||
|
if (!checkConn()) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const ack = await deviceStartContinuous();
|
const ack = await deviceStartContinuous();
|
||||||
@ -64,6 +75,7 @@ export function useDevice() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startSingle = useCallback(async () => {
|
const startSingle = useCallback(async () => {
|
||||||
|
if (!checkConn()) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const ack = await deviceStartSingle();
|
const ack = await deviceStartSingle();
|
||||||
@ -77,14 +89,22 @@ export function useDevice() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
// Optimistically transition to idle so the UI responds immediately.
|
setBusy(true);
|
||||||
// The ACK is sent best-effort; if the device doesn't reply the state
|
// Immediately stop processing buffered frames
|
||||||
// is already correct on our side.
|
|
||||||
useDeviceStore.getState().setDeviceStatus('idle');
|
useDeviceStore.getState().setDeviceStatus('idle');
|
||||||
|
flushPendingData();
|
||||||
try {
|
try {
|
||||||
await deviceStop();
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const ack = await deviceStop();
|
||||||
|
if (ack.result === 0x01) return;
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort — UI already reflects idle
|
// retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Alert.alert('停止采集', '设备未确认,已强制停止');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -12,20 +12,21 @@ const SAMPLE_FREQ_HZ: Record<number, number> = {
|
|||||||
0x08: 977, 0x09: 488, 0x0a: 244, 0x0b: 122, 0x0c: 61,
|
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 {
|
function downsample(data: Float64Array, targetLen: number): Float64Array {
|
||||||
if (data.length <= targetLen) return data;
|
if (data.length <= targetLen) return data;
|
||||||
const ratio = data.length / targetLen;
|
const pairLen = Math.floor(targetLen / 2);
|
||||||
const out = new Float64Array(targetLen);
|
const ratio = data.length / pairLen;
|
||||||
for (let i = 0; i < targetLen; i++) {
|
const out = new Float64Array(pairLen * 2);
|
||||||
|
for (let i = 0; i < pairLen; i++) {
|
||||||
const start = Math.floor(i * ratio);
|
const start = Math.floor(i * ratio);
|
||||||
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
|
const end = Math.min(Math.floor((i + 1) * ratio), data.length);
|
||||||
let maxAbs = 0;
|
let mn = data[start], mx = data[start];
|
||||||
let maxVal = 0;
|
for (let j = start + 1; j < end; j++) {
|
||||||
for (let j = start; j < end; j++) {
|
if (data[j] < mn) mn = data[j];
|
||||||
if (Math.abs(data[j]) > maxAbs) { maxAbs = Math.abs(data[j]); maxVal = data[j]; }
|
if (data[j] > mx) mx = data[j];
|
||||||
}
|
}
|
||||||
out[i] = maxVal;
|
out[i * 2] = mn;
|
||||||
|
out[i * 2 + 1] = mx;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -86,10 +87,16 @@ export function computeWaveformData(
|
|||||||
if (!isFinite(linMin)) linMin = -1;
|
if (!isFinite(linMin)) linMin = -1;
|
||||||
if (!isFinite(linMax)) linMax = 1;
|
if (!isFinite(linMax)) linMax = 1;
|
||||||
|
|
||||||
// Symmetric linear range for cleaner display
|
// Auto-range: pad the actual data range by 10% for visual breathing room
|
||||||
const linPeak = Math.max(Math.abs(linMin), Math.abs(linMax), 1);
|
const linRange = linMax - linMin;
|
||||||
linMin = -linPeak;
|
if (linRange < 1e-9) {
|
||||||
linMax = linPeak;
|
linMin -= 1;
|
||||||
|
linMax += 1;
|
||||||
|
} else {
|
||||||
|
const pad = linRange * 0.1;
|
||||||
|
linMin -= pad;
|
||||||
|
linMax += pad;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channels, timeMs, totalTimeMs,
|
channels, timeMs, totalTimeMs,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
export const FRAME_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
|
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_FLAG_4BYTE = 0xfe;
|
||||||
|
export const FRAME_TAIL = new Uint8Array([0x68, 0x68]);
|
||||||
export const DEFAULT_DEVICE_IP = '192.168.4.1';
|
export const DEFAULT_DEVICE_IP = '192.168.4.1';
|
||||||
export const DEFAULT_TCP_PORT = 4321;
|
export const DEFAULT_TCP_PORT = 4321;
|
||||||
export const MAX_PAYLOAD_SIZE = 400 * 1024; // 400KB sanity limit
|
export const MAX_PAYLOAD_SIZE = 400 * 1024; // 400KB sanity limit
|
||||||
@ -10,7 +12,6 @@ export const FuncCode = {
|
|||||||
CONTINUOUS_REQ: 0x02,
|
CONTINUOUS_REQ: 0x02,
|
||||||
SINGLE_REQ: 0x03,
|
SINGLE_REQ: 0x03,
|
||||||
STOP_REQ: 0x04,
|
STOP_REQ: 0x04,
|
||||||
ACTIVE_REQ: 0x05,
|
|
||||||
SPLITFRAME_REQ: 0x08,
|
SPLITFRAME_REQ: 0x08,
|
||||||
// Device → App
|
// Device → App
|
||||||
SETUP_ACK: 0x81,
|
SETUP_ACK: 0x81,
|
||||||
@ -33,13 +34,11 @@ export const SEND_FREQ_TABLE = [
|
|||||||
{ code: 0x02, label: '2 Hz' },
|
{ code: 0x02, label: '2 Hz' },
|
||||||
{ code: 0x03, label: '4 Hz' },
|
{ code: 0x03, label: '4 Hz' },
|
||||||
{ code: 0x04, label: '8 Hz' },
|
{ code: 0x04, label: '8 Hz' },
|
||||||
{ code: 0x05, label: '12.5 Hz' },
|
{ code: 0x05, label: '16 Hz' },
|
||||||
{ code: 0x06, label: '16 Hz' },
|
{ code: 0x06, label: '25 Hz' },
|
||||||
{ code: 0x07, label: '25 Hz' },
|
{ code: 0x07, label: '32 Hz' },
|
||||||
{ code: 0x08, label: '32 Hz' },
|
{ code: 0x08, label: '50 Hz' },
|
||||||
{ code: 0x09, label: '50 Hz' },
|
{ code: 0x09, label: '64 Hz' },
|
||||||
{ code: 0x0a, label: '64 Hz' },
|
|
||||||
// 0xFE (ZTEM) not supported
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SAMPLE_FREQ_TABLE = [
|
export const SAMPLE_FREQ_TABLE = [
|
||||||
|
|||||||
@ -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';
|
import type { SetupConfig } from './types';
|
||||||
|
|
||||||
const HEADER_MAGIC = new Uint8Array([0x68, 0x68, 0xff, 0xff]);
|
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 {
|
function buildHeader(func: number, payloadLen: number): Uint8Array {
|
||||||
const buf = new Uint8Array(10);
|
const buf = new Uint8Array(HEADER_LEN);
|
||||||
buf.set(HEADER_MAGIC, 0);
|
buf.set(HEADER_MAGIC, 0);
|
||||||
buf[4] = FRAME_FLAG_4BYTE;
|
|
||||||
buf[5] = func;
|
buf[5] = func;
|
||||||
|
|
||||||
|
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[6] = payloadLen & 0xff;
|
||||||
buf[7] = (payloadLen >> 8) & 0xff;
|
buf[7] = (payloadLen >> 8) & 0xff;
|
||||||
buf[8] = (payloadLen >> 16) & 0xff;
|
buf[8] = (payloadLen >> 16) & 0xff;
|
||||||
buf[9] = (payloadLen >> 24) & 0xff;
|
buf[9] = (payloadLen >> 24) & 0xff;
|
||||||
|
}
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a command packet with a zero-filled 54-byte payload (minimum)
|
function buildSimpleCommand(func: number, freqFlag = 0): Uint8Array {
|
||||||
function buildSimpleCommand(func: number): Uint8Array {
|
const payload = new Uint8Array(STANDARD_PAYLOAD_LEN);
|
||||||
const payload = new Uint8Array(54);
|
payload[0] = 0x01;
|
||||||
const header = buildHeader(func, 54);
|
payload[1] = freqFlag & 0xff;
|
||||||
const pkt = new Uint8Array(10 + 54);
|
|
||||||
|
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(header, 0);
|
||||||
pkt.set(payload, 10);
|
pkt.set(payload, HEADER_LEN);
|
||||||
return pkt;
|
return pkt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write a little-endian uint16 into buf at offset
|
|
||||||
function writeUint16LE(buf: Uint8Array, offset: number, value: number) {
|
function writeUint16LE(buf: Uint8Array, offset: number, value: number) {
|
||||||
buf[offset] = value & 0xff;
|
buf[offset] = value & 0xff;
|
||||||
buf[offset + 1] = (value >> 8) & 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) {
|
function writeString(buf: Uint8Array, offset: number, str: string, maxLen: number) {
|
||||||
for (let i = 0; i < maxLen; i++) {
|
for (let i = 0; i < maxLen; i++) {
|
||||||
buf[offset + i] = i < str.length ? str.charCodeAt(i) & 0xff : 0;
|
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 {
|
export function buildSetupPacket(cfg: SetupConfig): Uint8Array {
|
||||||
const PAYLOAD_LEN = 64; // sizeof(setupReq_t)
|
const payload = new Uint8Array(STANDARD_PAYLOAD_LEN);
|
||||||
const payload = new Uint8Array(PAYLOAD_LEN);
|
|
||||||
|
|
||||||
payload[0] = cfg.channelNum & 0xff;
|
payload[0] = cfg.channelNum & 0xff;
|
||||||
payload[1] = cfg.sendFreq & 0xff;
|
payload[1] = cfg.sendFreq & 0xff;
|
||||||
payload[2] = cfg.sampleFreq & 0xff;
|
payload[2] = cfg.sampleFreq & 0xff;
|
||||||
writeUint16LE(payload, 3, cfg.sampleDepth);
|
writeUint16LE(payload, 3, cfg.sampleDepth);
|
||||||
|
|
||||||
// accNum is 14-bit; bit1 = negAcc456; bit0 = negAcc123
|
let negAccNum = cfg.accNum & 0x3fff;
|
||||||
let accFlags = (cfg.accNum & 0x3fff) << 2;
|
if (cfg.negAcc123) negAccNum |= 0x8000;
|
||||||
if (cfg.negAcc456) accFlags |= 0x02;
|
if (cfg.negAcc456) negAccNum |= 0x4000;
|
||||||
if (cfg.negAcc123) accFlags |= 0x01;
|
writeUint16LE(payload, 5, negAccNum);
|
||||||
writeUint16LE(payload, 5, accFlags);
|
|
||||||
|
|
||||||
payload[7] = cfg.ampRatio & 0xff;
|
payload[7] = cfg.ampRatio & 0xff;
|
||||||
payload[8] = cfg.dataChannel & 0xff;
|
payload[8] = cfg.dataChannel & 0xff;
|
||||||
payload[9] = cfg.compRes & 0xff;
|
payload[9] = cfg.compRes & 0xff;
|
||||||
payload[10] = cfg.secondSampleFreq & 0xff;
|
payload[10] = cfg.sampleFreq & 0xff;
|
||||||
writeUint16LE(payload, 11, cfg.compDisableDelay);
|
writeUint16LE(payload, 11, cfg.compDisableDelay);
|
||||||
payload[13] = cfg.sourceMode & 0xff;
|
payload[13] = cfg.sourceMode & 0xff;
|
||||||
writeUint16LE(payload, 14, cfg.batteryVoltageMin);
|
writeUint16LE(payload, 14, cfg.batteryVoltageMin);
|
||||||
// bytes 16-37: reserved (zeros)
|
|
||||||
writeString(payload, 38, cfg.filePrefix, 16);
|
writeString(payload, 38, cfg.filePrefix, 16);
|
||||||
|
|
||||||
const header = buildHeader(FuncCode.SETUP_REQ, PAYLOAD_LEN);
|
const header = buildHeader(FuncCode.SETUP_REQ, STANDARD_PAYLOAD_LEN);
|
||||||
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
|
const pkt = new Uint8Array(STANDARD_TOTAL_LEN);
|
||||||
pkt.set(header, 0);
|
pkt.set(header, 0);
|
||||||
pkt.set(payload, 10);
|
pkt.set(payload, HEADER_LEN);
|
||||||
return pkt;
|
return pkt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildContinuousReq = () => buildSimpleCommand(FuncCode.CONTINUOUS_REQ);
|
export const buildContinuousReq = (freqFlag = 0) =>
|
||||||
export const buildSingleReq = () => buildSimpleCommand(FuncCode.SINGLE_REQ);
|
buildSimpleCommand(FuncCode.CONTINUOUS_REQ, freqFlag);
|
||||||
export const buildStopReq = () => buildSimpleCommand(FuncCode.STOP_REQ);
|
|
||||||
export const buildActiveReq = () => buildSimpleCommand(FuncCode.ACTIVE_REQ);
|
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 {
|
export function buildSplitFrameReq(subCmd: number, frameNo: number): Uint8Array {
|
||||||
const PAYLOAD_LEN = 54;
|
const payload = new Uint8Array(STANDARD_PAYLOAD_LEN);
|
||||||
const payload = new Uint8Array(PAYLOAD_LEN);
|
|
||||||
payload[0] = subCmd & 0xff;
|
payload[0] = subCmd & 0xff;
|
||||||
writeUint16LE(payload, 1, frameNo);
|
writeUint16LE(payload, 1, frameNo);
|
||||||
const header = buildHeader(FuncCode.SPLITFRAME_REQ, PAYLOAD_LEN);
|
const header = buildHeader(FuncCode.SPLITFRAME_REQ, STANDARD_PAYLOAD_LEN);
|
||||||
const pkt = new Uint8Array(10 + PAYLOAD_LEN);
|
const pkt = new Uint8Array(STANDARD_TOTAL_LEN);
|
||||||
pkt.set(header, 0);
|
pkt.set(header, 0);
|
||||||
pkt.set(payload, 10);
|
pkt.set(payload, HEADER_LEN);
|
||||||
return pkt;
|
return pkt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export class TemFrameParser {
|
|||||||
private buf: Uint8Array;
|
private buf: Uint8Array;
|
||||||
private len = 0;
|
private len = 0;
|
||||||
private readonly capacity: number;
|
private readonly capacity: number;
|
||||||
|
onOverflow?: () => void;
|
||||||
|
|
||||||
constructor(capacity = 2 * 1024 * 1024) {
|
constructor(capacity = 2 * 1024 * 1024) {
|
||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
@ -25,8 +26,9 @@ export class TemFrameParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.len + incoming.length > this.capacity) {
|
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.len = 0;
|
||||||
|
this.onOverflow?.();
|
||||||
}
|
}
|
||||||
this.buf.set(incoming, this.len);
|
this.buf.set(incoming, this.len);
|
||||||
this.len += incoming.length;
|
this.len += incoming.length;
|
||||||
@ -50,19 +52,31 @@ export class TemFrameParser {
|
|||||||
|
|
||||||
const flag = this.buf[offset + 4];
|
const flag = this.buf[offset + 4];
|
||||||
const func = this.buf[offset + 5];
|
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) {
|
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;
|
offset += 4;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLen = 10 + payloadLen;
|
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);
|
const payload = new Uint8Array(totalLen - 10);
|
||||||
payload.set(this.buf.subarray(offset + 10, offset + totalLen));
|
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 });
|
results.push({ flag, func, payload });
|
||||||
offset += totalLen;
|
offset += totalLen;
|
||||||
}
|
}
|
||||||
@ -91,6 +105,10 @@ export class TemFrameParser {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readUint16LE(offset: number): number {
|
||||||
|
return this.buf[offset] | (this.buf[offset + 1] << 8);
|
||||||
|
}
|
||||||
|
|
||||||
private readUint32LE(offset: number): number {
|
private readUint32LE(offset: number): number {
|
||||||
const b = this.buf;
|
const b = this.buf;
|
||||||
return b[offset] | (b[offset + 1] << 8) | (b[offset + 2] << 16) | (b[offset + 3] * 0x1000000);
|
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
|
// 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(
|
export function parseAdcData(
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
channelNum: number,
|
channelNum: number,
|
||||||
accNum: number,
|
_accNum: number,
|
||||||
): { adcRaw: Int32Array[]; adcUV: Float64Array[] } {
|
): { adcRaw: Int32Array[]; adcUV: Float64Array[] } {
|
||||||
const META_SIZE = 54;
|
const META_SIZE = 54;
|
||||||
const sampleDataLen = payload.length - META_SIZE;
|
const sampleDataLen = payload.length - META_SIZE;
|
||||||
const totalSamples = sampleDataLen / 4; // int32 per sample
|
const totalSamples = sampleDataLen / 4;
|
||||||
const samplesPerChannel = Math.floor(totalSamples / channelNum);
|
const samplesPerChannel = Math.floor(totalSamples / channelNum);
|
||||||
|
|
||||||
const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen);
|
const view = new DataView(payload.buffer, payload.byteOffset + META_SIZE, sampleDataLen);
|
||||||
const ampRatio = payload[9]; // ampRatio in metadata at offset 9 from payload start...
|
const gain = AMP_GAIN[payload[30]] ?? 1;
|
||||||
// actually re-derive from parseMetadata
|
const scale = ADC_UV_SCALE / gain;
|
||||||
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 adcRaw: Int32Array[] = [];
|
const adcRaw: Int32Array[] = [];
|
||||||
const adcUV: Float64Array[] = [];
|
const adcUV: Float64Array[] = [];
|
||||||
@ -152,10 +172,10 @@ export function parseAdcData(
|
|||||||
const raw = new Int32Array(samplesPerChannel);
|
const raw = new Int32Array(samplesPerChannel);
|
||||||
const uv = new Float64Array(samplesPerChannel);
|
const uv = new Float64Array(samplesPerChannel);
|
||||||
for (let i = 0; i < samplesPerChannel; i++) {
|
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);
|
const val = view.getInt32(idx, true);
|
||||||
raw[i] = val;
|
raw[i] = val;
|
||||||
uv[i] = val / (accNum || 1) / correctGain;
|
uv[i] = val * scale;
|
||||||
}
|
}
|
||||||
adcRaw.push(raw);
|
adcRaw.push(raw);
|
||||||
adcUV.push(uv);
|
adcUV.push(uv);
|
||||||
@ -168,11 +188,12 @@ export function parseAdcData(
|
|||||||
export function parseAck(payload: Uint8Array): AckPacket {
|
export function parseAck(payload: Uint8Array): AckPacket {
|
||||||
const result = payload[0] ?? 0x02;
|
const result = payload[0] ?? 0x02;
|
||||||
let reason = '';
|
let reason = '';
|
||||||
// reason string starts at offset 22 in the payload, max 32 chars
|
|
||||||
const reasonOffset = 22;
|
const reasonOffset = 22;
|
||||||
|
if (payload.length > reasonOffset) {
|
||||||
for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) {
|
for (let i = reasonOffset; i < Math.min(reasonOffset + 32, payload.length); i++) {
|
||||||
if (payload[i] === 0) break;
|
if (payload[i] === 0) break;
|
||||||
reason += String.fromCharCode(payload[i]);
|
reason += String.fromCharCode(payload[i]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { result, reason };
|
return { result, reason };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,13 +153,14 @@ export function decodeBin(bytes: Uint8Array): LoadedBin | null {
|
|||||||
if (bytes.length < expected) return null;
|
if (bytes.length < expected) return null;
|
||||||
|
|
||||||
const gain = AMP_GAIN[ampRatio] ?? 1;
|
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[] = [];
|
const adcUV: Float64Array[] = [];
|
||||||
let off = HEADER_SIZE;
|
let off = HEADER_SIZE;
|
||||||
for (let ch = 0; ch < channelNum; ch++) {
|
for (let ch = 0; ch < channelNum; ch++) {
|
||||||
const channel = new Float64Array(sampleDepth);
|
const channel = new Float64Array(sampleDepth);
|
||||||
for (let i = 0; i < sampleDepth; i++) {
|
for (let i = 0; i < sampleDepth; i++) {
|
||||||
channel[i] = dv.getInt32(off, true) / acc / gain;
|
channel[i] = dv.getInt32(off, true) * scale;
|
||||||
off += 4;
|
off += 4;
|
||||||
}
|
}
|
||||||
adcUV.push(channel);
|
adcUV.push(channel);
|
||||||
|
|||||||
@ -11,11 +11,12 @@ import {
|
|||||||
parseAdcData,
|
parseAdcData,
|
||||||
parseAck,
|
parseAck,
|
||||||
} from '../protocol/parser';
|
} 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 type { RawFrame, SetupConfig, MeasurementFrame, AckPacket } from '../protocol/types';
|
||||||
import { useConnectionStore } from '../stores/connectionStore';
|
import { useConnectionStore } from '../stores/connectionStore';
|
||||||
import { useDeviceStore } from '../stores/deviceStore';
|
import { useDeviceStore } from '../stores/deviceStore';
|
||||||
import { useDataStore } from '../stores/dataStore';
|
import { useDataStore } from '../stores/dataStore';
|
||||||
|
import * as StorageService from './StorageService';
|
||||||
|
|
||||||
// Pending ACK promise resolver keyed by func code
|
// Pending ACK promise resolver keyed by func code
|
||||||
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
|
const pendingAcks = new Map<number, (ack: AckPacket) => void>();
|
||||||
@ -27,6 +28,7 @@ let splitFrameNo = 0;
|
|||||||
|
|
||||||
export function handleIncomingFrame(frame: RawFrame) {
|
export function handleIncomingFrame(frame: RawFrame) {
|
||||||
const { func, payload } = frame;
|
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 devStore = useDeviceStore.getState();
|
||||||
const dataStore = useDataStore.getState();
|
const dataStore = useDataStore.getState();
|
||||||
|
|
||||||
@ -90,15 +92,20 @@ export function handleIncomingFrame(frame: RawFrame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _lastUIUpdateTime = 0;
|
||||||
|
const UI_THROTTLE_MS = 150;
|
||||||
|
let _processing = false;
|
||||||
|
|
||||||
function processMeasurementPayload(
|
function processMeasurementPayload(
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
devStore: ReturnType<typeof useDeviceStore.getState>,
|
devStore: ReturnType<typeof useDeviceStore.getState>,
|
||||||
dataStore: ReturnType<typeof useDataStore.getState>,
|
dataStore: ReturnType<typeof useDataStore.getState>,
|
||||||
) {
|
) {
|
||||||
// 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 (devStore.deviceStatus === 'idle') return;
|
||||||
|
if (_processing) return;
|
||||||
|
_processing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
const meta = parseMetadata(payload);
|
const meta = parseMetadata(payload);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
|
||||||
@ -111,13 +118,20 @@ function processMeasurementPayload(
|
|||||||
adcRaw,
|
adcRaw,
|
||||||
adcUV,
|
adcUV,
|
||||||
accNum,
|
accNum,
|
||||||
gain: cfg.ampRatio,
|
gain: AMP_GAIN[cfg.ampRatio] ?? 1,
|
||||||
sampleFreqCode: cfg.sampleFreq,
|
sampleFreqCode: cfg.sampleFreq,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
frameId: dataStore.nextFrameId(),
|
frameId: dataStore.nextFrameId(),
|
||||||
};
|
};
|
||||||
|
|
||||||
dataStore.addFrame(measurementFrame);
|
void StorageService.persistFrame(measurementFrame, dataStore.sessionId);
|
||||||
|
|
||||||
|
const isSingle = devStore.deviceStatus === 'single';
|
||||||
|
const now = Date.now();
|
||||||
|
if (isSingle || now - _lastUIUpdateTime >= UI_THROTTLE_MS) {
|
||||||
|
_lastUIUpdateTime = now;
|
||||||
|
dataStore.addFrameUI(measurementFrame);
|
||||||
|
}
|
||||||
|
|
||||||
devStore.updateTelemetry({
|
devStore.updateTelemetry({
|
||||||
batteryVolt: meta.batteryVolt,
|
batteryVolt: meta.batteryVolt,
|
||||||
@ -127,24 +141,35 @@ function processMeasurementPayload(
|
|||||||
frameId: measurementFrame.frameId,
|
frameId: measurementFrame.frameId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Single acquisition completes as soon as the data frame arrives —
|
if (isSingle) {
|
||||||
// automatically send STOP so the device returns to idle.
|
|
||||||
if (devStore.deviceStatus === 'single') {
|
|
||||||
void deviceStop();
|
void deviceStop();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_processing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise<AckPacket> {
|
function sendAndWaitAck(pkt: Uint8Array, ackFunc: number, timeoutMs = 5000): Promise<AckPacket> {
|
||||||
return new Promise((resolve, reject) => {
|
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(() => {
|
const timer = setTimeout(() => {
|
||||||
pendingAcks.delete(ackFunc);
|
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'));
|
reject(new Error('ACK timeout'));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const existing = pendingAcks.get(ackFunc);
|
||||||
|
if (existing) {
|
||||||
|
pendingAcks.delete(ackFunc);
|
||||||
|
existing({ result: 0x02, reason: 'superseded' });
|
||||||
|
}
|
||||||
|
|
||||||
pendingAcks.set(ackFunc, (ack) => {
|
pendingAcks.set(ackFunc, (ack) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
if (__DEV__) console.log(`[Device] ACK received for func=0x${ackFunc.toString(16)}, result=0x${ack.result.toString(16)}`);
|
||||||
resolve(ack);
|
resolve(ack);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -171,7 +196,15 @@ export async function deviceStartSingle(): Promise<AckPacket> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deviceStop(): Promise<AckPacket> {
|
export async function deviceStop(): Promise<AckPacket> {
|
||||||
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
|
// Initiate split frame transfer to request large data
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import * as SQLite from 'expo-sqlite';
|
import * as SQLite from 'expo-sqlite';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types';
|
import type { MeasurementFrame, MeasurementMeta, SetupConfig, GateConfig } from '../protocol/types';
|
||||||
|
import { AMP_GAIN } from '../protocol/constants';
|
||||||
import * as BinLoader from './BinLoader';
|
import * as BinLoader from './BinLoader';
|
||||||
import * as TemBundle from './TemBundle';
|
import * as TemBundle from './TemBundle';
|
||||||
|
|
||||||
@ -70,10 +71,37 @@ async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id);
|
CREATE INDEX IF NOT EXISTS idx_fs ON frames(session_id);
|
||||||
`);
|
`);
|
||||||
|
await runMigrations(_db);
|
||||||
}
|
}
|
||||||
return _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() {
|
async function ensureDataDir() {
|
||||||
const info = await FileSystem.getInfoAsync(DATA_DIR);
|
const info = await FileSystem.getInfoAsync(DATA_DIR);
|
||||||
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
|
if (!info.exists) await FileSystem.makeDirectoryAsync(DATA_DIR, { intermediates: true });
|
||||||
@ -84,23 +112,44 @@ export async function initStorage(): Promise<void> {
|
|||||||
await ensureDataDir();
|
await ensureDataDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasAnyProject(): Promise<boolean> {
|
||||||
|
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<ProjectInfo | null> {
|
||||||
|
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 ────────────────────────────────────────────────────────────
|
// ── Project CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateProjectId(): string {
|
function generateProjectId(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
|
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
|
||||||
|
const rand = Math.random().toString(36).slice(2, 6);
|
||||||
return (
|
return (
|
||||||
`P${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
`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<string> {
|
export async function createProject(name: string): Promise<string> {
|
||||||
const d = await getDb();
|
const d = await getDb();
|
||||||
const id = generateProjectId();
|
const id = generateProjectId();
|
||||||
await d.runAsync(
|
await d.runAsync(
|
||||||
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
|
'INSERT INTO projects (project_id, name, created_at) VALUES (?,?,?)',
|
||||||
[id, name.trim(), Date.now()],
|
[id, sanitizeName(name), Date.now()],
|
||||||
);
|
);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@ -132,6 +181,19 @@ export async function renameProject(projectId: string, name: string): Promise<vo
|
|||||||
|
|
||||||
export async function deleteProject(projectId: string): Promise<void> {
|
export async function deleteProject(projectId: string): Promise<void> {
|
||||||
const d = await getDb();
|
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]);
|
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; }
|
} catch { binPath = null; }
|
||||||
|
|
||||||
await d.runAsync(
|
await d.runAsync(
|
||||||
`INSERT OR REPLACE INTO frames (
|
`INSERT OR IGNORE INTO frames (
|
||||||
frame_id, session_id, ts, utc, lon, lat, alt,
|
frame_id, session_id, ts, utc, lon, lat, alt,
|
||||||
ch_num, acc_num, gain, gps_st, sd_st, amp_ratio,
|
ch_num, acc_num, gain, gps_st, sd_st, amp_ratio,
|
||||||
current, temp, batt, roll, pitch, yaw, src_mode, bin_path
|
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);
|
const bundleBytes = await TemBundle.packBundle(manifest, bins);
|
||||||
|
|
||||||
await ensureDataDir();
|
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);
|
await BinLoader.writeFileBytes(outPath, bundleBytes);
|
||||||
return outPath;
|
return outPath;
|
||||||
}
|
}
|
||||||
@ -473,7 +537,7 @@ export async function importProject(
|
|||||||
[
|
[
|
||||||
frameId, newSessionId, meta.timestamp,
|
frameId, newSessionId, meta.timestamp,
|
||||||
meta.utc, meta.longitude, meta.latitude, meta.altitude,
|
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.gpsStatus, meta.sdStatus, meta.ampRatio,
|
||||||
meta.current, meta.temperature, meta.batteryVolt,
|
meta.current, meta.temperature, meta.batteryVolt,
|
||||||
meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath,
|
meta.roll, meta.pitch, meta.yaw, meta.srcMode, binPath,
|
||||||
|
|||||||
@ -15,11 +15,13 @@ class TcpService {
|
|||||||
private callbacks: TcpCallbacks | null = null;
|
private callbacks: TcpCallbacks | null = null;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private reconnectEnabled = false;
|
private reconnectEnabled = false;
|
||||||
private host = '';
|
private host = '';
|
||||||
private port = 0;
|
private port = 0;
|
||||||
|
|
||||||
connect(host: string, port: number, callbacks: TcpCallbacks) {
|
connect(host: string, port: number, callbacks: TcpCallbacks) {
|
||||||
|
this.disconnect();
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.callbacks = callbacks;
|
this.callbacks = callbacks;
|
||||||
@ -34,33 +36,62 @@ class TcpService {
|
|||||||
this.socket = null;
|
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(
|
const sock = TcpSocket.createConnection(
|
||||||
{ host: this.host, port: this.port, tls: false },
|
{ host: this.host, port: this.port, tls: false },
|
||||||
() => {
|
() => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
clearTimeout(this.reconnectTimer!);
|
clearTimeout(this.reconnectTimer!);
|
||||||
|
this.startHeartbeat();
|
||||||
|
if (__DEV__) console.log(`[TCP] ✓ connected to ${this.host}:${this.port}`);
|
||||||
this.callbacks?.onConnect();
|
this.callbacks?.onConnect();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
sock.on('data', (data: any) => {
|
sock.on('data', (data: any) => {
|
||||||
const arr: Uint8Array =
|
if (__DEV__) console.log('[TCP] data event fired, type:', typeof data, 'constructor:', data?.constructor?.name, 'length:', data?.length ?? data?.byteLength ?? '?');
|
||||||
data instanceof Uint8Array
|
let arr: Uint8Array;
|
||||||
? data
|
if (data instanceof Uint8Array) {
|
||||||
: data instanceof ArrayBuffer
|
arr = data;
|
||||||
? new Uint8Array(data)
|
} else if (data instanceof ArrayBuffer) {
|
||||||
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
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);
|
const frames = this.parser.feed(arr);
|
||||||
|
if (__DEV__) console.log('[TCP] parsed frames:', frames.length);
|
||||||
frames.forEach((f) => this.callbacks?.onFrame(f));
|
frames.forEach((f) => this.callbacks?.onFrame(f));
|
||||||
});
|
});
|
||||||
|
|
||||||
sock.on('error', (err: Error) => {
|
sock.on('error', (err: Error) => {
|
||||||
|
if (__DEV__) console.warn('[TCP] error event:', err.message);
|
||||||
|
this.stopHeartbeat();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.callbacks?.onError(err);
|
this.callbacks?.onError(err);
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
sock.on('close', () => {
|
sock.on('close', () => {
|
||||||
|
if (__DEV__) console.log('[TCP] close event');
|
||||||
|
this.stopHeartbeat();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.callbacks?.onClose();
|
this.callbacks?.onClose();
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
@ -69,6 +100,29 @@ class TcpService {
|
|||||||
this.socket = sock;
|
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() {
|
private scheduleReconnect() {
|
||||||
if (!this.reconnectEnabled) return;
|
if (!this.reconnectEnabled) return;
|
||||||
clearTimeout(this.reconnectTimer!);
|
clearTimeout(this.reconnectTimer!);
|
||||||
@ -83,15 +137,18 @@ class TcpService {
|
|||||||
send(data: Uint8Array): boolean {
|
send(data: Uint8Array): boolean {
|
||||||
if (!this.connected || !this.socket) return false;
|
if (!this.connected || !this.socket) return false;
|
||||||
try {
|
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);
|
this.socket.write(data as unknown as string);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
if (__DEV__) console.warn('[TCP] send error:', e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.reconnectEnabled = false;
|
this.reconnectEnabled = false;
|
||||||
|
this.stopHeartbeat();
|
||||||
clearTimeout(this.reconnectTimer!);
|
clearTimeout(this.reconnectTimer!);
|
||||||
this.socket?.destroy();
|
this.socket?.destroy();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
@ -102,6 +159,10 @@ class TcpService {
|
|||||||
isConnected() {
|
isConnected() {
|
||||||
return this.connected;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetParser() {
|
||||||
|
this.parser.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance shared across the app
|
// Singleton instance shared across the app
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import type { MeasurementFrame } from '../protocol/types';
|
|||||||
import * as StorageService from '../services/StorageService';
|
import * as StorageService from '../services/StorageService';
|
||||||
import { fileStorage } from '../utils/storage';
|
import { fileStorage } from '../utils/storage';
|
||||||
|
|
||||||
const MAX_HISTORY = 500;
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
interface DataState {
|
interface DataState {
|
||||||
|
hasProject: boolean;
|
||||||
currentFrame: MeasurementFrame | null;
|
currentFrame: MeasurementFrame | null;
|
||||||
history: MeasurementFrame[];
|
history: MeasurementFrame[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@ -14,24 +15,23 @@ interface DataState {
|
|||||||
_frameCounter: number;
|
_frameCounter: number;
|
||||||
nextFrameId: () => number;
|
nextFrameId: () => number;
|
||||||
addFrame: (frame: MeasurementFrame) => void;
|
addFrame: (frame: MeasurementFrame) => void;
|
||||||
|
addFrameUI: (frame: MeasurementFrame) => void;
|
||||||
deleteFrame: (frameId: number) => void;
|
deleteFrame: (frameId: number) => void;
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
/** Start a new line (session), optionally under a project. */
|
newSession: (projectId: string) => Promise<void>;
|
||||||
newSession: (projectId?: string | null) => Promise<void>;
|
|
||||||
/** 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. */
|
|
||||||
resumeSession: (sessionId: string, projectId?: string | null) => Promise<void>;
|
resumeSession: (sessionId: string, projectId?: string | null) => Promise<void>;
|
||||||
/** Change the project association of the current session. */
|
|
||||||
setProject: (projectId: string | null) => Promise<void>;
|
setProject: (projectId: string | null) => Promise<void>;
|
||||||
|
resetToNoProject: () => void;
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDataStore = create<DataState>()(
|
export const useDataStore = create<DataState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
|
hasProject: false,
|
||||||
currentFrame: null,
|
currentFrame: null,
|
||||||
history: [],
|
history: [],
|
||||||
sessionId: generateSessionId(),
|
sessionId: '',
|
||||||
projectId: null,
|
projectId: null,
|
||||||
_frameCounter: 0,
|
_frameCounter: 0,
|
||||||
|
|
||||||
@ -49,6 +49,13 @@ export const useDataStore = create<DataState>()(
|
|||||||
void StorageService.persistFrame(frame, get().sessionId);
|
void StorageService.persistFrame(frame, get().sessionId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addFrameUI: (frame) => {
|
||||||
|
set((s) => ({
|
||||||
|
currentFrame: frame,
|
||||||
|
history: [frame, ...s.history].slice(0, MAX_HISTORY),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
deleteFrame: (frameId) => {
|
deleteFrame: (frameId) => {
|
||||||
const { sessionId, history, currentFrame } = get();
|
const { sessionId, history, currentFrame } = get();
|
||||||
void StorageService.deleteFrame(frameId, sessionId);
|
void StorageService.deleteFrame(frameId, sessionId);
|
||||||
@ -61,25 +68,23 @@ export const useDataStore = create<DataState>()(
|
|||||||
|
|
||||||
clearHistory: () => set({ history: [], currentFrame: null }),
|
clearHistory: () => set({ history: [], currentFrame: null }),
|
||||||
|
|
||||||
newSession: async (projectId) => {
|
newSession: async (projectId: string) => {
|
||||||
const id = generateSessionId();
|
const id = generateSessionId();
|
||||||
const pid = projectId !== undefined ? projectId : get().projectId;
|
await StorageService.ensureSession(id, projectId);
|
||||||
await StorageService.ensureSession(id, pid);
|
set({ sessionId: id, projectId, history: [], currentFrame: null, _frameCounter: 0, hasProject: true });
|
||||||
set({ sessionId: id, projectId: pid, history: [], currentFrame: null, _frameCounter: 0 });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
resumeSession: async (sessionId, projectId) => {
|
resumeSession: async (sessionId, projectId) => {
|
||||||
const pid = projectId !== undefined ? projectId : get().projectId;
|
const pid = projectId !== undefined ? projectId : get().projectId;
|
||||||
await StorageService.ensureSession(sessionId, pid);
|
await StorageService.ensureSession(sessionId, pid);
|
||||||
const maxId = await StorageService.getMaxFrameId(sessionId);
|
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({
|
set({
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId: pid ?? null,
|
projectId: pid ?? null,
|
||||||
history: [],
|
history: [],
|
||||||
currentFrame: null,
|
currentFrame: null,
|
||||||
_frameCounter: maxId + 1,
|
_frameCounter: maxId + 1,
|
||||||
|
hasProject: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -89,19 +94,51 @@ export const useDataStore = create<DataState>()(
|
|||||||
set({ projectId });
|
set({ projectId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetToNoProject: () => set({
|
||||||
|
hasProject: false,
|
||||||
|
projectId: null,
|
||||||
|
sessionId: '',
|
||||||
|
history: [],
|
||||||
|
currentFrame: null,
|
||||||
|
_frameCounter: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
await StorageService.initStorage();
|
await StorageService.initStorage();
|
||||||
|
|
||||||
|
const exists = await StorageService.hasAnyProject();
|
||||||
|
if (!exists) {
|
||||||
|
set({ hasProject: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ hasProject: true });
|
||||||
const { sessionId, projectId } = get();
|
const { sessionId, projectId } = get();
|
||||||
|
|
||||||
|
if (projectId && sessionId) {
|
||||||
|
const projects = await StorageService.listProjects();
|
||||||
|
if (projects.some((p) => p.projectId === projectId)) {
|
||||||
await StorageService.ensureSession(sessionId, projectId);
|
await StorageService.ensureSession(sessionId, projectId);
|
||||||
const maxId = await StorageService.getMaxFrameId(sessionId);
|
const maxId = await StorageService.getMaxFrameId(sessionId);
|
||||||
set({ _frameCounter: maxId + 1 });
|
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',
|
name: 'data-session',
|
||||||
storage: createJSONStorage(() => fileStorage),
|
storage: createJSONStorage(() => fileStorage),
|
||||||
// Only persist identity fields. Runtime data (frames, history) is always
|
|
||||||
// rebuilt from SQLite on demand.
|
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
projectId: state.projectId,
|
projectId: state.projectId,
|
||||||
@ -113,8 +150,10 @@ export const useDataStore = create<DataState>()(
|
|||||||
function generateSessionId(): string {
|
function generateSessionId(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
|
const pad = (n: number, d = 2) => String(n).padStart(d, '0');
|
||||||
|
const rand = Math.random().toString(36).slice(2, 6);
|
||||||
return (
|
return (
|
||||||
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
`${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}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,20 +13,20 @@ const DEFAULT_GATE_CONFIG: GateConfig = {
|
|||||||
|
|
||||||
const DEFAULT_CONFIG: SetupConfig = {
|
const DEFAULT_CONFIG: SetupConfig = {
|
||||||
channelNum: 3,
|
channelNum: 3,
|
||||||
sendFreq: 0x03, // 4 Hz
|
sendFreq: 0x05, // 16 Hz
|
||||||
sampleFreq: 0x03, // 31.25 kHz
|
sampleFreq: 0x00, // 250 kHz
|
||||||
sampleDepth: 1024,
|
sampleDepth: 2000,
|
||||||
accNum: 50,
|
accNum: 32,
|
||||||
negAcc456: false,
|
negAcc456: false,
|
||||||
negAcc123: false,
|
negAcc123: false,
|
||||||
ampRatio: 0x03, // 1×
|
ampRatio: 0x03, // 1×
|
||||||
dataChannel: DataChannel.WIFI,
|
dataChannel: DataChannel.WIFI,
|
||||||
compRes: 0,
|
compRes: 12,
|
||||||
secondSampleFreq: 0x03,
|
secondSampleFreq: 0x00,
|
||||||
compDisableDelay: 300, // 300 × 50μs = 15ms
|
compDisableDelay: 60,
|
||||||
sourceMode: 0x00, // single source A
|
sourceMode: 0x00, // single source A
|
||||||
batteryVoltageMin: 0,
|
batteryVoltageMin: 0,
|
||||||
filePrefix: 'TEM',
|
filePrefix: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DeviceState {
|
interface DeviceState {
|
||||||
|
|||||||
@ -91,11 +91,64 @@ export async function saveFrameBin(frame: MeasurementFrame, sessionId: string):
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shareFile(filePath: string) {
|
function getMime(filePath: string): string {
|
||||||
const canShare = await Sharing.isAvailableAsync();
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
if (canShare) {
|
const map: Record<string, string> = {
|
||||||
await Sharing.shareAsync(filePath);
|
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<boolean> {
|
||||||
|
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<string[]> {
|
export async function listSessionFiles(sessionId: string): Promise<string[]> {
|
||||||
|
|||||||