一次大的提交

This commit is contained in:
2026-08-07 08:49:29 +08:00
parent bf2fc49c19
commit db70665ced
181 changed files with 23060 additions and 2804 deletions

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 🔥 加载签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.maibu.maibu_satabot_v2"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +40,20 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -6,6 +6,14 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<application
android:label="maibu_satabot_v2"
android:name="${applicationName}"

View File

@@ -437,7 +437,8 @@
"high": "高",
"medium": "中",
"low": "低",
"progress": "进度"
"progress": "进度",
"no_data": "暂无数据"
},
"my_v2": {

3
dist/download/.gitkeep vendored Normal file
View File

@@ -0,0 +1,3 @@
# 完整安装包目录
将 app-release.apk 放在此目录下用于测试

3
dist/patch/.gitkeep vendored Normal file
View File

@@ -0,0 +1,3 @@
# 差量更新包目录
将生成的 .patch 文件放在此目录下

View File

@@ -0,0 +1,296 @@
# 无人机机场 OSD 实时数据展示功能
## 📋 功能概述
在无人机机场详情页面添加了 **OSD 实时数据卡片**,通过 MQTT 订阅 `thing/product/${gatewaySn}/osd` topic,实时展示机场的遥测数据。
## 🎯 核心特性
### 1. 滑动窗口设计
- **左右滑动切换**:用户可以左右滑动查看不同的 OSD 数据字段
- **底部指示器**:显示当前页码和总页数
- **滑动提示**:左右箭头提示用户可以滑动
### 2. 智能状态管理
- **在线时**:显示实时 OSD 数据(电量、温度、风速等)
- **离线时**:显示"机场离线,暂无实时数据"提示
- **加载中**:显示加载动画
### 3. 数据字段展示
目前展示的 OSD 字段包括:
| 字段 | 图标 | 颜色规则 | 示例值 |
|------|------|---------|--------|
| 电量 | 🔋 battery_full | >50% 绿色, 20-50% 橙色, <20% 红色 | 85% |
| 温度 | 🌡️ thermostat | 蓝色 | 25°C |
| 风速 | 💨 air | 绿色 | 3.5 m/s |
| 降雨量 | 💧 water_drop | 蓝色 | 小雨 / 12 mm |
| 网络状态 | 📶 network_check | 灰色 | 等级 4 |
| GPS | 🛰️ gps_fixed | 紫色 | 12 颗 |
## 📁 文件结构
```
lib/features/v2/device_list/
├── presentation/
│ ├── pages/
│ │ └── drone_station_detail_page.dart # 详情页(集成 OSD 卡片)
│ └── widgets/
│ └── drone_station_osd_card.dart # OSD 实时数据卡片组件
```
## 🔧 技术实现
### 1. MQTT 数据订阅
```dart
// 订阅机场 OSD topic
final stationTopic = 'thing/product/$gatewaySn/osd';
// 监听数据流
_subscription = _dataSource.stationOsdStream.listen((osd) {
setState(() {
_currentOsd = osd;
_parseOsdFields(osd); // 解析并更新 UI
});
});
```
### 2. 数据解析
```dart
void _parseOsdFields(DroneOsdEntity osd) {
final data = osd.rawData;
_osdFields = [
{
'icon': Icons.battery_full_rounded,
'label': '电量',
'value': data['battery'] != null ? '${data['battery']}%' : '未知',
'color': _getBatteryColor(data['battery']),
},
// ... 其他字段
];
}
```
### 3. 滑动卡片实现
使用 `PageView.builder` 实现左右滑动:
```dart
PageView.builder(
itemCount: _osdFields.length,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
itemBuilder: (context, index) {
final field = _osdFields[index];
return _buildOsdPage(field, index);
},
)
```
## 🎨 UI 设计
### 卡片布局
```
┌─────────────────────────────┐
│ [图标] 电量 ◀ ▶ │
│ │
│ 85% │
│ │
│ 1 / 6 │
│ │
│ ● ○ ○ ○ ○ ○ │
└─────────────────────────────┘
```
### 状态指示器
- **当前页**:长条蓝色圆点(16px)
- **其他页**:短条灰色圆点(6px)
## 📊 数据映射规则
### 降雨量格式化
```dart
String _formatRainfall(dynamic rainfall) {
if (rainfall is String) {
switch (rainfall.toLowerCase()) {
case 'no_rain': return '无降雨';
case 'light_rain': return '小雨';
case 'moderate_rain': return '中雨';
case 'heavy_rain': return '大雨';
default: return '$rainfall mm';
}
}
return '$rainfall mm';
}
```
### 电量颜色规则
```dart
Color _getBatteryColor(dynamic battery) {
final value = battery is num ? battery.toDouble() : 0.0;
if (value > 50) return Color(0xFF00B42A); // 绿色
if (value > 20) return Color(0xFFFF7D00); // 橙色
return Color(0xFFF53F3F); // 红色
}
```
## 🔄 生命周期管理
### 启动监听
```dart
@override
void initState() {
super.initState();
_startListening(); // 开始监听 MQTT
}
```
### 停止监听
```dart
@override
void dispose() {
_stopListening(); // 取消订阅,释放资源
super.dispose();
}
```
### 动态更新
```dart
@override
void didUpdateWidget(DroneStationOsdCard oldWidget) {
super.didUpdateWidget(oldWidget);
// gatewaySn 或 isOnline 变化时重新订阅
if (oldWidget.gatewaySn != widget.gatewaySn ||
oldWidget.isOnline != widget.isOnline) {
_stopListening();
_startListening();
}
}
```
## 🚀 使用方法
### 在详情页中集成
```dart
DroneStationOsdCard(
gatewaySn: widget.station.gatewaySn,
isOnline: detail.isOnline,
)
```
### 参数说明
| 参数 | 类型 | 说明 |
|------|------|------|
| `gatewaySn` | String | 机场网关序列号(用于 MQTT topic) |
| `isOnline` | bool | 机场是否在线(决定是否显示实时数据) |
## 📝 扩展建议
### 1. 添加更多 OSD 字段
在 `_parseOsdFields` 方法中添加新字段:
```dart
{
'icon': Icons.new_icon,
'label': '新字段名称',
'value': data['newField'] != null ? '${data['newField']}' : '未知',
'color': const Color(0xFF165DFF),
},
```
### 2. 自定义滑动方向
修改 `PageView` 的 `scrollDirection`:
```dart
PageView.builder(
scrollDirection: Axis.vertical, // 改为上下滑动
// ...
)
```
### 3. 自动轮播
添加定时器自动切换卡片:
```dart
Timer.periodic(Duration(seconds: 3), (timer) {
if (_osdFields.isNotEmpty) {
setState(() {
_currentIndex = (_currentIndex + 1) % _osdFields.length;
});
_pageController.animateToPage(_currentIndex, ...);
}
});
```
### 4. 手势优化
添加双击放大、长按复制等功能:
```dart
GestureDetector(
onDoubleTap: () {
// 双击放大
},
onLongPress: () {
// 长按复制数值
Clipboard.setData(ClipboardData(text: field['value']));
},
child: _buildOsdPage(field, index),
)
```
## ⚠️ 注意事项
1. **MQTT 连接**:确保应用已连接到 MQTT 服务器
2. **topic 格式**:必须为 `thing/product/${gatewaySn}/osd`
3. **数据格式**:MQTT 消息 payload 必须是 JSON 格式
4. **内存管理**:页面销毁时必须取消订阅,避免内存泄漏
5. **在线判断**:只有机场在线时才订阅 MQTT,离线时不订阅
## 🐛 调试技巧
### 查看 MQTT 日志
```dart
debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
debugPrint('✅ [DroneStationOsdCard] 收到机场 OSD 数据');
```
### 检查数据解析
```dart
debugPrint('📊 [DroneStationOsdCard] OSD 原始数据: ${osd.rawData}');
debugPrint('📊 [DroneStationOsdCard] 解析后字段数: ${_osdFields.length}');
```
## 📦 依赖项
- `flutter_bloc`: 状态管理
- `mqtt_client`: MQTT 通信
- `equatable`: 实体类比较
- `get_it`: 依赖注入
## ✅ 完成清单
- [x] 创建 `DroneStationOsdCard` 组件
- [x] 实现 MQTT 数据订阅
- [x] 实现滑动窗口 UI
- [x] 实现数据解析和格式化
- [x] 集成到详情页
- [x] 添加状态管理(在线/离线/加载中)
- [x] 添加生命周期管理
- [x] 添加调试日志

View File

@@ -0,0 +1,195 @@
# 无人机实时视频接口对接完成总结
## ✅ 已完成工作
### 1. API 层
- [x] 添加 API 常量 `changeUAVLens` 到 `HttpApiConsts`
### 2. 实体层 (Domain/Entities)
- [x] 创建 `UavVideoStreamEntity` 实体类
- [x] 定义 `UavLensType` 枚举(广角、变焦、红外)
- [x] 定义 `VideoQualityType` 枚举(自适应、低、中、高清晰度)
- [x] 实现 RTC 参数解析方法(appId、roomId、token、userId)
### 3. 数据源层 (Data/Datasources)
- [x] 扩展 `DroneStationDataSource` 接口,添加 `getUavVideoStream` 方法
- [x] 实现 `DroneStationDataSourceImpl.getUavVideoStream`
- [x] 支持可选的镜头类型参数
- [x] 支持自定义清晰度和 Token 有效期
- [x] 添加详细的日志输出用于调试
### 4. 仓库层 (Data/Repositories)
- [x] 扩展 `DroneStationRepository` 接口
- [x] 实现 `DroneStationRepositoryImpl.getUavVideoStream`
- [x] 使用 `Either<Failure, T>` 模式处理错误
### 5. 用例层 (Domain/UseCases)
- [x] 创建 `GetUavVideoStreamUseCase`
- [x] 封装业务逻辑,供 BLoC 调用
- [x] 提供合理的默认值
### 6. 状态管理层 (Presentation/BLoC)
- [x] 添加 `UavVideoStreamLoad` 事件
- [x] 添加 `UavVideoStreamLoading` 状态
- [x] 添加 `UavVideoStreamLoaded` 状态
- [x] 添加 `UavVideoStreamError` 状态
- [x] 在 `DroneStationBloc` 中实现事件处理逻辑
### 7. 依赖注入 (DI)
- [x] 导入 `GetUavVideoStreamUseCase`
- [x] 注册 `GetUavVideoStreamUseCase` 为单例
- [x] 更新 `DroneStationBloc` 工厂,注入新的 UseCase
### 8. UI 层 (Presentation/Pages)
- [x] 创建示例页面 `UavLiveVideoPage`
- [x] 实现镜头切换功能(PopupMenuButton)
- [x] 实现加载状态显示
- [x] 实现错误处理和重试机制
- [x] 预留 RTC SDK 集成位置
### 9. 测试
- [x] 编写单元测试 `get_uav_video_stream_usecase_test.dart`
- [x] 测试成功场景
- [x] 测试失败场景
- [x] 测试默认参数
### 10. 文档
- [x] 创建集成指南 `UAV_VIDEO_INTEGRATION_GUIDE.md`
- [x] 创建使用示例 `UAV_VIDEO_USAGE_EXAMPLES.md`
## 📁 文件清单
### 新增文件(7个)
1. `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart` - 实体类
2. `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart` - 用例
3. `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart` - 示例页面
4. `test/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase_test.dart` - 单元测试
5. `docs/UAV_VIDEO_INTEGRATION_GUIDE.md` - 集成指南
6. `docs/UAV_VIDEO_USAGE_EXAMPLES.md` - 使用示例
7. `docs/UAV_VIDEO_COMPLETION_SUMMARY.md` - 本文件
### 修改文件(9个)
1. `lib/core/consts/http_api_consts.dart` - 添加 API 常量
2. `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
3. `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
4. `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
5. `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
6. `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
7. `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
8. `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
9. `lib/core/di/injection.dart` - 注册依赖
## 核心特性
### 清洁架构设计
- **分层清晰**:Entity → UseCase → Repository → DataSource → BLoC → UI
- **依赖倒置**:高层模块不依赖低层模块的具体实现
- **可测试性**:每层都可以独立测试
### 可扩展性
- **多镜头支持**:通过枚举轻松扩展更多镜头类型
- **多清晰度支持**:支持自适应、低、中、高四种清晰度
- **多 RTC SDK 支持**:兼容火山引擎和声网两种 RTC SDK
- **可复用**:可在任何页面中使用,无需重复实现
### 健壮性
- **错误处理**:使用 `Either<Failure, T>` 统一处理错误
- **默认值**:提供合理的默认参数,简化调用
- **日志记录**:详细的日志输出,方便调试
- **重试机制**:支持手动重试和自动刷新
## 快速开始
### 方式一:直接使用示例页面
```dart
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U',
cameraIndex: '176-0-0',
),
),
);
```
### 方式二:在自定义页面中集成
参考 `docs/UAV_VIDEO_USAGE_EXAMPLES.md` 中的详细示例。
## 📋 API 说明
### 请求参数
```json
{
"sn": "1581F8HGX253U00A063U", // 必填:设备序列号
"lensType": "", // 可选:wide/zoom/ir
"cameraIndex": "176-0-0", // 必填:摄像头编号
"qualityType": "adaptive", // 可选:adaptive/low/medium/high
"videoExpire": 720000000 // 可选:Token有效期(毫秒)
}
```
### 返回数据
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"sn": "1581F8HGX253U00A063U",
"camera_index": "176-0-0",
"url": "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx",
"expire_ts": 1781155234,
"url_type": "volc" // volc 或 agora
}
}
```
## 🔧 后续工作
1. [ ] 集成 RTC SDK 显示实际视频画面
- 根据 `urlType` 选择火山引擎或声网 SDK
- 初始化 RTC 引擎并加入房间
- 渲染远程视频流
2. [ ] 添加视频录制功能
- 开始/停止录制
- 保存录制文件
3. [ ] 添加截图功能
- 截取当前帧
- 保存到相册
4. [ ] 优化视频加载超时处理
- 设置合理的超时时间
- 提供友好的超时提示
5. [ ] 支持多路视频同时观看
- 分屏显示多个摄像头
- 切换主视图
## 📚 相关文档
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
- [使用示例](./UAV_VIDEO_USAGE_EXAMPLES.md)
- [API 接口定义](../lib/core/consts/http_api_consts.dart)
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
## ✅ 验证结果
- [x] 所有文件编译通过,无错误
- [x] 单元测试编写完成
- [x] 依赖注入配置正确
- [x] 文档齐全
## 💡 注意事项
1. **不要在页面上直接调用 API**:必须通过 BLoC 管理状态
2. **及时释放资源**:在 `dispose()` 中关闭 BLoC
3. **合理设置 Token 有效期**:建议设置为较长的时间,避免频繁刷新
4. **检查 RTC 参数**:确保 AppId、RoomId、Token、UserId 正确解析
5. **根据 urlType 选择 SDK**:volc 使用火山引擎,agora 使用声网
## 🎉 总结
无人机实时视频接口已成功对接,采用清洁架构设计,具有良好的可扩展性和可维护性。所有代码已通过编译检查,单元测试覆盖主要场景,文档齐全。下一步只需集成 RTC SDK 即可显示实际视频画面。

View File

@@ -0,0 +1,323 @@
# 无人机实时视频接口对接说明
## 概述
本模块实现了无人机实时视频流的获取功能,采用清洁架构设计,支持多种镜头类型切换(广角、变焦、红外),并兼容火山引擎和声网两种RTC SDK。
## API 接口
### 接口地址
```
POST http://1.95.137.212:59015/iot/UAV/changeLens
```
### 请求参数
```json
{
"sn": "1581F8HGX253U00A063U", // 无人机设备序列号(必填)
"lensType": "", // 镜头类型:wide(广角)、zoom(变焦)、ir(红外),可为空
"cameraIndex": "176-0-0", // 摄像头编号(必填)
"qualityType": "adaptive", // 清晰度:adaptive(自适应)、low、medium、high(默认adaptive)
"videoExpire": 720000000 // Token有效期(毫秒,默认720000000)
}
```
### 返回数据
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"sn": "1581F8HGX253U00A063U",
"camera_index": "176-0-0",
"url": "app_id=xxx&expire_time=xxx&room_id=xxx&token=xxx&user_id=xxx",
"expire_ts": 1781155234,
"url_type": "volc" // volc(火山引擎) 或 agora(声网)
}
}
```
## 架构设计
### 目录结构
```
lib/features/v2/device_list/
├── domain/
│ ├── entities/
│ │ └── uav_video_stream_entity.dart # 实体类
│ ├── repositories/
│ │ ── drone_station_repository.dart # 仓库接口
│ └── usecases/
│ └── get_uav_video_stream_usecase.dart # 用例
├── data/
│ ├── datasources/
│ │ ├── drone_station_datasource.dart # 数据源接口
│ │ └── drone_station_datasource_impl.dart # 数据源实现
│ └── repositories/
│ └── drone_station_repository_impl.dart # 仓库实现
── presentation/
├── bloc/
│ ├── drone_station_bloc.dart # BLoC状态管理
│ ├── drone_station_event.dart # 事件定义
│ └── drone_station_state.dart # 状态定义
└── pages/
── uav_live_video_page.dart # 示例页面
```
### 核心组件
#### 1. 实体类 (Entity)
**文件**: `domain/entities/uav_video_stream_entity.dart`
定义了枚举类型和实体类:
- `UavLensType`: 镜头类型枚举(wide、zoom、ir)
- `VideoQualityType`: 视频质量枚举(adaptive、low、medium、high)
- `UavVideoStreamEntity`: 视频流实体,包含解析RTC参数的方法
#### 2. 数据源 (DataSource)
**文件**: `data/datasources/drone_station_datasource_impl.dart`
实现了API调用逻辑:
- 发送POST请求到 `/iot/UAV/changeLens`
- 处理响应并转换为实体对象
- 包含详细的日志输出用于调试
#### 3. 仓库 (Repository)
**文件**: `data/repositories/drone_station_repository_impl.dart`
使用 `fpdart` 的 `Either` 类型处理错误:
- 成功时返回 `Right(UavVideoStreamEntity)`
- 失败时返回 `Left(Failure)`
#### 4. 用例 (UseCase)
**文件**: `domain/usecases/get_uav_video_stream_usecase.dart`
封装业务逻辑,供BLoC调用。
#### 5. BLoC 状态管理
**文件**: `presentation/bloc/drone_station_bloc.dart`
新增事件和状态:
- **事件**: `UavVideoStreamLoad` - 加载视频流
- **状态**:
- `UavVideoStreamLoading` - 加载中
- `UavVideoStreamLoaded` - 加载成功
- `UavVideoStreamError` - 加载失败
#### 6. 依赖注入
**文件**: `core/di/injection.dart`
已注册以下单例:
```dart
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
() => GetUavVideoStreamUseCase(sl()),
);
sl.registerFactory<DroneStationBloc>(
() => DroneStationBloc(sl(), sl(), sl(), sl()),
);
```
## 使用方式
### 方式一:直接使用示例页面
```dart
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U',
cameraIndex: '176-0-0',
),
),
);
```
### 方式二:在现有页面中使用
#### 1. 导入必要的类
```dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
```
#### 2. 初始化 BLoC
```dart
late DroneStationBloc _bloc;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 加载视频流
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
```
#### 3. 加载视频流
```dart
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
```
#### 4. 监听状态变化
```dart
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
final videoStream = state.videoStream;
debugPrint('URL Type: ${videoStream.urlType}');
debugPrint('AppId: ${videoStream.appId}');
debugPrint('RoomId: ${videoStream.roomId}');
debugPrint('UserId: ${videoStream.userId}');
// TODO: 根据 urlType 初始化对应的 RTC 引擎
if (videoStream.urlType.toLowerCase() == 'volc') {
// 使用火山引擎 RTC SDK
} else if (videoStream.urlType.toLowerCase() == 'agora') {
// 使用声网 RTC SDK
}
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
});
}
},
builder: (context, state) {
// 根据状态渲染UI
if (state is UavVideoStreamLoading) {
return const CircularProgressIndicator();
}
if (state is UavVideoStreamLoaded) {
// 显示视频画面
return Container();
}
if (state is UavVideoStreamError) {
return Text('错误: ${state.message}');
}
return Container();
},
)
```
#### 5. 切换镜头类型
```dart
// 切换到广角镜头
_loadVideoStream(UavLensType.wide);
// 切换到变焦镜头
_loadVideoStream(UavLensType.zoom);
// 切换到红外镜头
_loadVideoStream(UavLensType.ir);
```
## RTC 集成
获取到视频流后,需要根据 `urlType` 选择对应的 RTC SDK:
### 火山引擎 (volc)
```dart
final appId = videoStream.appId;
final roomId = videoStream.roomId;
final token = videoStream.token;
final userId = videoStream.userId;
// 使用 volc_engine_rtc SDK
final engine = await RTCEngine.createRTCEngine(
RTCVideoContext(appId: appId, eventHandler: handler),
);
final room = await engine.createRTCRoom(roomId);
await room.joinRoom(token: token, userId: userId);
```
### 声网 (agora)
```dart
final appId = videoStream.appId;
final channelId = videoStream.roomId;
final token = videoStream.token;
final uid = int.tryParse(videoStream.userId) ?? 0;
// 使用 agora_rtc_engine SDK
final engine = createAgoraRtcEngine();
await engine.initialize(RtcEngineContext(appId: appId));
await engine.joinChannel(
token: token,
channelId: channelId,
uid: uid,
options: ChannelMediaOptions(...),
);
```
## 注意事项
1. **可扩展性**: 本模块采用清洁架构设计,所有业务逻辑与UI分离,便于在其他页面复用。
2. **错误处理**: 使用 `Either<Failure, T>` 模式统一处理错误,确保异常不会直接抛出。
3. **日志记录**: 数据源层包含详细的日志输出,方便调试和问题排查。
4. **默认值**:
- `lensType` 可为空,后端会根据实际情况选择默认镜头
- `qualityType` 默认为 `adaptive`(自适应)
- `videoExpire` 默认为 `720000000` 毫秒
5. **Token 有效期**: `videoExpire` 参数单位为毫秒,建议设置较长的有效期以避免频繁刷新。
6. **多镜头支持**: 通过 `UavLensType` 枚举可以轻松扩展更多镜头类型。
## 后续工作
1. [ ] 集成 RTC SDK 显示实际视频画面
2. [ ] 添加视频录制功能
3. [ ] 添加截图功能
4. [ ] 优化视频加载超时处理
5. [ ] 添加视频质量切换功能
6. [ ] 支持多路视频同时观看
## 相关文件清单
### 新增文件
- `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart`
- `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart`
- `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart`
### 修改文件
- `lib/core/consts/http_api_consts.dart` - 添加 API 常量
- `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
- `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
- `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
- `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
- `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
- `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
- `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
- `lib/core/di/injection.dart` - 注册依赖

View File

@@ -0,0 +1,298 @@
# 无人机视频页面集成完成
## 📋 更新内容
已将新的无人机实时视频接口集成到现有的**无人机视频回传/远程控制**页面中。
---
## 🎯 集成位置
### 1. 无人机详情页面 → 无人机状态卡片
**文件**: `lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart`
**修改内容**:
```dart
// 点击"无人机状态"卡片时,传递设备序列号和摄像头索引
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DroneVideoControlPage(
droneSn: detail.deviceSn, // ✅ 传递设备序列号
cameraIndex: detail.gatewayCameraList != null &&
detail.gatewayCameraList!.isNotEmpty
? detail.gatewayCameraList!.first.cameraIndex
: '176-0-0', // 默认值
),
),
);
```
---
### 2. 无人机视频控制页面
**文件**: `lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart`
**新增功能**:
#### ✅ 接收参数
```dart
class DroneVideoControlPage extends StatefulWidget {
final String droneSn; // 无人机设备序列号
final String cameraIndex; // 摄像头编号
const DroneVideoControlPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
}
```
#### ✅ 自动加载视频流
```dart
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头的视频流
_loadVideoStream(UavLensType.wide);
}
```
#### ✅ 镜头切换功能
在视频播放器右下角添加了镜头类型选择器:
- **广角** (wide)
- **变焦** (zoom)
- **红外** (ir)
点击可快速切换不同镜头的视频流。
#### ✅ 加载状态显示
```
┌─────────────────────────┐
│ │
│ ⏳ 正在加载... │
│ │
└─────────────────────────┘
```
#### ✅ 错误处理和重试
```
┌─────────────────────────┐
│ │
│ ❌ 加载失败 │
│ 错误信息: xxxxx │
│ │
│ [ 重试 ] │
│ │
└─────────────────────────┘
```
---
## 🔄 工作流程
```mermaid
graph LR
A[无人机详情页] --> B[点击无人机状态卡片]
B --> C[打开视频控制页面]
C --> D[自动调用changeLens接口]
D --> E{加载结果}
E -->|成功| F[显示视频画面]
E -->|失败| G[显示错误+重试按钮]
F --> H[用户切换镜头]
H --> D
G -->|点击重试| D
```
---
## 📱 UI 展示
### 视频播放器区域
```
┌──────────────────────────────────────┐
│ ● REC 00:12:36 │
│ │
│ │
│ [视频画面/加载状态] │
│ │
│ │
│ ┌────────┐ │
│ │广角 ▼│ │ ← 镜头切换
│ └────────┘ │
└──────────────────────────────────────┘
```
### 镜头切换菜单
```
┌──────────┐
│ 广角 │ ← 当前选中
│ 变焦 │
│ 红外 │
└──────────┘
```
---
## 🔧 技术实现
### 1. BLoC 状态管理
```dart
// 监听视频流状态
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
// 获取视频流数据
_videoStream = state.videoStream;
// TODO: 初始化 RTC 引擎并显示视频
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
} else if (state is UavVideoStreamError) {
// 显示错误信息
_errorMessage = state.message;
}
},
// ...
)
```
### 2. 视频流加载
```dart
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
```
### 3. 镜头切换
```dart
PopupMenuButton<UavLensType>(
icon: Text(_getLensTypeName(_currentLensType)),
onSelected: (UavLensType lensType) {
if (lensType != _currentLensType) {
_loadVideoStream(lensType); // 重新加载视频流
}
},
itemBuilder: (context) => [
PopupMenuItem(value: UavLensType.wide, child: Text('广角')),
PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')),
PopupMenuItem(value: UavLensType.ir, child: Text('红外')),
],
)
```
---
## ✅ 已完成的功能
- ✅ 从无人机详情页传递设备序列号和摄像头索引
- ✅ 自动调用 `changeLens` 接口获取视频流
- ✅ 支持三种镜头类型切换(广角/变焦/红外)
- ✅ 加载状态显示
- ✅ 错误处理和重试机制
- ✅ 打印视频流参数(AppId、RoomId、UserId等)
- ✅ 清洁架构设计,易于扩展和维护
---
## 🔜 下一步工作(TODO)
### 1. 集成 RTC SDK 显示实际视频
在 `_buildVideoPlayer()` 方法中,当 `_videoStream != null` 时:
```dart
if (_videoStream != null) {
// 根据 urlType 选择对应的 RTC SDK
if (_videoStream!.urlType == 'volc') {
// 使用火山引擎 RTC SDK
return VolcEngineRtcView(
appId: _videoStream!.appId,
roomId: _videoStream!.roomId,
token: _videoStream!.token,
userId: _videoStream!.userId,
);
} else if (_videoStream!.urlType == 'agora') {
// 使用声网 RTC SDK
return AgoraRtcView(
appId: _videoStream!.appId,
channelId: _videoStream!.roomId,
token: _videoStream!.token,
uid: int.parse(_videoStream!.userId),
);
}
}
```
### 2. 参考现有实现
可以参考 `drone_station_detail_page.dart` 中的以下方法:
- `_initFloatingVolcEngine()` - 火山引擎初始化
- `_initFloatingAgoraEngine()` - 声网引擎初始化
- `_disposeVolcEngine()` / `_disposeAgoraEngine()` - 资源释放
### 3. 添加更多控制功能
- 云台控制(上下左右)
- 变焦倍数调节
- 拍照/录像
- AI识别结果展示
---
## 📝 测试步骤
1. **进入无人机详情页**
- 确保无人机在线且有摄像头列表
2. **点击"无人机状态"卡片**
- 应该跳转到视频控制页面
3. **观察加载过程**
- 看到 "正在加载视频流..." 提示
- 成功后显示视频画面(当前为占位图)
4. **测试镜头切换**
- 点击右下角的镜头类型按钮
- 选择"广角"、"变焦"或"红外"
- 观察是否重新加载并切换成功
5. **测试错误处理**
- 断开网络连接
- 应该显示错误信息和重试按钮
- 点击重试应该重新加载
---
## 🎉 总结
✅ **集成完成!**
现在无人机视频控制页面已经:
- 正确接收设备参数
- 自动调用新的视频流接口
- 支持多镜头切换
- 完善的加载和错误处理
只需最后一步:**集成 RTC SDK 显示实际视频画面**,即可完整实现无人机实时视频监控功能!

View File

@@ -0,0 +1,642 @@
# 无人机实时视频接口使用示例
## 快速开始
本指南展示如何在你的项目中快速集成无人机实时视频功能。
## 1. 基础用法 - 直接导航到视频页面
最简单的方式是直接导航到 `UavLiveVideoPage`:
```dart
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
// 在任意地方调用
void _openVideo() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U', // 无人机序列号
cameraIndex: '176-0-0', // 摄像头编号
),
),
);
}
```
## 2. 高级用法 - 在自定义页面中集成
如果你需要在自己的页面中集成视频功能,可以按照以下步骤操作:
### 步骤 1: 创建页面并初始化 BLoC
```dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
class MyCustomVideoPage extends StatefulWidget {
final String droneSn;
final String cameraIndex;
const MyCustomVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<MyCustomVideoPage> createState() => _MyCustomVideoPageState();
}
class _MyCustomVideoPageState extends State<MyCustomVideoPage> {
late DroneStationBloc _bloc;
UavVideoStreamEntity? _videoStream;
bool _isLoading = false;
String? _errorMessage;
UavLensType? _currentLensType;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
}
```
### 步骤 2: 实现加载视频流方法
```dart
/// 加载视频流
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive, // 自适应清晰度
videoExpire: 720000000, // Token有效期(毫秒)
),
);
}
```
### 步骤 3: 构建 UI 并监听状态变化
```dart
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'无人机实时视频',
style: TextStyle(color: Colors.white),
),
centerTitle: true,
actions: [
// 镜头切换按钮
PopupMenuButton<UavLensType>(
icon: const Icon(Icons.videocam, color: Colors.white),
tooltip: '切换镜头',
onSelected: (lensType) {
_loadVideoStream(lensType);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: UavLensType.wide,
child: Row(
children: [
Icon(Icons.videocam, size: 20),
SizedBox(width: 8),
Text('广角镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.zoom,
child: Row(
children: [
Icon(Icons.zoom_in, size: 20),
SizedBox(width: 8),
Text('变焦镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.ir,
child: Row(
children: [
Icon(Icons.thermostat, size: 20),
SizedBox(width: 8),
Text('红外镜头'),
],
),
),
],
),
],
),
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
// 监听状态变化
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
_isLoading = false;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
// TODO: 这里可以初始化 RTC 引擎并显示视频
_initRtcEngine(state.videoStream);
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
_isLoading = false;
});
}
},
builder: (context, state) {
// 根据状态渲染不同的 UI
// 加载中状态
if (_isLoading || state is UavVideoStreamLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'正在加载视频流...',
style: TextStyle(color: Colors.white),
),
],
),
);
}
// 错误状态
if (_errorMessage != null || state is UavVideoStreamError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
_errorMessage ?? (state as UavVideoStreamError).message,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_currentLensType != null) {
_loadVideoStream(_currentLensType!);
}
},
child: const Text('重试'),
),
],
),
);
}
// 无视频信号
if (_videoStream == null) {
return const Center(
child: Text(
'暂无视频信号',
style: TextStyle(color: Colors.white),
),
);
}
// 视频已加载,显示视频画面
return Container(
width: double.infinity,
height: double.infinity,
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.videocam_off,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'请集成 RTC SDK 后在此处显示视频画面',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
);
},
),
),
);
}
```
### 步骤 4: 初始化 RTC 引擎(可选)
```dart
/// 初始化 RTC 引擎
Future<void> _initRtcEngine(UavVideoStreamEntity videoStream) async {
final appId = videoStream.appId;
final roomId = videoStream.roomId;
final token = videoStream.token;
final userId = videoStream.userId.isNotEmpty
? videoStream.userId
: 'user_${DateTime.now().millisecondsSinceEpoch}';
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
debugPrint('RTC 参数缺失');
return;
}
debugPrint('=== RTC 初始化 ===');
debugPrint('AppId: $appId');
debugPrint('RoomId: $roomId');
debugPrint('UserId: $userId');
debugPrint('URL Type: ${videoStream.urlType}');
// 根据 urlType 选择不同的 RTC SDK
final sdkType = videoStream.urlType.toLowerCase() == 'agora'
? RtcSdkType.agora
: RtcSdkType.volcengine;
if (sdkType == RtcSdkType.agora) {
await _initAgoraEngine(appId, roomId, token, userId);
} else {
await _initVolcEngine(appId, roomId, token, userId);
}
}
// TODO: 实现具体的 RTC 引擎初始化逻辑
// 参考 drone_station_detail_page.dart 中的实现
```
## 3. 常见场景示例
### 场景 1: 从列表页跳转到视频页
```dart
// 在设备列表中点击某个设备
void _onDeviceTap(Device device) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: device.sn,
cameraIndex: device.cameraIndex,
),
),
);
}
```
### 场景 2: 支持多个摄像头切换
```dart
class MultiCameraVideoPage extends StatefulWidget {
final String droneSn;
final List<String> cameraIndices;
const MultiCameraVideoPage({
super.key,
required this.droneSn,
required this.cameraIndices,
});
@override
State<MultiCameraVideoPage> createState() => _MultiCameraVideoPageState();
}
class _MultiCameraVideoPageState extends State<MultiCameraVideoPage> {
late DroneStationBloc _bloc;
int _currentCameraIndex = 0;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
_loadCurrentCamera();
}
void _loadCurrentCamera() {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndices[_currentCameraIndex],
lensType: UavLensType.wide,
),
);
}
void _switchCamera(int index) {
setState(() {
_currentCameraIndex = index;
});
_loadCurrentCamera();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('摄像头 ${_currentCameraIndex + 1}/${widget.cameraIndices.length}'),
actions: [
// 切换摄像头按钮
IconButton(
icon: const Icon(Icons.switch_camera),
onPressed: () {
final nextIndex = (_currentCameraIndex + 1) % widget.cameraIndices.length;
_switchCamera(nextIndex);
},
),
],
),
body: BlocBuilder<DroneStationBloc, DroneStationState>(
builder: (context, state) {
// ... 根据状态渲染UI
},
),
);
}
}
```
### 场景 3: 自动刷新视频流(Token 过期时)
```dart
class AutoRefreshVideoPage extends StatefulWidget {
final String droneSn;
final String cameraIndex;
const AutoRefreshVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<AutoRefreshVideoPage> createState() => _AutoRefreshVideoPageState();
}
class _AutoRefreshVideoPageState extends State<AutoRefreshVideoPage> {
late DroneStationBloc _bloc;
Timer? _refreshTimer;
static const _tokenRefreshInterval = Duration(minutes: 55); // 每55分钟刷新一次(Token有效期约1小时)
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
_loadVideoStream();
// 启动定时刷新
_startAutoRefresh();
}
void _startAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(_tokenRefreshInterval, (timer) {
debugPrint('⏰ 自动刷新视频流 Token');
_loadVideoStream();
});
}
void _loadVideoStream() {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: UavLensType.wide,
),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
_bloc.close();
super.dispose();
}
}
```
## 4. 最佳实践
### ✅ 推荐做法
1. **始终检查错误状态**
```dart
if (state is UavVideoStreamError) {
// 显示友好的错误提示
showSnackBar(context, '视频加载失败: ${state.message}');
}
```
2. **提供重试机制**
```dart
ElevatedButton(
onPressed: () => _loadVideoStream(_currentLensType!),
child: const Text('重试'),
)
```
3. **记录关键日志**
```dart
debugPrint('视频流加载成功: URL Type=${videoStream.urlType}');
```
4. **合理设置 Token 有效期**
```dart
videoExpire: 720000000, // 约8天,避免频繁刷新
```
5. **及时释放资源**
```dart
@override
void dispose() {
_bloc.close();
super.dispose();
}
```
### ❌ 避免的做法
1. **不要在页面外直接调用 API**
```dart
// 错误:绕过 BLoC 直接调用
final result = await repository.getUavVideoStream(...);
// ✅ 正确:通过 BLoC 管理状态
_bloc.add(UavVideoStreamLoad(...));
```
2. **不要忘记处理加载状态**
```dart
// ❌ 错误:没有加载指示器
if (state is UavVideoStreamLoaded) { ... }
// ✅ 正确:显示加载状态
if (state is UavVideoStreamLoading) {
return CircularProgressIndicator();
}
```
3. **不要硬编码参数**
```dart
// 错误:硬编码
sn: '1581F8HGX253U00A063U',
// ✅ 正确:使用变量
sn: widget.droneSn,
```
## 5. 故障排查
### 问题 1: 视频流加载失败
**可能原因:**
- 网络问题
- 设备序列号错误
- 摄像头编号错误
- Token 过期
**解决方案:**
1. 检查网络连接
2. 验证 `droneSn` 和 `cameraIndex` 是否正确
3. 查看控制台日志输出
4. 尝试重新加载
### 问题 2: 视频画面不显示
**可能原因:**
- RTC SDK 未正确初始化
- RTC 参数解析失败
- SDK 版本不兼容
**解决方案:**
1. 检查 `urlType` 字段,确认使用正确的 SDK
2. 验证 AppId、RoomId、Token、UserId 是否正确解析
3. 查看 RTC SDK 的日志输出
4. 参考 `drone_station_detail_page.dart` 中的实现
### 问题 3: 镜头切换无效
**可能原因:**
- 后端不支持该镜头类型
- 摄像头不支持指定的镜头
**解决方案:**
1. 检查后端返回的错误信息
2. 尝试其他镜头类型
3. 联系后端确认支持的镜头类型
## 6. 扩展功能
### 添加视频录制功能
```dart
// TODO: 集成 RTC SDK 的录制功能
Future<void> _startRecording() async {
// 根据使用的 RTC SDK 调用相应的录制 API
}
Future<void> _stopRecording() async {
// 停止录制并保存文件
}
```
### 添加截图功能
```dart
// TODO: 集成 RTC SDK 的截图功能
Future<void> _takeScreenshot() async {
// 根据使用的 RTC SDK 调用相应的截图 API
// 保存截图到相册
}
```
### 添加视频质量切换
```dart
void _changeQuality(VideoQualityType quality) {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: _currentLensType,
qualityType: quality, // 切换清晰度
),
);
}
```
## 7. 相关文档
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
- [API 接口文档](../lib/core/consts/http_api_consts.dart)
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
## 8. 技术支持
如遇到问题,请:
1. 查看控制台日志输出
2. 参考示例代码 `uav_live_video_page.dart`
3. 查阅集成指南文档
4. 联系开发团队

View File

@@ -15,7 +15,7 @@ import '../features/devices/presentation/bloc/devices_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_state.dart';
const int DATA_TIMEOUT_SECONDS = 5;
const int DATA_TIMEOUT_SECONDS = 15;
class DeviceStatusModal extends StatefulWidget {
const DeviceStatusModal({super.key});
@@ -162,7 +162,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
}
Widget _buildCardContentView(DeviceStatusState state) {
if (_isDataTimeout || state is DeviceStatusInitial) {
if (state is DeviceStatusInitial) {
return _noDataWidget();
}
@@ -445,11 +445,9 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
Widget build(BuildContext context) {
return BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
if (!_isDataTimeout && state is DeviceStatusUpdated) {
_startDataTimeoutTimer();
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
if (state is DeviceStatusUpdated) {
_startDataTimeoutTimer(); // 每次收到数据重置超时计时器
_isDataTimeout = false; // 直接赋值,不调 setState(build 阶段禁止)
_appendChartData(state);
}
@@ -527,7 +525,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
String satelliteCnt = _isDataTimeout ? '--' : '--';
String headingStatus = _isDataTimeout ? "--" : "--";
if (!_isDataTimeout && state is DeviceStatusUpdated) {
if (state is DeviceStatusUpdated) {
headingStatus = state.status.headingStatus == 0
? AppLocalizations.of(
context,
@@ -680,7 +678,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
}
Widget _buildChartContentView(DeviceStatusState state) {
if (_isDataTimeout || state is DeviceStatusInitial) {
if (state is DeviceStatusInitial) {
return _noDataWidget();
}

View File

@@ -0,0 +1,448 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:typed_data';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'bytes_util.dart';
import 'protocol_parser.dart';
class BleManager {
static final BleManager instance = BleManager._internal();
BleManager._internal();
BluetoothDevice? _connectedDevice;
BluetoothDevice? _connectingDevice;
BluetoothCharacteristic? _writeCharacteristic;
BluetoothCharacteristic? _readCharacteristic;
/// 有状态的协议解析器(支持 BLE 分片)
final ProtocolParser _parser = ProtocolParser();
final Map<DeviceIdentifier, ScanResult> _scanResults = {};
final StreamController<List<ScanResult>> _scanController =
StreamController.broadcast();
final StreamController<BlePacket> _packetController =
StreamController.broadcast();
final StreamController<BluetoothDevice?> _connectionController =
StreamController.broadcast();
final StreamController<BluetoothDevice?> _connectingController =
StreamController.broadcast();
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<List<int>>? _readSubscription;
StreamSubscription<BluetoothAdapterState>? _adapterStateSubscription;
StreamSubscription<BluetoothConnectionState>? _deviceConnectionSubscription;
/// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效
int _scanGen = 0;
/// 协商后的 MTU 值,用于分片写入
int _negotiatedMtu = 23;
/// 已收到的数据包存储(跨页面持久化)
final List<BlePacket> receivedPacketStore = [];
static const int _maxStoredPackets = 100;
void clearReceivedPackets() {
receivedPacketStore.clear();
}
bool get isConnected => _connectedDevice != null;
BluetoothDevice? get connectedDevice => _connectedDevice;
BluetoothDevice? get connectingDevice => _connectingDevice;
Stream<List<ScanResult>> get scanResults => _scanController.stream;
Stream<BlePacket> get packetStream => _packetController.stream;
Stream<BluetoothAdapterState> get adapterState =>
FlutterBluePlus.adapterState;
/// 连接状态变化流:连接成功时发出 device,断开时发出 null
Stream<BluetoothDevice?> get connectionStream => _connectionController.stream;
/// 连接中状态流:开始连接时发出 device,连接完成/失败时发出 null
Stream<BluetoothDevice?> get connectingStream => _connectingController.stream;
Future<bool> checkBluetooth() async {
final state = await FlutterBluePlus.adapterState.first;
return state == BluetoothAdapterState.on;
}
Future<bool> requestPermissions() async {
final connStatus = await Permission.bluetoothConnect.status;
developer.log(
'[BLE] bluetoothConnect status: $connStatus',
name: 'BleManager',
);
if (connStatus.isDenied) {
developer.log('[BLE] requesting bluetoothConnect...', name: 'BleManager');
await Permission.bluetoothConnect.request();
}
final scanStatus = await Permission.bluetoothScan.status;
developer.log(
'[BLE] bluetoothScan status: $scanStatus',
name: 'BleManager',
);
if (scanStatus.isDenied) {
developer.log('[BLE] requesting bluetoothScan...', name: 'BleManager');
await Permission.bluetoothScan.request();
}
final locStatus = await Permission.locationWhenInUse.status;
developer.log(
'[BLE] locationWhenInUse status: $locStatus',
name: 'BleManager',
);
if (locStatus.isDenied) {
developer.log(
'[BLE] requesting locationWhenInUse...',
name: 'BleManager',
);
await Permission.locationWhenInUse.request();
}
final allGranted =
await Permission.bluetoothConnect.isGranted &&
await Permission.bluetoothScan.isGranted;
developer.log(
'[BLE] all permissions granted: $allGranted',
name: 'BleManager',
);
return allGranted;
}
Future<void> openBluetooth() async {
try {
await FlutterBluePlus.turnOn();
} catch (e) {
await _goToSystemSettings();
}
}
Future<void> _goToSystemSettings() async {
await openAppSettings();
}
Future<void> _openLocationSettings() async {
await openAppSettings();
}
Future<bool> _checkLocationService() async {
final locStatus = await Permission.location.serviceStatus;
developer.log(
'[BLE] location service status: $locStatus',
name: 'BleManager',
);
return locStatus == ServiceStatus.enabled;
}
Future<void> startScan({bool continuous = true}) async {
final int myGen = ++_scanGen;
developer.log(
'[BLE] startScan called, gen=$myGen, continuous=$continuous',
name: 'BleManager',
);
// 先停止任何残留扫描,确保干净启动
try { await FlutterBluePlus.stopScan(); } catch (_) {}
_scanResults.clear();
_scanController.add([]);
final isOn = await checkBluetooth();
if (myGen != _scanGen) return;
developer.log('[BLE] Bluetooth adapter on: $isOn', name: 'BleManager');
if (!isOn) {
developer.log(
'[BLE] Bluetooth is OFF, aborting scan',
name: 'BleManager',
);
return;
}
final granted = await requestPermissions();
if (myGen != _scanGen) return;
developer.log('[BLE] Permissions granted: $granted', name: 'BleManager');
if (!granted) {
developer.log(
'[BLE] Permissions not granted, aborting scan',
name: 'BleManager',
);
return;
}
final locEnabled = await _checkLocationService();
if (myGen != _scanGen) return;
if (!locEnabled) {
developer.log(
'[BLE] Location service OFF, directing to settings',
name: 'BleManager',
);
await _openLocationSettings();
return;
}
_scanSubscription?.cancel();
_scanSubscription = FlutterBluePlus.scanResults.listen(
(results) {
for (final result in results) {
_scanResults[result.device.remoteId] = result;
}
developer.log(
'[BLE] 📡 扫描到 ${results.length} 个设备: ${results.map((r) => '${r.device.platformName.isNotEmpty ? r.device.platformName : r.device.advName.isNotEmpty ? r.device.advName : r.device.remoteId} (${r.rssi}dBm)').join(', ')}',
name: 'BleManager',
);
_scanController.add(_scanResults.values.toList());
},
onError: (e) {
developer.log('[BLE] scanResults error: $e', name: 'BleManager');
},
);
try {
if (continuous) {
developer.log('[BLE] Starting continuous scan...', name: 'BleManager');
await FlutterBluePlus.startScan(
timeout: const Duration(days: 1),
androidUsesFineLocation: true,
androidScanMode: AndroidScanMode.lowLatency,
androidLegacy: true,
);
} else {
developer.log('[BLE] Starting 10s scan...', name: 'BleManager');
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 10),
androidUsesFineLocation: true,
androidScanMode: AndroidScanMode.lowLatency,
androidLegacy: true,
);
}
developer.log('[BLE] startScan completed', name: 'BleManager');
} catch (e, stackTrace) {
developer.log(
'[BLE] startScan error: $e\n$stackTrace',
name: 'BleManager',
);
}
}
Future<void> refreshScan() async {
await stopScan();
await Future.delayed(const Duration(milliseconds: 200));
await startScan(continuous: true);
}
Future<void> stopScan() async {
_scanGen++;
developer.log('[BLE] stopScan, gen=$_scanGen', name: 'BleManager');
_scanSubscription?.cancel();
await FlutterBluePlus.stopScan();
}
/// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败
Future<String?> connect(BluetoothDevice device) async {
// 标记正在连接,通知所有监听者
_connectingDevice = device;
_connectingController.add(device);
try {
await device
.connect(license: License.nonprofit, mtu: 512)
.timeout(const Duration(seconds: 15));
_connectedDevice = device;
// 连接成功,立即停止扫描(避免扫描射频干扰导致连接断开)
await stopScan();
// 清除连接中状态
_connectingDevice = null;
_connectingController.add(null);
// 监听设备连接状态,任何一方断开都能感知
_deviceConnectionSubscription?.cancel();
_deviceConnectionSubscription = device.connectionState.listen((state) {
developer.log(
'[BLE] device connectionState: $state',
name: 'BleManager',
);
if (state == BluetoothConnectionState.disconnected) {
_onDeviceDisconnected();
}
});
_connectionController.add(device);
await _discoverServices(device);
// 确认协商后的 MTU
try {
final mtu = await device.requestMtu(512);
_negotiatedMtu = mtu;
developer.log('[BLE] MTU: $mtu', name: 'BleManager');
} catch (e) {
developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
}
return null; // 成功
} catch (e) {
// 连接失败,清除连接中状态
_connectingDevice = null;
_connectingController.add(null);
// 返回具体的错误原因
if (e is TimeoutException) {
return '连接超时(15秒),请确认设备在附近且已开启';
}
return '连接失败: $e';
}
}
void _onDeviceDisconnected() {
_connectedDevice = null;
_connectingDevice = null;
_writeCharacteristic = null;
_readCharacteristic = null;
_negotiatedMtu = 23;
_readSubscription?.cancel();
_deviceConnectionSubscription?.cancel();
_connectionController.add(null);
_connectingController.add(null);
// 清空协议解析器缓冲区
_parser.clear();
}
Future<void> disconnect() async {
final device = _connectedDevice;
if (device == null) return;
developer.log('[BLE] 发起断开连接...', name: 'BleManager');
await device.disconnect();
// 等待连接状态流确认已断开,设置超时防止无限等待
try {
await device.connectionState
.firstWhere((s) => s == BluetoothConnectionState.disconnected)
.timeout(const Duration(seconds: 3));
developer.log('[BLE] 连接已确认断开', name: 'BleManager');
} catch (_) {
developer.log('[BLE] 等待断开超时,强制清理', name: 'BleManager');
}
_onDeviceDisconnected();
}
Future<void> _discoverServices(BluetoothDevice device) async {
final services = await device.discoverServices();
for (final service in services) {
for (final characteristic in service.characteristics) {
if (characteristic.properties.write) {
_writeCharacteristic = characteristic;
}
if (characteristic.properties.notify) {
_readCharacteristic = characteristic;
await characteristic.setNotifyValue(true);
_readSubscription?.cancel();
_readSubscription = characteristic.lastValueStream.listen((value) {
_onDataReceived(value);
});
}
}
}
}
void _onDataReceived(List<int> data) {
developer.log(
'[BLE] 📩 收: ${data.length}B ${_bytesToHex(data)}',
name: 'BleManager',
);
_parser.append(data);
final packets = _parser.parse();
developer.log(
'[BLE] 📦 解析 ${packets.length}包 (buf ${_parser.bufferLength}B)',
name: 'BleManager',
);
if (packets.isEmpty && data.isNotEmpty) {
developer.log(
'[BLE] ⚠️ 非标准帧 raw(${data.length}B)',
name: 'BleManager',
);
final packet = BlePacket(
command: 0x00,
payload: Uint8List.fromList(data),
);
_storePacket(packet);
_packetController.add(packet);
return;
}
for (final packet in packets) {
developer.log(
'[BLE] cmd=0x${packet.command.toRadixString(16).toUpperCase().padLeft(2, '0')} '
'payload(${packet.payload.length}B): ${_bytesToHex(packet.payload)}',
name: 'BleManager',
);
_storePacket(packet);
_packetController.add(packet);
}
}
void _storePacket(BlePacket packet) {
receivedPacketStore.insert(0, packet);
if (receivedPacketStore.length > _maxStoredPackets) {
receivedPacketStore.removeLast();
}
}
String _bytesToHex(List<int> bytes) {
if (bytes.isEmpty) return '';
return bytes
.map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
.join(' ');
}
Future<void> sendCommand(int command, List<int> payload) async {
final char = _writeCharacteristic;
if (char == null) {
developer.log('[BLE] ❌ 写特征为空,无法发送', name: 'BleManager');
return;
}
final frame = ProtocolParser.pack(command, payload);
final crc = frame.length >= 5
? 'CRC16=0x${frame[frame.length - 4].toRadixString(16).padLeft(2, '0')}${frame[frame.length - 3].toRadixString(16).padLeft(2, '0')}'
: '';
developer.log(
'[BLE] 📤 发送: 0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')}, '
'帧(${frame.length}B) $crc\n'
' ${_bytesToHex(frame)}',
name: 'BleManager',
);
// 分片写入:每次最多写 (MTU - 3) 字节,全部走 withResponse 确保可靠性
final maxWriteLen = _negotiatedMtu - 3;
final totalChunks = (frame.length + maxWriteLen - 1) ~/ maxWriteLen;
if (totalChunks > 1) {
developer.log(
'[BLE] 🔀 分${totalChunks}片写入 (MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
name: 'BleManager',
);
}
for (int offset = 0; offset < frame.length; offset += maxWriteLen) {
final end = (offset + maxWriteLen <= frame.length)
? offset + maxWriteLen
: frame.length;
final chunk = frame.sublist(offset, end);
if (totalChunks > 1) {
final chunkIdx = offset ~/ maxWriteLen + 1;
developer.log(
'[BLE] 片$chunkIdx/$totalChunks: offset=$offset len=${chunk.length}B ${_bytesToHex(chunk)}',
name: 'BleManager',
);
}
await char.write(chunk, withoutResponse: false);
}
developer.log('[BLE] ✅ 写入完成', name: 'BleManager');
}
Future<void> sendRawBytes(List<int> bytes) async {
if (_writeCharacteristic == null) return;
await _writeCharacteristic!.write(bytes, withoutResponse: false);
}
Future<void> sendHeartbeat() async {
await sendCommand(0xFF, []);
}
void dispose() {
_scanSubscription?.cancel();
_readSubscription?.cancel();
_adapterStateSubscription?.cancel();
_deviceConnectionSubscription?.cancel();
_scanController.close();
_packetController.close();
_connectionController.close();
_connectingController.close();
}
}

View File

@@ -0,0 +1,722 @@
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:typed_data';
import '../protocol/machine_protocol_constants.dart';
import 'mc700_device_config.dart';
/// BLE 协议解析出的单个字段
class BleField {
final String label;
final String value;
const BleField({required this.label, required this.value});
}
/// BLE 协议解析结果
class BleDecodedResult {
final List<BleField> fields;
final String rawHex;
/// 0x05 读配置时携带的配置实体(可修改后回写)
final Mc700DeviceConfig? configEntity;
const BleDecodedResult({
required this.fields,
required this.rawHex,
this.configEntity,
});
bool get isEmpty => fields.isEmpty;
}
/// BLE 协议解析器
/// 与 TCP net_message_dispatcher 使用相同的协议格式:AB AA cmd [payload] ... AA AB
/// payload 通常为 UTF-8 逗号分隔文本,部分指令为二进制
class BleProtocolDecoder {
/// 入口:按命令类型分发解析
static BleDecodedResult decode(int command, Uint8List payload) {
final hex = _bytesToHex(payload);
try {
switch (command) {
case MachineProtocolConstants.cmdStatusInfo:
return _decodeStatusInfo(payload, hex);
case MachineProtocolConstants.cmdRemoteControl:
return _decodeRemoteControl(payload, hex);
case MachineProtocolConstants.cmdGetId:
return _decodeGetId(payload, hex);
case MachineProtocolConstants.cmdGetAuth:
return _decodeGetAuth(payload, hex);
case MachineProtocolConstants.cmdReadConfig:
return _decodeReadConfig(payload, hex);
case MachineProtocolConstants.cmdWriteConfig:
return _decodeWriteConfig(payload, hex);
case MachineProtocolConstants.cmdHeartbeat:
return _decodeHeartbeat(payload, hex);
case MachineProtocolConstants.cmdPathPlanning:
return _decodePathPlanning(payload, hex);
case MachineProtocolConstants.cmdObstacleAvoid:
return _decodeObstacleAvoid(payload, hex);
default:
return _decodeGeneric(command, payload, hex);
}
} catch (e) {
return BleDecodedResult(
fields: [const BleField(label: '解析错误', value: '异常')],
rawHex: hex,
);
}
}
// ─── 0x02 状态信息(与 TCP RunningStatusEntity.fromFields 一致,24字段逗号分隔) ───
static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data);
if (text.contains(',')) {
final parts = text.split(',');
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
}
} catch (e) {
try {
final text = latin1.decode(data);
if (text.contains(',')) {
final parts = text.split(',');
return BleDecodedResult(
fields: _parseStatusFields(parts),
rawHex: hex,
);
}
} catch (_) {}
}
if (data.isNotEmpty) {
final status = data[0];
fields.add(
BleField(
label: '状态码',
value: '0x${status.toRadixString(16).toUpperCase().padLeft(2, '0')}',
),
);
}
if (data.length >= 2) {
final mode = data[1];
const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
fields.add(
BleField(
label: '控制模式',
value: modes[mode] ?? '未知(0x${mode.toRadixString(16)})',
),
);
}
if (data.length >= 3)
fields.add(BleField(label: '电量', value: '${data[2]}%'));
if (data.length >= 4) {
final fault = data[3];
fields.add(
BleField(
label: '故障状态',
value: fault == 0
? '无故障'
: '故障码: 0x${fault.toRadixString(16).toUpperCase().padLeft(2, '0')}',
),
);
}
if (data.length >= 6) {
final speed = (data[5] << 8) | data[4];
fields.add(BleField(label: '速度', value: '$speed'));
}
if (data.length > 8) {
try {
final text = String.fromCharCodes(data.sublist(8));
if (text.isNotEmpty && !text.contains('\x00')) {
fields.add(BleField(label: '附加', value: text));
}
} catch (_) {}
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
/// 解析逗号分隔的 24 字段状态数据(与 RunningStatusEntity.fromFields 完全一致)
static List<BleField> _parseStatusFields(List<String> parts) {
while (parts.length < 24) {
parts.add('');
}
String controlModeText(String v) {
return switch (v) {
'0' => '待机',
'1' => '遥控',
'2' => '自动',
'3' => '急停',
_ => v.isNotEmpty ? v : '--',
};
}
String qualText(String v) {
final q = int.tryParse(v) ?? -1;
return switch (q) {
0 => '无效',
1 => '单点定位',
2 => '差分定位',
4 => '固定解',
5 => '浮点解',
_ => v.isNotEmpty ? v : '--',
};
}
String headingText(String v) {
final h = int.tryParse(v) ?? -1;
return switch (h) {
0 => '未初始化',
1 => '已初始化',
_ => v.isNotEmpty ? v : '--',
};
}
String obstacleText(String v) {
final o = int.tryParse(v) ?? -1;
return switch (o) {
0 => '无障碍',
1 => '有障碍',
_ => v.isNotEmpty ? v : '--',
};
}
return [
BleField(label: '电压', value: '${_tryParseDouble(parts[0])} V'),
BleField(label: '左目标速度', value: _tryParseDouble(parts[1])),
BleField(label: '右目标速度', value: _tryParseDouble(parts[2])),
BleField(label: '左实测速度', value: _tryParseDouble(parts[3])),
BleField(label: '右实测速度', value: _tryParseDouble(parts[4])),
BleField(label: '左电流', value: '${_tryParseDouble(parts[5])} A'),
BleField(label: '右电流', value: '${_tryParseDouble(parts[6])} A'),
BleField(label: '左电机温度', value: '${_tryParseDouble(parts[7])} ℃'),
BleField(label: '右电机温度', value: '${_tryParseDouble(parts[8])} ℃'),
BleField(label: '芯片温度', value: '${_tryParseDouble(parts[9])} ℃'),
BleField(label: '偏航角', value: '${_tryParseDouble(parts[10])} °'),
BleField(label: '俯仰角', value: '${_tryParseDouble(parts[11])} °'),
BleField(label: '翻滚角', value: '${_tryParseDouble(parts[12])} °'),
BleField(label: '卫星数量', value: parts[13].isNotEmpty ? parts[13] : '--'),
BleField(label: '定位质量', value: qualText(parts[14])),
BleField(label: '航向状态', value: headingText(parts[15])),
BleField(label: '纬度', value: parts[16].isNotEmpty ? parts[16] : '--'),
BleField(label: '经度', value: parts[17].isNotEmpty ? parts[17] : '--'),
BleField(label: '时间戳', value: parts[18].isNotEmpty ? parts[18] : '--'),
BleField(
label: '割刀速度',
value: parts[19].isNotEmpty ? '${parts[19]} rpm' : '--',
),
BleField(label: '控制模式', value: controlModeText(parts[20])),
BleField(
label: '电量',
value: parts[21].isNotEmpty ? '${parts[21]}%' : '--',
),
BleField(label: '工作面积', value: parts[22].isNotEmpty ? parts[22] : '--'),
BleField(label: '障碍物', value: obstacleText(parts[23])),
];
}
static String _tryParseDouble(String s) {
if (s.isEmpty) return '--';
final v = double.tryParse(s);
if (v == null) return s;
if (v == v.roundToDouble() && v.abs() < 10000) return v.toInt().toString();
return v.toStringAsFixed(2);
}
// ─── 0x00 远程遥控 ───
static BleDecodedResult _decodeRemoteControl(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
if (text.contains(',')) {
final parts = text.split(',');
while (parts.length < 5) parts.add('');
fields.add(
BleField(label: '左右速度', value: parts[0].isNotEmpty ? parts[0] : '--'),
);
fields.add(
BleField(label: '前后速度', value: parts[1].isNotEmpty ? parts[1] : '--'),
);
final modeStr = switch (parts[2]) {
'0' => '停止',
'1' => '遥控',
'2' => '自动',
_ => parts[2].isNotEmpty ? parts[2] : '--',
};
fields.add(BleField(label: '控制模式', value: modeStr));
if (parts.length > 3 && parts[3].isNotEmpty)
fields.add(BleField(label: '参数4', value: parts[3]));
if (parts.length > 4 && parts[4].isNotEmpty)
fields.add(BleField(label: '参数5', value: parts[4]));
return BleDecodedResult(fields: fields, rawHex: hex);
}
} catch (_) {}
if (data.length >= 5) {
fields.add(BleField(label: '左右速度', value: '${(data[1] << 8) | data[0]}'));
fields.add(BleField(label: '前后速度', value: '${(data[3] << 8) | data[2]}'));
const modes = {0: '停止', 1: '遥控', 2: '自动'};
fields.add(
BleField(label: '控制模式', value: modes[data[4]] ?? '未知(${data[4]})'),
);
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x03 查询ID ───
static BleDecodedResult _decodeGetId(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
if (text.replaceAll('\x00', '').trim().isNotEmpty) {
fields.add(
BleField(label: '设备ID', value: text.replaceAll('\x00', '').trim()),
);
return BleDecodedResult(fields: fields, rawHex: hex);
}
} catch (_) {}
try {
final ascii = String.fromCharCodes(data);
if (RegExp(r'^[\x20-\x7E]+$').hasMatch(ascii)) {
fields.add(BleField(label: '设备ID', value: ascii));
} else {
fields.add(BleField(label: '设备ID(Hex)', value: hex));
}
} catch (_) {
fields.add(BleField(label: '设备ID(Hex)', value: hex));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x04 获取授权 ───
static BleDecodedResult _decodeGetAuth(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
final trimmed = text.replaceAll('\x00', '').trim();
if (trimmed == '1' || trimmed.toLowerCase() == 'true') {
fields.add(const BleField(label: '授权状态', value: '已授权'));
} else if (trimmed == '0' || trimmed.toLowerCase() == 'false') {
fields.add(const BleField(label: '授权状态', value: '未授权'));
} else if (trimmed.isNotEmpty) {
fields.add(BleField(label: '授权状态', value: trimmed));
}
if (fields.isNotEmpty)
return BleDecodedResult(fields: fields, rawHex: hex);
} catch (_) {}
if (data.isNotEmpty) {
if (data[0] == 0x01) {
fields.add(const BleField(label: '授权状态', value: '已授权'));
} else if (data[0] == 0x00) {
fields.add(const BleField(label: '授权状态', value: '未授权'));
} else {
fields.add(
BleField(label: '授权状态(Hex)', value: '0x${data[0].toRadixString(16)}'),
);
}
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x05 读配置(按嵌入式协议规范精确解析,171字节) ───
static BleDecodedResult _decodeReadConfig(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isEmpty) {
fields.add(const BleField(label: '状态', value: '空数据'));
return BleDecodedResult(fields: fields, rawHex: hex);
}
final bd = ByteData.sublistView(data);
final len = data.length;
// 1. UID固化标志 @payload[0] (帧字节3)
if (len >= 1) {
fields.add(
BleField(
label: 'UID固化标志',
value: data[0] == 1 ? '已固化' : '未固化(${data[0]})',
),
);
}
// 2. 芯片UID @payload[1..45] (帧字节4-48, 45字节)
if (len >= 46) {
fields.add(
BleField(label: '芯片UID', value: _readNullTermString(data, 1, 45)),
);
}
// 3. 遥控器通道配置 @payload[46..64] (帧字节49-67, 19字节)
if (len >= 65) {
fields.add(
BleField(label: '遥控器通道配置', value: _bytesToHex(data.sublist(46, 65))),
);
}
// 4. 字节68位域 @payload[65] (帧字节68)
if (len >= 66) {
final b = data[65];
fields.add(
BleField(
label: '割刀电机模式',
value: (b & 0x03) == 0 ? '纯电' : '油电(${b & 0x03})',
),
);
fields.add(
BleField(
label: '行走电机模式',
value: ((b >> 2) & 0x03) == 0 ? '轮式' : '履带(${(b >> 2) & 0x03})',
),
);
fields.add(
BleField(label: '左轮电机极性', value: (b & 0x10) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '右轮电机极性', value: (b & 0x20) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '左右轮通道交换', value: (b & 0x40) == 0 ? '不交换' : '通道互换'),
);
fields.add(
BleField(label: '联网目标', value: (b & 0x80) == 0 ? 'WiFi' : '4G'),
);
}
// 5. 前进速度限制 @payload[66..67] (帧字节69-70, uint16 LE)
if (len >= 68) {
fields.add(
BleField(label: '前进速度限制', value: '${bd.getUint16(66, Endian.little)}'),
);
}
// 6. 转向速度限制 @payload[68..69] (帧字节71-72, uint16 LE)
if (len >= 70) {
fields.add(
BleField(label: '转向速度限制', value: '${bd.getUint16(68, Endian.little)}'),
);
}
// 7. 字节73位域 @payload[70] (帧字节73)
if (len >= 71) {
final b = data[70];
fields.add(
BleField(label: '割刀通道极性', value: (b & 0x01) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '风门通道极性', value: (b & 0x02) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '油门通道极性', value: (b & 0x04) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(BleField(label: '底盘升降保护时间', value: '${(b >> 3) & 0x1F} 秒'));
fields.add(
BleField(label: 'RTK配置', value: (b & 0x80) == 0 ? '单天线' : '双天线'),
);
}
// 8. 字节74位域 @payload[71] (帧字节74)
if (len >= 72) {
final b = data[71];
fields.add(BleField(label: '割刀通道配置', value: '${b & 0x0F}'));
fields.add(BleField(label: '风门通道配置', value: '${(b >> 4) & 0x0F}'));
}
// 9. 字节75位域 @payload[72] (帧字节75)
if (len >= 73) {
final b = data[72];
fields.add(BleField(label: '油门通道配置', value: '${b & 0x0F}'));
fields.add(
BleField(
label: '遥控器类型',
value: (b >> 4) & 0x07 == 0 ? '飞控' : '自定义遥控器(${(b >> 4) & 0x07})',
),
);
fields.add(
BleField(label: '是否搭载继电器板', value: (b & 0x80) == 0 ? '未搭载' : '搭载'),
);
}
// 10. 字节76位域 @payload[73] (帧字节76)
if (len >= 74) {
final b = data[73];
fields.add(BleField(label: '底盘升降通道配置', value: '${b & 0x0F}'));
fields.add(BleField(label: '底盘通道配置', value: '${(b >> 4) & 0x0F}'));
}
// 11. 字节77位域 @payload[74] (帧字节77)
if (len >= 75) {
final b = data[74];
fields.add(BleField(label: '机械臂通道配置', value: '${b & 0x0F}'));
fields.add(BleField(label: '燃油泵通道配置', value: '${(b >> 4) & 0x0F}'));
}
// 12. WiFi名称 @payload[75..94] (帧字节78-97, 20字节)
if (len >= 95) {
fields.add(
BleField(label: 'WiFi名称', value: _readNullTermString(data, 75, 20)),
);
}
// 13. WiFi密码 @payload[95..114] (帧字节98-117, 20字节)
if (len >= 115) {
fields.add(
BleField(label: 'WiFi密码', value: _readNullTermString(data, 95, 20)),
);
}
// 14. 字节118位域 @payload[115] (帧字节118)
if (len >= 116) {
final b = data[115];
fields.add(
BleField(
label: '电池类型',
value: (b & 0x03) == 0 ? '铅酸' : '锂电(${b & 0x03})',
),
);
fields.add(
BleField(
label: '行走驱动配置',
value: switch ((b >> 2) & 0x03) {
0 => '风德控',
1 => '山东神澜BLDC',
2 => '山东神澜FOC',
_ => '未知(${(b >> 2) & 0x03})',
},
),
);
}
// 15. 转速比 @payload[116..119] (帧字节119-122, float LE)
if (len >= 120) {
final v = bd.getFloat32(116, Endian.little);
fields.add(BleField(label: '转速比', value: v.toStringAsFixed(2)));
}
// 16. 机器人长度 @payload[120..123] (帧字节123-126, float LE)
if (len >= 124) {
final v = bd.getFloat32(120, Endian.little);
fields.add(BleField(label: '机器人长度', value: v.toStringAsFixed(2)));
}
// 17. 机器人宽度 @payload[124..127] (帧字节127-130, float LE)
if (len >= 128) {
final v = bd.getFloat32(124, Endian.little);
fields.add(BleField(label: '机器人宽度', value: v.toStringAsFixed(2)));
}
// 18. 机器人高度 @payload[128..131] (帧字节131-134, float LE)
if (len >= 132) {
final v = bd.getFloat32(128, Endian.little);
fields.add(BleField(label: '机器人高度', value: v.toStringAsFixed(2)));
}
// 19. 割刀宽度 @payload[132..135] (帧字节135-138, float LE)
if (len >= 136) {
final v = bd.getFloat32(132, Endian.little);
fields.add(BleField(label: '割刀宽度', value: v.toStringAsFixed(2)));
}
// 20. 轮胎尺寸 @payload[136..139] (帧字节139-142, float LE)
if (len >= 140) {
final v = bd.getFloat32(136, Endian.little);
fields.add(BleField(label: '轮胎尺寸', value: v.toStringAsFixed(2)));
}
// 21. 左轮前进增益 @payload[140..143] (帧字节143-146, float LE)
if (len >= 144) {
final v = bd.getFloat32(140, Endian.little);
fields.add(BleField(label: '左轮前进增益', value: v.toStringAsFixed(2)));
}
// 22. 左轮后退增益 @payload[144..147] (帧字节147-150, float LE)
if (len >= 148) {
final v = bd.getFloat32(144, Endian.little);
fields.add(BleField(label: '左轮后退增益', value: v.toStringAsFixed(2)));
}
// 23. 右轮前进增益 @payload[148..151] (帧字节151-154, float LE)
if (len >= 152) {
final v = bd.getFloat32(148, Endian.little);
fields.add(BleField(label: '右轮前进增益', value: v.toStringAsFixed(2)));
}
// 24. 右轮后退增益 @payload[152..155] (帧字节155-158, float LE)
if (len >= 156) {
final v = bd.getFloat32(152, Endian.little);
fields.add(BleField(label: '右轮后退增益', value: v.toStringAsFixed(2)));
}
// 25. 固件版本 @payload[156..170] (帧字节159-173, 15字节)
if (len >= 171) {
fields.add(
BleField(label: '固件版本', value: _readNullTermString(data, 156, 15)),
);
}
// 构建配置实体(用于后续编辑和回写)
Mc700DeviceConfig? configEntity;
if (data.length >= 171) {
try {
configEntity = Mc700DeviceConfig.fromBytes(data);
} catch (e) {
developer.log('[BLE] 配置实体解析失败: $e', name: 'BleProtocolDecoder');
}
}
return BleDecodedResult(
fields: fields,
rawHex: hex,
configEntity: configEntity,
);
}
/// 读取 null 终止字符串(最多读取 [maxLen] 字节)
static String _readNullTermString(Uint8List data, int offset, int maxLen) {
final buffer = StringBuffer();
for (int i = 0; i < maxLen && offset + i < data.length; i++) {
final b = data[offset + i];
if (b == 0x00) break;
if (b >= 0x20 && b <= 0x7E) {
buffer.writeCharCode(b);
} else {
buffer.writeCharCode(0x2E);
}
}
return buffer.toString();
}
// ─── 0x06 写配置 ───
static BleDecodedResult _decodeWriteConfig(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isNotEmpty) {
final ok = data[0];
fields.add(
BleField(
label: '写入结果',
value: ok == 0x01
? '成功'
: ok == 0x00
? '失败'
: '0x${ok.toRadixString(16)}',
),
);
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0xFF 心跳包 ───
static BleDecodedResult _decodeHeartbeat(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isEmpty) {
fields.add(const BleField(label: '心跳', value: '正常'));
} else if (data.length >= 2) {
final interval = (data[1] << 8) | data[0];
fields.add(BleField(label: '心跳间隔', value: '${interval}ms'));
} else {
fields.add(BleField(label: '心跳数据', value: hex));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x01 路径规划 ───
static BleDecodedResult _decodePathPlanning(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
if (text.contains(',')) {
final parts = text.split(',');
if (parts.length >= 2) {
fields.add(BleField(label: '点编号', value: parts[0]));
fields.add(
BleField(
label: '状态',
value: switch (parts[1]) {
'1' => '已到达',
'2' => '收到指令',
_ => parts[1],
},
),
);
}
if (parts.length >= 4) {
fields.add(BleField(label: '纬度', value: parts[2]));
fields.add(BleField(label: '经度', value: parts[3]));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
} catch (_) {}
if (data.length >= 3) {
fields.add(BleField(label: '点编号', value: '${data[0]}'));
final status = data.length > 1 ? data[1] : -1;
fields.add(
BleField(
label: '状态',
value: status == 0x01
? '已到达'
: status == 0x02
? '收到指令'
: '0x${status.toRadixString(16)}',
),
);
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x07 避障 ───
static BleDecodedResult _decodeObstacleAvoid(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isNotEmpty) {
final flag = data[0];
fields.add(
BleField(
label: '避障状态',
value: flag == 0x01
? '触发'
: flag == 0x00
? '正常'
: '0x${flag.toRadixString(16)}',
),
);
}
if (data.length >= 3) {
fields.add(BleField(label: '距离', value: '${(data[2] << 8) | data[1]}mm'));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 通用/未知指令 ───
static BleDecodedResult _decodeGeneric(
int command,
Uint8List data,
String hex,
) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
final trimmed = text.replaceAll('\x00', '').trim();
if (trimmed.isNotEmpty) {
fields.add(
BleField(
label: '文本内容',
value: trimmed.length > 50
? '${trimmed.substring(0, 50)}...'
: trimmed,
),
);
}
} catch (_) {}
if (fields.isEmpty) {
fields.add(BleField(label: '数据长度', value: '${data.length}B'));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 工具方法 ───
static String _bytesToHex(List<int> bytes) {
if (bytes.isEmpty) return '';
return bytes
.map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
.join(' ');
}
}

View File

@@ -0,0 +1,38 @@
import 'dart:convert';
import 'dart:typed_data';
class BytesUtil {
static String bytesToHex(List<int> bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
}
static Uint8List hexToBytes(String hex) {
hex = hex.replaceAll(' ', '').replaceAll('-', '');
if (hex.length % 2 != 0) hex = '0' + hex;
final bytes = Uint8List(hex.length ~/ 2);
for (int i = 0; i < hex.length ~/ 2; i++) {
bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return bytes;
}
static String bytesToString(List<int> bytes) {
try {
return utf8.decode(bytes);
} catch (_) {
return String.fromCharCodes(bytes);
}
}
static Uint8List stringToBytes(String str) {
return Uint8List.fromList(utf8.encode(str));
}
static List<int> toByteList(Uint8List data) {
return List<int>.from(data);
}
static String bytesToHexSpaced(List<int> bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
}
}

View File

@@ -0,0 +1,402 @@
import 'dart:convert';
import 'dart:typed_data';
/// MC700设备配置实体类(与嵌入式协议规范一一对应)
/// 所有偏移均为 payload 内偏移(ProtocolParser 已剥离帧头/命令/校验/帧尾)
/// payload 共 171 字节,索引 0~170
class Mc700DeviceConfig {
// ====== 字段定义(按嵌入式协议规范) ======
// --- 基础标识 ---
late int uidSolidifiedFlag; // payload[0] UID固化标志 (0=未固化, 1=已固化)
late String chipUid; // payload[1..45] 芯片UID (45字节)
late List<int> remoteChannelConfig; // payload[46..64] 遥控器通道配置 (19字节)
// --- 字节68位域 (payload[65]) ---
late int knifeMotorMode; // bit0-1 割刀电机模式 (0=纯电, 1=油电)
late int walkMotorMode; // bit2-3 行走电机模式 (0=轮式, 1=履带)
late bool leftWheelPolarity; // bit4 左轮极性 (false=高, true=低)
late bool rightWheelPolarity; // bit5 右轮极性 (false=高, true=低)
late bool channelSwap; // bit6 左右轮通道交换
late bool use4g; // bit7 联网目标 (false=WiFi, true=4G)
// --- 速度限制 ---
late int forwardSpeedLimit; // payload[66..67] 前进速度限制 (uint16 LE)
late int turnSpeedLimit; // payload[68..69] 转向速度限制 (uint16 LE)
// --- 字节73位域 (payload[70]) ---
late bool knifeChannelPolarity; // bit0 割刀通道极性
late bool fanChannelPolarity; // bit1 风门通道极性
late bool throttleChannelPolarity; // bit2 油门通道极性
late int liftProtectTime; // bit3-6 底盘升降保护时间 (秒)
late bool dualRtk; // bit7 RTK配置 (false=单天线, true=双天线)
// --- 字节74位域 (payload[71]) ---
late int knifeChannelConfig; // bit0-3 割刀通道配置
late int fanChannelConfig; // bit4-7 风门通道配置
// --- 字节75位域 (payload[72]) ---
late int throttleChannelConfig; // bit0-3 油门通道配置
late int remoteType; // bit4-6 遥控器类型 (0=飞控, 1=自定义1)
late bool relayBoard; // bit7 是否搭载继电器板
// --- 字节76位域 (payload[73]) ---
late int chassisLiftChannel; // bit0-3 底盘升降通道配置
late int chassisChannel; // bit4-7 底盘通道配置
// --- 字节77位域 (payload[74]) ---
late int armChannel; // bit0-3 机械臂通道配置
late int fuelPumpChannel; // bit4-7 燃油泵通道配置
// --- WiFi ---
late String wifiName; // payload[75..94] WiFi名称 (20字节)
late String wifiPassword; // payload[95..114] WiFi密码 (20字节)
// --- 字节118位域 (payload[115]) ---
late int batteryType; // bit0-1 电池类型 (0=铅酸, 1=锂电)
late int walkDriveConfig; // bit2-3 行走驱动配置
// --- 尺寸参数 (float LE) ---
late double gearRatio; // payload[116..119] 转速比
late double robotLength; // payload[120..123] 机器人长度
late double robotWidth; // payload[124..127] 机器人宽度
late double robotHeight; // payload[128..131] 机器人高度
late double knifeWidth; // payload[132..135] 割刀宽度
late double tireSize; // payload[136..139] 轮胎尺寸
// --- 增益 (float LE) ---
late double leftForwardGain; // payload[140..143] 左轮前进增益
late double leftBackwardGain; // payload[144..147] 左轮后退增益
late double rightForwardGain; // payload[148..151] 右轮前进增益
late double rightBackwardGain; // payload[152..155] 右轮后退增益
// --- 固件版本 ---
late String firmwareVersion; // payload[156..170] 固件版本 (15字节)
Mc700DeviceConfig();
// ====== 从二进制数据解析(171字节 payload) ======
factory Mc700DeviceConfig.fromBytes(Uint8List data) {
if (data.length < 171) {
throw Exception('数据长度不足171字节,实际${data.length}字节');
}
final bd = ByteData.sublistView(data);
final obj = Mc700DeviceConfig();
// 1. UID固化标志 @0
obj.uidSolidifiedFlag = bd.getUint8(0);
// 2. 芯片UID @1..45 (45字节)
obj.chipUid = _readNullTerminatedString(data, 1, 45);
// 3. 遥控器通道配置 @46..64 (19字节)
obj.remoteChannelConfig = List.unmodifiable(data.sublist(46, 65));
// 4. 字节68位域 @65
final b68 = bd.getUint8(65);
obj.knifeMotorMode = b68 & 0x03;
obj.walkMotorMode = (b68 >> 2) & 0x03;
obj.leftWheelPolarity = (b68 & 0x10) != 0;
obj.rightWheelPolarity = (b68 & 0x20) != 0;
obj.channelSwap = (b68 & 0x40) != 0;
obj.use4g = (b68 & 0x80) != 0;
// 5. 前进速度限制 @66..67
obj.forwardSpeedLimit = bd.getUint16(66, Endian.little);
// 6. 转向速度限制 @68..69
obj.turnSpeedLimit = bd.getUint16(68, Endian.little);
// 7. 字节73位域 @70
final b73 = bd.getUint8(70);
obj.knifeChannelPolarity = (b73 & 0x01) != 0;
obj.fanChannelPolarity = (b73 & 0x02) != 0;
obj.throttleChannelPolarity = (b73 & 0x04) != 0;
obj.liftProtectTime = (b73 >> 3) & 0x1F;
obj.dualRtk = (b73 & 0x80) != 0;
// 8. 字节74位域 @71
final b74 = bd.getUint8(71);
obj.knifeChannelConfig = b74 & 0x0F;
obj.fanChannelConfig = (b74 >> 4) & 0x0F;
// 9. 字节75位域 @72
final b75 = bd.getUint8(72);
obj.throttleChannelConfig = b75 & 0x0F;
obj.remoteType = (b75 >> 4) & 0x07;
obj.relayBoard = (b75 & 0x80) != 0;
// 10. 字节76位域 @73
final b76 = bd.getUint8(73);
obj.chassisLiftChannel = b76 & 0x0F;
obj.chassisChannel = (b76 >> 4) & 0x0F;
// 11. 字节77位域 @74
final b77 = bd.getUint8(74);
obj.armChannel = b77 & 0x0F;
obj.fuelPumpChannel = (b77 >> 4) & 0x0F;
// 12. WiFi名称 @75..94 (20字节)
obj.wifiName = _readNullTerminatedString(data, 75, 20);
// 13. WiFi密码 @95..114 (20字节)
obj.wifiPassword = _readNullTerminatedString(data, 95, 20);
// 14. 字节118位域 @115
final b118 = bd.getUint8(115);
obj.batteryType = b118 & 0x03;
obj.walkDriveConfig = (b118 >> 2) & 0x03;
// 15. 转速比 @116..119
obj.gearRatio = bd.getFloat32(116, Endian.little);
// 16. 机器人长度 @120..123
obj.robotLength = bd.getFloat32(120, Endian.little);
// 17. 机器人宽度 @124..127
obj.robotWidth = bd.getFloat32(124, Endian.little);
// 18. 机器人高度 @128..131
obj.robotHeight = bd.getFloat32(128, Endian.little);
// 19. 割刀宽度 @132..135
obj.knifeWidth = bd.getFloat32(132, Endian.little);
// 20. 轮胎尺寸 @136..139
obj.tireSize = bd.getFloat32(136, Endian.little);
// 21. 左轮前进增益 @140..143
obj.leftForwardGain = bd.getFloat32(140, Endian.little);
// 22. 左轮后退增益 @144..147
obj.leftBackwardGain = bd.getFloat32(144, Endian.little);
// 23. 右轮前进增益 @148..151
obj.rightForwardGain = bd.getFloat32(148, Endian.little);
// 24. 右轮后退增益 @152..155
obj.rightBackwardGain = bd.getFloat32(152, Endian.little);
// 25. 固件版本 @156..170 (15字节)
obj.firmwareVersion = _readNullTerminatedString(data, 156, 15);
return obj;
}
// ====== 转换回二进制字节(用于写配置下发) ======
/// [originalPayload] 是上次读取的 171 字节 payload,用于保留未修改字段
Uint8List toBytes(Uint8List originalPayload) {
final config = Uint8List.fromList(originalPayload);
final bd = ByteData.sublistView(config);
// UID固化标志 @0(独立字段,不属于字节68)
bd.setUint8(0, uidSolidifiedFlag & 0x01);
// 字节68位域 @65
int b68 = 0;
b68 |= (knifeMotorMode & 0x03);
b68 |= (walkMotorMode & 0x03) << 2;
if (leftWheelPolarity) b68 |= 0x10;
if (rightWheelPolarity) b68 |= 0x20;
if (channelSwap) b68 |= 0x40;
if (use4g) b68 |= 0x80;
bd.setUint8(65, b68);
// 速度限制
bd.setUint16(66, forwardSpeedLimit, Endian.little);
bd.setUint16(68, turnSpeedLimit, Endian.little);
// 字节73位域 @70
int b73 = (liftProtectTime & 0x1F) << 3;
if (knifeChannelPolarity) b73 |= 0x01;
if (fanChannelPolarity) b73 |= 0x02;
if (throttleChannelPolarity) b73 |= 0x04;
if (dualRtk) b73 |= 0x80;
bd.setUint8(70, b73);
// 字节74位域 @71
int b74 = knifeChannelConfig & 0x0F;
b74 |= (fanChannelConfig & 0x0F) << 4;
bd.setUint8(71, b74);
// 字节75位域 @72
int b75 = throttleChannelConfig & 0x0F;
b75 |= (remoteType & 0x07) << 4;
if (relayBoard) b75 |= 0x80;
bd.setUint8(72, b75);
// 字节76位域 @73
int b76 = chassisLiftChannel & 0x0F;
b76 |= (chassisChannel & 0x0F) << 4;
bd.setUint8(73, b76);
// 字节77位域 @74
int b77 = armChannel & 0x0F;
b77 |= (fuelPumpChannel & 0x0F) << 4;
bd.setUint8(74, b77);
// WiFi
_writeNullTerminatedString(config, 75, wifiName, 20);
_writeNullTerminatedString(config, 95, wifiPassword, 20);
// 字节118位域 @115
int b118 = batteryType & 0x03;
b118 |= (walkDriveConfig & 0x03) << 2;
bd.setUint8(115, b118);
// 尺寸参数
bd.setFloat32(116, gearRatio, Endian.little);
bd.setFloat32(120, robotLength, Endian.little);
bd.setFloat32(124, robotWidth, Endian.little);
bd.setFloat32(128, robotHeight, Endian.little);
bd.setFloat32(132, knifeWidth, Endian.little);
bd.setFloat32(136, tireSize, Endian.little);
// 增益
bd.setFloat32(140, leftForwardGain, Endian.little);
bd.setFloat32(144, leftBackwardGain, Endian.little);
bd.setFloat32(148, rightForwardGain, Endian.little);
bd.setFloat32(152, rightBackwardGain, Endian.little);
// 固件版本
_writeNullTerminatedString(config, 156, firmwareVersion, 15);
return config;
}
/// 获取所有字段的 label→value 映射
Map<String, String> toFieldMap() {
return {
'UID固化标志': '$uidSolidifiedFlag',
'芯片UID': chipUid,
'割刀电机模式': knifeMotorMode == 0 ? '纯电' : '油电($knifeMotorMode)',
'行走电机模式': walkMotorMode == 0 ? '轮式' : '履带($walkMotorMode)',
'左轮极性': leftWheelPolarity ? '低' : '高',
'右轮极性': rightWheelPolarity ? '低' : '高',
'通道交换': channelSwap ? '是' : '否',
'联网目标': use4g ? '4G' : 'WiFi',
'前进速度限制': '$forwardSpeedLimit',
'转向速度限制': '$turnSpeedLimit',
'割刀通道极性': knifeChannelPolarity ? '低' : '高',
'风门通道极性': fanChannelPolarity ? '低' : '高',
'油门通道极性': throttleChannelPolarity ? '低' : '高',
'升降保护时间': '$liftProtectTime 秒',
'RTK配置': dualRtk ? '双天线' : '单天线',
'割刀通道配置': '$knifeChannelConfig',
'风门通道配置': '$fanChannelConfig',
'油门通道配置': '$throttleChannelConfig',
'遥控器类型': '$remoteType',
'搭载继电器板': relayBoard ? '是' : '否',
'底盘升降通道': '$chassisLiftChannel',
'底盘通道': '$chassisChannel',
'机械臂通道': '$armChannel',
'燃油泵通道': '$fuelPumpChannel',
'WiFi名称': wifiName,
'WiFi密码': wifiPassword,
'电池类型': batteryType == 0 ? '铅酸' : '锂电',
'行走驱动': walkDriveConfig.toString(),
'转速比': gearRatio.toStringAsFixed(2),
'机器人长度': robotLength.toStringAsFixed(2),
'机器人宽度': robotWidth.toStringAsFixed(2),
'机器人高度': robotHeight.toStringAsFixed(2),
'割刀宽度': knifeWidth.toStringAsFixed(2),
'轮胎尺寸': tireSize.toStringAsFixed(2),
'左轮前进增益': leftForwardGain.toStringAsFixed(2),
'左轮后退增益': leftBackwardGain.toStringAsFixed(2),
'右轮前进增益': rightForwardGain.toStringAsFixed(2),
'右轮后退增益': rightBackwardGain.toStringAsFixed(2),
'固件版本': firmwareVersion,
};
}
/// 根据字段标签设置新值(返回 null 表示成功,返回错误原因字符串)
String? setField(String label, String value) {
final intVal = int.tryParse(value);
final doubleVal = double.tryParse(value);
switch (label) {
case 'UID固化标志':
if (intVal == null || intVal < 0 || intVal > 1) return '值范围: 0~1';
uidSolidifiedFlag = intVal;
return null;
case '前进速度限制':
if (intVal == null || intVal < 0 || intVal > 65535)
return '值范围: 0~65535';
forwardSpeedLimit = intVal;
return null;
case '转向速度限制':
if (intVal == null || intVal < 0 || intVal > 65535)
return '值范围: 0~65535';
turnSpeedLimit = intVal;
return null;
case 'WiFi名称':
if (value.length > 20) return '最多20字符';
wifiName = value;
return null;
case 'WiFi密码':
if (value.length > 20) return '最多20字符';
wifiPassword = value;
return null;
case '转速比':
if (doubleVal == null) return '请输入有效数字';
gearRatio = doubleVal;
return null;
case '机器人长度':
if (doubleVal == null) return '请输入有效数字';
robotLength = doubleVal;
return null;
case '机器人宽度':
if (doubleVal == null) return '请输入有效数字';
robotWidth = doubleVal;
return null;
case '机器人高度':
if (doubleVal == null) return '请输入有效数字';
robotHeight = doubleVal;
return null;
case '割刀宽度':
if (doubleVal == null) return '请输入有效数字';
knifeWidth = doubleVal;
return null;
case '轮胎尺寸':
if (doubleVal == null) return '请输入有效数字';
tireSize = doubleVal;
return null;
case '左轮前进增益':
if (doubleVal == null) return '请输入有效数字';
leftForwardGain = doubleVal;
return null;
case '左轮后退增益':
if (doubleVal == null) return '请输入有效数字';
leftBackwardGain = doubleVal;
return null;
case '右轮前进增益':
if (doubleVal == null) return '请输入有效数字';
rightForwardGain = doubleVal;
return null;
case '右轮后退增益':
if (doubleVal == null) return '请输入有效数字';
rightBackwardGain = doubleVal;
return null;
case '固件版本':
if (value.length > 15) return '最多15字符';
firmwareVersion = value;
return null;
default:
return '未知字段: $label';
}
}
// 工具:读取0结尾的ASCII字符串
static String _readNullTerminatedString(
Uint8List data,
int offset,
int maxLen,
) {
final end = data.indexOf(0, offset);
final realEnd = (end == -1 || end > offset + maxLen)
? offset + maxLen
: end;
return ascii.decode(data.sublist(offset, realEnd));
}
// 工具:写入ASCII字符串,不足补0
static void _writeNullTerminatedString(
Uint8List data,
int offset,
String str,
int maxLen,
) {
final bytes = ascii.encode(str);
for (int i = 0; i < maxLen; i++) {
data[offset + i] = i < bytes.length ? bytes[i] : 0x00;
}
}
}

View File

@@ -0,0 +1,122 @@
import 'dart:typed_data';
class BlePacket {
final int command;
final Uint8List payload;
const BlePacket({required this.command, required this.payload});
}
/// 有状态的 BLE 协议解析器
/// 支持分片接收:多次调用 [append] 累积数据,[parse] 提取完整帧
class ProtocolParser {
static const int _header1 = 0xAB;
static const int _header2 = 0xAA;
static const int _tail1 = 0xAA;
static const int _tail2 = 0xAB;
final List<int> _buffer = [];
/// 清空缓冲区
void clear() {
_buffer.clear();
}
/// 获取缓冲区长度
int get bufferLength => _buffer.length;
/// 打包命令为字节帧(用于发送)
/// CRC16 覆盖 command + payload 确保整帧完整性
static Uint8List pack(int command, List<int> payload) {
final dataToCheck = [command, ...payload];
final crc = _crc16(dataToCheck);
final builder = BytesBuilder()
..addByte(_header1)
..addByte(_header2)
..addByte(command)
..add(payload)
..addByte(crc & 0xFF)
..addByte((crc >> 8) & 0xFF)
..addByte(_tail1)
..addByte(_tail2);
return builder.takeBytes();
}
/// 追加新接收的数据到缓冲区
void append(List<int> data) {
_buffer.addAll(data);
}
/// 从缓冲区解析所有完整的数据包
/// 未完成的帧保留在缓冲区等待后续数据
List<BlePacket> parse() {
final List<BlePacket> packets = [];
while (_buffer.length >= 6) {
// 1. 查找帧头
int headIdx = -1;
for (int i = 0; i < _buffer.length - 1; i++) {
if (_buffer[i] == _header1 && _buffer[i + 1] == _header2) {
headIdx = i;
break;
}
}
if (headIdx == -1) {
// 无有效帧头,清空缓冲区
_buffer.clear();
break;
}
// 移除帧头前的垃圾数据
if (headIdx > 0) _buffer.removeRange(0, headIdx);
// 2. 查找帧尾
int tailIdx = -1;
for (int i = 2; i < _buffer.length - 1; i++) {
if (_buffer[i] == _tail1 && _buffer[i + 1] == _tail2) {
tailIdx = i;
break;
}
}
if (tailIdx == -1) {
// 帧尾未找到,可能是分片数据,等待更多数据
break;
}
// 3. 提取数据包
if (_buffer.length >= 5) {
final cmd = _buffer[2];
final payload = Uint8List.fromList(_buffer.sublist(3, tailIdx));
packets.add(BlePacket(command: cmd, payload: payload));
}
// 4. 移除已处理的帧
_buffer.removeRange(0, tailIdx + 2);
}
return packets;
}
/// 便捷方法:追加数据并立即解析
List<BlePacket> appendAndParse(List<int> data) {
append(data);
return parse();
}
/// CRC16-Modbus: polynomial=0x8005, init=0xFFFF, refIn/refOut=true
static int _crc16(List<int> data) {
int crc = 0xFFFF;
for (final b in data) {
crc ^= b;
for (int j = 0; j < 8; j++) {
if ((crc & 0x0001) != 0) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
}

View File

@@ -11,14 +11,14 @@ class HttpApiConsts {
// 获取设备列表
static const String getUserDevicesList = "$baseUrl/iot/device/list";
// 绑定设备
static const String bindDevice = "$baseUrl/forward/device/bind";
static const String bindDevice = "$baseUrl/iot/device/bind";
// 解绑设备
static const String unbindDevice = "$baseUrl/forward/device/unbind";
// 切换设备
static const String switchDevice = "$baseUrl/forward/device/switchDevice";
// 获取光伏电站列表
static const String getSiteList = "$baseUrl/system/site/list";
static const String getSiteList = "$baseUrl/system/site/selectByUserId";
// 获取场站下的设备列表
static const String getSiteDeviceList = "$baseUrl/iot/device/getSiteList";
@@ -42,7 +42,8 @@ class HttpApiConsts {
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask";
// 获取飞行任务详情
static const String getFlightTaskDetail = "$baseUrl/iot/UAV/getFlightTaskDetail";
static const String getFlightTaskDetail =
"$baseUrl/iot/UAV/getFlightTaskDetail";
// 获取航线列表
static const String getWayline = "$baseUrl/iot/UAV/getWayline";
@@ -51,7 +52,8 @@ class HttpApiConsts {
static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask";
// 更新飞行任务状态
static const String updateFlightTaskStatus = "$baseUrl/iot/UAV/updateFlightTaskStatus";
static const String updateFlightTaskStatus =
"$baseUrl/iot/UAV/updateFlightTaskStatus";
// 切换无人机镜头获取视频流
static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens";
@@ -61,4 +63,56 @@ class HttpApiConsts {
// 飞行任务命令控制(暂停、返航等)
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
/// 告警相关
static const String alarmBaseUrl = "http://1.95.137.212:8081";
// 获取告警工单配置列表
static const String alarmOrderConfigList =
"$alarmBaseUrl/iot/alarmOrderConfig/list";
// 获取告警列表
static const String alarmList = "$baseUrl/iot/alarm/list";
// 获取告警详情
static const String alarmDetail = "$baseUrl/iot/alarm";
// 处理告警(确认/关闭等)
static const String alarmHandle = "$baseUrl/iot/alarm/handle";
/// 工单相关
// 获取工单模型配置列表
static const String orderModelList = "$baseUrl/iot/orderModel/list";
// 添加工单(上报)
static const String workOrderAdd = "$baseUrl/iot/ioTworkOrder/add";
// 获取工单列表
static const String workOrderList = "$baseUrl/iot/ioTworkOrder/list";
// 获取工单详情
static const String workOrderDetail = "$baseUrl/iot/ioTworkOrder";
// 派发工单
static const String workOrderDispat = "$baseUrl/iot/ioTworkOrder/dispatch";
// 挂起工单
static const String workOrderSuspend = "$baseUrl/iot/ioTworkOrder/suspend";
// 完成工单
static const String workOrderComplete = "$baseUrl/iot/ioTworkOrder/complete";
// 开始执行工单
static const String workOrderStart = "$baseUrl/iot/ioTworkOrder/start";
// 获取工单统计
static const String workOrderCount = "$baseUrl/iot/ioTworkOrder/count";
// 设备运行参数查询
static const String deviceRunParamSelect =
"$baseUrl/iot/deviceRunParam/selectByDeviceId";
// 设备运行参数保存
static const String deviceRunParamSave = "$baseUrl/iot/deviceRunParam/save";
// 设备操作权限校验
static const String hasPermission = "$baseUrl/iot/device/hasPermission";
/// 用户相关
// 获取用户列表
static const String systemUserList = "$baseUrl/system/user/list";
/// 组织与场站相关
// 获取组织列表
static const String orgList = "$baseUrl/system/org/list";
// 根据组织ID获取场站列表
static const String siteListByOrgId = "$baseUrl/system/site/selectByOrgId";
// 根据场站ID获取用户列表
static const String userListBySiteId = "$baseUrl/system/user/list";
}

View File

@@ -52,15 +52,12 @@ import '../../features/devices/domain/usecases/get_device_location_usecase.dart'
import '../../features/devices/domain/usecases/get_work_record_usecase.dart';
import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
import '../../features/devices/domain/usecases/create_device_task_usecase.dart';
import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
import '../../features/devices/domain/usecases/route_planning_usecase.dart';
import '../../features/devices/domain/usecases/save_work_record_usecase.dart';
import '../../features/devices/domain/usecases/select_work_record_usecase.dart';
import '../../features/devices/domain/usecases/switch_device_usecase.dart';
import '../../features/devices/presentation/bloc/device_status_bloc.dart';
import '../../features/remote_control/data/datasources/remote_http_datasource.dart';
import '../../features/remote_control/data/datasources/remote_tcp_datasource.dart';
import '../../features/remote_control/domain/usecase/remote_control_usecase.dart';
import '../../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../features/v2/home/data/datasources/home_remote_datasource.dart';
import '../../features/v2/home/data/datasources/site_datasource.dart';
@@ -96,6 +93,12 @@ import '../../features/v2/device_list/domain/usecases/return_home_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/drone_station_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/device_realtime_bloc.dart';
import '../../features/v2/device_list/data/datasources/bind_device_datasource.dart';
import '../../features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart';
import '../../features/v2/device_list/data/repositories/bind_device_repository_impl.dart';
import '../../features/v2/device_list/domain/repositories/bind_device_repository.dart';
import '../../features/v2/device_list/domain/usecases/bind_device_usecases.dart';
import '../../features/v2/device_list/presentation/cubit/bind_device_cubit.dart';
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart';
import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart';
@@ -116,6 +119,18 @@ import '../../features/v2/message_center/data/repositories/message_center_reposi
import '../../features/v2/message_center/domain/repositories/message_center_repository.dart';
import '../../features/v2/message_center/domain/usecases/message_center_usecase.dart';
import '../../features/v2/message_center/presentation/bloc/message_center_cubit.dart';
import '../../features/v2/work_order/data/datasources/work_order_remote_datasource.dart';
import '../../features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart';
import '../../features/v2/work_order/data/repositories/work_order_repository_impl.dart';
import '../../features/v2/work_order/domain/repositories/work_order_repository.dart';
import '../../features/v2/work_order/domain/usecases/work_order_usecases.dart';
import '../../features/v2/work_order/presentation/cubit/work_order_cubit.dart';
import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart';
import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart';
import '../../features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart';
import '../../features/v2/device_run_param/domain/repositories/device_run_param_repository.dart';
import '../../features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart';
import '../services/device_permission_service.dart';
import '../../features/devices/data/datasources/device_task_datasource.dart';
import '../../features/devices/data/repositories/device_task_repository_impl.dart';
import '../../features/devices/domain/repositories/device_task_repository.dart';
@@ -196,7 +211,9 @@ Future<void> init() async {
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
/// 1.4 --- Route Observer (路由监听器) ---
sl.registerLazySingleton<RouteObserver>(() => RouteObserver<ModalRoute<void>>());
sl.registerLazySingleton<RouteObserver>(
() => RouteObserver<ModalRoute<void>>(),
);
/// 1.5 --- MQTT Data Sources ---
sl.registerFactory<DroneOsdDataSource>(
@@ -367,9 +384,7 @@ Future<void> init() async {
sl.registerLazySingleton<PauseFlightTaskUseCase>(
() => PauseFlightTaskUseCase(sl()),
);
sl.registerLazySingleton<ReturnHomeUseCase>(
() => ReturnHomeUseCase(sl()),
);
sl.registerLazySingleton<ReturnHomeUseCase>(() => ReturnHomeUseCase(sl()));
sl.registerFactory<DroneStationBloc>(
() => DroneStationBloc(sl(), sl(), sl(), sl()),
);
@@ -384,7 +399,7 @@ Future<void> init() async {
/// Alarm Center V2
sl.registerLazySingleton<AlarmRemoteDataSource>(
() => AlarmRemoteDataSourceImpl(),
() => AlarmRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<AlarmRepository>(() => AlarmRepositoryImpl(sl()));
sl.registerLazySingleton<GetAlarmListUseCase>(
@@ -393,13 +408,11 @@ Future<void> init() async {
sl.registerLazySingleton<GetAlarmCountUseCase>(
() => GetAlarmCountUseCase(sl()),
);
sl.registerFactory<AlarmCubit>(
() => AlarmCubit(getAlarmListUseCase: sl(), getAlarmCountUseCase: sl()),
);
sl.registerFactory<AlarmCubit>(() => AlarmCubit(getAlarmListUseCase: sl()));
/// Alarm Detail V2
sl.registerLazySingleton<AlarmDetailRemoteDataSource>(
() => AlarmDetailRemoteDataSourceImpl(),
() => AlarmDetailRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<AlarmDetailRepository>(
() => AlarmDetailRepositoryImpl(sl()),
@@ -410,11 +423,13 @@ Future<void> init() async {
sl.registerLazySingleton<ConfirmAlarmUseCase>(
() => ConfirmAlarmUseCase(sl()),
);
sl.registerLazySingleton<HandleAlarmUseCase>(() => HandleAlarmUseCase(sl()));
sl.registerLazySingleton<AIDiagnosisUseCase>(() => AIDiagnosisUseCase(sl()));
sl.registerFactory<AlarmDetailCubit>(
() => AlarmDetailCubit(
getAlarmDetailUseCase: sl(),
confirmAlarmUseCase: sl(),
handleAlarmUseCase: sl(),
aiDiagnosisUseCase: sl(),
),
);
@@ -565,5 +580,84 @@ Future<void> init() async {
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
sl.registerFactory(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
sl.registerLazySingleton(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
/// 11. 工单管理 (Work Order)
sl.registerLazySingleton<WorkOrderRemoteDataSource>(
() => WorkOrderRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<WorkOrderRepository>(
() => WorkOrderRepositoryImpl(remoteDataSource: sl()),
);
sl.registerLazySingleton<GetWorkOrderListUseCase>(
() => GetWorkOrderListUseCase(sl()),
);
sl.registerLazySingleton<GetWorkOrderDetailUseCase>(
() => GetWorkOrderDetailUseCase(sl()),
);
sl.registerLazySingleton<DispatchWorkOrderUseCase>(
() => DispatchWorkOrderUseCase(sl()),
);
sl.registerLazySingleton<SuspendWorkOrderUseCase>(
() => SuspendWorkOrderUseCase(sl()),
);
sl.registerLazySingleton<CompleteWorkOrderUseCase>(
() => CompleteWorkOrderUseCase(sl()),
);
sl.registerLazySingleton<StartWorkOrderUseCase>(
() => StartWorkOrderUseCase(sl()),
);
sl.registerFactory<WorkOrderCubit>(
() => WorkOrderCubit(
getWorkOrderListUseCase: sl(),
getWorkOrderDetailUseCase: sl(),
dispatchWorkOrderUseCase: sl(),
suspendWorkOrderUseCase: sl(),
completeWorkOrderUseCase: sl(),
startWorkOrderUseCase: sl(),
),
);
/// 12. 设备运行参数管理 (Device Run Param)
sl.registerLazySingleton<DeviceRunParamRemoteDataSource>(
() => DeviceRunParamRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<DeviceRunParamRepository>(
() => DeviceRunParamRepositoryImpl(remoteDataSource: sl()),
);
sl.registerLazySingleton<GetDeviceRunParamUseCase>(
() => GetDeviceRunParamUseCase(sl()),
);
sl.registerLazySingleton<SaveDeviceRunParamUseCase>(
() => SaveDeviceRunParamUseCase(sl()),
);
/// 13. 设备操作权限校验服务 (Device Permission Service)
sl.registerLazySingleton<DevicePermissionService>(
() => DevicePermissionService(sl<Dio>()),
);
/// 14. 绑定智能装备 (Bind Device)
sl.registerLazySingleton<BindDeviceDatasource>(
() => BindDeviceDatasourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<BindDeviceRepository>(
() => BindDeviceRepositoryImpl(sl<BindDeviceDatasource>()),
);
sl.registerLazySingleton<GetOrgListUseCase>(() => GetOrgListUseCase(sl()));
sl.registerLazySingleton<GetSitesByOrgUseCase>(
() => GetSitesByOrgUseCase(sl()),
);
sl.registerLazySingleton<GetUsersBySiteUseCase>(
() => GetUsersBySiteUseCase(sl()),
);
sl.registerLazySingleton<BindDeviceV2UseCase>(
() => BindDeviceV2UseCase(sl()),
);
sl.registerLazySingleton<IsDeviceAtSiteUseCase>(
() => IsDeviceAtSiteUseCase(sl()),
);
sl.registerFactory<BindDeviceCubit>(
() => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl<AppUserCubit>()),
);
}

View File

@@ -5,12 +5,14 @@ class UserEntity extends Equatable {
final String username;
final String nickname;
final String token;
final int orgId; // 组织ID,用于获取场站列表
final int orgId;
final String? avatar;
final String? email;
final String? phone;
final String? roleKey;
final int? siteId;
UserEntity({
const UserEntity({
required this.userId,
required this.username,
required this.nickname,
@@ -19,6 +21,8 @@ class UserEntity extends Equatable {
this.avatar,
this.email,
this.phone,
this.roleKey,
this.siteId,
});
@override
@@ -31,5 +35,33 @@ class UserEntity extends Equatable {
orgId,
email,
phone,
roleKey,
siteId,
];
}
UserEntity copyWith({
String? userId,
String? username,
String? nickname,
String? token,
int? orgId,
String? avatar,
String? email,
String? phone,
String? roleKey,
int? siteId,
}) {
return UserEntity(
userId: userId ?? this.userId,
username: username ?? this.username,
nickname: nickname ?? this.nickname,
token: token ?? this.token,
orgId: orgId ?? this.orgId,
avatar: avatar ?? this.avatar,
email: email ?? this.email,
phone: phone ?? this.phone,
roleKey: roleKey ?? this.roleKey,
siteId: siteId ?? this.siteId,
);
}
}

View File

@@ -1,6 +1,24 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../features/v2/device_list/domain/entities/drone_station_entity.dart';
/// 无人机轨迹点(轻量级,避免在核心层引入 latlong2 依赖)
class DroneTrajectoryPoint {
final double latitude;
final double longitude;
final double? heading;
const DroneTrajectoryPoint({
required this.latitude,
required this.longitude,
this.heading,
});
@override
String toString() =>
'DroneTrajectoryPoint(lat=$latitude, lng=$longitude, heading=$heading)';
}
/// 无人机任务信息
class DroneTaskInfo {
final String droneSn;
@@ -33,9 +51,33 @@ class DroneTaskStateManager {
final ValueNotifier<DroneTaskInfo?> _currentTaskInfo =
ValueNotifier<DroneTaskInfo?>(null);
/// 任务下发成功后,等待无人机 OSD 推送数据(实时信息)
final ValueNotifier<bool> _isWaitingForOsdPush = ValueNotifier<bool>(false);
/// 任务下发成功后,等待无人机视频流
final ValueNotifier<bool> _isWaitingForVideo = ValueNotifier<bool>(false);
/// 无人机飞行轨迹点(跨页面持久化,退出视频页后不丢失)
final ValueNotifier<List<DroneTrajectoryPoint>> _trajectoryPoints =
ValueNotifier<List<DroneTrajectoryPoint>>([]);
/// 超时定时器,避免一直转圈(推送/视频迟迟不到)
Timer? _osdWaitTimeoutTimer;
Timer? _videoWaitTimeoutTimer;
/// 监听无人机任务信息变化
ValueListenable<DroneTaskInfo?> get currentTaskInfo => _currentTaskInfo;
/// 监听“等待 OSD 推送”状态
ValueListenable<bool> get isWaitingForOsdPush => _isWaitingForOsdPush;
/// 监听“等待视频流”状态
ValueListenable<bool> get isWaitingForVideo => _isWaitingForVideo;
/// 监听无人机轨迹点
ValueListenable<List<DroneTrajectoryPoint>> get trajectoryPoints =>
_trajectoryPoints;
/// 获取当前无人机任务信息
DroneTaskInfo? get currentInfo => _currentTaskInfo.value;
@@ -50,10 +92,84 @@ class DroneTaskStateManager {
_currentTaskInfo.value = info;
}
/// 任务下发成功后调用:开始监测无人机实时推送与视频流
/// 在接口返回成功之后调用
void markTaskIssued() {
debugPrint('🚀 [DroneTaskStateManager] 任务已下发,开始监测推送数据与视频流');
_isWaitingForOsdPush.value = true;
_isWaitingForVideo.value = true;
_osdWaitTimeoutTimer?.cancel();
_videoWaitTimeoutTimer?.cancel();
// 超时兜底:90 秒后仍未收到 OSD 推送,自动停止转圈
_osdWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
if (_isWaitingForOsdPush.value) {
debugPrint('⚠️ [DroneTaskStateManager] 等待 OSD 推送超时,自动停止');
_isWaitingForOsdPush.value = false;
}
});
// 超时兜底:90 秒后仍未收到视频,自动停止 toast
_videoWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
if (_isWaitingForVideo.value) {
debugPrint('⚠️ [DroneTaskStateManager] 等待视频流超时,自动停止');
_isWaitingForVideo.value = false;
}
});
}
/// 收到无人机 OSD 推送数据后调用:停止转圈,恢复绿点
void markOsdPushReceived() {
if (_isWaitingForOsdPush.value) {
debugPrint('✅ [DroneTaskStateManager] 收到 OSD 推送数据,停止监测');
_isWaitingForOsdPush.value = false;
_osdWaitTimeoutTimer?.cancel();
_osdWaitTimeoutTimer = null;
}
}
/// 收到无人机视频流后调用:隐藏"视频获取中" toast
void markVideoReceived() {
if (_isWaitingForVideo.value) {
debugPrint('✅ [DroneTaskStateManager] 收到视频流,停止监测');
_isWaitingForVideo.value = false;
_videoWaitTimeoutTimer?.cancel();
_videoWaitTimeoutTimer = null;
}
}
/// 添加无人机轨迹点
void addTrajectoryPoint(DroneTrajectoryPoint point) {
final list = List<DroneTrajectoryPoint>.from(_trajectoryPoints.value);
list.add(point);
// 性能优化:只保留最近 1000 个点
if (list.length > 1000) {
list.removeAt(0);
}
_trajectoryPoints.value = list;
}
/// 批量设置轨迹点(恢复历史轨迹时使用)
void setTrajectoryPoints(List<DroneTrajectoryPoint> points) {
_trajectoryPoints.value = List<DroneTrajectoryPoint>.from(points);
}
/// 清空轨迹
void clearTrajectory() {
_trajectoryPoints.value = [];
}
/// 清除无人机任务信息
void clearDroneTaskInfo() {
debugPrint('🛸 [DroneTaskStateManager] 清除无人机任务信息');
_currentTaskInfo.value = null;
_osdWaitTimeoutTimer?.cancel();
_videoWaitTimeoutTimer?.cancel();
_osdWaitTimeoutTimer = null;
_videoWaitTimeoutTimer = null;
_isWaitingForOsdPush.value = false;
_isWaitingForVideo.value = false;
}
/// 检查是否有当前任务

View File

@@ -15,7 +15,13 @@ class DioClient {
),
);
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
dio.interceptors.add(
LogInterceptor(
requestBody: true,
responseBody: true,
requestHeader: true,
),
);
dio.interceptors.add(
InterceptorsWrapper(
@@ -30,23 +36,29 @@ class DioClient {
return handler.next(options);
},
// ======================
// ✅ 关键:401 自动拦截
// ======================
onResponse: (response, handler) {
if (response.data is Map<String, dynamic>) {
final code = response.data['code'];
if (code == 401 || code == 403) {
print(
'>>> [DIO] 🚨🚨🚨 收到业务错误码 $code,触发 Token 过期处理!URL: ${response.requestOptions.uri},时间: ${DateTime.now()}',
);
try {
sl<AuthCubit>().tokenExpired();
} catch (ex) {}
}
}
return handler.next(response);
},
onError: (DioException e, handler) async {
// 401 = token 过期 / 未授权
if (e.response?.statusCode == 401) {
print('>>> [DIO] 🚨🚨🚨 收到 401 响应,触发自动退出登录!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}');
if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
print(
'>>> [DIO] 🚨🚨🚨 收到 HTTP ${e.response?.statusCode},触发 Token 过期处理!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}',
);
try {
// 调用 logout 清除本地缓存 + 跳登录
await sl<AuthCubit>().logout();
} catch (ex) {
// 防止报错
}
sl<AuthCubit>().tokenExpired();
} catch (ex) {}
}
return handler.next(e);

View File

@@ -28,6 +28,8 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
StreamSubscription<MqttMessage>? _subscription;
String? _deviceSn;
String? _gatewaySn;
int _listenerCount = 0;
bool _isDisposed = false;
DroneOsdDataSourceImpl(this.mqttClient);
@@ -42,10 +44,14 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
required String deviceSn,
required String gatewaySn,
}) async {
if (_deviceSn == deviceSn &&
if (_isDisposed) return;
if (_listenerCount > 0 &&
_deviceSn == deviceSn &&
_gatewaySn == gatewaySn &&
_subscription != null) {
debugPrint('[DroneOsdDataSource] already listening');
_listenerCount++;
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount');
return;
}
@@ -73,10 +79,20 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
_subscription = mqttClient.messageStream?.listen((message) {
_handleMessage(message);
});
_listenerCount = 1;
}
@override
Future<void> stopListening() async {
_listenerCount--;
if (_listenerCount > 0) {
debugPrint('[DroneOsdDataSource] 引用计数-1: $_listenerCount (保留订阅)');
return;
}
_listenerCount = 0;
await _subscription?.cancel();
_subscription = null;
@@ -94,26 +110,19 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
}
void _handleMessage(MqttMessage message) {
if (_isDisposed) return;
try {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
final osdData = DroneOsdEntity.fromJson(jsonData);
// 🔥 重要修复:先判断 gatewaySn,再判断 deviceSn
// 因为 topic 可能同时包含两者,但我们需要优先匹配机场
if (_gatewaySn != null &&
_gatewaySn!.isNotEmpty &&
message.topic.contains(_gatewaySn!)) {
debugPrint('🏢 [DroneOsdDataSource] 机场 OSD 更新');
debugPrint('🏢 [DroneOsdDataSource] Topic: ${message.topic}');
_stationOsdController.add(osdData);
} else if (_deviceSn != null &&
_deviceSn!.isNotEmpty &&
message.topic.contains(_deviceSn!)) {
debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
debugPrint('🛸 [DroneOsdDataSource] Topic: ${message.topic}');
_droneOsdController.add(osdData);
} else {
debugPrint('⚠️ [DroneOsdDataSource] 未知设备类型,Topic: ${message.topic}');
}
} catch (e) {
debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e');
@@ -121,8 +130,9 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
}
void dispose() {
_isDisposed = true;
stopListening();
_droneOsdController.close();
_stationOsdController.close();
}
}
}

View File

@@ -13,7 +13,7 @@ abstract class TaskMessageDataSource {
Stream<TaskArriveEntity> get taskArriveStream;
Stream<RealTimeMessageEntity> get realTimeMessageStream;
Future<void> startListening({required String deviceId});
Future<void> startListening({required String deviceId, int? taskId});
Future<void> stopListening();
}
@@ -26,6 +26,7 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
StreamSubscription<MqttMessage>? _subscription;
String? _deviceId;
int? _taskId;
TaskMessageDataSourceImpl(this.mqttClient);
@@ -40,9 +41,9 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
_realTimeMessageController.stream;
@override
Future<void> startListening({required String deviceId}) async {
if (_deviceId == deviceId && _subscription != null) {
debugPrint('[TaskMessageDataSource] already listening: $deviceId');
Future<void> startListening({required String deviceId, int? taskId}) async {
if (_deviceId == deviceId && _taskId == taskId && _subscription != null) {
debugPrint('[TaskMessageDataSource] already listening: deviceId=$deviceId, taskId=$taskId');
return;
}
@@ -54,18 +55,20 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
}
_deviceId = deviceId;
_taskId = taskId;
final taskStatusTopic = 'task/$deviceId/status';
final taskArriveTopic = 'task/$deviceId/arrive';
// 🔥 status/arrive 话题使用 taskId,realTimeMessage 使用 deviceId
final taskStatusTopic = taskId != null ? 'task/$taskId/status' : null;
final taskArriveTopic = taskId != null ? 'task/$taskId/arrive' : null;
final realTimeTopic = 'device/$deviceId/realTimeMessage';
debugPrint('📋 [TaskMessageDataSource] 开始监听:');
debugPrint(' 任务状态: $taskStatusTopic');
debugPrint(' 到达通知: $taskArriveTopic');
if (taskStatusTopic != null) debugPrint(' 任务状态: $taskStatusTopic');
if (taskArriveTopic != null) debugPrint(' 到达通知: $taskArriveTopic');
debugPrint(' 实时消息: $realTimeTopic');
await mqttClient.subscribe(taskStatusTopic);
await mqttClient.subscribe(taskArriveTopic);
if (taskStatusTopic != null) await mqttClient.subscribe(taskStatusTopic);
if (taskArriveTopic != null) await mqttClient.subscribe(taskArriveTopic);
await mqttClient.subscribe(realTimeTopic);
_subscription = mqttClient.messageStream?.listen((message) {
@@ -80,12 +83,16 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
if (_deviceId != null) {
final deviceId = _deviceId!;
await mqttClient.unsubscribe('task/$deviceId/status');
await mqttClient.unsubscribe('task/$deviceId/arrive');
// 🔥 status/arrive 用 taskId 取消订阅
if (_taskId != null) {
await mqttClient.unsubscribe('task/$_taskId/status');
await mqttClient.unsubscribe('task/$_taskId/arrive');
}
await mqttClient.unsubscribe('device/$deviceId/realTimeMessage');
}
_deviceId = null;
_taskId = null;
}
void _handleMessage(MqttMessage message) {

View File

@@ -24,9 +24,9 @@ class TaskMessageRepositoryImpl implements TaskMessageRepository {
Stream<TaskStatusEntity> get taskStatusStream => dataSource.taskStatusStream;
@override
Future<Either<Failure, void>> startListening({required String deviceId}) async {
Future<Either<Failure, void>> startListening({required String deviceId, int? taskId}) async {
try {
await dataSource.startListening(deviceId: deviceId);
await dataSource.startListening(deviceId: deviceId, taskId: taskId);
return right(null);
} catch (e) {
return left(Failure(e.toString()));

View File

@@ -71,7 +71,7 @@ class MqttConfig extends Equatable {
factory MqttConfig.taskMessage() {
return const MqttConfig(
host: '1.95.137.212',
port: 59020,
port: 1883,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,

View File

@@ -11,6 +11,6 @@ abstract class TaskMessageRepository {
Stream<TaskArriveEntity> get taskArriveStream;
Stream<TaskStatusEntity> get taskStatusStream;
Future<Either<Failure, void>> startListening({required String deviceId});
Future<Either<Failure, void>> startListening({required String deviceId, int? taskId});
Future<void> stopListening();
}

View File

@@ -36,9 +36,15 @@ class TcpClient {
final ILoggerService _logger = GetIt.I<ILoggerService>();
// 新增:心跳定时器
// 心跳超时定时器(收到服务端心跳后重置,超时则判定连接断开)
Timer? _heartbeatTimer;
Timer? _heartbeatSendTimer; // 🔥 主动发送心跳定时器:每5秒给服务端发一次,防止服务端判定客户端失效
Timer? _reconnectTimer; // 重连定时器
int _reconnectAttempt = 0; // 🔥 重连尝试次数,用于退避计算
int _reconnectFailCount = 0; // 🔥 连续重连失败次数(含心跳超时),达到阈值后停止重连
/// 🔥 重连耗尽回调:连续4次重连失败(含心跳超时)后触发,通知上层弹窗提示用户
VoidCallback? onReconnectExhausted;
static const Duration _heartbeatTimeout = Duration(seconds: 15); // 心跳超时时间
String? _lastHost;
int? _lastPort;
@@ -46,6 +52,7 @@ class TcpClient {
bool _isSwitching = false;
bool _connecting = false; // 🔥 是否正在建立连接(防止 connect() 和 connectBySwitch() 并发竞争)
Completer<void>? _connectionCompleter; // 🔥 用于通知连接就绪
int _connectionId = 0; // 🔥 连接代数:每次新建连接递增,旧socket回调发现ID不匹配则忽略
// 🔥 关键标志:记录客户端最后一次发送 0x03 认证包的时间
// 用于区分"自身认证触发的 have_logged_in"和"真正的异地登录"
@@ -95,6 +102,7 @@ class TcpClient {
bool get isConnected => _socket != null;
void disconnects({bool forSwitch = false}) {
_connectionId++; // 🔥 使旧socket回调失效
if (forSwitch) {
_isSwitching = true;
//debugPrint('🚫 [TCP] 标记为切换断开,将禁止自动重连');
@@ -103,7 +111,7 @@ class TcpClient {
// 🔥 关键:立即取消可能已经存在或即将触发的重连定时器
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
stopHeartbeat();
if (_socket != null) {
//debugPrint('🔌 [TCP] 物理断开 Socket...');
@@ -123,6 +131,7 @@ class TcpClient {
/// 🔥 彻底断开 TCP(用于退出登录),清除所有重连能力
void forceDisconnect() {
_logger.logWithLevel('🛑 [TCP] 强制断开并清除重连能力', shouldLog: true);
_connectionId++; // 🔥 使旧socket回调失效
// 1. 停止心跳
stopHeartbeat();
@@ -141,6 +150,8 @@ class TcpClient {
_connecting = false;
_connectionCompleter = null;
_lastAuthPacketSentAt = null;
_reconnectAttempt = 0; // 🔥 重置重连退避
_reconnectFailCount = 0; // 🔥 重置重连失败计数
// 5. 销毁 Socket
if (_socket != null) {
@@ -187,7 +198,9 @@ class TcpClient {
_connecting = true;
_connectionCompleter = Completer<void>();
debugPrint('🔌 [TCP-connect] 设置 _connecting=true');
_connectionId++; // 🔥 新一代连接,旧socket的回调将自动失效
final myConnId = _connectionId;
debugPrint('🔌 [TCP-connect] 设置 _connecting=true, connectionId=$myConnId');
_logger.logWithLevel('🔌 [TCP] 开始连接:$host:$port', shouldLog: true);
@@ -207,6 +220,9 @@ class TcpClient {
timeout: const Duration(seconds: 5),
);
_connecting = false; // 🔥 连接成功,重置标志
// 🔥 关键:在重置退避计数前,记住本次是否是重连(用于后续检测服务端残留session)
final bool wasReconnecting = _reconnectAttempt > 0;
_reconnectAttempt = 0; // 🔥 连接成功,重置重连退避
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete();
debugPrint('✅ [TCP-connect] 连接就绪,通知等待方');
@@ -225,6 +241,8 @@ class TcpClient {
// 开始认证tcp
await _sendAuthPacket();
// 🔥 启动心跳超时检测:等待服务端心跳,超时则触发重连
startHeartbeat();
_socket!.listen(
(data) {
//debugPrint('📥 [TCP] 收到原始数据:${data.length} 字节, 内容:$data');
@@ -248,12 +266,23 @@ class TcpClient {
shouldLog: false,
);
for (var packet in packets) {
// 🔥 最根部日志:收到任何推送都打印
// debugPrint('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}');
// _logger.logWithLevel(
// '📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}',
// shouldLog: true,
// );
// 🔥 关键修复:重连期间收到 have_logged_in,拦截不分发给 AuthCubit
// 必须在 _controller.add 之前检查,否则 AuthCubit 会判定为异地登录弹出退出弹窗
if (packet.command == 0x12 && wasReconnecting) {
try {
final payloadStr = utf8.decode(packet.payload.length > 2
? packet.payload.sublist(0, packet.payload.length - 2)
: packet.payload);
if (payloadStr.contains('have_logged_in')) {
debugPrint('🔄 [TCP] 重连后收到 have_logged_in,服务端session残留!立即断开让服务端清理session');
_logger.logWithLevel('🔄 [TCP] 重连后收到 have_logged_in,服务端session残留,强制重连', shouldLog: true);
_handleReconnectSessionReset(myConnId);
return; // 停止处理后续数据,不分发给任何监听器
}
} catch (_) {
// 解析失败不影响主流程
}
}
if (!_controller.isClosed) {
_controller.add(packet);
@@ -264,12 +293,9 @@ class TcpClient {
);
}
if (packet.command == 0xFF) {
//debugPrint('收到服务端心跳,自动回复...');
_logger.logWithLevel(
'✅ [TCP] 收到服务端心跳,自动回复...',
shouldLog: false,
);
debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
sendHeartbeat(); // 回复 AB AA FF AA AB
_resetHeartbeatTimeout(); // 重置超时计时器
}
if (packet.command == 0x03) {
// debugPrint('⚠️ 收到认证响应:${packet.payload}');
@@ -289,24 +315,28 @@ class TcpClient {
}
},
onDone: () {
// TODO: 断线重连
// debugPrint('来到断线重连!');
// 🔥 关键修复:检查 connectionId,防止旧socket的回调污染新连接
if (myConnId != _connectionId) {
debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 的回调,当前 #$_connectionId,忽略');
_logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
return;
}
if (!_isSwitching) {
//debugPrint('onDone❌ [TCP] 连接已断开!');
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: true);
_handleDisconnect();
}
_isSwitching = false; // 重置标志,以免影响下次
_isSwitching = false;
},
onError: (e) {
//debugPrint('来到断线重连!error');
if (myConnId != _connectionId) {
debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 的回调,当前 #$_connectionId,忽略');
return;
}
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
if (!_isSwitching) {
//debugPrint('❌ [TCP] 发生错误:$e');
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
_handleDisconnect();
} // 统一走重连逻辑,保护 Controller 不被关闭
_isSwitching = false; // 重置标志,以免影响下次
}
_isSwitching = false;
},
);
} catch (e) {
@@ -318,13 +348,46 @@ class TcpClient {
}
}
/// 🔥 重连后服务端session残留处理:立即销毁连接,等待服务端清理session后再重连
/// 这是解决"退出登录重连就好"问题的核心:
/// 退出登录 → forceDisconnect → 服务端有足够时间清理session → 重新登录 → 新session → 心跳正常
/// 自动重连 → 5秒内重连 → 服务端session还在 → have_logged_in → 不重启心跳 → 超时循环
void _handleReconnectSessionReset(int myConnId) {
_connectionId++; // 使旧socket回调失效
stopHeartbeat();
if (_socket != null) {
_socket!.destroy();
_socket = null;
}
// 🔥 服务端session残留也算一次重连失败
_reconnectFailCount++;
debugPrint('📊 [TCP] session残留,重连失败计数: $_reconnectFailCount/4');
if (_reconnectFailCount >= 4) {
debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
_logger.logWithLevel('🛑 [TCP] 重连失败已达 4 次,停止重连', shouldLog: true);
onReconnectExhausted?.call();
return;
}
// 🔥 递增重连尝试次数,让下次重连等待更长时间
_reconnectAttempt++;
_scheduleReconnect();
}
//
void _handleDisconnect() {
_connectionId++; // 🔥 使旧socket的所有回调立即失效,防止onDone/onError污染新连接
debugPrint('🔌 [TCP] _handleDisconnect, connectionId→$_connectionId');
_logger.logWithLevel('🔌 [TCP] _handleDisconnect, connectionId→$_connectionId', shouldLog: true);
stopHeartbeat();
_socket = null;
// 注意:这里不要关闭 _controller!否则监听者会丢失数据流
// _controller?.close();
// 🔥 连续失败达到4次,停止重连,通知上层弹窗
if (_reconnectFailCount >= 4) {
debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
_logger.logWithLevel('🛑 [TCP] 重连失败已达 4 次,停止重连', shouldLog: true);
onReconnectExhausted?.call();
return;
}
_scheduleReconnect();
}
@@ -333,15 +396,16 @@ class TcpClient {
if (isUserSwitch) return;
if (_reconnectTimer != null) return;
// debugPrint('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}'); // ✅ 检查 Host/Port 是否为空
_logger.logWithLevel('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}');
// 🔥 重连退避:5s → 10s → 20s → 30s(max)
final delay = [5, 10, 20, 30][_reconnectAttempt.clamp(0, 3)];
_reconnectAttempt++;
_logger.logWithLevel('⏳ 调度重连:第$_reconnectAttempt次,${delay}s后重连,Host=${_lastHost}, Port=${_lastPort}');
if (_lastHost == null || _lastPort == null) {
//debugPrint('❌ 无法重连:Host 或 Port 为空!');
_logger.logWithLevel('❌ 无法重连:Host 或 Port 为空!', shouldLog: true);
return;
}
_reconnectTimer = Timer(const Duration(seconds: 5), () {
_reconnectTimer = Timer(Duration(seconds: delay), () async {
_reconnectTimer = null;
// 🔥 关键修复:定时器触发时检查是否已有连接,避免重复创建
if (_socket != null) {
@@ -355,7 +419,29 @@ class TcpClient {
return;
}
_logger.logWithLevel('⏰ 定时器触发,开始执行重连...', shouldLog: true);
connect(host: _lastHost!, port: _lastPort!);
try {
await connect(host: _lastHost!, port: _lastPort!);
_reconnectAttempt = 0; // 🔥 重连成功,重置退避计数
// 🔥 重连成功后,补调 HTTP switchDevice 告知服务端推送目标设备
try {
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
final targetDevice = remoteControlCubit.state.targetDevice;
if (targetDevice != null) {
debugPrint('🔄 [TCP-重连] 补调 HTTP switchDevice: ${targetDevice.deviceName}');
_logger.logWithLevel('🔄 [TCP-重连] 补调 HTTP switchDevice: ${targetDevice.deviceName}', shouldLog: true);
await switchDeviceUseCase.deviceRepository.switchDevice('app', targetDevice.deviceName);
debugPrint('✅ [TCP-重连] HTTP switchDevice 成功');
} else {
debugPrint('⚠️ [TCP-重连] targetDevice 为 null,跳过 switchDevice');
}
} catch (e) {
debugPrint('❌ [TCP-重连] HTTP switchDevice 失败: $e');
_logger.logWithLevel('❌ [TCP-重连] HTTP switchDevice 失败: $e', shouldLog: true);
}
} catch (e) {
debugPrint('❌ [TCP-重连] connect 失败: $e');
_logger.logWithLevel('❌ [TCP-重连] connect 失败: $e', shouldLog: true);
}
});
}
@@ -446,19 +532,47 @@ class TcpClient {
..addByte(0xAA)
..addByte(0xAB);
_socket!.add(builder.takeBytes());
debugPrint('💓 [TCP] 发送心跳 AB AA FF AA AB');
}
// 新增:启动心跳(每 4 秒发送一次 0xFF 指令)
/// 启动心跳:主动定时发送 + 超时检测
/// 1. 每5秒主动给服务端发心跳,防止服务端判定客户端失效
/// 2. 15秒内没收到服务端心跳,判定连接断开并重连
void startHeartbeat({Duration interval = const Duration(seconds: 4)}) {
if (_heartbeatTimer != null) return; // 防止重复启动
debugPrint('⏰ [TCP] 启动心跳定时器,间隔: ${interval.inSeconds}秒');
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...', shouldLog: true);
_heartbeatTimer = Timer.periodic(interval, (_) {
// 发送心跳帧:AB AA FF AA AB
debugPrint('💓 [TCP] 发送心跳包 0xFF');
sendHeartbeat();
debugPrint('⏰ [TCP] 启动心跳:主动发送(5s) + 超时检测(${_heartbeatTimeout.inSeconds}s)');
_logger.logWithLevel('⏰ [TCP] 启动心跳:主动发送(5s) + 超时检测(${_heartbeatTimeout.inSeconds}s)', shouldLog: true);
// 🔥 主动定时发送心跳给服务端(每5秒)
_heartbeatSendTimer?.cancel();
_heartbeatSendTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (_socket != null) {
sendHeartbeat();
}
});
// 超时检测
_resetHeartbeatTimeout();
}
/// 重置心跳超时定时器
/// 每次收到服务端心跳(0xFF)时调用,重新开始计时
void _resetHeartbeatTimeout() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer(_heartbeatTimeout, () {
debugPrint('⚠️ [TCP] 心跳超时 ${_heartbeatTimeout.inSeconds}s 未收到服务端心跳,判定连接断开');
_logger.logWithLevel('⚠️ [TCP] 心跳超时,判定连接断开', shouldLog: true);
// 触发断开重连
if (_socket != null) {
_socket!.destroy();
_socket = null;
}
// 🔥 累计重连失败次数(心跳超时 = 本次连接无效 = 一次失败)
_reconnectFailCount++;
debugPrint('📊 [TCP] 重连失败计数: $_reconnectFailCount/4');
// 🔥 统一走普通断开重连路径(_scheduleReconnect 已包含 HTTP switchDevice)
_handleDisconnect();
});
debugPrint("✅ TCP Connected - 心跳已启动");
}
/// 🔥 封装完整的TCP初始化方法:连接 + 认证 + 心跳
@@ -478,6 +592,8 @@ class TcpClient {
_connecting = true;
_connectionCompleter = Completer<void>();
_connectionId++; // 🔥 新一代连接,旧socket回调自动失效
final myConnId = _connectionId;
try {
// 更新状态为连接中
@@ -516,7 +632,7 @@ class TcpClient {
_logger.logWithLevel('✅ [TCP] 心跳已启动', shouldLog: true);
// 设置数据监听(复用现有的监听逻辑)
_setupDataListener();
_setupDataListener(myConnId);
_connecting = false;
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
@@ -538,7 +654,7 @@ class TcpClient {
}
/// 设置数据监听器
void _setupDataListener() {
void _setupDataListener(int myConnId) {
if (_socket == null) return;
_socket!.listen(
@@ -557,7 +673,9 @@ class TcpClient {
_controller.add(packet);
}
if (packet.command == 0xFF) {
debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
sendHeartbeat();
_resetHeartbeatTimeout(); // 重置超时计时器
}
if (packet.command == 0x03) {
_logger.logWithLevel(
@@ -574,13 +692,23 @@ class TcpClient {
}
},
onDone: () {
// 🔥 检查 connectionId,防止旧socket回调污染新连接
if (myConnId != _connectionId) {
debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
_logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
return;
}
if (!_isSwitching) {
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: true);
_handleDisconnect();
}
_isSwitching = false;
},
onError: (e) {
if (myConnId != _connectionId) {
debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
return;
}
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
if (!_isSwitching) {
_handleDisconnect();
@@ -590,10 +718,12 @@ class TcpClient {
);
}
// 新增:停止心跳
// 停止心跳(主动发送 + 超时检测全部停止)
void stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_heartbeatSendTimer?.cancel();
_heartbeatSendTimer = null;
}
// void disconnect() {
@@ -608,7 +738,8 @@ class TcpClient {
//
// }
void disconnect() {
debugPrint('🛑 [TCP] 主动断开连接...');
_connectionId++; // 🔥 使旧socket回调失效
debugPrint('🛑 [TCP] 主动断开连接... connectionId→$_connectionId');
_logger.logWithLevel('🛑 [TCP] 主动断开连接...');
// 1. 停止心跳
@@ -629,8 +760,8 @@ class TcpClient {
_isSwitching = false;
_connecting = false;
_connectionCompleter = null;
// 5. 销毁 Socket
_reconnectAttempt = 0; // 🔥 重置重连退避
_reconnectFailCount = 0; // 🔥 重置重连失败计数
if (_socket != null) {
_socket!.destroy();
_socket = null;
@@ -736,7 +867,7 @@ class TcpClient {
_lastAuthPacketSentAt = DateTime.now(); // 🔥 记录发送 0x03 的时间
_socket!.add(builder.takeBytes());
//debugPrint('🔑 [TCP] 已发送认证包 (0x03): $authString');
await _socket!.flush(); // 🔥 关键修复:强制 flush,确保认证包立即到达服务端
_logger.logWithLevel('🔑 [TCP] 已发送认证包 (0x03): $authString');
// 🔥 关键修复:移除登录时自动获取设备列表和切换设备的逻辑
@@ -850,7 +981,9 @@ class TcpClient {
isUserSwitch = true;
_connecting = true; // 🔥 防止 connect() 并发创建新连接
debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建');
_connectionId++; // 🔥 新一代连接,旧socket回调自动失效
final myConnId = _connectionId;
debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建, connectionId=$myConnId');
// debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条
_logger.logWithLevel('🔌被动 [TCP] 开始连接:$host:$port');
@@ -908,10 +1041,9 @@ class TcpClient {
);
}
if (packet.command == 0xFF) {
//debugPrint('收到服务端心跳,自动回复...');
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...');
//
debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
sendHeartbeat(); // 回复 AB AA FF AA AB
_resetHeartbeatTimeout(); // 重置超时计时器
}
if (packet.command == 0x03) {
//debugPrint('⚠️ 收到认证响应:${packet.payload}');
@@ -925,18 +1057,28 @@ class TcpClient {
}
},
onDone: () {
// 🔥 检查 connectionId,防止旧socket回调污染新连接
if (myConnId != _connectionId) {
debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
_logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
return;
}
debugPrint('onDone [TCP] 被动连接已断开!_isSwitching=$_isSwitching');
if (!_isSwitching) {
_handleDisconnectBySwitch(deviceName);
_handleDisconnect();
} else {
debugPrint('🚫 [TCP] 切换过程中的断开,忽略重连调度');
_isSwitching = false;
}
},
onError: (e) {
if (myConnId != _connectionId) {
debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
return;
}
_logger.logWithLevel('❌ 被动[TCP] 发生错误:$e');
if (!_isSwitching) {
_handleDisconnectBySwitch(deviceName);
_handleDisconnect();
} else {
debugPrint('🚫 [TCP] 切换过程中的错误,忽略重连调度');
_isSwitching = false;
@@ -951,8 +1093,9 @@ class TcpClient {
rethrow; // 向上抛出连接异常
}
// 🔥 关键修复:只有在所有操作成功后才重置 _connecting
// 🔥 重置 _connecting 和 isUserSwitch
_connecting = false;
isUserSwitch = false; // 🔥 关键:切换完成后重置,后续超时走普通重连路径
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete();
debugPrint('✅ [TCP-connectBySwitch] 连接就绪,通知等待方');
@@ -1029,6 +1172,9 @@ class TcpClient {
}
void _handleDisconnectBySwitch(String deviceName) {
_connectionId++; // 🔥 使旧socket的所有回调立即失效
debugPrint('🔌 [TCP] _handleDisconnectBySwitch, connectionId→$_connectionId');
_logger.logWithLevel('🔌 [TCP] _handleDisconnectBySwitch, connectionId→$_connectionId', shouldLog: true);
stopHeartbeat();
_socket = null;

View File

@@ -9,6 +9,7 @@ import 'package:maibu_satabot_v2/features/home/presentation/routes/home_routes.d
import 'package:maibu_satabot_v2/features/main_container/presentation/pages/tab_settings_page.dart';
import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/routes/alarm_center_routes.dart';
import 'package:maibu_satabot_v2/features/v2/workorder/presentation/routes/workorder_routes.dart';
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
@@ -20,11 +21,13 @@ import 'go_router_refresh_stream.dart';
GoRouter createRouter(AuthCubit authCubit) {
// 调试:打印所有路由
debugPrint('🔍 [Router] 开始创建路由配置...');
debugPrint('🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}');
debugPrint(
'🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}',
);
for (var route in AlarmCenterRoutes.routes) {
debugPrint('🔍 [Router] 路由: ${route.toString()}');
}
return GoRouter(
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
initialLocation: RoutePaths.login,
@@ -74,6 +77,7 @@ GoRouter createRouter(AuthCubit authCubit) {
...AiRoutes.routes,
...MyRoutes.routes,
...AlarmCenterRoutes.routes,
...WorkOrderRoutes.routes,
],
);
}

View File

@@ -20,4 +20,8 @@ class RoutePaths {
static const settings = '/my/settings';
static const machineDetails = '/machine_details/machine_details';
/// 工单相关页面
static const workOrder = '/workorder';
static const workOrderDetail = '/workorder/detail/:orderId';
}

View File

@@ -0,0 +1,69 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../consts/http_api_consts.dart';
/// 设备操作权限校验服务
/// 接口: GET /iot/device/hasPermission?deviceId=xxx
/// 通过条件: code==200 且 data==true,缺一不可
/// 安全原则: 仅明确收到 code=200 && data=true 才放行,其余所有情况一律阻止
class DevicePermissionService {
final Dio _dio;
DevicePermissionService(this._dio);
/// 校验当前用户是否有权操作指定设备
/// 返回 true 表示有权限,false 表示无权限或校验失败
Future<bool> checkPermission(String deviceId) async {
debugPrint('🔐 [权限校验] 开始校验 - deviceId: $deviceId');
try {
final response = await _dio.get(
HttpApiConsts.hasPermission,
queryParameters: {'deviceId': deviceId},
);
debugPrint('🔐 [权限校验] HTTP响应 - statusCode: ${response.statusCode}');
// HTTP 层面必须 200
if (response.statusCode != 200) {
debugPrint('🔐 [权限校验] ❌ HTTP状态码非200: ${response.statusCode}');
return false;
}
final body = response.data;
debugPrint('🔐 [权限校验] 响应体: $body');
// body 必须是 Map
if (body is! Map<String, dynamic>) {
debugPrint('🔐 [权限校验] ❌ 响应体格式异常,非Map类型');
return false;
}
final code = body['code'];
final data = body['data'];
debugPrint('🔐 [权限校验] code=$code (type: ${code.runtimeType}), data=$data (type: ${data.runtimeType})');
// code 必须是 200(兼容 int 和 String)
final codeMatch = code == 200 || code.toString() == '200';
// data 必须是 true
final dataMatch = data == true || data.toString() == 'true';
if (codeMatch && dataMatch) {
debugPrint('🔐 [权限校验] ✅ 校验通过 - code=200, data=true');
return true;
}
debugPrint('🔐 [权限校验] ❌ 校验未通过 - codeMatch=$codeMatch, dataMatch=$dataMatch');
return false;
} on DioException catch (e) {
// 网络异常(断网、超时、服务器不可达等)
debugPrint('🔐 [权限校验] ❌ DioException: ${e.type} - ${e.message}');
return false;
} catch (e) {
// 任何未知异常
debugPrint('🔐 [权限校验] ❌ 未知异常: $e');
return false;
}
}
}

View File

@@ -12,19 +12,22 @@ class UserModel extends UserEntity implements BaseModel {
super.avatar,
super.email,
super.phone,
super.roleKey,
super.siteId,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
print(json);
return UserModel(
userId: json['userId'],
username: json['username'],
nickname: json['nickName'],
token: json['token'],
orgId: json['orgId'] ?? 0, // 从登录响应中获取 orgId
userId: json['userId']?.toString() ?? '',
username: json['username'] ?? '',
nickname: json['nickName'] ?? '',
token: json['token'] ?? '',
orgId: (json['orgId'] as num?)?.toInt() ?? 0,
avatar: json['avatar'],
email: json['email'],
phone: json['phone'],
roleKey: json['roleKey'],
siteId: (json['siteId'] as num?)?.toInt(),
);
}
@@ -38,6 +41,8 @@ class UserModel extends UserEntity implements BaseModel {
'avatar': avatar,
'email': email,
'phone': phone,
'roleKey': roleKey,
'siteId': siteId,
};
}
@@ -51,6 +56,8 @@ class UserModel extends UserEntity implements BaseModel {
avatar: avatar,
email: email,
phone: phone,
roleKey: roleKey,
siteId: siteId,
);
}
@@ -64,6 +71,8 @@ class UserModel extends UserEntity implements BaseModel {
avatar: entity.avatar,
email: entity.email,
phone: entity.phone,
roleKey: entity.roleKey,
siteId: entity.siteId,
);
}
}
}

View File

@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
@@ -20,6 +23,11 @@ import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../../../devices/presentation/bloc/device_status_bloc.dart';
import '../../../devices/presentation/bloc/device_status_event.dart';
import '../../../devices/presentation/bloc/devices_state.dart';
import '../../../devices/presentation/bloc/device_task_cubit.dart';
import '../../../home/presentation/bloc/permission_request_bloc.dart';
import '../../../my/presentation/bloc/my_cubit.dart';
import '../../../remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../../../core/network/mqtt/domain/interfaces/mqtt_client.dart';
import '../../../../features/v2/site/presentation/cubit/site_cubit.dart';
import '../../data/datasources/auth_tcp_datasource.dart';
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
@@ -39,7 +47,7 @@ class AuthCubit extends Cubit<AuthState> {
final ILoggerService _logger = GetIt.I<ILoggerService>();
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
// 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送
Completer<bool>? _loginVerificationCompleter;
@@ -56,11 +64,57 @@ class AuthCubit extends Cubit<AuthState> {
) : super(AuthInitial()) {
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
_listenToAuthResponse();
// 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录
tcp.onReconnectExhausted = _showReconnectFailedDialog;
}
/// App 启动时检查本地缓存
Future<void> appStarted() async {
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
try {
final prefs = GetIt.I<SharedPreferences>();
// Step 1: 先生成新的 session_id,保存旧的
final oldSessionId = prefs.getString('current_session_id');
final newSessionId = DateTime.now().millisecondsSinceEpoch.toString();
await prefs.setString('current_session_id', newSessionId);
debugPrint('📱 [AUTH] 新会话: $newSessionId,旧会话: $oldSessionId');
// Step 2: 检查是否从后台被杀
final pendingKillLogout = prefs.getBool('pending_kill_logout') ?? false;
final savedSessionId = prefs.getString('saved_session_id');
if (pendingKillLogout && savedSessionId != null && oldSessionId != null) {
if (savedSessionId == oldSessionId) {
// saved == old_current → App 在后台被杀,从未恢复过
logger.logWithLevel('🔄 [AUTH] 检测到 APP 被后台杀死,执行退出登录', level: 'INFO');
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
await storage.deleteUser();
emit(AuthUnauthenticated());
return;
} else {
// saved != old_current → App 被杀前已恢复过,清除标记
logger.logWithLevel(
'📱 [AUTH] pending_kill_logout=true 但会话已恢复过,清除标记',
level: 'INFO',
);
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
}
} else if (pendingKillLogout) {
logger.logWithLevel(
'📱 [AUTH] pending_kill_logout=true 但无会话信息,清除标记',
level: 'INFO',
);
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
}
} catch (e) {
logger.logWithLevel('❌ [AUTH] 检查杀后台标记失败: $e', level: 'ERROR');
}
try {
final user = await storage.getUser();
logger.logWithLevel(
@@ -70,14 +124,21 @@ class AuthCubit extends Cubit<AuthState> {
);
if (user != null) {
// 🔥 冷启动时不创建TCP连接,改为用户选择设备时再连接
// 避免发送 0x03 触发服务端残留 session 的 have_logged_in
// 2. 同步全局 App 状态
appCubit.setAuth(user);
// 3. 进入已登录状态
emit(AuthAuthenticated(user));
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
final isValid = await _verifyToken(user);
if (isValid) {
logger.logWithLevel('✅ [AUTH] Token 有效,恢复登录状态', level: 'INFO');
appCubit.setAuth(user);
emit(AuthAuthenticated(user));
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
} else {
logger.logWithLevel('⚠️ [AUTH] Token 已过期,清除本地缓存', level: 'WARN');
await storage.deleteUser();
emit(AuthUnauthenticated());
logger.logWithLevel(
'⚠️ [AUTH] 应用启动 - Token 过期,进入未登录状态',
level: 'INFO',
);
}
} else {
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
emit(AuthUnauthenticated());
@@ -88,6 +149,42 @@ class AuthCubit extends Cubit<AuthState> {
}
}
Future<bool> _verifyToken(UserEntity user) async {
try {
final verifyDio = Dio(
BaseOptions(
baseUrl: HttpApiConsts.baseUrl,
headers: {'Authorization': 'Bearer ${user.token}'},
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
),
);
final response = await verifyDio.get(
HttpApiConsts.getUserDevicesList,
queryParameters: {'tenantName': user.username},
);
if (response.statusCode == 200) {
final data = response.data;
if (data is Map<String, dynamic> && data['code'] == 401) {
return false;
}
return true;
}
return false;
} on DioException catch (e) {
final statusCode = e.response?.statusCode;
debugPrint('>>> [AUTH] Token 验证失败: HTTP $statusCode');
if (statusCode == 401 || statusCode == 403) {
return false;
}
return true;
} catch (e) {
debugPrint('>>> [AUTH] Token 验证异常: $e');
return true;
}
}
/// 当 HTTP 登录/注册成功后调用
Future<void> loginSuccess(UserEntity user) async {
await storage.saveUser(user);
@@ -96,33 +193,33 @@ class AuthCubit extends Cubit<AuthState> {
try {
debugPrint('>>> [AUTH] 登录成功,开始建立TCP连接...');
_logger.logWithLevel('>>> [AUTH] 登录成功,开始建立TCP连接...', shouldLog: true);
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
tcp.startHeartbeat(interval: const Duration(seconds: 4));
// 🔥 关键:等待 2.5 秒,看是否收到 have_logged_in
bool isKicked = await _waitForLoginVerification();
if (isKicked) {
// 🔥 不能进入 APP,必须清除所有登录信息
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息...');
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息', shouldLog: true);
// 1. 删除已保存的用户信息
await storage.deleteUser();
debugPrint('✅ [AUTH] 已删除用户信息');
// 2. 断开 TCP 连接
tcp.forceDisconnect();
debugPrint('✅ [AUTH] 已断开 TCP 连接');
// 3. 显示 Toast
_showLoginFailedToast("账号已在其他设备登录");
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页');
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页', shouldLog: true);
return; // 停留在登录页
return; // 停留在登录页
}
debugPrint('✅ [AUTH] TCP连接成功,验证通过');
_logger.logWithLevel('✅ [AUTH] TCP连接成功,验证通过', shouldLog: true);
} catch (e) {
@@ -149,6 +246,42 @@ class AuthCubit extends Cubit<AuthState> {
emit(AuthUnauthenticated());
}
/// 🔥 Token 过期处理:弹出提示后退出登录
Future<void> tokenExpired() async {
_showTokenExpiredDialog();
Future.delayed(const Duration(seconds: 2), () {
logout();
});
}
void _showTokenExpiredDialog() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('登录已过期'),
content: const Text('账号登录状态已过期,请重新登录'),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
},
child: const Text('确定'),
),
],
);
},
);
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示 Token 过期弹窗失败:$e');
}
}
/// 🔥 新增:显示登录失败 Toast(黑色背景,和登录错误提示一致)
void _showLoginFailedToast(String message) {
try {
@@ -173,7 +306,7 @@ class AuthCubit extends Cubit<AuthState> {
try {
debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗');
_logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true);
// 使用全局 navigatorKey 显示弹窗
final context = navigatorKey.currentContext;
if (context != null) {
@@ -190,9 +323,12 @@ class AuthCubit extends Cubit<AuthState> {
);
} else {
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出');
_logger.logWithLevel('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出', shouldLog: true);
_logger.logWithLevel(
'>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出',
shouldLog: true,
);
}
// 🔥 延迟 2 秒后执行退出,给用户时间看到提示
Future.delayed(const Duration(seconds: 2), () {
debugPrint('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录');
@@ -207,27 +343,142 @@ class AuthCubit extends Cubit<AuthState> {
}
}
/// 🔥 TCP 重连耗尽弹窗:连续4次重连失败后提示用户退出登录
void _showReconnectFailedDialog() {
try {
debugPrint('>>> [AUTH] 📢 重连耗尽,准备显示重连失败提示弹窗');
_logger.logWithLevel('>>> [AUTH] 📢 重连耗尽,显示重连失败弹窗', shouldLog: true);
final context = navigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off_rounded, size: 48, color: Color(0xFFF53F3F)),
const SizedBox(height: 12),
const Text(
'连接异常',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 8),
const Text(
'TCP重连认证无效请重新登陆',
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
textAlign: TextAlign.center,
),
],
),
actions: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
logout();
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('退出登录', style: TextStyle(fontSize: 15)),
),
),
],
);
},
);
} else {
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,直接退出登录');
logout();
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示重连失败弹窗失败:$e');
logout();
}
}
/// 🔥 新增:清空所有业务状态,防止数据泄露到新账户
void _clearAllBusinessState() {
try {
final devicesCubit = GetIt.I<DevicesCubit>();
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
final siteCubit = GetIt.I<SiteCubit>();
// 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等)
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
remoteControlCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态');
// 1. 清空设备列表和选中设备
// 2. 清空设备列表和选中设备
final devicesCubit = GetIt.I<DevicesCubit>();
devicesCubit.emit(const DevicesState());
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
// 2. 清空设备实时状态
// 3. 清空设备实时状态(图表数据等)
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
deviceStatusBloc.add(DeviceStatusReset());
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
_logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
// 3. 清空全局选中的场站
siteCubit.clearSelectedSite();
debugPrint('✅ [AUTH] 已清空 SiteCubit 选中状态');
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 选中状态');
// 4. 清空场站所有数据(列表、选中状态、_hasLoadedSites 标记)
final siteCubit = GetIt.I<SiteCubit>();
siteCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 SiteCubit 所有数据');
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 所有数据');
// 5. 清空设备任务(taskPool、currentTask、currentTaskId 等)
final deviceTaskCubit = GetIt.I<DeviceTaskCubit>();
deviceTaskCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
// 6. 清空权限请求弹窗状态
final permissionRequestBloc = GetIt.I<PermissionRequestBloc>();
permissionRequestBloc.clearAll();
debugPrint('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
// 7. 清空我的页面数据(昵称等个人信息)
final myCubit = GetIt.I<MyCubit>();
myCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 MyCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 MyCubit 状态');
// 8. 断开 MQTT 连接(避免新用户收到上个用户的实时推送)
try {
final droneOsdClient = GetIt.I<MqttClient>(instanceName: 'droneOsdClient');
if (droneOsdClient.isConnected) {
droneOsdClient.disconnect();
debugPrint('✅ [AUTH] 已断开 droneOsdClient MQTT');
}
} catch (_) {}
try {
final taskMessageClient = GetIt.I<MqttClient>(instanceName: 'taskMessageClient');
if (taskMessageClient.isConnected) {
taskMessageClient.disconnect();
debugPrint('✅ [AUTH] 已断开 taskMessageClient MQTT');
}
} catch (_) {}
// 9. 清除 SharedPreferences 会话相关 key
final prefs = GetIt.I<SharedPreferences>();
prefs.remove('current_session_id');
prefs.remove('pending_kill_logout');
prefs.remove('saved_session_id');
debugPrint('✅ [AUTH] 已清除 SharedPreferences 会话 key');
debugPrint('✅ [AUTH] 所有业务状态已清空');
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
@@ -240,15 +491,15 @@ class AuthCubit extends Cubit<AuthState> {
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
Future<bool> _waitForLoginVerification() async {
_loginVerificationCompleter = Completer<bool>();
// 等待 2.5 秒
await Future.delayed(const Duration(milliseconds: 2500));
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
if (!_loginVerificationCompleter!.isCompleted) {
_loginVerificationCompleter!.complete(false);
}
return _loginVerificationCompleter!.future;
}
@@ -290,35 +541,46 @@ class AuthCubit extends Cubit<AuthState> {
if (respond == 'have_logged_in') {
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
if (_loginVerificationCompleter != null && !_loginVerificationCompleter!.isCompleted) {
debugPrint('>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP');
if (_loginVerificationCompleter != null &&
!_loginVerificationCompleter!.isCompleted) {
debugPrint(
'>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP',
);
_logger.logWithLevel(
'🛡️ [AUTH] 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入',
shouldLog: true,
);
_loginVerificationCompleter!.complete(false); // 标记为可以进入
_loginVerificationCompleter!.complete(false); // 标记为可以进入
return;
}
// 🔥 已登录阶段策略:检查是否处于安全模式
if (_isSafeMode) {
print('>>> [AUTH] 🛡️ 安全模式下拦截 have_logged_in 推送,防止控制中断');
_logger.logWithLevel(
'🛡️ [AUTH] 安全模式下拦截异地登录推送',
level: 'WARN',
);
_logger.logWithLevel('🛡️ [AUTH] 安全模式下拦截异地登录推送', level: 'WARN');
return; // 拦截退出逻辑
}
print('>>> [AUTH] ⚠️ 已登录状态下收到 TCP 0x12 指令 respond=have_logged_in');
debugPrint('>>> [AUTH] ℹ️ 暂时忽略该推送,观察是否影响业务操作...');
_logger.logWithLevel(
'⚠️ [AUTH] 已登录状态收到 have_logged_in,暂不处理,观察业务影响',
'⚠️ [AUTH] 已登录状态收到 have_logged_in,弹出异地登录提示',
level: 'WARN',
);
// 如果你希望依然保持严格的安全策略,可以取消下面注释恢复退出逻辑:
// _showKickOutDialog();
// 🔥 关键:检查是否是自身 TCP 重连触发的 have_logged_in
// 如果是自身刚发送 0x03 认证包引起的,忽略这次推送
if (tcp.isOwnAuthTriggeredKick()) {
debugPrint('>>> [AUTH] 🛡️ 检测到是自身认证触发的 have_logged_in,忽略');
_logger.logWithLevel(
'🛡️ [AUTH] 自身认证触发的 have_logged_in,忽略',
level: 'INFO',
);
tcp.clearAuthTimestamp();
return;
}
// 🔥 弹出"账号被顶下线"提示,2秒后执行退出登录
_showKickOutDialog();
} else {
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
}

View File

@@ -28,15 +28,16 @@ class LoginCubit extends Cubit<LoginState> {
// if (user != null) {
// devicesCubit.fetchAllDevices(user.username);
// }
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
result.fold((failure) => emit(LoginFailure(failure.message)), (user) async {
// 先更新全局用户状态(确保首页能获取到 token)
authCubit.appCubit.setAuth(user);
// 发出登录成功状态(触发页面跳转)
emit(LoginSuccess(user));
// 后台异步执行 TCP 连接等初始化操作(不阻塞登录流程)
authCubit.loginSuccess(user).catchError((e) {
// 🔥 先等待 TCP 连接和认证完成,确保 AuthCubit 变为 AuthAuthenticated
// 再触发页面导航,避免 GoRouter 拦截踢回登录页
await authCubit.loginSuccess(user).catchError((e) {
print('TCP 初始化失败: $e');
});
// 发出登录成功状态(触发页面跳转)
emit(LoginSuccess(user));
});
} catch (e) {
emit(LoginFailure(e.toString()));

View File

@@ -52,9 +52,11 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
}) async {
try {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/deviceTaskPool';
final response = await dio.get(
_logger.logWithLevel('[getDeviceTaskPool] 请求: POST $url');
_logger.logWithLevel('[getDeviceTaskPool] 参数: userId=$userId, siteId=$siteId, orgId=$orgId');
final response = await dio.post(
url,
queryParameters: {
data: {
'userId': userId,
'siteId': siteId,
'orgId': orgId,
@@ -89,30 +91,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int orgId,
required int siteId,
}) async {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
final body = {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
};
_logger.logWithLevel('[cancelTask] 请求: POST $url');
_logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}');
try {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
final response = await dio.post(
url,
data: {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
},
);
final response = await dio.post(url, data: body);
_logger.logWithLevel('[cancelTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data['code'] == 200) {
_logger.logWithLevel('[cancelTask] 结果: 成功');
return data['data'] as bool? ?? false;
} else {
_logger.logWithLevel('[cancelTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
throw Exception(data['msg'] ?? '取消任务失败');
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
_logger.logWithLevel('❌ 取消任务失败: $e');
_logger.logWithLevel('[cancelTask] 异常: $e');
rethrow;
}
}
@@ -124,30 +129,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int orgId,
required int siteId,
}) async {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
final body = {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
};
_logger.logWithLevel('[pauseTask] 请求: POST $url');
_logger.logWithLevel('[pauseTask] 参数: ${jsonEncode(body)}');
try {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
final response = await dio.post(
url,
data: {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
},
);
final response = await dio.post(url, data: body);
_logger.logWithLevel('[pauseTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data['code'] == 200) {
_logger.logWithLevel('[pauseTask] 结果: 成功');
return data['data'] as bool? ?? false;
} else {
_logger.logWithLevel('[pauseTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
throw Exception(data['msg'] ?? '暂停任务失败');
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
_logger.logWithLevel('❌ 暂停任务失败: $e');
_logger.logWithLevel('[pauseTask] 异常: $e');
rethrow;
}
}
@@ -159,30 +167,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int orgId,
required int siteId,
}) async {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
final body = {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
};
_logger.logWithLevel('[recoveryTask] 请求: POST $url');
_logger.logWithLevel('[recoveryTask] 参数: ${jsonEncode(body)}');
try {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
final response = await dio.post(
url,
data: {
'deviceId': deviceId,
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
},
);
final response = await dio.post(url, data: body);
_logger.logWithLevel('[recoveryTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
if (data['code'] == 200) {
_logger.logWithLevel('[recoveryTask] 结果: 成功, data=${jsonEncode(data['data'])}');
return data['data'] as Map<String, dynamic>? ?? {};
} else {
_logger.logWithLevel('[recoveryTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
throw Exception(data['msg'] ?? '恢复任务失败');
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
_logger.logWithLevel('❌ 恢复任务失败: $e');
_logger.logWithLevel('[recoveryTask] 异常: $e');
rethrow;
}
}

View File

@@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dar
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/task_message_repository.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_arrive_entity.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_status_entity.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/network/net_message_dispatcher.dart';
@@ -27,6 +28,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 保存订阅引用,用于管理生命周期
StreamSubscription? _tcpSubscription;
StreamSubscription? _mqttArriveSubscription;
StreamSubscription? _mqttStatusSubscription;
// 🔥 节流相关:500ms节流控制0x02数据推送频率
Timer? _throttleTimer;
@@ -34,6 +36,9 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
RunningStatusEntity? _cachedStatus;
GPSEntity? _cachedGps;
// 🔥 调试计数器:跟踪0x02收包序号,排查断断续续问题
int _packetSeq = 0;
// 🔥 当前监听的设备ID
String? _currentDeviceId;
@@ -46,6 +51,9 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 初始化MQTT到达点监听
_initMqttArriveListener();
// 🔥 初始化MQTT任务状态监听(接收完成推送)
_initMqttStatusListener();
// 保留事件处理(用于手动重置等场景)
on<DeviceStatusReset>(_handleReset);
on<DeviceStatusLoaded>(_handleDeviceStatusLoaded);
@@ -89,23 +97,27 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 关键修复:直接监听 tcpClient.packetStream,不经过 dispatcher 的 filtered stream
// 这样即使没有其他监听者,TCP流也不会暂停
_tcpSubscription = tcpClient.packetStream
.where((p) => p.command == 0x02)
.where((p) {
final is02 = p.command == 0x02;
if (is02) {
_packetSeq++;
debugPrint('📥 [0x02] #$_packetSeq 收到原始TCP包 | payload长度=${p.payload.length} | ${DateTime.now().toString().substring(11, 19)}');
}
return is02;
})
.map((p) {
try {
final result = utf8.decode(p.payload, allowMalformed: true);
// debugPrint('✅ [DeviceStatusBloc] 收到0x02数据: $result');
return result;
} catch (e) {
// debugPrint('❌ [DeviceStatusBloc] 解码失败: $e');
debugPrint('❌ [0x02] #$_packetSeq 解码失败: $e, payload前20字节=${p.payload.take(20).toList()}');
return '';
}
})
.listen(
(message) {
//debugPrint('📩 [DeviceStatusBloc] 直接收到0x02数据,长度:${message.length}');
if (message.isEmpty) {
//debugPrint('⚠️ [DeviceStatusBloc] 消息为空,跳过');
debugPrint('⚠️ [0x02] #$_packetSeq 解码后为空,跳过');
return;
}
@@ -114,7 +126,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final fields = message.trim().split(',');
if (fields.length < 18) {
//debugPrint('⚠️ [DeviceStatusBloc] 字段不足:${fields.length},期望≥18');
debugPrint('⚠️ [0x02] #$_packetSeq 字段不足:${fields.length},期望≥18, 原始数据前100字符=${message.substring(0, message.length > 100 ? 100 : message.length)}');
// 🔥 错误不节流,立即emit以便UI显示错误
if (!isClosed) {
emit(DeviceStatusError('字段不足,期望≥18,实际:${fields.length}'));
@@ -125,6 +137,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final status = RunningStatusEntity.fromFields(fields);
final gps = GPSEntity(status.latitude, status.longitude);
debugPrint('✅ [0x02] #$_packetSeq 解析成功 | 字段数=${fields.length} | 电压=${status.voltage}V 电量=${status.battery}% 控制模式=${status.controlMode} | 节流等待${_throttleDuration.inMilliseconds}ms');
// 🔥 缓存最新数据用于节流发射
_cachedStatus = RunningStatusEntity(
voltage: status.voltage,
@@ -154,13 +168,14 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
);
_cachedGps = GPSEntity(status.latitude, status.longitude);
// 🔥 节流:取消之前的timer,重新计时500ms
_throttleTimer?.cancel();
_throttleTimer = Timer(_throttleDuration, () {
_emitCachedStatus();
});
// 🔥 节流:如果定时器已在运行,只更新缓存不重置;否则启动新的500ms节流周期
if (_throttleTimer == null || !_throttleTimer!.isActive) {
_throttleTimer = Timer(_throttleDuration, () {
_emitCachedStatus();
});
}
} catch (e, stack) {
//debugPrint('❌ [DeviceStatusBloc] 直接解析异常:$e\n$stack');
debugPrint('❌ [0x02] #$_packetSeq 解析异常:$e');
// _logger.log('❌ [DeviceStatusBloc] 直接解析异常:$e');
// 🔥 解析错误不节流,立即emit以便UI显示错误
if (!isClosed) {
@@ -225,6 +240,60 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_logger.log('✅ [DeviceStatusBloc] MQTT到达点监听器已建立完成');
}
// 🔥 初始化MQTT任务状态监听(接收 task/+/status 完成推送)
void _initMqttStatusListener() {
debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
_logger.log('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
_mqttStatusSubscription = _taskMessageRepo.taskStatusStream.listen(
(TaskStatusEntity status) {
debugPrint(
'📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
);
_logger.log(
'📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
);
// 检查设备ID是否匹配当前监听的设备
if (_currentDeviceId != null && status.deviceId != _currentDeviceId) {
debugPrint(
'⚠️ [DeviceStatusBloc] 任务状态设备ID不匹配,跳过 - 当前:$_currentDeviceId, 收到:${status.deviceId}',
);
return;
}
// 🔥 状态为 FINISH 表示任务完成
if (status.status == 'FINISH') {
debugPrint('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
_logger.log('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
final devicesCubit = GetIt.I<DevicesCubit>();
devicesCubit.finishWork();
// 🔥 无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
if (!isClosed) {
emit(DeviceStatusUpdated(
_cachedStatus ?? RunningStatusEntity(),
_cachedGps ?? GPSEntity(0.0, 0.0),
));
debugPrint('📤 [DeviceStatusBloc] 已 emit 完成信号,触发 UI 更新');
}
Future.delayed(Duration(seconds: 1), () {
devicesCubit.resetWorkStatus();
});
}
},
onError: (e) {
debugPrint('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
_logger.log('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
},
);
debugPrint('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
_logger.log('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
}
// 🔥 设置当前监听的设备ID(用于过滤MQTT消息)
void setListeningDeviceId(String deviceId) {
debugPrint('📱 [DeviceStatusBloc] 设置监听设备ID: $deviceId');
@@ -235,7 +304,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 节流发射:500ms到期后发射缓存的最新数据
void _emitCachedStatus() {
if (_cachedStatus != null && _cachedGps != null && !isClosed) {
// debugPrint('📤 [DeviceStatusBloc] 🔥节流发射 - 电压:${_cachedStatus!.voltage}, 电量:${_cachedStatus!.battery}');
debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}');
emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!));
}
}
@@ -327,6 +396,16 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final devicesCubit = GetIt.I<DevicesCubit>();
devicesCubit.finishWork();
// 🔥 关键:无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
if (!isClosed) {
emit(DeviceStatusUpdated(
_cachedStatus ?? RunningStatusEntity(),
_cachedGps ?? GPSEntity(0.0, 0.0),
));
debugPrint('📤 [DeviceStatusBloc] 已 emit 停止信号,触发 UI 更新');
}
Future.delayed(Duration(seconds: 1), () {
devicesCubit.resetWorkStatus();
});
@@ -341,6 +420,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 清理MQTT订阅
_mqttArriveSubscription?.cancel();
_mqttArriveSubscription = null;
_mqttStatusSubscription?.cancel();
_mqttStatusSubscription = null;
// 🔥 清理节流timer和缓存
_throttleTimer?.cancel();

View File

@@ -96,7 +96,7 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
taskPool: taskList,
currentTask: currentTask,
currentTaskId: currentTask?.id,
activeTasks: activeTasks, // 🔥 保存所有活跃任务列表
activeTasks: activeTasks,
));
},
);
@@ -340,4 +340,12 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
));
_logger.logWithLevel('🧹 清除当前任务');
}
/// 🔥 退出登录时清空所有状态
void clearAll() {
if (!isClosed) {
emit(const DeviceTaskState());
}
_logger.logWithLevel('🧹 [DeviceTaskCubit] clearAll - 所有状态已重置');
}
}

View File

@@ -665,13 +665,14 @@ class DevicesCubit extends Cubit<DevicesState> {
/// 🔥 启动MQTT到达点监听(用于路径规划动画)
/// [deviceId] - 目标设备ID,即targetDevice的deviceId
Future<void> startListeningMqttArrive({required String deviceId}) async {
debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId');
_logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId');
/// [taskId] - 任务ID,用于订阅 task/{taskId}/status 和 task/{taskId}/arrive
Future<void> startListeningMqttArrive({required String deviceId, required int taskId}) async {
debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId, taskId: $taskId');
_logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId, taskId: $taskId');
try {
// 启动MQTT订阅
await _taskMessageRepo.startListening(deviceId: deviceId);
await _taskMessageRepo.startListening(deviceId: deviceId, taskId: taskId);
// 设置DeviceStatusBloc监听的设备ID
_deviceStatusBloc.setListeningDeviceId(deviceId);

View File

@@ -28,6 +28,7 @@ class PermissionRequestBloc
// 事件处理
on<PermissionRequestReceived>(_handleRequestReceived);
on<PermissionDialogDismissed>(_handleDialogDismissed);
on<PermissionClearAll>((event, emit) => emit(const PermissionRequestInitial()));
}
// 🔥 通过 NetMessageDispatcher 监听 TCP 0x12 指令,解析权限请求
@@ -116,4 +117,11 @@ class PermissionRequestBloc
_permissionSubscription?.cancel();
_initPermissionListener();
}
/// 🔥 退出登录时清空所有状态
void clearAll() {
if (!isClosed) {
add(const PermissionClearAll());
}
}
}

View File

@@ -32,3 +32,8 @@ class PermissionDialogDismissed extends PermissionRequestEvent {
@override
List<Object?> get props => [agree, deviceId];
}
/// 🔥 退出登录时清空所有状态
class PermissionClearAll extends PermissionRequestEvent {
const PermissionClearAll();
}

View File

@@ -38,7 +38,7 @@ class _RoutePlanPageState extends State<RoutePlanPage> {
final remoteControlState = context.watch<RemoteControlCubit>().state;
final targetDevice = remoteControlState.targetDevice;
debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}');
// debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}');
// 检查是否有选中的设备
if (targetDevice == null) {

View File

@@ -23,8 +23,8 @@ import '../../../devices/presentation/bloc/device_status_bloc.dart';
import '../../../devices/presentation/bloc/device_status_event.dart';
import '../../../devices/presentation/bloc/device_status_state.dart';
// 配置:数据超时时间(5秒)
const int DATA_TIMEOUT_SECONDS = 5;
// 配置:数据超时时间(15秒)
const int DATA_TIMEOUT_SECONDS = 15;
class RunningStatusPage extends StatefulWidget {
const RunningStatusPage({super.key});
@@ -483,7 +483,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
// ====================== 图表视图 ======================
Widget _buildChartContentView(DeviceStatusState state) {
// 修改1:超时/无数据时显示暂无数据,而非loading
if (_isDataTimeout || state is DeviceStatusInitial) {
if (state is DeviceStatusInitial) {
return _noDataWidget();
}
@@ -710,7 +710,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
// ====================== 卡片视图 ======================
Widget _buildCardContentView(DeviceStatusState state) {
// 修改2:卡片视图同样替换loading为暂无数据
if (_isDataTimeout || state is DeviceStatusInitial) {
if (state is DeviceStatusInitial) {
return _noDataWidget();
}
@@ -904,7 +904,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
String satelliteCnt = _isDataTimeout ? '--' : '--';
String headingStatus = _isDataTimeout ? "--" : "--";
if (!_isDataTimeout && state is DeviceStatusUpdated) {
if (state is DeviceStatusUpdated) {
headingStatus = state.status.headingStatus == 0 ? AppLocalizations.of(context).translate('running_status.not_initialized') : AppLocalizations.of(context).translate('running_status.initialized');
int qualValue = 0;
try {
@@ -917,9 +917,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
// 收到新数据,重置超时计时器和状态
_startDataTimeoutTimer();
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
_isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState
} else if (!_isDataTimeout && state is DeviceStatusError) {
qual = '-';
satelliteCnt = '-';
@@ -1017,7 +1015,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
});
}
if (!_isDataTimeout && state is DeviceStatusUpdated) {
if (state is DeviceStatusUpdated) {
debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据');
// 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发
if (_hasSeededFromCache) {
@@ -1028,10 +1026,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
}
// 🔥 关键:收到数据后立即重置超时计时器
_startDataTimeoutTimer();
// 如果之前是超时状态,现在恢复
if (_isDataTimeout) {
setState(() => _isDataTimeout = false);
}
_isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState
}
return Container(margin: const EdgeInsets.all(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state));
},

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:maibu_satabot_v2/components/tcp_status_indicator.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart';

View File

@@ -80,6 +80,7 @@ const String kSavedTPMode = "saved_tp_mode";
const String kSavedCurrentRobotMode = "kSavedCurrentRobotMode";
const String kSavedIsPanelOpen = "kSavedIsPanelOpen";
const String kSavedCurrentWorkMode = "kSavedCurrentWorkMode";
const String kSavedDeviceTaskIds = "saved_device_task_ids"; // {deviceId: taskId} Map
// 保持 PlotData 类不变
class PlotData {
@@ -246,7 +247,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
});
// 🔥 关键修复:开始监听路径规划指令应答
_setupPathPlanningListener();
// 🔥 已禁用:新流程使用 HTTP/MQTT 管理任务,不再需要 TCP 路径规划指令应答监听
// 保留 TCP 监听会导致机器正常 TCP 0x01 响应触发 finishWork(),错误地将作业状态重置为 idle
// _setupPathPlanningListener();
// 监听地图移动事件,实时更新连线
_mapController.mapEventStream.listen((event) {
@@ -309,6 +312,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
await prefs.remove(kSavedSelectedPlot);
await prefs.remove(kSavedIsPanelOpen);
await prefs.remove(kSavedCurrentWorkMode);
await prefs.remove(kSavedDeviceTaskIds); // 🔥 清除 taskId 持久化数据
} catch (e) {
debugPrint('清空本地数据失败:$e');
}
@@ -595,11 +599,69 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
} else {
prefs.remove(kSavedSelectedPlot);
}
// 🔥 5. 持久化 taskId(按 deviceId 隔离)
try {
final taskCubit = sl<DeviceTaskCubit>();
final taskId = taskCubit.state.currentTaskId;
final deviceId = context.read<RemoteControlCubit>().state.targetDevice?.deviceName;
if (taskId != null && deviceId != null) {
final existingJson = prefs.getString(kSavedDeviceTaskIds);
Map<String, dynamic> taskIdMap = {};
if (existingJson != null) {
taskIdMap = jsonDecode(existingJson) as Map<String, dynamic>;
}
taskIdMap[deviceId] = taskId;
prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
debugPrint('💾 [持久化] taskId 已保存: deviceId=$deviceId, taskId=$taskId');
} else {
debugPrint('💾 [持久化] 跳过: taskId=$taskId, deviceId=$deviceId');
}
} catch (e) {
debugPrint('💾 [持久化] taskId 保存失败: $e');
}
} catch (e) {
debugPrint('保存本地数据失败:$e');
}
}
/// 🔥 从 SharedPreferences 恢复指定设备的 taskId
Future<int?> _restoreTaskIdFromLocal(String deviceId) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(kSavedDeviceTaskIds);
if (json != null) {
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
final taskId = taskIdMap[deviceId];
if (taskId is int) return taskId;
if (taskId is String) return int.tryParse(taskId);
}
} catch (e) {
debugPrint('📥 [恢复] taskId 恢复失败: $e');
}
return null;
}
/// 🔥 从 SharedPreferences 清除指定设备的 taskId
Future<void> _clearTaskIdFromLocal(String deviceId) async {
try {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString(kSavedDeviceTaskIds);
if (json != null) {
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
taskIdMap.remove(deviceId);
if (taskIdMap.isEmpty) {
await prefs.remove(kSavedDeviceTaskIds);
} else {
await prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
}
debugPrint('🧹 [清除] taskId 已从本地移除: deviceId=$deviceId');
}
} catch (e) {
debugPrint('🧹 [清除] taskId 清除失败: $e');
}
}
// ========== 新增:计算坐标列表的边界范围 ==========
LatLngBounds? calculateBounds(List<LatLng> points) {
if (points.isEmpty) return null;
@@ -1823,8 +1885,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
_traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE);
tracePoint = _traceManager.getTracePoint();
gctracePoint = batchWgs84ToGcj02(tracePoint!);
_logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
_logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
// _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
// _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
//_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新
if (_isValidLatLng(gcjPoint.latitude, gcjPoint.longitude)) {
@@ -2230,7 +2292,12 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('设备号: ${task.deviceId}'),
Text(
'设备号: ${task.deviceId}',
softWrap: true,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
Row(
children: [
Container(
@@ -2323,7 +2390,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
// 🔥 6. 先过滤出活跃任务,让用户选择
debugPrint('🔍 [开始作业] 正在查询活跃任务...');
debugPrint('══════════ [开始作业] 开始 ══════════');
debugPrint('🔍 [开始作业] 步骤0: 查询活跃任务, deviceId=$deviceId');
final taskCubit = sl<DeviceTaskCubit>();
await taskCubit.fetchAndFilterTask(deviceId);
@@ -2345,9 +2413,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
taskCubit.selectTask(selectedTask);
} else if (activeTasks.length == 1) {
selectedTask = activeTasks.first;
taskCubit.selectTask(selectedTask); // 🔥 单任务也要存入 cubit
debugPrint('✅ [开始作业] 自动选择唯一任务 #${selectedTask.id}');
} else {
debugPrint('ℹ️ [开始作业] 无活跃任务,将直接创建新任务');
// 🔥 无活跃任务:先清除旧的本地 taskId,再创建新任务
debugPrint('ℹ️ [开始作业] 无活跃任务,清除旧 taskId 后创建新任务');
await _clearTaskIdFromLocal(deviceId);
taskCubit.clearCurrentTask();
}
debugPrint(
@@ -2362,28 +2434,31 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
debugPrint(' ├─ orgId: $orgId');
debugPrint(' └─ taskId: ${selectedTask?.id ?? "(无,将新建)"}');
// 8. 更新UI状态
setState(() {
isStopWork = false;
isStartWork = true;
_workStatus = WorkStatus.working;
_traceManager.reset();
tracePoint?.clear();
gctracePoint?.clear();
});
// 9. 更新应用状态
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
// 8. 重置轨迹 + 切换到定位模式(关键:防止飞过去的路径被画出来)
debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到定位模式');
_traceManager.reset();
_traceManager.setMode(TPMode.LOCATION);
tracePoint?.clear();
gctracePoint?.clear();
// 10. 如果池子里已有活跃任务,直接使用;否则创建新任务
debugPrint('🔀 [开始作业] 步骤2: 确认任务来源');
if (selectedTask != null) {
// 🔥 验证 taskId 是否有效
debugPrint('📋 [开始作业] 步骤2: 使用已有任务 #${selectedTask.id}');
if (selectedTask.id == null) {
debugPrint('⚠️ [开始作业] 活跃任务无有效 taskId,无法控制');
_showPageToast(message: "已有任务在其他平台执行,暂不可控制", type: ToastType.warn);
return;
}
// 🔥 池子里已有任务,直接使用,不需要再创建
debugPrint('✅ [开始作业] 使用已有任务 #${selectedTask.id},跳过创建');
debugPrint('✅ [开始作业] 步骤2完成: 使用已有任务 #${selectedTask.id},跳过创建');
// taskId 已由 selectTask 存入 cubit
} else {
// 🔥 池子里没有,创建新任务
debugPrint('🆕 [开始作业] 池子为空,创建新任务...');
debugPrint('🆕 [开始作业] 步骤2a: 池子为空,创建新任务...');
try {
debugPrint('📤 [开始作业] 调用 createDeviceTask API, deviceId=$deviceId, routeId=$routeId');
final result = await sl<CreateDeviceTaskUseCase>().execute(
deviceId: deviceId,
routeId: routeId,
@@ -2392,50 +2467,80 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
);
var needRequery = false;
var failed = false;
result.fold(
(failure) {
(failure) {
final failMsg = failure.toString();
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
needRequery = true;
return;
}
failed = true;
debugPrint('[开始作业] 创建失败: $failMsg');
_showPageToast(message: '作业启动失败', type: ToastType.error);
taskCubit.clearCurrentTask();
return;
debugPrint('⚠️ [开始作业] createDeviceTask API 失败: $failMsg');
// 🔥 无论什么失败(网络错误/任务已存在等),都尝试重查池子
// 网络错误时服务端可能已创建成功,只是响应丢失
needRequery = true;
},
(taskId) {
(taskId) {
debugPrint('✅ [开始作业] createDeviceTask 成功, taskId=$taskId');
taskCubit.updateCurrentTaskId(taskId);
},
);
if (failed) return;
if (needRequery) {
debugPrint('🔁 [开始作业] 步骤2b: API失败,重新查询池子(服务端可能已创建)...');
await taskCubit.fetchAndFilterTask(deviceId);
await Future.delayed(const Duration(milliseconds: 300));
await Future.delayed(const Duration(milliseconds: 500));
final existingTasks = taskCubit.state.activeTasks;
if (existingTasks.isNotEmpty) {
taskCubit.selectTask(existingTasks.first);
debugPrint('✅ [开始作业] 重查成功,使用已有任务 #${existingTasks.first.id}');
// 🔥 继续执行后续 UI更新 + MQTT + 保存逻辑
} else {
debugPrint('[开始作业] needRequery 后仍无活跃任务');
_showPageToast(message: '未找到活跃任务', type: ToastType.warn);
debugPrint('❌ [开始作业] 步骤2b失败: 重查后仍无活跃任务,终止');
_showPageToast(message: '作业启动失败,未找到活跃任务', type: ToastType.error);
taskCubit.clearCurrentTask();
return;
}
return;
}
} catch (e) {
debugPrint('❌ [开始作业] 异常: $e');
debugPrint('❌ [开始作业] 步骤2异常: $e');
_showPageToast(message: "作业启动异常: $e", type: ToastType.error);
taskCubit.clearCurrentTask();
// 🔥 不重置 _workStatus,保持按钮可见
return;
}
} // end if/else 任务确认
// 🔥 安全检查:taskId 必须有效才能更新 UI 为作业中
if (taskCubit.state.currentTaskId == null) {
debugPrint('❌ [开始作业] 步骤3前检查: taskId 为空,任务未就绪,终止');
_showPageToast(message: '作业启动失败,未获取到任务ID', type: ToastType.error);
return;
}
// 🔥 到这里说明任务已就绪(无论是已有还是新建),刷新一次任务池显示最新状态
taskCubit.fetchAndFilterTask(deviceId);
// 🔥 步骤3: 任务确认成功,更新UI状态
debugPrint('🎯 [开始作业] 步骤3: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}');
setState(() {
isStopWork = false;
isStartWork = true;
_workStatus = WorkStatus.working;
});
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
// 🔥 步骤4: 刷新任务池(保护 taskId 不被覆盖)
debugPrint('🔄 [开始作业] 步骤4: 刷新任务池');
final _confirmedTaskId = taskCubit.state.currentTaskId;
await taskCubit.fetchAndFilterTask(deviceId);
if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) {
taskCubit.updateCurrentTaskId(_confirmedTaskId);
debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId');
}
// 🔥 步骤5: 启动MQTT到达点监听
debugPrint('📡 [开始作业] 步骤5: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId');
try {
await context.read<DevicesCubit>().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!);
debugPrint('✅ [开始作业] MQTT到达点监听已启动');
} catch (e) {
debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)');
}
// 🔥 步骤6: 持久化状态
debugPrint('💾 [开始作业] 步骤6: 持久化状态, taskId=${taskCubit.state.currentTaskId}');
_showPageToast(message: "作业已开始", type: ToastType.success);
_saveDataToLocal();
@@ -2448,9 +2553,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
_traceManager.setMode(TPMode.LOCATION);
tracePoint = _traceManager.getTracePoint();
gctracePoint = batchWgs84ToGcj02(tracePoint!);
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
setState(() {
isStopWork = false;
@@ -2492,9 +2597,21 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
final taskCubit = sl<DeviceTaskCubit>();
final taskId = taskCubit.state.currentTaskId;
var taskId = taskCubit.state.currentTaskId;
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
if (taskId == null) {
_showPageToast(message: "无可用任务", type: ToastType.warn);
debugPrint('📥 [暂停作业] taskId 为空,尝试从本地恢复...');
taskId = await _restoreTaskIdFromLocal(deviceId);
if (taskId != null) {
taskCubit.updateCurrentTaskId(taskId);
debugPrint('📥 [暂停作业] 从本地恢复 taskId: $taskId');
}
}
if (taskId == null) {
debugPrint('❌ [暂停作业] taskId 为空,无法操控');
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
return;
}
@@ -2535,11 +2652,22 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
final taskCubit = sl<DeviceTaskCubit>();
final taskId = taskCubit.state.currentTaskId;
var taskId = taskCubit.state.currentTaskId;
debugPrint('[停止作业] taskId: $taskId');
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
if (taskId == null) {
debugPrint('[停止作业] ❌ taskId 为 null,退出');
_showPageToast(message: "无可用任务", type: ToastType.warn);
debugPrint('📥 [停止作业] taskId 为空,尝试从本地恢复...');
taskId = await _restoreTaskIdFromLocal(deviceId);
if (taskId != null) {
taskCubit.updateCurrentTaskId(taskId);
debugPrint('📥 [停止作业] 从本地恢复 taskId: $taskId');
}
}
if (taskId == null) {
debugPrint('[停止作业] ❌ taskId 为空,无法操控');
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
return;
}
@@ -2567,6 +2695,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
debugPrint('[停止作业] cancelTask 返回成功');
taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务
debugPrint('[停止作业] clearCurrentTask 完成');
await _clearTaskIdFromLocal(deviceId); // 🔥 同步清除本地持久化
// 🔥 停止MQTT到达点监听
debugPrint('[停止作业] 停止MQTT到达点监听...');
await context.read<DevicesCubit>().stopListeningMqttArrive();
debugPrint('[停止作业] MQTT到达点监听已停止');
_showPageToast(message: "作业已停止", type: ToastType.success);
debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令');
@@ -2613,9 +2748,21 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
final taskCubit = sl<DeviceTaskCubit>();
final taskId = taskCubit.state.currentTaskId;
var taskId = taskCubit.state.currentTaskId;
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
if (taskId == null) {
_showPageToast(message: "无可用任务", type: ToastType.warn);
debugPrint('📥 [继续作业] taskId 为空,尝试从本地恢复...');
taskId = await _restoreTaskIdFromLocal(deviceId);
if (taskId != null) {
taskCubit.updateCurrentTaskId(taskId);
debugPrint('📥 [继续作业] 从本地恢复 taskId: $taskId');
}
}
if (taskId == null) {
debugPrint('❌ [继续作业] taskId 为空,无法操控');
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
return;
}
@@ -2660,18 +2807,18 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
List<dynamic> _parseStartWorkListFromPathData(
List<Map<String, dynamic>>? pathData,
) {
debugPrint(
'🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
);
// debugPrint(
// '🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
// );
if (pathData == null || pathData.isEmpty) {
debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
// debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
return [];
}
final firstRecord = pathData.first;
debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
// debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
final nestedJsonRaw = firstRecord['jsonData'];
debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
// debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
// 🔥 兼容两种格式:String(需 jsonDecode)和 Map(已解析)
Map<String, dynamic> parsedJson;
@@ -2679,31 +2826,31 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
try {
final decoded = jsonDecode(nestedJsonRaw);
if (decoded is! Map<String, dynamic>) {
debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
// debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
return [];
}
parsedJson = decoded;
} catch (_) {
debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
// debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
return [];
}
} else if (nestedJsonRaw is Map<String, dynamic>) {
parsedJson = nestedJsonRaw;
} else {
debugPrint(
'❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
);
// debugPrint(
// '❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
// );
return [];
}
debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
// debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
final planModel = parsedJson['planModel'];
// 🔥 安全解析:支持 int 和 String 类型
final int planModelValue = int.tryParse(planModel?.toString() ?? '0') ?? 0;
debugPrint(
'🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
);
// debugPrint(
// '🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
// );
final isBow = planModelValue == WorkMode.bow.value;
List<dynamic> pathList = [];
@@ -2728,15 +2875,15 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
if (isBow) {
final result = pathList.isNotEmpty ? pathList : outerList;
debugPrint(
'✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
);
// debugPrint(
// '✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
// );
return result;
} else {
final result = outerList.isNotEmpty ? outerList : pathList;
debugPrint(
'✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
);
// debugPrint(
// '✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
// );
return result;
}
}
@@ -2750,19 +2897,20 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
// 🔥 核心修复:从 state.pathData 实时计算 startWorkList
// 确保 BlocBuilder 触发时数据已就绪,不再依赖 onTap 的异步赋值时序
debugPrint(
'🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
);
// debugPrint(
// '🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
// );
final parsedList = _parseStartWorkListFromPathData(state.pathData);
if (parsedList.isNotEmpty) {
startWorkList = parsedList; // 同步到类字段,供 _saveDataToLocal 等方法使用
debugPrint(
'✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
);
// debugPrint(
// '✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
// );
} else {
debugPrint(
'⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
);
// debugPrint(
// '⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
// );
}
return Positioned(
@@ -2953,37 +3101,34 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
'设备: ${currentTask.deviceId}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF4E5969),
),
Text(
'设备: ${currentTask.deviceId}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF4E5969),
),
softWrap: true,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w500,
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(
4,
),
),
child: Text(
statusText,
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
),
@@ -3672,6 +3817,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
DeviceStatusUpdated? updatedState;
//有停止信号
if (isFinishWork) {
// 🔥 清除 taskId 持久化数据(任务已完成)
final finishDeviceId = context.read<RemoteControlCubit>().state.targetDevice?.deviceName;
if (finishDeviceId != null) {
sl<DeviceTaskCubit>().clearCurrentTask();
_clearTaskIdFromLocal(finishDeviceId);
debugPrint('🏁 [完成] 任务已到达终点,清除 taskId: deviceId=$finishDeviceId');
}
// 🔥 关键:用微任务延迟执行状态更新,避开构建阶段
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@@ -3685,7 +3837,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
tracePoint?.clear();
gctracePoint?.clear();
});
_showPageToast(message: "作业已停止", type: ToastType.error);
_showPageToast(message: "作业已完成", type: ToastType.success);
// 延迟重置轨迹管理器
Future.delayed(const Duration(seconds: 1), () {

View File

@@ -349,7 +349,7 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
streamUrl: _videoStreamUrl,
showLeftPip: false, // 不显示悬浮小窗
showRightPip: false,
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment, // 🔥 根据视角切换画面
)
: Container(
color: Colors.black87,

View File

@@ -45,4 +45,11 @@ class MyCubit extends Cubit<MyState> {
emit(state.copyWith(isLoading: true, errorMessage: ''));
// 解绑逻辑(如需保留,需补充 UnbindDeviceUsecase 依赖注入)
}
/// 🔥 退出登录时清空所有状态(昵称等个人信息)
void clearAll() {
if (!isClosed) {
emit(const MyState());
}
}
}

View File

@@ -1070,4 +1070,28 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
debugPrint('🗑️ [RemoteControl] 缓存已清空 - voltage, battery, controlMode, ping');
}
/// 🔥 退出登录时清空所有状态(比 _clearAllCache 更彻底,重置全部 state 字段)
void clearAll() {
_timer?.cancel();
_timer = null;
_cacheVoltage = null;
_cacheBattery = null;
_cacheCtrlMode = null;
_cachePing = null;
_lastUiUpdateTime = null;
_lastStatusPushTime = null;
_currentOriginX = 0;
_currentOriginY = 0;
if (!isClosed) {
emit(RemoteControlState(
controlEntity: MachineControlStatusEntity(),
runningStatusModel: RunningStatusModel(),
));
}
debugPrint('🗑️ [RemoteControl] clearAll - 所有状态已重置为初始值');
}
}

View File

@@ -119,8 +119,6 @@ class _RightJoystickAreaState extends State<RightJoystickArea> {
}
void _triggerVibration() {
Vibration.hasVibrator().then((has) {
if (has ?? false) Vibration.vibrate(duration: 12);
});
Vibration.vibrate(duration: 12);
}
}

View File

@@ -171,6 +171,14 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
Widget _buildQuadrantView({required Alignment alignment}) {
if (_renderer.srcObject == null) return Container(color: Colors.black);
// 🔥 俯视(center):显示完整视频帧,不做2倍裁剪
if (alignment == Alignment.center) {
return RepaintBoundary(
child: RTCVideoView(_renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false),
);
}
// 前/后/左/右:2倍放大后裁剪对应象限
return RepaintBoundary(
child: ClipRect(
child: FractionallySizedBox(

View File

@@ -0,0 +1,14 @@
import '../../domain/entities/bind_device_entities.dart';
abstract class BindDeviceDatasource {
Future<List<OrgEntity>> getOrgList();
Future<List<SiteEntity>> getSitesByOrgId(int orgId);
Future<List<UserSimpleEntity>> getUsersBySiteId(int siteId);
Future<bool> isDeviceAtSite({required String deviceId, required int siteId});
Future<void> bindDevice({
required List<String> deviceIds,
required int orgId,
required int siteId,
required int userId,
});
}

View File

@@ -0,0 +1,159 @@
import 'package:dio/dio.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
import '../../../domain/entities/bind_device_entities.dart';
import '../bind_device_datasource.dart';
class BindDeviceDatasourceImpl implements BindDeviceDatasource {
final Dio _dio;
BindDeviceDatasourceImpl(this._dio);
Future<String?> _getToken() async {
final token = sl<AppUserCubit>().state.user?.token;
if (token != null) return token;
final user = await sl<UserStorage>().getUser();
return user?.token;
}
@override
Future<List<OrgEntity>> getOrgList() async {
final token = await _getToken();
final response = await _dio.get(
HttpApiConsts.orgList,
queryParameters: {'pageNum': 1, 'pageSize': 1000},
options: Options(
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
),
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final data = response.data;
if (data['code'] != 200) {
throw Exception(data['msg'] ?? '获取组织列表失败');
}
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
return rows
.map((e) => OrgEntity.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<List<SiteEntity>> getSitesByOrgId(int orgId) async {
final token = await _getToken();
final response = await _dio.get(
HttpApiConsts.siteListByOrgId,
queryParameters: {'id': orgId},
options: Options(
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
),
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final data = response.data;
if (data['code'] != 200) {
throw Exception(data['msg'] ?? '获取场站列表失败');
}
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
return rows
.map((e) => SiteEntity.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<List<UserSimpleEntity>> getUsersBySiteId(int siteId) async {
final token = await _getToken();
final response = await _dio.get(
HttpApiConsts.userListBySiteId,
queryParameters: {'siteId': siteId, 'pageNum': 1, 'pageSize': 1000},
options: Options(
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
),
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final data = response.data;
if (data['code'] != 200) {
throw Exception(data['msg'] ?? '获取人员列表失败');
}
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
return rows
.map((e) => UserSimpleEntity.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<bool> isDeviceAtSite({
required String deviceId,
required int siteId,
}) async {
final token = await _getToken();
try {
final response = await _dio.get(
HttpApiConsts.getSiteDeviceList,
queryParameters: {'siteId': siteId, 'pageSize': 9999, 'pageNum': 1},
options: Options(
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
),
);
if (response.statusCode != 200) return false;
final data = response.data;
if (data['code'] != 200) return false;
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
return rows.any((e) => e['deviceId']?.toString() == deviceId);
} catch (e) {
return false;
}
}
@override
Future<void> bindDevice({
required List<String> deviceIds,
required int orgId,
required int siteId,
required int userId,
}) async {
final token = await _getToken();
final response = await _dio.post(
HttpApiConsts.bindDevice,
data: {
'deviceIds': deviceIds,
'orgId': orgId,
'siteId': siteId,
'userId': userId,
},
options: Options(
headers: {
'Authorization': token != null ? 'Bearer $token' : '',
'Content-Type': 'application/json',
},
),
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final data = response.data;
if (data['code'] != 200) {
throw Exception(data['msg'] ?? '绑定设备失败');
}
}
}

View File

@@ -0,0 +1,80 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../../domain/entities/bind_device_entities.dart';
import '../../domain/repositories/bind_device_repository.dart';
import '../datasources/bind_device_datasource.dart';
class BindDeviceRepositoryImpl implements BindDeviceRepository {
final BindDeviceDatasource _datasource;
BindDeviceRepositoryImpl(this._datasource);
@override
Future<Either<Failure, List<OrgEntity>>> getOrgList() async {
try {
final result = await _datasource.getOrgList();
return Right(result);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, List<SiteEntity>>> getSitesByOrgId(int orgId) async {
try {
final result = await _datasource.getSitesByOrgId(orgId);
return Right(result);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, List<UserSimpleEntity>>> getUsersBySiteId(
int siteId,
) async {
try {
final result = await _datasource.getUsersBySiteId(siteId);
return Right(result);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, bool>> isDeviceAtSite({
required String deviceId,
required int siteId,
}) async {
try {
final result = await _datasource.isDeviceAtSite(
deviceId: deviceId,
siteId: siteId,
);
return Right(result);
} catch (e) {
return Left(Failure(e.toString()));
}
}
@override
Future<Either<Failure, void>> bindDevice({
required List<String> deviceIds,
required int orgId,
required int siteId,
required int userId,
}) async {
try {
await _datasource.bindDevice(
deviceIds: deviceIds,
orgId: orgId,
siteId: siteId,
userId: userId,
);
return const Right(null);
} catch (e) {
return Left(Failure(e.toString()));
}
}
}

View File

@@ -0,0 +1,62 @@
class OrgEntity {
final int id;
final String name;
const OrgEntity({required this.id, required this.name});
static int _parseId(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
factory OrgEntity.fromJson(Map<String, dynamic> json) {
return OrgEntity(
id: _parseId(json['id']),
name: json['name'] ?? json['orgName'] ?? '',
);
}
}
class SiteEntity {
final int id;
final String name;
const SiteEntity({required this.id, required this.name});
static int _parseId(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
factory SiteEntity.fromJson(Map<String, dynamic> json) {
return SiteEntity(
id: _parseId(json['id']),
name: json['name'] ?? json['siteName'] ?? '',
);
}
}
class UserSimpleEntity {
final int id;
final String name;
const UserSimpleEntity({required this.id, required this.name});
static int _parseId(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
factory UserSimpleEntity.fromJson(Map<String, dynamic> json) {
return UserSimpleEntity(
id: _parseId(json['userId'] ?? json['id']),
name: json['nickName'] ?? json['name'] ?? json['username'] ?? '',
);
}
}

View File

@@ -93,6 +93,8 @@ class UAVDetailEntity extends Equatable {
final double? homeDistance;
final double? liveCapacity;
final String? rainfall;
final String? airConditionerStatus;
final String? hangarStatus;
final List<CameraInfo>? gatewayCameraList;
final List<CameraInfo>? droneCameraList;
final int? orgId;
@@ -117,6 +119,8 @@ class UAVDetailEntity extends Equatable {
this.homeDistance,
this.liveCapacity,
this.rainfall,
this.airConditionerStatus,
this.hangarStatus,
this.gatewayCameraList,
this.droneCameraList,
this.orgId,
@@ -166,6 +170,8 @@ class UAVDetailEntity extends Equatable {
? (json['live_capacity'] as num).toDouble()
: null,
rainfall: json['rainfall']?.toString(),
airConditionerStatus: json['air_conditioner_status'] ?? '',
hangarStatus: json['hangar_status'] ?? '',
gatewayCameraList: json['gateway_camera_list'] != null
? (json['gateway_camera_list'] as List)
.map((item) => CameraInfo.fromJson(item))
@@ -203,6 +209,8 @@ class UAVDetailEntity extends Equatable {
'home_distance': homeDistance,
'live_capacity': liveCapacity,
'rainfall': rainfall,
'air_conditioner_status': airConditionerStatus,
'hangar_status': hangarStatus,
'gateway_camera_list': gatewayCameraList?.map((c) => c.toJson()).toList(),
'drone_camera_list': droneCameraList,
'orgId': orgId,
@@ -233,6 +241,8 @@ class UAVDetailEntity extends Equatable {
homeDistance,
liveCapacity,
rainfall,
airConditionerStatus,
hangarStatus,
gatewayCameraList,
droneCameraList,
orgId,
@@ -260,6 +270,8 @@ class DroneStationEntity extends Equatable {
final double? homeDistance; // 距离home点距离
final double? liveCapacity; // 实时容量
final double? rainfall; // 降雨量
final String? airConditionerStatus; // 机场空调状态
final String? hangarStatus; // 机库状态
final List<CameraInfo>? gatewayCameraList; // 网关摄像头列表
final List<dynamic>? droneCameraList; // 无人机摄像头列表
final int orgId; // 组织ID
@@ -284,6 +296,8 @@ class DroneStationEntity extends Equatable {
this.homeDistance,
this.liveCapacity,
this.rainfall,
this.airConditionerStatus,
this.hangarStatus,
this.gatewayCameraList,
this.droneCameraList,
required this.orgId,
@@ -328,6 +342,8 @@ class DroneStationEntity extends Equatable {
rainfall: json['rainfall'] != null
? (json['rainfall'] as num).toDouble()
: null,
airConditionerStatus: json['air_conditioner_status'] ?? '',
hangarStatus: json['hangar_status'] ?? '',
gatewayCameraList: json['gateway_camera_list'] != null
? (json['gateway_camera_list'] as List)
.map((item) => CameraInfo.fromJson(item))
@@ -398,6 +414,8 @@ class DroneStationEntity extends Equatable {
homeDistance,
liveCapacity,
rainfall,
airConditionerStatus,
hangarStatus,
gatewayCameraList,
droneCameraList,
orgId,

View File

@@ -0,0 +1,19 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../../core/error/failure.dart';
import '../entities/bind_device_entities.dart';
abstract class BindDeviceRepository {
Future<Either<Failure, List<OrgEntity>>> getOrgList();
Future<Either<Failure, List<SiteEntity>>> getSitesByOrgId(int orgId);
Future<Either<Failure, List<UserSimpleEntity>>> getUsersBySiteId(int siteId);
Future<Either<Failure, bool>> isDeviceAtSite({
required String deviceId,
required int siteId,
});
Future<Either<Failure, void>> bindDevice({
required List<String> deviceIds,
required int orgId,
required int siteId,
required int userId,
});
}

View File

@@ -0,0 +1,50 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../entities/bind_device_entities.dart';
import '../repositories/bind_device_repository.dart';
class GetOrgListUseCase {
final BindDeviceRepository _repository;
GetOrgListUseCase(this._repository);
Future<Either<Failure, List<OrgEntity>>> call() => _repository.getOrgList();
}
class GetSitesByOrgUseCase {
final BindDeviceRepository _repository;
GetSitesByOrgUseCase(this._repository);
Future<Either<Failure, List<SiteEntity>>> call(int orgId) =>
_repository.getSitesByOrgId(orgId);
}
class GetUsersBySiteUseCase {
final BindDeviceRepository _repository;
GetUsersBySiteUseCase(this._repository);
Future<Either<Failure, List<UserSimpleEntity>>> call(int siteId) =>
_repository.getUsersBySiteId(siteId);
}
class BindDeviceV2UseCase {
final BindDeviceRepository _repository;
BindDeviceV2UseCase(this._repository);
Future<Either<Failure, void>> call({
required List<String> deviceIds,
required int orgId,
required int siteId,
required int userId,
}) => _repository.bindDevice(
deviceIds: deviceIds,
orgId: orgId,
siteId: siteId,
userId: userId,
);
}
class IsDeviceAtSiteUseCase {
final BindDeviceRepository _repository;
IsDeviceAtSiteUseCase(this._repository);
Future<Either<Failure, bool>> call({
required String deviceId,
required int siteId,
}) => _repository.isDeviceAtSite(deviceId: deviceId, siteId: siteId);
}

View File

@@ -0,0 +1,15 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../repositories/drone_station_repository.dart';
class PauseFlightTaskUseCase {
final DroneStationRepository repository;
PauseFlightTaskUseCase(this.repository);
Future<Either<Failure, Map<String, dynamic>>> execute({
required String deviceSn,
}) async {
return await repository.pauseFlightTask(deviceSn: deviceSn);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../repositories/drone_station_repository.dart';
class ReturnHomeUseCase {
final DroneStationRepository repository;
ReturnHomeUseCase(this.repository);
Future<Either<Failure, Map<String, dynamic>>> execute({
required String deviceSn,
}) async {
return await repository.returnHome(deviceSn: deviceSn);
}
}

View File

@@ -43,27 +43,37 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
DeviceStatusRefresh event,
Emitter<DeviceStatusState> emit,
) async {
if (state is DeviceStatusLoaded) {
final currentState = state as DeviceStatusLoaded;
// 🔥 切换电站时,即便当前是 Loading/Initial 也需要重新拉取
final currentSiteId = (state is DeviceStatusLoaded)
? (state as DeviceStatusLoaded).siteId
: null;
final targetSiteId = event.siteId ?? currentSiteId;
final currentSelectedType = (state is DeviceStatusLoaded)
? (state as DeviceStatusLoaded).selectedType
: 'all';
try {
final response = await getDeviceStatusDataUseCase.execute(
siteId: currentState.siteId,
typeFilter: currentState.selectedType == 'all'
? null
: currentState.selectedType,
);
// 切换电站时显示 Loading,让用户感知到正在刷新
if (event.siteId != null) {
emit(const DeviceStatusLoading());
}
emit(currentState.copyWith(
deviceStatus: response.status,
devices: response.devices,
));
} catch (e) {
emit(DeviceStatusError(
message: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 标记需要显示弹窗
));
}
try {
final response = await getDeviceStatusDataUseCase.execute(
siteId: targetSiteId,
typeFilter: currentSelectedType == 'all' ? null : currentSelectedType,
);
emit(DeviceStatusLoaded(
deviceStatus: response.status,
devices: response.devices,
siteId: targetSiteId,
selectedType: currentSelectedType,
));
} catch (e) {
emit(DeviceStatusError(
message: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 标记需要显示弹窗
));
}
}

View File

@@ -17,7 +17,13 @@ class DeviceStatusLoadData extends DeviceStatusEvent {
}
class DeviceStatusRefresh extends DeviceStatusEvent {
const DeviceStatusRefresh();
/// 可选:切换电站时传入新 siteId 重新请求;不传则用当前 state 中的 siteId
final int? siteId;
const DeviceStatusRefresh({this.siteId});
@override
List<Object?> get props => [siteId];
}
class DeviceStatusChangeType extends DeviceStatusEvent {

View File

@@ -0,0 +1,256 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import '../../domain/entities/bind_device_entities.dart';
import '../../domain/usecases/bind_device_usecases.dart';
import 'bind_device_state.dart';
class BindDeviceCubit extends Cubit<BindDeviceState> {
final GetOrgListUseCase _getOrgListUseCase;
final GetSitesByOrgUseCase _getSitesByOrgUseCase;
final GetUsersBySiteUseCase _getUsersBySiteUseCase;
final BindDeviceV2UseCase _bindDeviceUseCase;
final IsDeviceAtSiteUseCase _isDeviceAtSiteUseCase;
final AppUserCubit _appUserCubit;
BindDeviceCubit(
this._getOrgListUseCase,
this._getSitesByOrgUseCase,
this._getUsersBySiteUseCase,
this._bindDeviceUseCase,
this._isDeviceAtSiteUseCase,
this._appUserCubit,
) : super(const BindDeviceState());
UserEntity? get _currentUser => _appUserCubit.state.user;
String get _roleKey => _currentUser?.roleKey ?? 'user';
Future<void> init() async {
final user = _currentUser;
if (user == null) return;
emit(state.copyWith(isLoading: true));
switch (_roleKey) {
case 'admin':
await _loadAllOrgs();
break;
case 'manager':
await _loadForManager(user);
break;
case 'siteManager':
await _loadForSiteManager(user);
break;
case 'user':
await _loadForUser(user);
break;
default:
await _loadForUser(user);
}
emit(state.copyWith(isLoading: false));
}
Future<void> _loadAllOrgs() async {
final result = await _getOrgListUseCase();
result.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(orgs) => emit(state.copyWith(orgList: orgs)),
);
}
Future<void> _loadForManager(UserEntity user) async {
final result = await _getOrgListUseCase();
result.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(orgs) async {
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
await _loadSitesByOrg(user.orgId);
},
);
}
Future<void> _loadForSiteManager(UserEntity user) async {
final result = await _getOrgListUseCase();
result.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(orgs) async {
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
await _loadSitesAndUsers(user);
},
);
}
Future<void> _loadForUser(UserEntity user) async {
final result = await _getOrgListUseCase();
result.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(orgs) async {
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
await _loadSitesAndUsers(user);
},
);
}
Future<void> _loadSitesByOrg(int orgId) async {
final result = await _getSitesByOrgUseCase(orgId);
result.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(sites) => emit(state.copyWith(siteList: sites)),
);
}
Future<void> _loadSitesAndUsers(UserEntity user) async {
final sitesResult = await _getSitesByOrgUseCase(user.orgId);
sitesResult.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(sites) async {
emit(state.copyWith(siteList: sites, selectedSiteId: user.siteId));
if (user.siteId != null) {
await _loadUsers(user);
} else {
final currentUser = UserSimpleEntity(
id: int.tryParse(user.userId) ?? 0,
name: user.nickname,
);
emit(
state.copyWith(
userList: [currentUser],
selectedUserId: currentUser.id,
isLoading: false,
),
);
}
},
);
}
Future<void> _loadUsers(UserEntity user) async {
final result = await _getUsersBySiteUseCase(user.siteId!);
final currentUser = UserSimpleEntity(
id: int.tryParse(user.userId) ?? 0,
name: user.nickname,
);
result.fold(
(failure) =>
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
(users) {
final allUsers = <UserSimpleEntity>[];
final seenIds = <int>{};
for (final u in users) {
if (u.id != currentUser.id && !seenIds.contains(u.id)) {
allUsers.add(u);
seenIds.add(u.id);
}
}
if (!seenIds.contains(currentUser.id)) {
allUsers.add(currentUser);
}
emit(
state.copyWith(
userList: allUsers,
selectedUserId: currentUser.id,
isLoading: false,
),
);
},
);
}
Future<void> onOrgChanged(int? orgId) async {
if (orgId == null) return;
emit(
state.copyWith(
selectedOrgId: orgId,
selectedSiteId: null,
selectedUserId: null,
siteList: [],
userList: [],
),
);
final result = await _getSitesByOrgUseCase(orgId);
result.fold(
(failure) => emit(state.copyWith(errorMessage: failure.message)),
(sites) => emit(state.copyWith(siteList: sites)),
);
}
Future<void> onSiteChanged(int? siteId) async {
if (siteId == null) return;
emit(
state.copyWith(
selectedSiteId: siteId,
selectedUserId: null,
userList: [],
),
);
final result = await _getUsersBySiteUseCase(siteId);
result.fold(
(failure) => emit(state.copyWith(errorMessage: failure.message)),
(users) => emit(state.copyWith(userList: users)),
);
}
void onUserChanged(int? userId) {
emit(state.copyWith(selectedUserId: userId));
}
Future<bool> submitBind(String deviceId) async {
if (state.selectedOrgId == null ||
state.selectedSiteId == null ||
state.selectedUserId == null) {
emit(state.copyWith(errorMessage: '请选择完整的绑定信息'));
return false;
}
emit(state.copyWith(isSubmitting: true, errorMessage: null));
final existResult = await _isDeviceAtSiteUseCase(
deviceId: deviceId,
siteId: state.selectedSiteId!,
);
final isAtSite = existResult.fold((failure) => false, (exists) => exists);
if (isAtSite) {
emit(state.copyWith(isSubmitting: false, errorMessage: '该设备已在当前场站,无需绑定'));
return false;
}
final result = await _bindDeviceUseCase(
deviceIds: [deviceId],
orgId: state.selectedOrgId!,
siteId: state.selectedSiteId!,
userId: state.selectedUserId!,
);
return result.fold(
(failure) {
emit(
state.copyWith(isSubmitting: false, errorMessage: failure.message),
);
return false;
},
(_) {
emit(state.copyWith(isSubmitting: false, isSuccess: true));
return true;
},
);
}
bool get isOrgEnabled => _roleKey == 'admin';
bool get isSiteEnabled => _roleKey == 'admin' || _roleKey == 'manager';
bool get isUserEnabled =>
_roleKey == 'admin' || _roleKey == 'manager' || _roleKey == 'siteManager';
String get roleKey => _roleKey;
}

View File

@@ -0,0 +1,68 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/bind_device_entities.dart';
class BindDeviceState extends Equatable {
final List<OrgEntity> orgList;
final List<SiteEntity> siteList;
final List<UserSimpleEntity> userList;
final int? selectedOrgId;
final int? selectedSiteId;
final int? selectedUserId;
final bool isLoading;
final bool isSubmitting;
final String? errorMessage;
final bool isSuccess;
const BindDeviceState({
this.orgList = const [],
this.siteList = const [],
this.userList = const [],
this.selectedOrgId,
this.selectedSiteId,
this.selectedUserId,
this.isLoading = false,
this.isSubmitting = false,
this.errorMessage,
this.isSuccess = false,
});
BindDeviceState copyWith({
List<OrgEntity>? orgList,
List<SiteEntity>? siteList,
List<UserSimpleEntity>? userList,
int? selectedOrgId,
int? selectedSiteId,
int? selectedUserId,
bool? isLoading,
bool? isSubmitting,
String? errorMessage,
bool? isSuccess,
}) {
return BindDeviceState(
orgList: orgList ?? this.orgList,
siteList: siteList ?? this.siteList,
userList: userList ?? this.userList,
selectedOrgId: selectedOrgId ?? this.selectedOrgId,
selectedSiteId: selectedSiteId ?? this.selectedSiteId,
selectedUserId: selectedUserId ?? this.selectedUserId,
isLoading: isLoading ?? this.isLoading,
isSubmitting: isSubmitting ?? this.isSubmitting,
errorMessage: errorMessage,
isSuccess: isSuccess ?? this.isSuccess,
);
}
@override
List<Object?> get props => [
orgList,
siteList,
userList,
selectedOrgId,
selectedSiteId,
selectedUserId,
isLoading,
isSubmitting,
errorMessage,
isSuccess,
];
}

View File

@@ -0,0 +1,429 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import '../cubit/bind_device_cubit.dart';
import '../cubit/bind_device_state.dart';
class BindDevicePage extends StatefulWidget {
final String scanResult;
const BindDevicePage({super.key, required this.scanResult});
@override
State<BindDevicePage> createState() => _BindDevicePageState();
}
class _BindDevicePageState extends State<BindDevicePage> {
late final BindDeviceCubit _cubit;
@override
void initState() {
super.initState();
_cubit = sl<BindDeviceCubit>();
_cubit.init();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => _cubit,
child: BlocListener<BindDeviceCubit, BindDeviceState>(
listener: (context, state) {
if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
}
if (state.isSuccess) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('绑定成功')));
Navigator.of(context).pop();
}
},
child: Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Color(0xFF1D2129),
size: 20,
),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'绑定智能装备',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
centerTitle: true,
),
body: BlocBuilder<BindDeviceCubit, BindDeviceState>(
builder: (context, state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRequiredField(
label: '已选装备 ID',
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFE5E6EB),
),
),
child: Text(
widget.scanResult,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF1D2129),
),
),
),
),
const SizedBox(height: 16),
_buildRequiredField(
label: '所属组织',
child: _buildOrgDropdown(state),
),
const SizedBox(height: 16),
_buildField(
label: '所属场站',
child: _buildSiteDropdown(state),
),
const SizedBox(height: 16),
_buildField(
label: '负责人',
child: _buildUserDropdown(state),
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: SizedBox(
height: 44,
child: OutlinedButton(
onPressed: state.isSubmitting
? null
: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFFE5E6EB),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'取消',
style: TextStyle(
fontSize: 15,
color: Color(0xFF4E5969),
),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 44,
child: ElevatedButton(
onPressed: state.isSubmitting
? null
: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: state.isSubmitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'确定',
style: TextStyle(
fontSize: 15,
color: Colors.white,
),
),
),
),
),
],
),
],
),
),
),
);
},
),
),
),
);
}
Widget _buildOrgDropdown(BindDeviceState state) {
final enabled = _cubit.isOrgEnabled;
final selectedName = state.selectedOrgId != null
? state.orgList
.where((org) => org.id == state.selectedOrgId)
.fold<String?>(null, (_, org) => org.name)
: null;
if (!enabled && selectedName != null) {
return _buildReadonlyField(selectedName);
}
return Container(
decoration: BoxDecoration(
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
),
child: DropdownButtonFormField<int>(
value: state.selectedOrgId,
decoration: _dropdownDecoration(enabled),
icon: Icon(
Icons.expand_more,
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
),
items: state.orgList.map((org) {
return DropdownMenuItem<int>(
value: org.id,
child: Text(
org.name,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: enabled
? (v) {
if (v != null) _cubit.onOrgChanged(v);
}
: null,
),
);
}
Widget _buildSiteDropdown(BindDeviceState state) {
final enabled = _cubit.isSiteEnabled && state.selectedOrgId != null;
final selectedName = state.selectedSiteId != null
? state.siteList
.where((site) => site.id == state.selectedSiteId)
.fold<String?>(null, (_, site) => site.name)
: null;
if (!enabled && selectedName != null) {
return _buildReadonlyField(selectedName);
}
return Container(
decoration: BoxDecoration(
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
),
child: DropdownButtonFormField<int>(
value: state.selectedSiteId,
decoration: _dropdownDecoration(enabled),
icon: Icon(
Icons.expand_more,
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
),
items: state.siteList.map((site) {
return DropdownMenuItem<int>(
value: site.id,
child: Text(
site.name,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: enabled
? (v) {
if (v != null) _cubit.onSiteChanged(v);
}
: null,
),
);
}
Widget _buildUserDropdown(BindDeviceState state) {
final enabled = _cubit.isUserEnabled && state.selectedSiteId != null;
final selectedName = state.selectedUserId != null
? state.userList
.where((user) => user.id == state.selectedUserId)
.fold<String?>(null, (_, user) => user.name)
: null;
if (!enabled && selectedName != null) {
return _buildReadonlyField(selectedName);
}
return Container(
decoration: BoxDecoration(
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
),
child: DropdownButtonFormField<int>(
value: state.selectedUserId,
decoration: _dropdownDecoration(enabled),
icon: Icon(
Icons.expand_more,
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
),
items: state.userList.map((user) {
return DropdownMenuItem<int>(
value: user.id,
child: Text(
user.name,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: enabled
? (v) {
_cubit.onUserChanged(v);
}
: null,
),
);
}
Widget _buildReadonlyField(String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE5E6EB)),
),
child: Row(
children: [
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.lock, size: 16, color: Color(0xFFC9CDD4)),
],
),
);
}
InputDecoration _dropdownDecoration(bool enabled) {
return InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: enabled ? '请选择' : '请选择',
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
);
}
Widget _buildRequiredField({required String label, required Widget child}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
children: [
const TextSpan(
text: '* ',
style: TextStyle(
color: Color(0xFFF53F3F),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
TextSpan(
text: label,
style: const TextStyle(
color: Color(0xFF1D2129),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 8),
child,
],
);
}
Widget _buildField({required String label, required Widget child}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: Color(0xFF1D2129),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
child,
],
);
}
void _handleSubmit() {
_cubit.submitBind(widget.scanResult);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../../../../../core/consts/http_api_consts.dart';
import '../../../../../core/managers/drone_task_state_manager.dart';
class CreateTaskPage extends StatefulWidget {
final String sn;
@@ -430,6 +431,9 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
final taskUuid = jsonData['data']['task_uuid'];
print('✅ [CreateTask] 任务创建成功, task_uuid: $taskUuid');
// 🔥 任务下发成功,开始监测无人机实时推送与视频流
droneTaskStateManager.markTaskIssued();
Navigator.pop(context);
ScaffoldMessenger.of(
context,
@@ -570,8 +574,8 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
wayline['waylineUuid'] ??
wayline['id'] ??
'';
// print('🔍 [CreateTask] 航线数据: $wayline');
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
// print('🔍 [CreateTask] 航线数据: $wayline');
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
return ListTile(
title: Text(waylineName),
subtitle: waylineUuid.isNotEmpty

View File

@@ -1,12 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/app/app_user_cubit.dart';
import '../../../../../core/bluetooth/ble_manager.dart';
import '../../../../../components/tcp_status_indicator.dart';
import '../../../../../components/device_status_modal.dart';
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
import '../../../site/presentation/widgets/site_selector_widget.dart';
import '../bloc/device_status_bloc.dart' as DeviceListBloc;
import '../bloc/device_status_event.dart' as DeviceListEvent;
import '../bloc/device_status_state.dart' as DeviceListState;
@@ -20,11 +23,16 @@ import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入
import '../widgets/device_item_widget.dart';
import '../widgets/drone_station_item_card.dart';
import '../widgets/robot_item_card.dart'; // 🔥 添加 RobotItemCard 导入
import '../widgets/bluetooth_scan_modal.dart';
import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入
import 'robot_list_page.dart';
import 'robot_control_page.dart';
import 'drone_station_detail_page.dart';
import 'qr_scanner_page.dart';
import 'bind_device_page.dart';
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../../../devices/domain/entities/device_entity.dart';
import 'package:get_it/get_it.dart';
/// 设备状态页面 - 使用 BLoC 模式
class DeviceStatusPage extends StatelessWidget {
@@ -45,9 +53,70 @@ class DeviceStatusPage extends StatelessWidget {
}
/// 设备状态视图
class DeviceStatusView extends StatelessWidget {
class DeviceStatusView extends StatefulWidget {
const DeviceStatusView({super.key});
@override
State<DeviceStatusView> createState() => _DeviceStatusViewState();
}
class _DeviceStatusViewState extends State<DeviceStatusView> {
final _searchController = TextEditingController();
/// 🔥 监听电站切换:只要电站发生变化,就根据当前选中的标签栏自动刷新对应接口
StreamSubscription<SiteState>? _siteSub;
/// 当前缓存的 siteId,用于判断是否真的发生了变化
int? _currentSiteId;
@override
void initState() {
super.initState();
_currentSiteId = sl<SiteCubit>().state.selectedSite?.id;
_siteSub = sl<SiteCubit>().stream.listen((siteState) {
if (!mounted) return;
final newSiteId = siteState.selectedSite?.id;
// 只有 siteId 真正变化时才刷新(避免无意义的重复请求)
if (newSiteId != _currentSiteId) {
_currentSiteId = newSiteId;
_refreshCurrentTabForSiteChange(newSiteId);
}
});
}
@override
void dispose() {
_siteSub?.cancel();
_searchController.dispose();
super.dispose();
}
/// 🔥 电站切换后,根据当前选中的标签栏自动刷新对应接口
void _refreshCurrentTabForSiteChange(int? newSiteId) {
final bloc = context.read<DeviceListBloc.DeviceStatusBloc>();
final state = bloc.state;
// 取出当前选中的标签类型;若 BLoC 还未加载完成则默认 'all'
final selectedType = (state is DeviceListState.DeviceStatusLoaded)
? state.selectedType
: 'all';
debugPrint(
'🔄 [DeviceStatus] 电站切换 → siteId=$newSiteId, 当前标签=$selectedType, 自动刷新',
);
// 1. 主设备列表(all / inverter / combiner_box / module / monitor 共用 DeviceStatusBloc)
// 直接派发 Refresh 事件并带上新 siteId
bloc.add(DeviceListEvent.DeviceStatusRefresh(siteId: newSiteId));
// 2. robot / drone_station 由各自 BlocProvider 创建,通过 setState + ValueKey 重建子树
// 使其用新 siteId 重新加载数据
if (selectedType == 'robot' ||
selectedType == 'drone_station' ||
selectedType == 'all') {
setState(() {});
}
}
/// 🔥 机器人设备名称前缀,用于从通用设备列表中过滤掉机器人
static const _robotPrefixes = [
'RCHETD-CN',
@@ -109,31 +178,63 @@ class DeviceStatusView extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Text(
AppLocalizations.of(context).translate('device_list_v2.title'),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
Expanded(
child: Row(
children: [
const Flexible(child: SiteSelectorWidget(compact: true)),
const SizedBox(width: 8),
Container(width: 1, height: 20, color: const Color(0xFFE5E6EB)),
const SizedBox(width: 8),
Text(
AppLocalizations.of(
context,
).translate('device_list_v2.title'),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF86909C),
),
),
],
),
),
const Spacer(),
Row(
children: [
const Text(
'TCP',
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue.withOpacity(0.1),
),
child: TcpStatusIndicator(
size: 12,
onTap: () => _showDeviceStatusModal(context),
// 🔥 注释掉原有的 TCP 指示灯
// const Text(
// 'TCP',
// style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
// ),
// const SizedBox(width: 4),
// Container(
// padding: const EdgeInsets.all(4),
// decoration: BoxDecoration(
// shape: BoxShape.circle,
// color: Colors.blue.withOpacity(0.1),
// ),
// child: TcpStatusIndicator(
// size: 12,
// onTap: () => _showDeviceStatusModal(context),
// ),
// ),
GestureDetector(
onTap: () => _showAddMenu(context),
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFFE5E6EB),
width: 1,
),
color: Colors.white,
),
child: const Icon(
Icons.add,
color: Color(0xFF4E5969),
size: 18,
),
),
),
],
@@ -146,56 +247,107 @@ class DeviceStatusView extends StatelessWidget {
Widget _buildSearchBar(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8, 16.0, 8),
child: TextField(
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
).translate('device_list_v2.search_hint'),
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFF86909C),
size: 24,
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
).translate('device_list_v2.search_hint'),
hintStyle: const TextStyle(
color: Color(0xFF86909C),
fontSize: 14,
),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFF86909C),
size: 24,
),
suffixIcon: IconButton(
onPressed: () {
context.read<DeviceListBloc.DeviceStatusBloc>().add(
DeviceListEvent.DeviceStatusSearch(
_searchController.text,
),
);
},
icon: const Icon(
Icons.search,
color: Color(0xFF165DFF),
size: 22,
),
),
filled: true,
fillColor: const Color(0xFFF2F3F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(
color: Color(0xFFE5E6EB),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(
color: Color(0xFFE5E6EB),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(
color: Color(0xFF165DFF),
width: 1,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
onSubmitted: (value) {
context.read<DeviceListBloc.DeviceStatusBloc>().add(
DeviceListEvent.DeviceStatusSearch(value),
);
},
),
),
suffixIcon: TextButton(
onPressed: () {
// TODO: 触发搜索
const SizedBox(width: 8),
GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
builder: (context) => const BluetoothScanModal(),
).whenComplete(() {
// 弹窗关闭时立即停止扫描(无论以什么方式关闭)
BleManager.instance.stopScan();
});
},
child: Text(
AppLocalizations.of(context).translate('device_list_v2.search'),
style: const TextStyle(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF165DFF).withOpacity(0.1),
),
child: const Icon(
Icons.bluetooth,
color: Color(0xFF165DFF),
fontSize: 14,
fontWeight: FontWeight.w500,
size: 22,
),
),
),
filled: true,
fillColor: const Color(0xFFF2F3F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24.0),
borderSide: const BorderSide(color: Color(0xFF165DFF), width: 1),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
onSubmitted: (value) {
// 🔥 点击键盘确定键时触发搜索
context.read<DeviceListBloc.DeviceStatusBloc>().add(
DeviceListEvent.DeviceStatusSearch(value),
);
},
],
),
);
}
@@ -290,14 +442,20 @@ class DeviceStatusView extends StatelessWidget {
BuildContext context,
DeviceListState.DeviceStatusState state,
) {
// 🔥 用当前 siteId 作为 Key,电站切换时强制重建子树(重建对应 BlocProvider)
final siteId = _currentSiteId;
if (state is DeviceListState.DeviceStatusLoaded &&
state.selectedType == 'robot') {
return const RobotListPage();
return RobotListPage(key: ValueKey('robot_$siteId'));
}
if (state is DeviceListState.DeviceStatusLoaded &&
state.selectedType == 'drone_station') {
return _buildDroneStationList(context);
return _buildDroneStationList(
context,
key: ValueKey('drone_station_$siteId'),
);
}
return _buildDeviceList(context, state);
@@ -398,6 +556,8 @@ class DeviceStatusView extends StatelessWidget {
debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId');
return BlocProvider(
// 🔥 电站切换时通过 key 变化强制重建 BlocProvider,重新用新 siteId 加载
key: ValueKey('embedded_robot_$siteId'),
create: (_) =>
sl<RobotListBloc>()..add(RobotListLoadData(siteId: siteId)),
child: BlocConsumer<RobotListBloc, RobotListState>(
@@ -441,16 +601,49 @@ class DeviceStatusView extends StatelessWidget {
return RobotItemCard(
name: robot.name,
id: robot.id,
alias: robot.alias,
type: robot.type,
status: robot.status,
battery: robot.battery,
task: robot.task,
onTap: () {
onTap: () async {
debugPrint(
'🔴🔴🔴 [全部-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}',
);
// 1. 将当前机器人设置为全局待控制设备
final device = DeviceEntity(
deviceName: robot.name,
productId: -1,
productName: robot.type,
tenantId: 0,
tenantName: '',
status: robot.status == '在线' ? 1 : 0,
onlineStatus: robot.status == '在线' ? 1 : 0,
);
final remoteCubit = GetIt.I<RemoteControlCubit>();
remoteCubit.setTargetDevice(device);
debugPrint(
'✅ [全部-EmbeddedRobotList] setTargetDevice 已调用',
);
// 2. 跳转到机器人控制页面
final robotMap = {
'name': robot.name,
'id': robot.id,
'alias': robot.alias,
'type': robot.type,
'status': robot.status,
'battery': robot.battery,
'task': robot.task,
};
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
RobotControlPage(robot: robot.toJson()),
RobotControlPage(robot: robotMap),
),
);
},
@@ -470,7 +663,11 @@ class DeviceStatusView extends StatelessWidget {
);
}
Widget _buildDroneStationList(BuildContext context, {bool embedded = false}) {
Widget _buildDroneStationList(
BuildContext context, {
bool embedded = false,
Key? key,
}) {
// 从全局 SiteCubit 获取选中的场站 ID
final selectedSite = sl<SiteCubit>().state.selectedSite;
@@ -493,6 +690,7 @@ class DeviceStatusView extends StatelessWidget {
}
return BlocProvider(
key: key,
create: (_) =>
sl<DroneStationBloc>()..add(DroneStationLoadData(selectedSite.id)),
child: BlocConsumer<DroneStationBloc, DroneStationState>(
@@ -692,6 +890,96 @@ class DeviceStatusView extends StatelessWidget {
}
// 显示设备状态模态框 - 从底部滑出
/// 显示添加菜单(扫一扫、添加设备)
void _showAddMenu(BuildContext context) {
showDialog(
context: context,
barrierColor: Colors.black38,
builder: (ctx) => Stack(
children: [
// 点击遮罩关闭
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: Container(color: Colors.transparent),
),
Positioned(
top: MediaQuery.of(context).padding.top + 50,
right: 16,
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
elevation: 8,
shadowColor: Colors.black26,
child: SizedBox(
width: 150,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildMenuOption(
ctx,
icon: Icons.qr_code_scanner,
label: '扫一扫',
onTap: () async {
Navigator.pop(ctx);
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const QrScannerPage(),
),
);
if (result != null && mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => BindDevicePage(scanResult: result),
),
);
}
},
),
const Divider(height: 1, color: Color(0xFFF2F3F5)),
_buildMenuOption(
ctx,
icon: Icons.add_circle_outline,
label: '添加设备',
onTap: () {
Navigator.pop(ctx);
// TODO: 添加设备功能
},
),
],
),
),
),
),
],
),
);
}
Widget _buildMenuOption(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(icon, color: const Color(0xFF4E5969), size: 20),
const SizedBox(width: 12),
Text(
label,
style: const TextStyle(fontSize: 15, color: Color(0xFF1D2129)),
),
],
),
),
);
}
void _showDeviceStatusModal(BuildContext context) {
showModalBottomSheet(
context: context,
@@ -794,9 +1082,11 @@ class DeviceStatusView extends StatelessWidget {
if (isRobot) {
// 跳转到机器人控制页面
debugPrint('🚀 [DeviceStatusPage] 跳转到机器人控制页面');
final deviceAlias = device.deviceAlias ?? '';
final robotMap = {
'name': deviceName,
'id': deviceId,
'alias': deviceAlias,
'type': deviceType,
'status': device.status ?? '在线',
'battery': 100.0, // DeviceEntity 没有 battery 字段,使用默认值

View File

@@ -30,7 +30,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
FlightTaskDetailEntity? _detailTask; // 详情数据
bool _isLoading = false;
final Dio _dio = Dio();
// 🔥 任务状态管理
bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮)
bool _isReturning = false; // 是否正在返航中
@@ -220,9 +220,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
print('🔍 [DroneMissionControl] 开始暂停任务, deviceSn: ${_detailTask!.sn}');
final useCase = GetIt.I<PauseFlightTaskUseCase>();
final result = await useCase.execute(
deviceSn: _detailTask!.sn,
);
final result = await useCase.execute(deviceSn: _detailTask!.sn);
result.fold(
(failure) {
@@ -737,7 +735,9 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
child: ElevatedButton(
onPressed: _isPaused ? _resumeTask : _pauseTask,
style: ElevatedButton.styleFrom(
backgroundColor: _isPaused ? const Color(0xFF165DFF) : const Color(0xFFFF7D00),
backgroundColor: _isPaused
? const Color(0xFF165DFF)
: const Color(0xFFFF7D00),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
@@ -747,7 +747,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
),
child: Text(
_isPaused ? '恢复任务' : '暂停任务',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
),
@@ -756,8 +759,14 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
child: OutlinedButton(
onPressed: _isReturning ? null : _returnHome,
style: OutlinedButton.styleFrom(
foregroundColor: _isReturning ? const Color(0xFF86909C) : const Color(0xFF4E5969),
side: BorderSide(color: _isReturning ? const Color(0xFFE5E6EB) : const Color(0xFFC9CDD4)),
foregroundColor: _isReturning
? const Color(0xFF86909C)
: const Color(0xFF4E5969),
side: BorderSide(
color: _isReturning
? const Color(0xFFE5E6EB)
: const Color(0xFFC9CDD4),
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
@@ -765,7 +774,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
),
child: Text(
_isReturning ? '已在返航' : '返航降落',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
),

View File

@@ -3,12 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/managers/drone_task_state_manager.dart';
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
import '../../domain/entities/drone_station_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
import '../widgets/drone_station_osd_card.dart'; // 🔥 添加机场 OSD 卡片
import '../widgets/drone_osd_card.dart'; // 🔥 添加无人机 OSD 卡片
import '../widgets/drone_station_osd_card.dart';
import '../widgets/drone_osd_card.dart';
import 'drone_video_control_page.dart';
import 'drone_mission_control_page.dart';
import 'drone_monitor_page.dart';
@@ -26,16 +28,17 @@ class DroneStationDetailPage extends StatefulWidget {
class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
late DroneStationBloc _bloc;
// 无人机详情数据
UAVDetailEntity? _detail;
String? _droneSn;
// 无人机状态轮询计时器
Timer? _droneStatusPollingTimer;
// 🔥 标记是否已经初始化过(用于判断是否从其他页面返回)
bool _hasInitialized = false;
late DroneOsdDataSource _osdDataSource;
StreamSubscription<DroneOsdEntity>? _stationOsdSubscription;
final ValueNotifier<Map<String, dynamic>> _stationHostData =
ValueNotifier<Map<String, dynamic>>({});
@override
void initState() {
super.initState();
@@ -46,20 +49,78 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
deviceSn: widget.station.deviceSn,
),
);
// 🔥 标记已初始化
_hasInitialized = true;
// 启动无人机状态轮询(每5秒刷新一次)
// 🔥 已禁用自动轮询,改为手动下拉刷新
// _startDroneStatusPolling();
_osdDataSource = sl<DroneOsdDataSource>();
_startStationOsdListening();
_hasInitialized = true;
}
void _startStationOsdListening() {
_osdDataSource.startListening(
deviceSn: '',
gatewaySn: widget.station.gatewaySn,
);
_stationOsdSubscription = _osdDataSource.stationOsdStream.listen((osd) {
if (!mounted) return;
_parseAndUpdateHostData(osd);
});
}
void _parseAndUpdateHostData(DroneOsdEntity osd) {
final data = osd.rawData;
final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null;
if (hostData == null || hostData is! Map) return;
final Map<String, dynamic> parsed = {};
parsed['environment_temperature'] =
(hostData['environment_temperature'] as num?)?.toDouble();
parsed['humidity'] = (hostData['humidity'] as num?)?.toDouble();
parsed['wind_speed'] = (hostData['wind_speed'] as num?)?.toDouble();
parsed['rainfall'] = hostData['rainfall']?.toString();
parsed['cover_state'] = hostData['cover_state']?.toString();
parsed['drone_in_dock'] = hostData['drone_in_dock']?.toString();
parsed['temperature'] = (hostData['temperature'] as num?)?.toDouble();
parsed['putter_state'] = hostData['putter_state']?.toString();
parsed['supplement_light_state'] = hostData['supplement_light_state']
?.toString();
parsed['alarm_state'] = hostData['alarm_state']?.toString();
parsed['emergency_stop_state'] = hostData['emergency_stop_state']
?.toString();
parsed['silent_mode'] = hostData['silent_mode']?.toString();
parsed['mode_code'] = hostData['mode_code']?.toString();
parsed['heading'] = (hostData['heading'] as num?)?.toDouble();
parsed['height'] = (hostData['height'] as num?)?.toDouble();
parsed['latitude'] = (hostData['latitude'] as num?)?.toDouble();
parsed['longitude'] = (hostData['longitude'] as num?)?.toDouble();
parsed['home_position_is_valid'] = hostData['home_position_is_valid']
?.toString();
parsed['battery_store_mode'] = hostData['battery_store_mode']?.toString();
parsed['first_power_on'] = hostData['first_power_on']?.toString();
parsed['drone_charge_state'] = hostData['drone_charge_state'];
parsed['air_conditioner'] = hostData['air_conditioner'];
parsed['network_state'] = hostData['network_state'];
parsed['position_state'] = hostData['position_state'];
parsed['storage'] = hostData['storage'];
parsed['sub_device'] = hostData['sub_device'];
parsed['alternate_land_point'] = hostData['alternate_land_point'];
if (hostData['air_conditioner'] is Map) {
final ac = hostData['air_conditioner'] as Map;
parsed['air_conditioner_state'] = ac['air_conditioner_state']?.toString();
parsed['air_conditioner_switch_time'] = ac['switch_time']?.toString();
}
_stationHostData.value = Map<String, dynamic>.from(parsed);
}
/// 🔥 页面重新激活时调用(从其他页面返回时)
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新)
if (_hasInitialized && _bloc.state is UAVDetailLoaded) {
debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据');
@@ -75,7 +136,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
/// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新)
void _refreshData() {
if (!mounted) return;
debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据');
_bloc.add(
UAVDetailLoad(
@@ -87,9 +148,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
@override
void dispose() {
_stationOsdSubscription?.cancel();
_osdDataSource.stopListening();
_stationHostData.dispose();
_bloc.close();
// 🔥 已禁用自动轮询,无需停止
// _droneStatusPollingTimer?.cancel();
super.dispose();
}
@@ -216,27 +278,41 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
}
Widget _buildContent(UAVDetailEntity detail) {
_detail = detail; // 保存详情数据供其他方法使用
_droneSn = detail.deviceSn; // 保存无人机序列号
_detail = detail;
_droneSn = detail.deviceSn;
if (_droneSn != null && _droneSn!.isNotEmpty) {
_osdDataSource.startListening(
deviceSn: _droneSn!,
gatewaySn: widget.station.gatewaySn,
);
}
return RefreshIndicator(
onRefresh: _handleRefresh,
color: const Color(0xFF165DFF),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildAirportStatusCard(detail),
ValueListenableBuilder<Map<String, dynamic>>(
valueListenable: _stationHostData,
builder: (context, hostData, _) {
return _buildAirportStatusCard(detail, hostData);
},
),
const SizedBox(height: 12),
// 🔥 添加机场 OSD 实时数据卡片
DroneStationOsdCard(
stationOsdStream: _osdDataSource.stationOsdStream,
gatewaySn: widget.station.gatewaySn,
isOnline: detail.isOnline,
),
const SizedBox(height: 12),
// 🔥 添加无人机 OSD 实时数据卡片(无人机在线时显示)
DroneOsdCard(
droneOsdStream: _osdDataSource.droneOsdStream,
deviceSn: widget.station.deviceSn,
gatewaySn: widget.station.gatewaySn,
isDroneOnline: detail.isDroneOnline,
onRefresh: _refreshData,
),
const SizedBox(height: 12),
_buildMonitorCard(),
@@ -253,10 +329,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
/// 处理下拉刷新
Future<void> _handleRefresh() async {
debugPrint('🔄 [DroneStationDetailPage] 开始下拉刷新');
// 🔥 创建一个 Completer 来等待 Bloc 状态更新
final completer = Completer<void>();
// 监听 Bloc 状态变化
final subscription = _bloc.stream.listen((state) {
if (state is UAVDetailLoaded || state is UAVDetailError) {
@@ -265,7 +341,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
}
}
});
// 重新加载无人机详情
_bloc.add(
UAVDetailLoad(
@@ -273,7 +349,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
deviceSn: widget.station.deviceSn,
),
);
// 🔥 等待数据加载完成(最多等待5秒)
await completer.future.timeout(
const Duration(seconds: 5),
@@ -281,10 +357,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时');
},
);
// 取消订阅
subscription.cancel();
debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成');
}
@@ -343,7 +419,90 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
}
Widget _buildAirportStatusCard(UAVDetailEntity detail) {
Widget _buildAirportStatusCard(
UAVDetailEntity detail,
Map<String, dynamic> hostData,
) {
final envTemp = hostData['environment_temperature'];
final humidity = hostData['humidity'];
final windSpeed = hostData['wind_speed'];
final rainfall = hostData['rainfall'];
final coverState = hostData['cover_state'];
final droneInDock = hostData['drone_in_dock'];
final acState = hostData['air_conditioner_state'];
final alarmState = hostData['alarm_state'];
String envTempStr = '未知';
if (envTemp != null) {
envTempStr = '${envTemp.toStringAsFixed(1)}°C';
} else if (detail.environmentTemperature != null) {
envTempStr = '${detail.environmentTemperature}°C';
}
String humidityStr = '未知';
if (humidity != null) {
humidityStr = '${humidity.toStringAsFixed(0)}%';
}
String windStr = '未知';
if (windSpeed != null) {
windStr = '${windSpeed.toStringAsFixed(1)} m/s';
} else if (detail.windSpeed != null) {
windStr = '${detail.windSpeed} m/s';
}
String rainfallStr = '未知';
if (rainfall != null) {
rainfallStr = _formatRainfall(rainfall);
} else if (detail.rainfall != null) {
rainfallStr = _formatRainfall(detail.rainfall);
}
String coverStr = '未知';
Color coverColor = const Color(0xFF86909C);
if (coverState != null) {
final state = int.tryParse(coverState) ?? -1;
if (state == 1) {
coverStr = '开启';
coverColor = const Color(0xFFFF7D00);
} else if (state == 0) {
coverStr = '关闭';
coverColor = const Color(0xFF00B42A);
}
}
String droneDockStr = '未知';
Color droneDockColor = const Color(0xFF86909C);
if (droneInDock != null) {
final state = int.tryParse(droneInDock) ?? -1;
if (state == 1) {
droneDockStr = '在库内';
droneDockColor = const Color(0xFF00B42A);
} else if (state == 0) {
droneDockStr = '出库';
droneDockColor = const Color(0xFFFF7D00);
}
}
String acStr = '未知';
Color acColor = const Color(0xFF86909C);
if (acState != null) {
final state = int.tryParse(acState) ?? -1;
if (state == 1) {
acStr = '开启';
acColor = const Color(0xFF00B42A);
} else if (state == 0) {
acStr = '关闭';
acColor = const Color(0xFF86909C);
}
}
bool hasAlarm = false;
if (alarmState != null) {
final state = int.tryParse(alarmState) ?? 0;
hasAlarm = state != 0;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -371,6 +530,37 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
),
),
const Spacer(),
if (hasAlarm)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFF53F3F).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber,
size: 14,
color: Color(0xFFF53F3F),
),
SizedBox(width: 4),
Text(
'告警',
style: TextStyle(
fontSize: 12,
color: Color(0xFFF53F3F),
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
@@ -392,9 +582,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
),
],
),
const SizedBox(height: 16),
const Divider(height: 1, color: Color(0xFFF2F3F5)),
const SizedBox(height: 16),
const SizedBox(height: 12),
_buildInfoRow(
'机场名称',
detail.callsign.isNotEmpty ? detail.callsign : '未知',
@@ -413,17 +601,13 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
? '${detail.capacityPercent}%'
: '未知',
),
_buildInfoRow(
'环境温度',
detail.environmentTemperature != null
? '${detail.environmentTemperature}°C'
: '未知',
),
_buildInfoRow(
'风速',
detail.windSpeed != null ? '${detail.windSpeed} m/s' : '未知',
),
_buildInfoRow('降雨量', _formatRainfall(detail.rainfall)),
_buildInfoRowColor('环境温度', envTempStr, const Color(0xFF165DFF)),
_buildInfoRowColor('湿度', humidityStr, const Color(0xFF00B42A)),
_buildInfoRowColor('风速', windStr, const Color(0xFF722ED1)),
_buildInfoRowColor('降雨量', rainfallStr, const Color(0xFF00B42A)),
_buildInfoRowColor('机库舱门', coverStr, coverColor),
_buildInfoRowColor('无人机位置', droneDockStr, droneDockColor),
_buildInfoRowColor('空调状态', acStr, acColor),
_buildInfoRow('网络状态', detail.networkState?.toString() ?? '未知'),
_buildPositionStateRow('位置状态', detail.positionState),
],
@@ -431,6 +615,41 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
);
}
Widget _buildInfoRowColor(String label, String value, Color valueColor) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
),
const SizedBox(width: 12),
const Text(
':',
style: TextStyle(fontSize: 12, color: Color(0xFFC0C4CC)),
),
const SizedBox(width: 8),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 12,
color: valueColor,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.left,
),
),
],
),
);
}
Widget _buildPositionStateRow(String label, PositionState? positionState) {
String value = '未知';
if (positionState != null) {

View File

@@ -229,6 +229,18 @@ class _DroneStationStatusPageState extends State<DroneStationStatusPage> {
const SizedBox(height: 12),
_buildStatusRow('舱门状态', '关闭', status: '正常'),
const SizedBox(height: 12),
_buildStatusRow(
'机场空调',
_uavDetail?.airConditionerStatus ?? '未开启',
status: (_uavDetail?.airConditionerStatus == '开启') ? '正常' : '关闭',
),
const SizedBox(height: 12),
_buildStatusRow(
'机库状态',
_uavDetail?.hangarStatus ?? '关闭',
status: (_uavDetail?.hangarStatus == '开启') ? '正常' : '关闭',
),
const SizedBox(height: 12),
_buildStatusRowWithProgress('充电状态', '充电中', '85%'),
const SizedBox(height: 12),
_buildWeatherRow(),
@@ -319,8 +331,6 @@ class _DroneStationStatusPageState extends State<DroneStationStatusPage> {
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 8),
_buildDroneBatteryRow(),
],
),
),

View File

@@ -1,11 +1,16 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dio/dio.dart';
import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../../../../core/consts/http_api_consts.dart';
import '../../../../../core/di/injection.dart';
import '../../../../../core/managers/drone_task_state_manager.dart';
import '../../../../../core/network/dio_client.dart';
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
@@ -47,6 +52,11 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
List<CameraInfo> _effectiveCameraList = []; // 实际使用的摄像头列表
List<CameraInfo>? _backupCameraList; // 备选摄像头列表(网关摄像头)
// 暂停状态
bool _isPaused = false;
// 任务下发后等待视频流(用于显示“无人机已启动 视频获取中”toast)
bool _isWaitingForVideo = false;
// 火山引擎 RTC
volc.RTCEngine? _rtcEngine;
volc.RTCRoom? _rtcRoom;
@@ -64,6 +74,9 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
List<LatLng> _trajectoryPoints = [];
LatLng? _currentPosition;
double? _currentHeading;
/// 从全局管理器恢复的轨迹点(避免退出后丢失)
bool _hasRestoredTrajectory = false;
StreamSubscription<DroneOsdEntity>? _osdSubscription;
DroneOsdDataSource? _droneOsdDataSource;
@@ -72,6 +85,23 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
super.initState();
_bloc = sl<DroneStationBloc>();
// 🔥 初始化"等待视频流"状态(任务下发后进入此页面时显示 toast)
_isWaitingForVideo = droneTaskStateManager.isWaitingForVideo.value;
droneTaskStateManager.isWaitingForVideo.addListener(_onVideoWaitingChanged);
// 🔥 从全局管理器恢复历史轨迹(退出视频页后重新进入时不丢失)
final savedPoints = droneTaskStateManager.trajectoryPoints.value;
if (savedPoints.isNotEmpty) {
_trajectoryPoints = savedPoints
.map((p) => LatLng(p.latitude, p.longitude))
.toList();
_currentPosition = _trajectoryPoints.last;
_hasRestoredTrajectory = true;
debugPrint(
'🗺️ [DroneVideoControlPage] 恢复历史轨迹: ${_trajectoryPoints.length} 个点',
);
}
// 🔥 初始化 MQTT OSD 数据源
_droneOsdDataSource = sl<DroneOsdDataSource>();
_startOsdListening();
@@ -123,11 +153,101 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
// 默认加载广角镜头
if (_currentCamera != null) {
_loadVideoStream(UavLensType.wide);
} else {
// 摄像头列表为空(任务下发后无人机刚上线,详情接口尚未返回摄像头数据)
// 自动获取无人机详情,拿到摄像头列表
_fetchDroneDetailAndLoadCamera();
}
}
/// 自动获取无人机详情,拿到摄像头列表
/// 任务下发后无人机刚上线,传入的 cameraList 可能为空
Future<void> _fetchDroneDetailAndLoadCamera() async {
debugPrint('🔍 [DroneVideoControlPage] 摄像头列表为空,自动获取无人机详情');
try {
final response = await Dio().get(
HttpApiConsts.getUAVDetail,
queryParameters: {
'gatewaySn': widget.gatewaySn,
'deviceSn': widget.droneSn,
},
);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonData = (response.data is String)
? json.decode(response.data)
: Map<String, dynamic>.from(response.data);
if (jsonData['code'] == 0 || jsonData['code'] == 200) {
final detailData = jsonData['data'];
final Map<String, dynamic> detailMap = (detailData is Map)
? Map<String, dynamic>.from(detailData)
: <String, dynamic>{};
final droneDetail = UAVDetailEntity.fromJson(detailMap);
final cameras = droneDetail.droneCameraList ?? [];
debugPrint('✅ [DroneVideoControlPage] 获取到摄像头列表: ${cameras.length}');
if (cameras.isNotEmpty && mounted) {
setState(() {
_effectiveCameraList = cameras;
_currentCamera = cameras.first;
_useBackupSource = false;
});
_loadVideoStream(UavLensType.wide);
return;
}
}
}
// 如果无人机摄像头仍然为空,尝试网关摄像头
if (mounted) {
_tryGatewayCameraFallback();
}
} catch (e) {
debugPrint('❌ [DroneVideoControlPage] 获取无人机详情失败: $e');
if (mounted) {
_tryGatewayCameraFallback();
}
}
}
/// 尝试使用网关摄像头作为备选
void _tryGatewayCameraFallback() {
if (widget.gatewayCameraList != null &&
widget.gatewayCameraList!.isNotEmpty &&
_currentCamera == null) {
debugPrint('⚠️ [DroneVideoControlPage] 使用网关摄像头作为备选');
setState(() {
_effectiveCameraList = widget.gatewayCameraList!;
_currentCamera = _effectiveCameraList.first;
_useBackupSource = true;
});
_loadVideoStream(UavLensType.wide);
} else {
setState(() {
_errorMessage = '没有可用的摄像头';
_isLoading = false;
});
}
}
void _onVideoWaitingChanged() {
if (!mounted) return;
final waiting = droneTaskStateManager.isWaitingForVideo.value;
if (waiting != _isWaitingForVideo) {
setState(() {
_isWaitingForVideo = waiting;
});
}
}
@override
void dispose() {
droneTaskStateManager.isWaitingForVideo.removeListener(
_onVideoWaitingChanged,
);
_osdSubscription?.cancel();
_droneOsdDataSource?.dispose();
_mapController?.dispose();
@@ -253,13 +373,16 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
gatewaySn: widget.gatewaySn,
);
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen((osdData) {
if (!mounted) return;
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
_handleOsdUpdate(osdData);
}, onError: (error) {
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
});
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen(
(osdData) {
if (!mounted) return;
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
_handleOsdUpdate(osdData);
},
onError: (error) {
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
},
);
debugPrint('✅ [DroneVideoControlPage] OSD 监听已启动');
}
@@ -268,103 +391,110 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
void _handleOsdUpdate(DroneOsdEntity osdData) {
// 从 rawData 中提取位置信息
final rawData = osdData.rawData;
// 🔥 尝试从嵌套结构中获取经纬度
double? lat;
double? lng;
double? heading;
// 路径1: rawData['data']['host']['99-0-0']['measure_target_latitude'] (无人机)
if (rawData['data'] is Map &&
(rawData['data'] as Map)['host'] is Map) {
if (rawData['data'] is Map && (rawData['data'] as Map)['host'] is Map) {
final host = (rawData['data'] as Map)['host'] as Map;
// 尝试从 99-0-0 载荷获取(无人机)
if (host.containsKey('99-0-0') && host['99-0-0'] is Map) {
final payload = host['99-0-0'] as Map;
lat = (payload['measure_target_latitude'] as num?)?.toDouble();
lng = (payload['measure_target_longitude'] as num?)?.toDouble();
debugPrint('✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng');
debugPrint(
'✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng',
);
}
// 如果 99-0-0 中没有,尝试从 host 直接获取(机场)
if (lat == null || lng == null) {
lat = (host['latitude'] as num?)?.toDouble();
lng = (host['longitude'] as num?)?.toDouble();
if (lat != null && lng != null) {
debugPrint('✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng');
debugPrint(
'✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng',
);
}
}
// 获取航向角
heading = (host['attitude_head'] as num?)?.toDouble() ??
(host['heading'] as num?)?.toDouble();
heading =
(host['attitude_head'] as num?)?.toDouble() ??
(host['heading'] as num?)?.toDouble();
}
// 兼容旧格式:直接从 rawData 获取
if (lat == null || lng == null) {
lat = lat ?? (rawData['latitude'] as num?)?.toDouble() ??
(rawData['lat'] as num?)?.toDouble();
lng = lng ?? (rawData['longitude'] as num?)?.toDouble() ??
(rawData['lng'] as num?)?.toDouble() ??
(rawData['lon'] as num?)?.toDouble();
heading = heading ?? (rawData['heading'] as num?)?.toDouble() ??
(rawData['attitudeHeading'] as num?)?.toDouble();
lat =
lat ??
(rawData['latitude'] as num?)?.toDouble() ??
(rawData['lat'] as num?)?.toDouble();
lng =
lng ??
(rawData['longitude'] as num?)?.toDouble() ??
(rawData['lng'] as num?)?.toDouble() ??
(rawData['lon'] as num?)?.toDouble();
heading =
heading ??
(rawData['heading'] as num?)?.toDouble() ??
(rawData['attitudeHeading'] as num?)?.toDouble();
}
debugPrint('🛰️ [DroneVideoControlPage] 收到 OSD 数据');
debugPrint(' lat=$lat, lng=$lng, heading=$heading');
debugPrint(' 当前轨迹点数: ${_trajectoryPoints.length}');
debugPrint(' 当前位置: $_currentPosition');
// 验证位置有效性
if (lat != null && lng != null && lat.abs() <= 90 && lng.abs() <= 180) {
final newPos = LatLng(lat, lng);
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
bool shouldAddPoint = false;
if (_trajectoryPoints.isEmpty) {
shouldAddPoint = true;
} else {
final distance = _calculateDistance(
_trajectoryPoints.last.latitude,
_trajectoryPoints.last.longitude,
lat,
lng,
);
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
shouldAddPoint = distance > 0.5;
}
if (shouldAddPoint) {
_trajectoryPoints.add(newPos);
// 性能优化:只保留最近 1000 个点
if (_trajectoryPoints.length > 1000) {
_trajectoryPoints.removeAt(0);
}
// 🔥 同步到全局管理器(退出页面后不丢失)
droneTaskStateManager.addTrajectoryPoint(
DroneTrajectoryPoint(latitude: lat, longitude: lng, heading: heading),
);
}
setState(() {
// 更新当前位置(驱动飞机 Marker)
_currentPosition = newPos;
_currentHeading = heading;
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
if (_trajectoryPoints.isEmpty) {
_trajectoryPoints.add(newPos);
debugPrint('✅ [DroneVideoControlPage] 添加第一个轨迹点: $newPos');
// 🔥 重要:第一个点添加后,等待 UI 构建完成再移动地图
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_mapController != null && mounted) {
_mapController!.move(newPos, 18); // 缩放到 18 级
debugPrint('🗺️ [DroneVideoControlPage] 首次定位到: $newPos');
}
});
} else {
final distance = _calculateDistance(
_trajectoryPoints.last.latitude,
_trajectoryPoints.last.longitude,
lat!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
lng!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
);
debugPrint(' 📏 距离上一个点: ${distance.toStringAsFixed(2)} 米');
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
if (distance > 0.5) {
_trajectoryPoints.add(newPos);
debugPrint('✅ [DroneVideoControlPage] 添加新轨迹点,当前总数: ${_trajectoryPoints.length}');
// 性能优化:只保留最近 1000 个点
if (_trajectoryPoints.length > 1000) {
_trajectoryPoints.removeAt(0);
}
} else {
debugPrint('⚠️ [DroneVideoControlPage] 距离不足0.5米(${distance.toStringAsFixed(2)}m),跳过此点');
}
}
});
// 地图跟随:后续点也移动地图(保持飞机在视野中)
// 地图跟随:保持飞机在视野中
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_mapController != null && mounted) {
_mapController!.move(newPos, _mapController!.camera.zoom);
debugPrint('🗺️ [DroneVideoControlPage] 地图已移动到: $newPos');
if (_trajectoryPoints.length == 1) {
_mapController!.move(newPos, 18); // 首次定位缩放到 18 级
} else {
_mapController!.move(newPos, _mapController!.camera.zoom);
}
}
});
} else {
@@ -373,12 +503,20 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
}
/// 🔥 计算两点之间的距离(米)
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
double _calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const p = 0.017453292519943295; // Math.PI / 180
final a = 0.5 -
final a =
0.5 -
cos((lat2 - lat1) * p) / 2 +
cos(lat1 * p) * cos(lat2 * p) * (1 - cos((lon2 - lon1) * p)) / 2;
return 12742 * asin(sqrt(a)) * 1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
return 12742 *
asin(sqrt(a)) *
1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
}
// 初始化火山引擎事件处理器
@@ -416,6 +554,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
);
_isLoading = false;
});
// 收到视频流,隐藏“无人机已启动 视频获取中”toast
droneTaskStateManager.markVideoReceived();
}
};
@@ -664,58 +804,111 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
),
],
),
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
body: Stack(
fit: StackFit.expand,
children: [
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
if (state.videoStream.urlType == 'volc') {
_destroyRtcEngine();
_initRtcEngine(state.videoStream);
} else {
setState(() {
_errorMessage = '不支持的 URL 类型: ${state.videoStream.urlType}';
_isLoading = false;
});
}
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = '暂无视频';
_isLoading = false;
});
}
},
builder: (context, state) {
return Column(
children: [
_buildTabBar(),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildVideoPlayer(),
const SizedBox(height: 12),
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
_buildTrajectoryMap(),
const SizedBox(height: 12),
_buildFlightData(),
const SizedBox(height: 12),
_buildAIResults(),
const SizedBox(height: 12),
// 🔥 摇杆控制(单独一行)
_buildJoystickControl(),
const SizedBox(height: 16),
_buildBottomToolbar(),
],
),
),
],
);
},
if (state.videoStream.urlType == 'volc') {
_destroyRtcEngine();
_initRtcEngine(state.videoStream);
} else {
setState(() {
_errorMessage =
'不支持的 URL 类型: ${state.videoStream.urlType}';
_isLoading = false;
});
}
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = '暂无视频';
_isLoading = false;
});
}
},
builder: (context, state) {
return Column(
children: [
_buildTabBar(),
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildVideoPlayer(),
const SizedBox(height: 12),
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
_buildTrajectoryMap(),
const SizedBox(height: 12),
_buildFlightData(),
const SizedBox(height: 12),
_buildAIResults(),
const SizedBox(height: 12),
// 抓拍/录像/变焦/补光灯(挪到滚动区内)
_buildBottomToolbar(),
],
),
),
// 🔥 固定底部栏:方向控制 + 暂停 + 返航
_buildFixedBottomBar(),
],
);
},
),
if (_isWaitingForVideo)
Positioned.fill(
child: IgnorePointer(child: _buildVideoWaitingToast()),
),
],
),
),
);
}
/// 任务下发后等待视频流时的居中提示
Widget _buildVideoWaitingToast() {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: const Color(0xE61D2129),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x29000000),
blurRadius: 16,
offset: Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
SizedBox(height: 12),
Text(
'无人机已启动 视频获取中',
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
),
],
),
),
);
@@ -1073,7 +1266,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
debugPrint(' currentPosition: $_currentPosition');
debugPrint(' currentHeading: $_currentHeading');
debugPrint(' trajectoryPoints.length: ${_trajectoryPoints.length}');
return Container(
height: 200,
decoration: BoxDecoration(
@@ -1087,9 +1280,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
children: [
// 🔥 地图
FlutterMap(
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
options: MapOptions(
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
initialCenter:
_currentPosition ?? const LatLng(39.9042, 116.4074),
initialZoom: 18,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
@@ -1098,7 +1292,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
children: [
// 高德地图瓦片(最底层)
TileLayer(
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
urlTemplate:
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
subdomains: ['1', '2', '3', '4'],
userAgentPackageName: 'com.example.app',
),
@@ -1178,7 +1373,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
@@ -1206,81 +1404,138 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
);
}
/// 🔥 摇杆控制(单独一行)
Widget _buildJoystickControl() {
/// 🔥 固定底部栏:暂停 + 返航(不随页面滚动)
Widget _buildFixedBottomBar() {
return Container(
height: 200,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
boxShadow: [
BoxShadow(
color: Color(0x0D000000),
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
offset: const Offset(0, -2),
),
],
),
child: Stack(
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFC9CDD4),
shape: BoxShape.circle,
),
// 暂停/恢复按钮
_buildActionBtn(
label: _isPaused ? '恢复' : '暂停',
icon: _isPaused ? Icons.play_arrow : Icons.pause,
color: _isPaused
? const Color(0xFF00B42A)
: const Color(0xFFFF7D00),
onTap: () {
if (_isPaused) {
_sendFlightCommand('flighttask_recovery');
} else {
_sendFlightCommand('flighttask_pause');
}
setState(() => _isPaused = !_isPaused);
},
),
Positioned(
top: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_drop_up,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
Positioned(
bottom: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_drop_down,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
Positioned(
left: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_left,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
Positioned(
right: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_right,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
// 返航按钮
_buildActionBtn(
label: '返航',
icon: Icons.flight_land,
color: const Color(0xFF165DFF),
onTap: () {
_sendFlightCommand('return_home');
},
),
],
),
);
}
/// 发送飞行指令
Future<void> _sendFlightCommand(String command) async {
try {
final dio = DioClient.create();
final response = await dio.post(
'http://1.95.137.212:8081/iot/UAV/flightTaskCommand',
data: {'command': command, 'deviceSn': widget.droneSn},
);
debugPrint('✅ 飞行指令发送成功: $command, deviceSn: ${widget.droneSn}');
debugPrint('响应: ${response.data}');
String message;
switch (command) {
case 'flighttask_pause':
message = '已发送暂停指令';
break;
case 'flighttask_recovery':
message = '已发送恢复指令';
break;
case 'return_home':
message = '命令下达成功';
break;
default:
message = '指令发送成功';
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) {
debugPrint('❌ 飞行指令发送失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('指令发送失败'),
duration: Duration(seconds: 2),
),
);
}
}
}
Widget _buildActionBtn({
required String label,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 14,
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
Widget _buildMapAndJoystick() {
return Row(
children: [
@@ -1300,7 +1555,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
initialCenter:
_currentPosition ?? const LatLng(39.9042, 116.4074),
initialZoom: 18,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
@@ -1309,7 +1565,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
children: [
// 高德地图瓦片(最底层)
TileLayer(
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
urlTemplate:
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
subdomains: ['1', '2', '3', '4'],
userAgentPackageName: 'com.example.app',
),
@@ -1357,7 +1614,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(4),
@@ -1389,14 +1649,20 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'轨迹点: ${_trajectoryPoints.length}',
style: const TextStyle(fontSize: 10, color: Colors.white),
style: const TextStyle(
fontSize: 10,
color: Colors.white,
),
),
),
),

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class QrScannerPage extends StatefulWidget {
const QrScannerPage({super.key});
@override
State<QrScannerPage> createState() => _QrScannerPageState();
}
class _QrScannerPageState extends State<QrScannerPage> {
final MobileScannerController _controller = MobileScannerController();
bool _isScanned = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final String? code = barcodes.first.rawValue;
if (code != null && code.isNotEmpty) {
_isScanned = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && Navigator.canPop(context)) {
Navigator.pop(context, code);
}
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
MobileScanner(controller: _controller, onDetect: _onDetect),
SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
IconButton(
icon: const Icon(
Icons.close,
color: Colors.white,
size: 28,
),
onPressed: () => Navigator.of(context).pop(),
),
const Expanded(
child: Center(
child: Text(
'扫一扫',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 48),
],
),
),
const Spacer(),
Center(
child: Container(
width: 250,
height: 250,
decoration: BoxDecoration(
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 2,
),
borderRadius: BorderRadius.circular(12),
),
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
child: Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
border: Border(
top: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
left: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
),
),
),
),
Positioned(
top: 0,
right: 0,
child: Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
border: Border(
top: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
right: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
),
borderRadius: BorderRadius.only(
topRight: Radius.circular(12),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
child: Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
left: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
),
),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
right: BorderSide(
color: Color(0xFF165DFF),
width: 3,
),
),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(12),
),
),
),
),
],
),
),
),
const SizedBox(height: 32),
const Text(
'将二维码放入框内,即可自动扫描',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 40),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildActionButton(
icon: Icons.flash_on,
label: '手电筒',
onTap: () async {
await _controller.toggleTorch();
},
),
],
),
),
],
),
),
],
),
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 24),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
);
}
}

View File

@@ -0,0 +1,766 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../../../core/bluetooth/ble_manager.dart';
import '../pages/ble_device_detail_page.dart';
class BluetoothScanModal extends StatefulWidget {
const BluetoothScanModal({super.key});
@override
State<BluetoothScanModal> createState() => _BluetoothScanModalState();
}
class _BluetoothScanModalState extends State<BluetoothScanModal> {
BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
List<ScanResult> _scanResults = [];
bool _isConnecting = false;
BluetoothDevice? _connectingDevice;
BluetoothDevice? _connectedDevice;
int _connectingCountdown = 15;
Timer? _connectingTimer;
bool _isStartingScan = false;
StreamSubscription<BluetoothAdapterState>? _stateSubscription;
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothDevice?>? _connectionSubscription;
StreamSubscription<BluetoothDevice?>? _connectingSubscription;
@override
void initState() {
super.initState();
_initBluetooth();
}
Future<void> _initBluetooth() async {
// 先设置扫描结果监听,确保 startScan 前订阅已就绪
_scanSubscription = BleManager.instance.scanResults.listen((results) {
if (mounted) {
final mgrConnected = BleManager.instance.connectedDevice;
setState(() {
_connectedDevice = mgrConnected;
_scanResults = _sortResults(results);
});
}
});
// 先设置连接状态监听
_connectionSubscription = BleManager.instance.connectionStream.listen((
device,
) {
if (!mounted) return;
setState(() {
_connectedDevice = device;
_isConnecting = false;
if (device != null) {
_scanResults = _sortResults(_scanResults);
}
});
});
// 监听连接中状态(跨页面同步:详情页连接时列表页也能看到"连接中")
_connectingSubscription = BleManager.instance.connectingStream.listen((
device,
) {
if (!mounted) return;
setState(() {
if (device != null) {
_isConnecting = true;
_connectingDevice = device;
} else {
_isConnecting = false;
_connectingDevice = null;
}
});
});
// 初始检查蓝牙状态并启动扫描(只调用一次)
final currentState = await BleManager.instance.checkBluetooth();
if (mounted) {
setState(() {
_adapterState = currentState
? BluetoothAdapterState.on
: BluetoothAdapterState.off;
});
if (currentState) {
_startScan();
}
}
// 监听后续蓝牙开关变化(用户操作)
_stateSubscription = BleManager.instance.adapterState.listen((state) {
if (mounted) {
setState(() => _adapterState = state);
if (state == BluetoothAdapterState.on) {
_startScan();
}
}
});
}
Future<void> _startScan() async {
if (_isStartingScan) return;
_isStartingScan = true;
try {
await BleManager.instance.requestPermissions();
if (!mounted) return;
await BleManager.instance.startScan(continuous: true);
} finally {
_isStartingScan = false;
}
}
Future<void> _refreshScan() async {
setState(() {
_scanResults = [];
});
await BleManager.instance.refreshScan();
if (!mounted) return;
}
Future<void> _openBluetooth() async {
await BleManager.instance.openBluetooth();
}
/// 排序:已连接设备排第一,有名称设备优先
List<ScanResult> _sortResults(List<ScanResult> results) {
final mgrConnected = BleManager.instance.connectedDevice;
final sorted = List<ScanResult>.from(results);
sorted.sort((a, b) {
// 第一级:已连接排最前
final aConnected = a.device.remoteId == mgrConnected?.remoteId;
final bConnected = b.device.remoteId == mgrConnected?.remoteId;
if (aConnected && !bConnected) return -1;
if (!aConnected && bConnected) return 1;
// 第二级:有名称的排前面
final aHasName = _hasDeviceName(a);
final bHasName = _hasDeviceName(b);
if (aHasName && !bHasName) return -1;
if (!aHasName && bHasName) return 1;
return 0;
});
return sorted;
}
/// 判断设备是否有可读名称(非 MAC 地址)
bool _hasDeviceName(ScanResult r) {
final advData = r.advertisementData;
return advData.advName.isNotEmpty ||
r.device.platformName.isNotEmpty ||
(advData.localName?.isNotEmpty == true) ||
r.device.advName.isNotEmpty;
}
/// 从详情页返回时同步连接状态(包括连接中和已连接)
void _syncConnectedDevice() {
final mgrConnected = BleManager.instance.connectedDevice;
final mgrConnecting = BleManager.instance.connectingDevice;
if (mgrConnected?.remoteId != _connectedDevice?.remoteId ||
mgrConnecting?.remoteId != _connectingDevice?.remoteId) {
setState(() {
_connectedDevice = mgrConnected;
_connectingDevice = mgrConnecting;
_isConnecting = mgrConnecting != null;
_scanResults = _sortResults(_scanResults);
});
}
}
Future<void> _connectDevice(ScanResult result) async {
// 如果已连接其他设备,弹出提示
final currentConnected = BleManager.instance.connectedDevice;
if (currentConnected != null &&
currentConnected.remoteId != result.device.remoteId) {
final connName = currentConnected.platformName.isNotEmpty
? currentConnected.platformName
: '${currentConnected.remoteId}';
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('已有设备连接'),
content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('知道了'),
),
],
),
);
return;
}
setState(() {
_isConnecting = true;
_connectingDevice = result.device;
_connectingCountdown = 15;
});
// 启动倒计时
_connectingTimer?.cancel();
_connectingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_connectingCountdown > 0) {
_connectingCountdown--;
}
});
});
final error = await BleManager.instance.connect(result.device);
// 取消倒计时
_connectingTimer?.cancel();
_connectingTimer = null;
if (!mounted) return;
setState(() {
_isConnecting = false;
_connectingDevice = null;
if (error == null) {
_connectedDevice = result.device;
}
});
if (error == null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'已连接 ${result.device.platformName.isEmpty ? result.device.remoteId : result.device.platformName}',
),
),
);
} else if (error != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _disconnectDevice() async {
await BleManager.instance.disconnect();
if (mounted) {
setState(() => _connectedDevice = null);
}
}
bool get _isBluetoothOn => _adapterState == BluetoothAdapterState.on;
@override
void dispose() {
_stateSubscription?.cancel();
_scanSubscription?.cancel();
_connectionSubscription?.cancel();
_connectingSubscription?.cancel();
_connectingTimer?.cancel();
unawaited(BleManager.instance.stopScan());
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
onPopInvokedWithResult: (didPop, result) {
// 弹窗关闭时立即停止扫描,不等待 dispose() 动画延迟
BleManager.instance.stopScan();
},
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(context),
Flexible(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
shrinkWrap: true,
children: [
_buildStatusCard(),
if (_connectedDevice != null) ...[
const SizedBox(height: 12),
_buildConnectedDeviceCard(),
],
const SizedBox(height: 16),
_buildDeviceList(),
const SizedBox(height: 12),
_buildTipBar(),
],
),
),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFF1677FF),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'蓝牙设备',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
_connectedDevice != null
? '当前已连接: ${_connectedDevice!.platformName.isEmpty ? _connectedDevice!.remoteId : _connectedDevice!.platformName}'
: _isBluetoothOn
? '正在扫描周边设备...'
: '请先打开蓝牙',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white, size: 22),
onPressed: _isBluetoothOn ? _refreshScan : null,
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () {
BleManager.instance.stopScan();
Navigator.pop(context);
},
),
],
),
);
}
Widget _buildStatusCard() {
final isOn = _isBluetoothOn;
final hasConnected = _connectedDevice != null;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isOn ? const Color(0xFF165DFF) : const Color(0xFF86909C),
),
child: const Icon(Icons.bluetooth, color: Colors.white, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isOn ? '蓝牙已开启' : '蓝牙未打开',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 4),
Text(
hasConnected
? '已连接设备'
: isOn
? '正在扫描周边设备'
: '请打开蓝牙以扫描附近设备',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
),
),
],
),
),
isOn
? Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Color(0xFF00B42A),
shape: BoxShape.circle,
),
)
: GestureDetector(
onTap: _openBluetooth,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF165DFF),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'去打开',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
Widget _buildDeviceList() {
// 过滤掉已连接设备(它已在状态卡片下方单独显示)
final otherResults = _connectedDevice != null
? _scanResults
.where((r) => r.device.remoteId != _connectedDevice!.remoteId)
.toList()
: _scanResults;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'附近设备',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 12),
if (!_isBluetoothOn)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Column(
children: [
Icon(
Icons.bluetooth_disabled,
size: 48,
color: Color(0xFF86909C),
),
SizedBox(height: 12),
Text(
'蓝牙未开启,无法扫描设备',
style: TextStyle(color: Color(0xFF86909C)),
),
],
),
),
)
else if (otherResults.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Column(
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 12),
Text(
'正在扫描附近设备...',
style: TextStyle(color: Color(0xFF86909C)),
),
],
),
),
)
else
for (int i = 0; i < otherResults.length; i++) ...[
_buildDeviceItem(otherResults[i]),
if (i < otherResults.length - 1) const SizedBox(height: 10),
],
],
);
}
/// 已连接设备卡片(始终显示在状态卡片下方,不依赖扫描结果)
Widget _buildConnectedDeviceCard() {
final device = _connectedDevice!;
final name = device.platformName.isNotEmpty
? device.platformName
: (device.advName.isNotEmpty ? device.advName : '${device.remoteId}');
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BleDeviceDetailPage(device: device),
),
).then((_) => _syncConnectedDevice());
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF0FFF4),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF00B42A), width: 1),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF00B42A).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.bluetooth_connected,
color: Color(0xFF00B42A),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1D2129),
),
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF00B42A),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'已连接',
style: TextStyle(
fontSize: 10,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'${device.remoteId}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
),
),
],
),
),
GestureDetector(
onTap: _disconnectDevice,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFF00B42A),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'断开',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
Widget _buildDeviceItem(ScanResult result) {
final device = result.device;
final advData = result.advertisementData;
// 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的
final name = advData.advName.isNotEmpty
? advData.advName
: (device.platformName.isNotEmpty
? device.platformName
: (advData.localName?.isNotEmpty == true
? advData.localName!
: (device.advName.isNotEmpty
? device.advName
: '${device.remoteId}')));
final isConnected = _connectedDevice?.remoteId == device.remoteId;
final isConnecting =
_connectingDevice?.remoteId == device.remoteId && _isConnecting;
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
BleDeviceDetailPage(device: device, scanResult: result),
),
).then((_) => _syncConnectedDevice());
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF165DFF).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.devices,
color: Color(0xFF165DFF),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 4),
Text(
'${device.remoteId}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
),
),
],
),
),
GestureDetector(
onTap: () {
if (isConnected) {
_disconnectDevice();
} else if (!isConnecting) {
_connectDevice(result);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isConnected
? const Color(0xFF00B42A)
: const Color(0xFFFF7D00),
borderRadius: BorderRadius.circular(6),
),
child: isConnecting
? Text(
'${_connectingCountdown}s',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
)
: Text(
isConnected ? '已连接' : '连接',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
Widget _buildTipBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFFF7E6),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16),
SizedBox(width: 8),
Expanded(
child: Text(
'请选择现场本机设备,避免连接无关设备',
style: TextStyle(fontSize: 12, color: Color(0xFFFF7D00)),
),
),
],
),
);
}
}

View File

@@ -4,9 +4,11 @@ import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/app/app_user_state.dart'; // 🔥 导入 AppUserState
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart';
import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart';
/// 机器人顶部信息卡片
class RobotHeaderCard extends StatefulWidget {
class RobotHeaderCard extends StatefulWidget {
final Map<String, dynamic> robot;
const RobotHeaderCard({super.key, required this.robot});
@@ -58,20 +60,26 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.robot['name'] as String,
(widget.robot['alias'] as String?)?.isNotEmpty == true
? widget.robot['alias'] as String
: '暂无别名',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'ID: ${widget.robot['id']}',
widget.robot['name'] as String,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
@@ -115,7 +123,23 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
),
const SizedBox(width: 12),
// 设置图标
const Icon(Icons.settings, size: 20, color: Color(0xFF86909C)),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => RobotParamSettingsPage(
robot: widget.robot,
),
),
);
},
child: const Icon(
Icons.settings,
size: 20,
color: Color(0xFF86909C),
),
),
],
),
const SizedBox(height: 12),

View File

@@ -1,7 +1,14 @@
import 'package:flutter/material.dart';
import '../../../../home/presentation/pages/running_status_page.dart';
import 'dart:async';
/// 机器人状态栏
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../../components/device_status_modal.dart';
import '../../../../devices/presentation/bloc/device_status_bloc.dart';
import '../../../../devices/presentation/bloc/device_status_state.dart';
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
/// 机器人状态栏 - 实时显示设备推送的运行状态
class RobotStatusBar extends StatelessWidget {
final Map<String, dynamic> robot;
@@ -11,67 +18,127 @@ class RobotStatusBar extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RunningStatusPage(),
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
builder: (BuildContext ctx) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: GetIt.I<DeviceStatusBloc>()),
BlocProvider.value(value: GetIt.I<RemoteControlCubit>()),
],
child: const DeviceStatusModal(),
);
},
);
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
child: StreamBuilder<DeviceStatusState>(
stream: GetIt.I<DeviceStatusBloc>().stream,
initialData: GetIt.I<DeviceStatusBloc>().state,
builder: (context, snapshot) {
final state = snapshot.data;
String speed = '--';
String mode = '--';
String battery = '--';
String signal = '--';
Color signalColor = const Color(0xFF86909C);
Color batteryColor = const Color(0xFF86909C);
if (state is DeviceStatusUpdated) {
final s = state.status;
speed = s.leftMeasureSpeed.toStringAsFixed(0);
mode = s.controlMode == '3' ? '远程' : '本地';
battery = s.battery.isNotEmpty ? s.battery : '--';
final qual = s.qual;
if (qual >= 4) {
signal = '强';
signalColor = const Color(0xFF00B42A);
} else if (qual >= 2) {
signal = '中';
signalColor = const Color(0xFFFF7D00);
} else if (qual >= 1) {
signal = '弱';
signalColor = const Color(0xFFF53F3F);
} else {
signal = '无';
signalColor = const Color(0xFF86909C);
}
final batValue = int.tryParse(battery) ?? 0;
if (batValue > 50) {
batteryColor = const Color(0xFF00B42A);
} else if (batValue > 20) {
batteryColor = const Color(0xFFFF7D00);
} else if (batValue > 0) {
batteryColor = const Color(0xFFF53F3F);
}
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
],
),
child: Row(
children: [
Expanded(
child: Row(
children: [
_buildStatusItem(
label: '速度',
value: '1.2',
unit: 'm/s',
child: Row(
children: [
Expanded(
child: Row(
children: [
_buildStatusItem(
label: '转速',
value: speed,
unit: 'rpm',
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatusItem(
label: '模式',
value: mode,
unit: '',
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatusItem(
label: '电量',
value: battery,
unit: '%',
valueColor: batteryColor,
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatusItem(
label: '信号',
value: signal,
unit: '',
valueColor: signalColor,
),
],
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatusItem(
label: '里程',
value: '2.36',
unit: 'km',
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatusItem(
label: '电量',
value: '82',
unit: '%',
valueColor: const Color(0xFF00B42A),
),
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
_buildStatusItem(
label: '信号',
value: '强',
unit: '',
valueColor: const Color(0xFF00B42A),
),
],
),
),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward_ios,
size: 16,
color: Color(0xFF86909C),
),
],
),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward_ios,
size: 16,
color: Color(0xFF86909C),
),
],
),
);
},
),
);
}
@@ -88,27 +155,31 @@ class RobotStatusBar extends StatelessWidget {
Text(
label,
style: const TextStyle(
fontSize: 13,
fontSize: 12,
color: Color(0xFF86909C),
),
),
const SizedBox(height: 8),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: valueColor ?? const Color(0xFF1D2129),
Flexible(
child: Text(
value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: valueColor ?? const Color(0xFF1D2129),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (unit.isNotEmpty)
Text(
unit,
style: TextStyle(
fontSize: 12,
fontSize: 11,
color: valueColor ?? const Color(0xFF1D2129),
fontWeight: FontWeight.w500,
),

View File

@@ -0,0 +1,9 @@
import 'package:dio/dio.dart';
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../models/device_run_param_model.dart';
abstract class DeviceRunParamRemoteDataSource {
Future<Either<Failure, DeviceRunParamModel>> getByDeviceId(String deviceId);
Future<Either<Failure, bool>> save(Map<String, dynamic> params);
}

View File

@@ -0,0 +1,65 @@
import 'package:dio/dio.dart';
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../../domain/entities/device_run_param_entity.dart';
import '../models/device_run_param_model.dart';
import 'device_run_param_remote_datasource.dart';
class DeviceRunParamRemoteDataSourceImpl
implements DeviceRunParamRemoteDataSource {
DeviceRunParamRemoteDataSourceImpl(this._dio);
final Dio _dio;
@override
Future<Either<Failure, DeviceRunParamModel>> getByDeviceId(
String deviceId,
) async {
try {
final response = await _dio.get(
HttpApiConsts.deviceRunParamSelect,
queryParameters: {'deviceId': deviceId},
);
if (response.statusCode == 200) {
final body = response.data as Map<String, dynamic>;
final code = body['code'];
if (code != null && code.toString() == '200') {
final data = body['data'] as Map<String, dynamic>? ?? {};
return right(DeviceRunParamModel.fromJson(data));
} else {
return left(Failure(body['msg'] ?? '获取设备运行参数失败'));
}
} else {
return left(Failure('HTTP错误: ${response.statusCode}'));
}
} catch (e) {
return left(Failure('获取设备运行参数异常: $e'));
}
}
@override
Future<Either<Failure, bool>> save(Map<String, dynamic> params) async {
try {
final response = await _dio.post(
HttpApiConsts.deviceRunParamSave,
data: params,
);
if (response.statusCode == 200) {
final body = response.data as Map<String, dynamic>;
final code = body['code'];
if (code != null && code.toString() == '200') {
return right(true);
} else {
return left(Failure(body['msg'] ?? '保存设备运行参数失败'));
}
} else {
return left(Failure('HTTP错误: ${response.statusCode}'));
}
} catch (e) {
return left(Failure('保存设备运行参数异常: $e'));
}
}
}

View File

@@ -0,0 +1,49 @@
import '../../domain/entities/device_run_param_entity.dart';
class DeviceRunParamModel extends DeviceRunParamEntity {
DeviceRunParamModel({
required super.id,
required super.deviceId,
required super.siteId,
required super.orgId,
required super.runSpeed,
required super.leftForwardGain,
required super.leftBackwardGain,
required super.rightForwardGain,
required super.rightBackwardGain,
required super.rawData,
});
factory DeviceRunParamModel.fromJson(Map<String, dynamic> json) {
return DeviceRunParamModel(
id: (json['id'] as dynamic)?.toInt() ?? 0,
deviceId: json['deviceId'] as String? ?? '',
siteId: (json['siteId'] as dynamic)?.toInt() ?? 0,
orgId: (json['orgId'] as dynamic)?.toInt() ?? 0,
runSpeed: (json['runSpeed'] as dynamic)?.toDouble() ?? 0.0,
leftForwardGain:
(json['leftForwardGain'] as dynamic)?.toDouble() ?? 0.0,
leftBackwardGain:
(json['leftBackwardGain'] as dynamic)?.toDouble() ?? 0.0,
rightForwardGain:
(json['rightForwardGain'] as dynamic)?.toDouble() ?? 0.0,
rightBackwardGain:
(json['rightBackwardGain'] as dynamic)?.toDouble() ?? 0.0,
rawData: Map<String, dynamic>.from(json),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'deviceId': deviceId,
'siteId': siteId,
'orgId': orgId,
'runSpeed': runSpeed,
'leftForwardGain': leftForwardGain,
'leftBackwardGain': leftBackwardGain,
'rightForwardGain': rightForwardGain,
'rightBackwardGain': rightBackwardGain,
};
}
}

View File

@@ -0,0 +1,27 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../../domain/entities/device_run_param_entity.dart';
import '../../domain/repositories/device_run_param_repository.dart';
import '../datasources/device_run_param_remote_datasource.dart';
class DeviceRunParamRepositoryImpl implements DeviceRunParamRepository {
DeviceRunParamRepositoryImpl({required this.remoteDataSource});
final DeviceRunParamRemoteDataSource remoteDataSource;
@override
Future<Either<Failure, DeviceRunParamEntity>> getByDeviceId(
String deviceId,
) async {
final result = await remoteDataSource.getByDeviceId(deviceId);
return result.fold(
(failure) => left(failure),
(model) => right(model),
);
}
@override
Future<Either<Failure, bool>> save(Map<String, dynamic> params) async {
return await remoteDataSource.save(params);
}
}

View File

@@ -0,0 +1,27 @@
/// 设备运行参数实体
class DeviceRunParamEntity {
final int id;
final String deviceId;
final int siteId;
final int orgId;
final double runSpeed;
final double leftForwardGain;
final double leftBackwardGain;
final double rightForwardGain;
final double rightBackwardGain;
/// API 返回的全部原始字段,用于页面展示
final Map<String, dynamic> rawData;
DeviceRunParamEntity({
required this.id,
required this.deviceId,
required this.siteId,
required this.orgId,
required this.runSpeed,
required this.leftForwardGain,
required this.leftBackwardGain,
required this.rightForwardGain,
required this.rightBackwardGain,
required this.rawData,
});
}

View File

@@ -0,0 +1,8 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../entities/device_run_param_entity.dart';
abstract class DeviceRunParamRepository {
Future<Either<Failure, DeviceRunParamEntity>> getByDeviceId(String deviceId);
Future<Either<Failure, bool>> save(Map<String, dynamic> params);
}

View File

@@ -0,0 +1,24 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../entities/device_run_param_entity.dart';
import '../repositories/device_run_param_repository.dart';
class GetDeviceRunParamUseCase {
final DeviceRunParamRepository repository;
GetDeviceRunParamUseCase(this.repository);
Future<Either<Failure, DeviceRunParamEntity>> execute(String deviceId) async {
return await repository.getByDeviceId(deviceId);
}
}
class SaveDeviceRunParamUseCase {
final DeviceRunParamRepository repository;
SaveDeviceRunParamUseCase(this.repository);
Future<Either<Failure, bool>> execute(Map<String, dynamic> params) async {
return await repository.save(params);
}
}

View File

@@ -0,0 +1,581 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import '../../../../../core/app/app_user_cubit.dart';
import '../../../site/presentation/cubit/site_cubit.dart';
import '../../domain/entities/device_run_param_entity.dart';
import '../../domain/usecases/device_run_param_usecases.dart';
import '../../../../../core/services/device_permission_service.dart';
class RobotParamSettingsPage extends StatefulWidget {
final Map<String, dynamic> robot;
const RobotParamSettingsPage({super.key, required this.robot});
@override
State<RobotParamSettingsPage> createState() => _RobotParamSettingsPageState();
}
class _RobotParamSettingsPageState extends State<RobotParamSettingsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _isLoading = true;
bool _isSaving = false;
String? _errorMessage;
DeviceRunParamEntity? _param;
// 表单控制器(仅可编辑的5个字段)
late TextEditingController _runSpeedController;
late TextEditingController _leftForwardGainController;
late TextEditingController _leftBackwardGainController;
late TextEditingController _rightForwardGainController;
late TextEditingController _rightBackwardGainController;
String get _deviceId => widget.robot['name'] as String? ?? '';
// ============ 字段定义 ============
static const _editableKeys = {
'runSpeed',
'leftForwardGain',
'leftBackwardGain',
'rightForwardGain',
'rightBackwardGain',
};
static const _skipKeys = {
'createBy', 'createTime', 'updateBy', 'updateTime', 'delFlag', 'remark',
};
static const _fieldLabels = {
'id': 'ID',
'deviceId': '设备ID',
'siteId': '场站ID',
'orgId': '组织ID',
'runSpeed': '运行速度',
'header1': 'Header1',
'header2': 'Header2',
'cmd': 'CMD',
'chipUidSign': '芯片UID标记',
'chipUid': '芯片UID',
'remoteConfig': '远程配置',
'knifeMotorMode': '刀盘电机模式',
'walkMotorMode': '行走电机模式',
'leftMotorReverse': '左电机反转',
'rightMotorReverse': '右电机反转',
'swapChannel': '交换通道',
'use4G': '使用4G',
'forwardSpeedLimit': '前进限速',
'turnSpeedLimit': '转弯限速',
'knifePolarity': '刀盘极性',
'fanPolarity': '风扇极性',
'throttlePolarity': '油门极性',
'liftProtectTime': '升降保护时间',
'dualRtk': '双RTK',
'knifeChannel': '刀盘通道',
'fanChannel': '风扇通道',
'throttleChannel': '油门通道',
'remoteType': '遥控类型',
'relayBoard': '继电器板',
'liftChannel': '升降通道',
'chassisChannel': '底盘通道',
'armChannel': '机械臂通道',
'fuelPumpChannel': '燃油泵通道',
'wifiName': 'WiFi名称',
'wifiPassword': 'WiFi密码',
'batteryType': '电池类型',
'driveType': '驱动类型',
'gearRatio': '齿轮比',
'robotLength': '机器人长度',
'robotWidth': '机器人宽度',
'robotHeight': '机器人高度',
'knifeWidth': '刀盘宽度',
'tyreSize': '轮胎尺寸',
'leftForwardGain': '左轮前进',
'leftBackwardGain': '左轮后退',
'rightForwardGain': '右轮前进',
'rightBackwardGain': '右轮后退',
'firmwareVersion': '固件版本',
'crc16': 'CRC16',
'tail1': '尾部1',
'tail2': '尾部2',
};
// ============ 生命周期 ============
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_initControllers();
_loadData();
}
void _initControllers() {
_runSpeedController = TextEditingController();
_leftForwardGainController = TextEditingController();
_leftBackwardGainController = TextEditingController();
_rightForwardGainController = TextEditingController();
_rightBackwardGainController = TextEditingController();
}
void _fillControllers(DeviceRunParamEntity param) {
_runSpeedController.text = param.runSpeed.toStringAsFixed(0);
_leftForwardGainController.text = param.leftForwardGain.toString();
_leftBackwardGainController.text = param.leftBackwardGain.toString();
_rightForwardGainController.text = param.rightForwardGain.toString();
_rightBackwardGainController.text = param.rightBackwardGain.toString();
}
TextEditingController? _controllerForKey(String key) {
switch (key) {
case 'runSpeed': return _runSpeedController;
case 'leftForwardGain': return _leftForwardGainController;
case 'leftBackwardGain': return _leftBackwardGainController;
case 'rightForwardGain': return _rightForwardGainController;
case 'rightBackwardGain': return _rightBackwardGainController;
default: return null;
}
}
void _showReadonlyTip() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('暂不支持修改'),
duration: Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80, left: 80, right: 80),
),
);
}
@override
void dispose() {
_tabController.dispose();
_runSpeedController.dispose();
_leftForwardGainController.dispose();
_leftBackwardGainController.dispose();
_rightForwardGainController.dispose();
_rightBackwardGainController.dispose();
super.dispose();
}
// ============ 数据加载 ============
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final useCase = GetIt.I<GetDeviceRunParamUseCase>();
final result = await useCase.execute(_deviceId);
if (!mounted) return;
result.fold(
(failure) {
setState(() {
_isLoading = false;
_errorMessage = failure.message;
});
},
(param) {
setState(() {
_isLoading = false;
_param = param;
});
_fillControllers(param);
},
);
}
// ============ 保存 ============
Future<void> _handleSave() async {
if (_isSaving || _param == null) return;
setState(() => _isSaving = true);
try {
// 🔐 前置权限校验:只有 code=200 && data=true 才允许保存
final permissionService = GetIt.I<DevicePermissionService>();
final hasPermission = await permissionService.checkPermission(_deviceId);
if (!mounted) return;
if (!hasPermission) {
setState(() => _isSaving = false);
_showSnackBar('权限校验未通过,无法保存', isError: true);
return;
}
// 权限通过提示
await showDialog(
context: context,
barrierDismissible: false,
builder: (dialogCtx) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 28),
SizedBox(width: 8),
Text('权限通过'),
],
),
content: const Text('您有权限操作此设备'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: const Text('确定'),
),
],
),
);
if (!mounted) return;
final siteId = GetIt.I<SiteCubit>().state.selectedSite?.id ?? 0;
final orgId = GetIt.I<AppUserCubit>().state.user?.orgId ?? 0;
final useCase = GetIt.I<SaveDeviceRunParamUseCase>();
final params = <String, dynamic>{
'id': _param!.id,
'deviceId': _deviceId,
'siteId': siteId,
'orgId': orgId,
'runSpeed': int.tryParse(_runSpeedController.text) ?? _param!.runSpeed,
'leftForwardGain':
double.tryParse(_leftForwardGainController.text) ??
_param!.leftForwardGain,
'leftBackwardGain':
double.tryParse(_leftBackwardGainController.text) ??
_param!.leftBackwardGain,
'rightForwardGain':
double.tryParse(_rightForwardGainController.text) ??
_param!.rightForwardGain,
'rightBackwardGain':
double.tryParse(_rightBackwardGainController.text) ??
_param!.rightBackwardGain,
};
final result = await useCase.execute(params);
if (!mounted) return;
result.fold(
(failure) {
setState(() => _isSaving = false);
_showSnackBar('保存失败: ${failure.message}', isError: true);
},
(success) {
setState(() => _isSaving = false);
_showSnackBar('保存成功');
Navigator.pop(context);
},
);
} catch (e) {
if (mounted) {
setState(() => _isSaving = false);
_showSnackBar('保存异常: $e', isError: true);
}
}
}
void _showSnackBar(String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : Colors.green,
duration: const Duration(seconds: 2),
),
);
}
// ============ UI ============
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0.5,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
onPressed: () => Navigator.pop(context),
),
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'参数设置',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 2),
Text(
_deviceId,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF86909C),
),
maxLines: 2,
softWrap: true,
textAlign: TextAlign.center,
),
],
),
centerTitle: true,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(44),
child: Container(
color: Colors.white,
child: TabBar(
controller: _tabController,
indicatorColor: const Color(0xFF165DFF),
indicatorWeight: 2,
labelColor: const Color(0xFF165DFF),
unselectedLabelColor: const Color(0xFF86909C),
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
tabs: const [
Tab(text: '参数设置'),
Tab(text: '增益参数设置'),
],
),
),
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildParamTab(),
_buildGainParamTab(),
],
),
bottomNavigationBar: _buildBottomBar(),
),
);
}
// ============ 参数设置 Tab ============
Widget _buildParamTab() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Color(0xFF86909C)),
const SizedBox(height: 16),
Text(
_errorMessage!,
style: const TextStyle(fontSize: 14, color: Color(0xFF86909C)),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadData,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
),
child: const Text('重试'),
),
],
),
);
}
final raw = _param!.rawData;
final keys = raw.keys
.where((k) => !_skipKeys.contains(k))
.toList();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: List.generate(keys.length, (i) {
final key = keys[i];
final label = _fieldLabels[key] ?? key;
final isEditable = _editableKeys.contains(key);
final isLast = i == keys.length - 1;
final value = raw[key];
return Column(
children: [
_buildFieldRow(key, label, value, isEditable),
if (!isLast) _buildDivider(),
],
);
}),
),
),
);
}
// ============ 增益参数 Tab ============
Widget _buildGainParamTab() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.construction, size: 48, color: Color(0xFFC9CDD4)),
SizedBox(height: 16),
Text(
'暂未开放',
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
),
],
),
);
}
// ============ 单行字段 ============
Widget _buildFieldRow(
String key,
String label,
dynamic value,
bool editable,
) {
final controller = editable ? _controllerForKey(key) : null;
final displayValue = value?.toString() ?? '-';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(
label,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF4E5969),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Expanded(
child: editable
? TextField(
controller: controller,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
],
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
decoration: _inputDecoration(),
)
: GestureDetector(
onTap: _showReadonlyTip,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE5E6EB)),
),
child: Text(
displayValue,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF86909C),
),
),
),
),
),
],
),
);
}
InputDecoration _inputDecoration() {
return const InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
filled: true,
fillColor: Color(0xFFF7F8FA),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
borderSide: BorderSide(color: Color(0xFFE5E6EB)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
borderSide: BorderSide(color: Color(0xFFE5E6EB)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
borderSide: BorderSide(color: Color(0xFF165DFF)),
),
isDense: true,
);
}
Widget _buildDivider() {
return const Divider(height: 1, color: Color(0xFFF2F3F5));
}
// ============ 底部保存按钮 ============
Widget _buildBottomBar() {
final bottom = MediaQuery.of(context).padding.bottom;
return Container(
padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottom),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ElevatedButton(
onPressed: _isLoading || _isSaving ? null : _handleSave,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
elevation: 0,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'保存',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
}

View File

@@ -1,5 +1,5 @@
import '../../domain/entities/site_entity.dart';
abstract class SiteDataSource {
Future<List<SiteEntity>> getSiteList(int orgId);
Future<List<SiteEntity>> getSiteList(String userId);
}

View File

@@ -3,6 +3,7 @@ import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/home/data/datasources/site_datasource.dart';
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
@@ -14,42 +15,38 @@ class SiteDataSourceImpl implements SiteDataSource {
SiteDataSourceImpl(this.dio, this._userStorage, this._appUserCubit);
@override
Future<List<SiteEntity>> getSiteList(int orgId) async {
Future<List<SiteEntity>> getSiteList(String userId) async {
print('🔍 [SiteDataSource] 开始获取 Token...');
print('🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}');
// 优先从全局状态获取 Token(更快更可靠)
print(
'🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}',
);
var token = _appUserCubit.state.user?.token;
print('🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
// 如果全局状态没有,再从本地存储获取
print(
'🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
);
if (token == null) {
print('⚠️ [SiteDataSource] AppUserCubit 没有 Token,尝试从本地存储获取...');
final user = await _userStorage.getUser();
token = user?.token;
print('🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
print(
'🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
);
}
print('🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
// 构建查询参数:orgId 为 0 时不传递
final queryParams = <String, dynamic>{
'pageNum': 1,
'pageSize': 9999,
};
if (orgId != 0) {
queryParams['orgId'] = orgId;
}
print(
'🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
);
final queryParams = <String, dynamic>{'userId': userId};
final response = await dio.get(
HttpApiConsts.getSiteList,
queryParameters: queryParams,
options: Options(
headers: {
'Authorization': token != null ? 'Bearer $token' : '',
},
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
),
);
@@ -59,17 +56,27 @@ class SiteDataSourceImpl implements SiteDataSource {
final responseData = response.data;
if (responseData['code'] == 401 || responseData['code'] == 403) {
print(
'🚨 [SiteDataSource] 收到认证错误码 ${responseData['code']},触发 Token 过期处理',
);
try {
GetIt.I<AuthCubit>().tokenExpired();
} catch (e) {}
throw Exception('登录已过期,请重新登录');
}
if (responseData['code'] != 200) {
throw Exception(responseData['msg'] ?? '业务异常');
}
final List<dynamic> rows = responseData['rows'] ?? [];
final List<dynamic> rows = responseData['data'] ?? [];
print('📤 [SiteDataSource] 接口返回原始数据:');
for (var i = 0; i < rows.length && i < 3; i++) {
print(' 场站$i: ${rows[i]}');
}
return rows.map((item) => SiteEntity.fromJson(item)).toList();
}
}

View File

@@ -11,9 +11,9 @@ class SiteRepositoryImpl implements SiteRepository {
SiteRepositoryImpl(this.dataSource);
@override
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId) async {
Future<Either<Failure, List<SiteEntity>>> getSiteList(String userId) async {
try {
final sites = await dataSource.getSiteList(orgId);
final sites = await dataSource.getSiteList(userId);
return Right(sites);
} catch (e) {
return Left(Failure(e.toString()));

View File

@@ -5,5 +5,5 @@ import '../../../../../core/error/failure.dart';
import '../entities/site_entity.dart';
abstract class SiteRepository {
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId);
Future<Either<Failure, List<SiteEntity>>> getSiteList(String userId);
}

View File

@@ -10,8 +10,8 @@ class GetSiteListUseCase {
GetSiteListUseCase(this.repository);
// pageNum 和 pageSize 固定,orgId 从登录用户信息中获取
Future<Either<Failure, List<SiteEntity>>> call(int orgId) async {
return await repository.getSiteList(orgId);
// userId 从登录用户信息中获取
Future<Either<Failure, List<SiteEntity>>> call(String userId) async {
return await repository.getSiteList(userId);
}
}

View File

@@ -14,7 +14,12 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
final AppUserCubit appUserCubit;
final SiteCubit siteCubit;
HomeV2Bloc(this.getHomeDataUseCase, this.getSiteListUseCase, this.appUserCubit, this.siteCubit) : super(const HomeV2Initial()) {
HomeV2Bloc(
this.getHomeDataUseCase,
this.getSiteListUseCase,
this.appUserCubit,
this.siteCubit,
) : super(const HomeV2Initial()) {
on<HomeV2LoadData>(_onLoadData);
on<HomeV2Refresh>(_onRefresh);
on<HomeV2ToggleTrendType>(_onToggleTrendType);
@@ -28,26 +33,25 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
final user = appUserCubit.state.user;
if (user == null) {
emit(const HomeV2Error(
message: '用户未登录',
shouldShowError: true,
));
emit(const HomeV2Error(message: '用户未登录', shouldShowError: true));
return;
}
// 并行加载首页数据和场站列表
final homeResult = await getHomeDataUseCase(const NoParams());
final siteResult = await getSiteListUseCase(user.orgId); // 使用用户的 orgId
final siteResult = await getSiteListUseCase(user.userId); // 使用用户的 userId
homeResult.fold(
(failure) => emit(HomeV2Error(
message: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示弹窗
)),
(failure) => emit(
HomeV2Error(
message: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示弹窗
),
),
(homeData) {
List<SiteEntity> sites = [];
SiteEntity? selectedSite;
siteResult.fold(
(failure) {
print('加载场站列表失败: ${failure.message}');
@@ -56,7 +60,7 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
sites = siteList;
// 从全局 SiteCubit 获取之前选中的场站
final savedSelectedSite = siteCubit.state.selectedSite;
// 尝试找到之前选中的场站
if (savedSelectedSite != null && siteList.isNotEmpty) {
selectedSite = siteList.firstWhere(
@@ -67,19 +71,21 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
// 没有选中过,默认选中第一个
selectedSite = siteList.isNotEmpty ? siteList.first : null;
}
// 更新全局 SiteCubit 的选中状态
if (selectedSite != null) {
siteCubit.selectSite(selectedSite!);
}
},
);
emit(HomeV2Loaded(
homeData: homeData,
sites: sites,
selectedSite: selectedSite,
));
emit(
HomeV2Loaded(
homeData: homeData,
sites: sites,
selectedSite: selectedSite,
),
);
},
);
}
@@ -90,29 +96,28 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
) async {
if (state is HomeV2Loaded) {
final currentState = state as HomeV2Loaded;
final user = appUserCubit.state.user;
if (user == null) {
emit(const HomeV2Error(
message: '用户未登录',
shouldShowError: true,
));
emit(const HomeV2Error(message: '用户未登录', shouldShowError: true));
return;
}
// 并行刷新首页数据和电站列表
final homeResult = await getHomeDataUseCase(const NoParams());
final siteResult = await getSiteListUseCase(user.orgId);
final siteResult = await getSiteListUseCase(user.userId);
homeResult.fold(
(failure) => emit(HomeV2Error(
message: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示弹窗
)),
(failure) => emit(
HomeV2Error(
message: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示弹窗
),
),
(homeData) {
List<SiteEntity> sites = currentState.sites;
SiteEntity? selectedSite = currentState.selectedSite;
siteResult.fold(
(failure) {
print('刷新场站列表失败: ${failure.message}');
@@ -129,20 +134,22 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
} else if (siteList.isNotEmpty) {
selectedSite = siteList.first;
}
// 更新全局 SiteCubit 的选中状态
if (selectedSite != null) {
siteCubit.selectSite(selectedSite!);
}
},
);
emit(HomeV2Loaded(
homeData: homeData,
trendType: currentState.trendType,
sites: sites,
selectedSite: selectedSite,
));
emit(
HomeV2Loaded(
homeData: homeData,
trendType: currentState.trendType,
sites: sites,
selectedSite: selectedSite,
),
);
},
);
}

View File

@@ -7,6 +7,7 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_bloc
import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_event.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_state.dart';
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/site/presentation/widgets/site_selector_widget.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_card.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/stats_grid.dart';
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/work_order_card.dart';
@@ -60,9 +61,16 @@ class _HomeV2PageState extends State<HomeV2Page> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.orange),
const Icon(
Icons.error_outline,
size: 48,
color: Colors.orange,
),
const SizedBox(height: 12),
Text(state.message, style: const TextStyle(fontSize: 14, color: Colors.grey)),
Text(
state.message,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _bloc.add(const HomeV2LoadData()),
@@ -91,47 +99,8 @@ class _HomeV2PageState extends State<HomeV2Page> {
color: Colors.white,
child: Row(
children: [
Expanded(
child: InkWell(
onTap: _showPlantSelector,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: StreamBuilder<SiteState>(
stream: sl<SiteCubit>().stream,
builder: (context, snapshot) {
final siteState =
snapshot.data ??
sl<SiteCubit>().state;
return Text(
siteState.selectedSite?.siteName ??
AppLocalizations.of(
context,
).translate(
'home_v2.select_site',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
);
},
),
),
const SizedBox(width: 4),
const Icon(
Icons.arrow_drop_down,
size: 20,
color: Color(0xFF1D2129),
),
],
),
),
),
Expanded(child: SiteSelectorWidget()),
const SizedBox(width: 12),
TcpStatusIndicator(
onTap: () => _showDeviceStatusModal(context),
),
@@ -252,7 +221,9 @@ class _HomeV2PageState extends State<HomeV2Page> {
}
// 🔥 Initial/Loading 状态显示加载指示器
return const Center(child: CircularProgressIndicator(color: Color(0xFF165DFF)));
return const Center(
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
);
},
),
);

View File

@@ -1,19 +1,179 @@
import 'dart:convert';
import 'dart:io';
import 'package:fpdart/fpdart.dart';
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import '../../../../../../core/consts/http_api_consts.dart';
import '../../../../../../core/error/failure.dart';
import '../../../../../../core/app/app_user_cubit.dart';
import '../models/report_model.dart';
import 'report_remote_datasource.dart';
/// 上报远程数据源实现(模拟接口请求)
/// 上报远程数据源实现
class ReportRemoteDataSourceImpl implements ReportRemoteDataSource {
@override
Future<Either<Failure, bool>> submitReport(ReportModel report) async {
// 模拟网络请求延迟
await Future.delayed(const Duration(seconds: 1));
print('========================================');
print('[上报工单] 开始提交');
print('[上报工单] 请求URL: ${HttpApiConsts.workOrderAdd}');
print('[上报工单] 请求方法: POST (multipart/form-data)');
try {
// 1. 构造 workOrder JSON(直接映射 IOTWorkOrder 实体)
print('[上报工单] 入参: siteId=${report.siteId}, siteName=${report.siteName}, reportType=${report.reportType}');
print('[上报工单] 入参: deviceId=${report.deviceId}, deviceName=${report.deviceName}');
print('[上报工单] 入参: description=${report.description}, problemLevel=${report.problemLevel}');
print('[上报工单] 入参: mediaUrls数量=${report.mediaUrls?.length ?? 0}');
final workOrder = <String, dynamic>{
'siteId': report.siteId,
'siteName': report.siteName ?? '',
'sourceType': 6, // 6=人工创建
'orderType': report.reportType ?? '',
'deviceId': report.deviceId ?? '',
'deviceName': report.deviceName ?? '',
'taskDescription': report.description ?? '',
'priorityLevel': report.problemLevel ?? 'INFO',
'orderTitle': '${report.reportType ?? '上报'} - ${report.deviceName ?? ''}',
'orderStatus': 1, // 1=待处理
'imgUrl': <String>[],
'videoUrl': <String>[],
};
final workOrderJson = jsonEncode(workOrder);
print('[上报工单] workOrder JSON: $workOrderJson');
// 模拟成功返回
return right(true);
// 2. 分离图片和视频
final imagePaths = <String>[];
final videoPaths = <String>[];
final imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic'];
final videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', '3gp'];
// 模拟失败情况(取消注释以测试错误处理)
// return left(ServerFailure('提交失败,请重试'));
if (report.mediaUrls != null) {
for (final path in report.mediaUrls!) {
final ext = path.split('.').last.toLowerCase();
if (videoExts.contains(ext)) {
videoPaths.add(path);
print('[上报工单] 识别为视频: $path');
} else {
imagePaths.add(path);
print('[上报工单] 识别为图片: $path');
}
}
}
print('[上报工单] 图片: ${imagePaths.length}个, 视频: ${videoPaths.length}个');
// 3. 创建 multipart 请求
final request = http.MultipartRequest(
'POST',
Uri.parse(HttpApiConsts.workOrderAdd),
);
// 4. 添加 Authorization 认证头
final userToken = GetIt.I<AppUserCubit>().state.user?.token;
if (userToken != null) {
request.headers['Authorization'] = 'Bearer $userToken';
print('[上报工单] Authorization token: ${userToken.substring(0, userToken.length > 20 ? 20 : userToken.length)}...');
} else {
print('[上报工单] WARNING: Token为空');
}
// 5. 处理图片文件(后端要求 file 字段必须存在)
if (imagePaths.isNotEmpty) {
final firstImage = imagePaths.first;
final file = File(firstImage);
if (await file.exists()) {
final bytes = await file.readAsBytes();
print('[上报工单] 上传图片: $firstImage, 大小: ${bytes.length} bytes');
request.files.add(http.MultipartFile.fromBytes(
'file',
bytes,
filename: firstImage.split('/').last,
contentType: http.MediaType('image', 'jpeg'),
));
} else {
print('[上报工单] WARNING: 图片文件不存在: $firstImage');
}
} else {
print('[上报工单] 无图片,使用空文件占位');
request.files.add(http.MultipartFile.fromBytes(
'file',
<int>[],
filename: 'empty.jpg',
contentType: http.MediaType('image', 'jpeg'),
));
}
// 6. 处理视频文件(后端要求 video 字段必须存在)
if (videoPaths.isNotEmpty) {
final firstVideo = videoPaths.first;
final file = File(firstVideo);
if (await file.exists()) {
final bytes = await file.readAsBytes();
print('[上报工单] 上传视频: $firstVideo, 大小: ${bytes.length} bytes');
request.files.add(http.MultipartFile.fromBytes(
'video',
bytes,
filename: firstVideo.split('/').last,
contentType: http.MediaType('video', 'mp4'),
));
} else {
print('[上报工单] WARNING: 视频文件不存在: $firstVideo');
}
} else {
print('[上报工单] 无视频,使用空文件占位');
request.files.add(http.MultipartFile.fromBytes(
'video',
<int>[],
filename: 'empty.mp4',
contentType: http.MediaType('video', 'mp4'),
));
}
// 7. 添加 workOrder JSON(对应前端 new Blob,filename 为空字符串)
print('[上报工单] workOrder JSON长度: ${workOrderJson.length} chars');
request.files.add(http.MultipartFile.fromBytes(
'workOrder',
utf8.encode(workOrderJson),
filename: '',
contentType: http.MediaType('application', 'json'),
));
print('[上报工单] 请求字段: ${request.fields.keys.toList()}');
print('[上报工单] 文件字段: ${request.files.map((f) => f.field).toList()}');
print('[上报工单] 开始发送请求...');
// 8. 发送请求
final http.StreamedResponse response = await request.send();
final String responseBody = await response.stream.bytesToString();
print('[上报工单] 响应状态码: ${response.statusCode}');
print('[上报工单] 响应头: ${response.headers}');
print('[上报工单] 响应体: $responseBody');
if (response.statusCode == 200) {
final respJson = jsonDecode(responseBody) as Map<String, dynamic>;
final code = respJson['code'];
print('[上报工单] 业务code: $code');
if (code != null && code.toString() == '200') {
print('[上报工单] 提交成功');
print('========================================');
return right(true);
} else {
final msg = respJson['msg'] ?? respJson['message'] ?? '提交失败';
print('[上报工单] 业务失败: $msg');
print('========================================');
return left(Failure(msg.toString()));
}
} else {
print('[上报工单] HTTP错误: ${response.statusCode}');
print('========================================');
return left(Failure('服务器错误: HTTP ${response.statusCode}, 响应: $responseBody'));
}
} catch (e) {
print('[上报工单] 异常: $e');
print('[上报工单] 异常类型: ${e.runtimeType}');
print('========================================');
return left(Failure('提交失败: $e'));
}
}
}

View File

@@ -4,7 +4,10 @@ import '../../domain/entities/report_entity.dart';
class ReportModel {
final String? location;
final String? reportType;
final int? siteId;
final String? siteName;
final String? deviceId;
final String? deviceName;
final String? description;
final List<String>? mediaUrls;
final String? problemLevel;
@@ -12,7 +15,10 @@ class ReportModel {
ReportModel({
this.location,
this.reportType,
this.siteId,
this.siteName,
this.deviceId,
this.deviceName,
this.description,
this.mediaUrls,
this.problemLevel,
@@ -23,7 +29,10 @@ class ReportModel {
return ReportModel(
location: json['location'] as String?,
reportType: json['reportType'] as String?,
siteId: json['siteId'] as int?,
siteName: json['siteName'] as String?,
deviceId: json['deviceId'] as String?,
deviceName: json['deviceName'] as String?,
description: json['description'] as String?,
mediaUrls: (json['mediaUrls'] as List<dynamic>?)?.cast<String>(),
problemLevel: json['problemLevel'] as String?,
@@ -35,7 +44,10 @@ class ReportModel {
return {
'location': location,
'reportType': reportType,
'siteId': siteId,
'siteName': siteName,
'deviceId': deviceId,
'deviceName': deviceName,
'description': description,
'mediaUrls': mediaUrls,
'problemLevel': problemLevel,
@@ -47,7 +59,10 @@ class ReportModel {
return ReportEntity(
location: location,
reportType: reportType,
siteId: siteId,
siteName: siteName,
deviceId: deviceId,
deviceName: deviceName,
description: description,
mediaUrls: mediaUrls,
problemLevel: problemLevel,
@@ -59,7 +74,10 @@ class ReportModel {
return ReportModel(
location: entity.location,
reportType: entity.reportType,
siteId: entity.siteId,
siteName: entity.siteName,
deviceId: entity.deviceId,
deviceName: entity.deviceName,
description: entity.description,
mediaUrls: entity.mediaUrls,
problemLevel: entity.problemLevel,

View File

@@ -1,16 +1,23 @@
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';
import '../data/datasources/report_remote_datasource.dart';
import '../data/datasources/report_remote_datasource_impl.dart';
import '../data/repositories/report_repository_impl.dart';
import '../domain/repositories/report_repository.dart';
import '../domain/usecases/submit_report_usecase.dart';
import '../presentation/cubit/report_cubit.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
/// 上报模块依赖注入
class ReportDependencyInjector {
/// 创建 ReportCubit 实例
static ReportCubit createReportCubit() {
final sl = GetIt.I;
// 创建数据源
final ReportRemoteDataSource remoteDataSource = ReportRemoteDataSourceImpl();
final ReportRemoteDataSource remoteDataSource =
ReportRemoteDataSourceImpl();
// 创建仓储
final ReportRepository repository = ReportRepositoryImpl(
@@ -18,9 +25,16 @@ class ReportDependencyInjector {
);
// 创建用例
final SubmitReportUseCase submitReportUseCase = SubmitReportUseCase(repository);
final SubmitReportUseCase submitReportUseCase = SubmitReportUseCase(
repository,
);
// 创建 Cubit
return ReportCubit(submitReportUseCase: submitReportUseCase);
// 创建 Cubit(注入 Dio、AppUserCubit、UserStorage)
return ReportCubit(
submitReportUseCase: submitReportUseCase,
dio: sl<Dio>(),
appUserCubit: sl<AppUserCubit>(),
userStorage: sl<UserStorage>(),
);
}
}

View File

@@ -2,7 +2,10 @@
class ReportEntity {
final String? location;
final String? reportType;
final int? siteId;
final String? siteName;
final String? deviceId;
final String? deviceName;
final String? description;
final List<String>? mediaUrls;
final String? problemLevel;
@@ -10,7 +13,10 @@ class ReportEntity {
const ReportEntity({
this.location,
this.reportType,
this.siteId,
this.siteName,
this.deviceId,
this.deviceName,
this.description,
this.mediaUrls,
this.problemLevel,
@@ -19,7 +25,10 @@ class ReportEntity {
ReportEntity copyWith({
String? location,
String? reportType,
int? siteId,
String? siteName,
String? deviceId,
String? deviceName,
String? description,
List<String>? mediaUrls,
String? problemLevel,
@@ -27,7 +36,10 @@ class ReportEntity {
return ReportEntity(
location: location ?? this.location,
reportType: reportType ?? this.reportType,
siteId: siteId ?? this.siteId,
siteName: siteName ?? this.siteName,
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
description: description ?? this.description,
mediaUrls: mediaUrls ?? this.mediaUrls,
problemLevel: problemLevel ?? this.problemLevel,

View File

@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
/// 全局颜色常量
class AppColors {
@@ -27,26 +27,32 @@ class AppDimensions {
/// 上报类型枚举
enum ReportType {
deviceFault('report.device_fault', Icons.notifications_active),
patrolRecord('report.patrol_record', Icons.description),
hiddenDanger('report.hidden_danger', Icons.shield),
defectReport('report.defect_report', Icons.build);
mowerError('MOWER_ERROR', '割草机故障', Icons.notifications_active),
uavError('UAV_ERROR', '无人机故障', Icons.flight),
inspectionTask('INSPECTION_TASK', '巡检', Icons.search),
componentDefect('COMPONENT_DEFECT', '光伏组件故障', Icons.power),
cleanTask('CLEAN_TASK', '清洗任务', Icons.cleaning_services),
maintainTask('MAINTAIN_TASK', '维护任务', Icons.handyman),
repairTask('REPAIR_TASK', '维修任务', Icons.build),
other('OTHER', '其他', Icons.more_horiz);
final String labelKey;
final String orderType;
final String label;
final IconData icon;
const ReportType(this.labelKey, this.icon);
const ReportType(this.orderType, this.label, this.icon);
}
/// 问题等级枚举
enum ProblemLevel {
normal('report.normal', Color(0xFF86909C), Color(0xFFE5E6EB)),
important('report.important', Color(0xFFFF7D00), Color(0xFFE5E6EB)),
urgent('report.urgent', Color(0xFFF53F3F), Color(0xFFF53F3F));
info('INFO', '信息', Color(0xFF165DFF), Color(0xFF165DFF)),
warning('WARNING', '警告', Color(0xFFFF7D00), Color(0xFFFF7D00)),
error('ERROR', '错误', Color(0xFFF53F3F), Color(0xFFF53F3F));
final String labelKey;
final String level;
final String label;
final Color textColor;
final Color borderColor;
const ProblemLevel(this.labelKey, this.textColor, this.borderColor);
const ProblemLevel(this.level, this.label, this.textColor, this.borderColor);
}

View File

@@ -1,43 +1,174 @@
import 'package:dio/dio.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/report_entity.dart';
import '../../domain/usecases/submit_report_usecase.dart';
import '../constants/report_constants.dart';
import '../states/report_state.dart';
import '../../../home/domain/entities/site_entity.dart';
import '../../../home/domain/usecases/get_site_list_usecase.dart';
import '../../../home/domain/repositories/site_repository.dart';
import '../../../home/data/repositories/site_repository_impl.dart';
import '../../../home/data/datasources/site_datasource_impl.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import '../../../device_list/data/models/robot_data_model.dart';
import '../../../device_list/domain/entities/drone_station_entity.dart';
/// 上报页面 Cubit
class ReportCubit extends Cubit<ReportState> {
final SubmitReportUseCase submitReportUseCase;
final Dio dio;
final AppUserCubit appUserCubit;
final UserStorage userStorage;
ReportCubit({required this.submitReportUseCase})
: super(ReportFormState(report: ReportEntity()));
ReportCubit({
required this.submitReportUseCase,
required this.dio,
required this.appUserCubit,
required this.userStorage,
}) : super(ReportFormState(report: ReportEntity())) {
_loadSiteList();
}
Future<void> _loadSiteList() async {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
emit(currentState.copyWith(sites: []));
try {
final siteDataSource = SiteDataSourceImpl(
dio,
userStorage,
appUserCubit,
);
final siteRepository = SiteRepositoryImpl(siteDataSource);
final getSiteListUseCase = GetSiteListUseCase(siteRepository);
final user = appUserCubit.state.user;
if (user == null) {
emit(currentState.copyWith(sites: []));
return;
}
final result = await getSiteListUseCase(user.userId);
result.fold(
(failure) {
emit(currentState.copyWith(sites: []));
},
(sites) {
SiteEntity? selectedSite;
if (sites.isNotEmpty) {
selectedSite = sites.first;
}
emit(
currentState.copyWith(
sites: sites,
selectedSite: selectedSite,
location: selectedSite?.siteName ?? '请选择场站',
report: currentState.report.copyWith(
siteId: selectedSite?.id,
siteName: selectedSite?.siteName,
),
),
);
},
);
} catch (e) {
emit(currentState.copyWith(sites: []));
}
}
}
Future<void> loadDeviceList(int siteId) async {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
emit(ReportDevicesLoading());
try {
final robotResponse = await dio.get(
HttpApiConsts.getRobotList,
queryParameters: {'siteId': siteId, 'pageSize': 9999, 'pageNum': 1},
);
final droneResponse = await dio.get(
HttpApiConsts.getSiteUAVList,
queryParameters: {'siteId': siteId},
);
List<RobotDataModel> robots = [];
List<DroneStationEntity> drones = [];
if (robotResponse.statusCode == 200 &&
robotResponse.data['code'] == 200) {
final List<dynamic> rows = robotResponse.data['rows'] ?? [];
robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
}
if (droneResponse.statusCode == 200 &&
droneResponse.data['code'] == 200) {
final List<dynamic> rows = droneResponse.data['rows'] ?? [];
drones = rows
.map((item) => DroneStationEntity.fromJson(item))
.toList();
}
emit(currentState.copyWith(robots: robots, drones: drones));
} catch (e) {
emit(currentState.copyWith(robots: [], drones: []));
}
}
}
/// 选择上报类型
void selectReportType(ReportType type) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
emit(
currentState.copyWith(
selectedReportType: type,
report: currentState.report.copyWith(reportType: type.labelKey),
report: currentState.report.copyWith(reportType: type.orderType),
selectedDevice: null,
selectedDeviceId: null,
),
);
}
}
/// 选择设备
void selectDevice(String device) {
void selectSite(SiteEntity site) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
emit(
currentState.copyWith(
selectedDevice: device,
report: currentState.report.copyWith(deviceId: device),
selectedSite: site,
location: site.siteName,
report: currentState.report.copyWith(
siteId: site.id,
siteName: site.siteName,
),
selectedDevice: null,
selectedDeviceId: null,
robots: [],
drones: [],
),
);
}
}
void selectDevice(String deviceName, String deviceId) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
emit(
currentState.copyWith(
selectedDevice: deviceName,
selectedDeviceId: deviceId,
report: currentState.report.copyWith(
deviceId: deviceId,
deviceName: deviceName,
),
),
);
}
}
/// 更新位置
void updateLocation(String location) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
@@ -50,7 +181,6 @@ class ReportCubit extends Cubit<ReportState> {
}
}
/// 更新问题描述
void updateDescription(String description) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
@@ -62,20 +192,18 @@ class ReportCubit extends Cubit<ReportState> {
}
}
/// 选择问题等级
void selectProblemLevel(ProblemLevel level) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
emit(
currentState.copyWith(
selectedProblemLevel: level,
report: currentState.report.copyWith(problemLevel: level.labelKey),
report: currentState.report.copyWith(problemLevel: level.level),
),
);
}
}
/// 添加媒体文件
void addMediaFile(String filePath) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
@@ -90,7 +218,6 @@ class ReportCubit extends Cubit<ReportState> {
}
}
/// 删除媒体文件
void removeMediaFile(int index) {
if (state is ReportFormState) {
final currentState = state as ReportFormState;
@@ -105,43 +232,68 @@ class ReportCubit extends Cubit<ReportState> {
}
}
/// 提交上报
Future<void> submitReport() async {
if (state is! ReportFormState) return;
final currentState = state as ReportFormState;
// 验证必填项
if (currentState.selectedSite == null) {
emit(currentState.copyWith(errorMessage: '请选择场站'));
return;
}
if (currentState.selectedReportType == null) {
emit(currentState.copyWith(errorMessage: '请选择上报类型'));
return;
}
if (currentState.selectedDevice == null ||
currentState.selectedDevice!.isEmpty) {
emit(const ReportFailure('请选择设备'));
emit(currentState.copyWith(errorMessage: '请选择设备'));
return;
}
if (currentState.report.description == null ||
currentState.report.description!.isEmpty) {
emit(const ReportFailure('请填写问题描述'));
emit(currentState.copyWith(errorMessage: '请填写问题描述'));
return;
}
if (currentState.selectedProblemLevel == null) {
emit(const ReportFailure('请选择问题等级'));
emit(currentState.copyWith(errorMessage: '请选择问题等级'));
return;
}
// 开始提交
emit(ReportSubmitting());
final result = await submitReportUseCase.execute(currentState.report);
result.fold(
(failure) => emit(ReportFailure(failure.message)),
(success) => emit(ReportSuccess()),
(failure) => emit(currentState.copyWith(errorMessage: failure.message)),
(success) {
// 提交成功:清空表单,保留场站列表
emit(currentState.copyWith(
report: ReportEntity(),
selectedReportType: null,
selectedProblemLevel: null,
selectedDevice: null,
selectedDeviceId: null,
mediaFiles: [],
successMessage: '提交成功',
));
},
);
}
/// 重置表单
void clearMessages() {
if (state is ReportFormState) {
final s = state as ReportFormState;
emit(s.copyWith(errorMessage: null, successMessage: null));
}
}
void resetForm() {
emit(ReportFormState(report: ReportEntity()));
_loadSiteList();
}
}

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../cubit/report_cubit.dart';
import '../states/report_state.dart';
import '../constants/report_constants.dart';
@@ -14,7 +13,6 @@ import '../widgets/level_selector.dart';
import 'media_preview_page.dart';
import '../../di/report_di.dart';
/// 现场上报主页面
class ReportPage extends StatelessWidget {
const ReportPage({super.key});
@@ -38,14 +36,30 @@ class _ReportPageContent extends StatelessWidget {
backgroundColor: AppColors.cardBackground,
body: BlocConsumer<ReportCubit, ReportState>(
listener: (context, state) {
if (state is ReportSuccess) {
_showSuccessDialog(context);
} else if (state is ReportFailure) {
_showErrorDialog(context, state.message);
if (state is ReportFormState) {
if (state.successMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.successMessage!),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
context.read<ReportCubit>().clearMessages();
} else if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
context.read<ReportCubit>().clearMessages();
}
}
},
builder: (context, state) {
if (state is ReportSubmitting) {
if (state is ReportSubmitting || state is ReportDevicesLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
@@ -55,7 +69,7 @@ class _ReportPageContent extends StatelessWidget {
final cubit = context.read<ReportCubit>();
return Column(
children: [
_buildAppBar(context, state.location, cubit),
_buildAppBar(context, state, cubit),
Expanded(child: _buildFormContent(context, state)),
],
);
@@ -68,10 +82,9 @@ class _ReportPageContent extends StatelessWidget {
);
}
/// 构建导航栏
PreferredSizeWidget _buildAppBar(
BuildContext context,
String location,
ReportFormState state,
ReportCubit cubit,
) {
return PreferredSize(
@@ -86,13 +99,12 @@ class _ReportPageContent extends StatelessWidget {
),
child: Column(
children: [
// 第一行:标题 + 提交按钮
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context).translate('report.title'),
style: const TextStyle(
const Text(
'现场上报',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
@@ -102,9 +114,9 @@ class _ReportPageContent extends StatelessWidget {
onTap: () {
context.read<ReportCubit>().submitReport();
},
child: Text(
AppLocalizations.of(context).translate('report.submit'),
style: const TextStyle(
child: const Text(
'提交',
style: TextStyle(
color: AppColors.primary,
fontSize: 18,
fontWeight: FontWeight.w600,
@@ -114,10 +126,9 @@ class _ReportPageContent extends StatelessWidget {
],
),
const SizedBox(height: 12),
// 第二行:位置信息(可点击)
GestureDetector(
onTap: () {
_showLocationPicker(context, cubit);
_showSitePicker(context, cubit, state);
},
child: Row(
children: [
@@ -129,7 +140,7 @@ class _ReportPageContent extends StatelessWidget {
const SizedBox(width: 6),
Flexible(
child: Text(
location,
state.location,
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
@@ -156,7 +167,6 @@ class _ReportPageContent extends StatelessWidget {
);
}
/// 构建表单内容
Widget _buildFormContent(BuildContext context, ReportFormState state) {
final cubit = context.read<ReportCubit>();
@@ -168,30 +178,23 @@ class _ReportPageContent extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 上报类型选择
ReportTypeSelector(
selectedType: state.selectedReportType,
onSelected: (type) => cubit.selectReportType(type),
),
const SizedBox(height: AppDimensions.moduleSpacing),
// 设备选择
DeviceSelector(
selectedDevice: state.selectedDevice,
onTap: () {
_showDevicePicker(context, cubit);
_showDevicePicker(context, cubit, state);
},
),
const SizedBox(height: AppDimensions.moduleSpacing),
// 问题描述
DescriptionInput(
description: state.report.description,
onChanged: (value) => cubit.updateDescription(value),
),
const SizedBox(height: AppDimensions.moduleSpacing),
// 媒体上传
MediaUploader(
mediaFiles: state.mediaFiles,
onCameraTap: () {
@@ -209,8 +212,6 @@ class _ReportPageContent extends StatelessWidget {
onRemove: (index) => cubit.removeMediaFile(index),
),
const SizedBox(height: AppDimensions.moduleSpacing),
// 问题等级选择
LevelSelector(
selectedLevel: state.selectedProblemLevel,
onSelected: (level) => cubit.selectProblemLevel(level),
@@ -221,40 +222,6 @@ class _ReportPageContent extends StatelessWidget {
);
}
/// 构建位置信息行
Widget _buildLocationRow(BuildContext context, String location) {
return Container(
margin: const EdgeInsets.symmetric(
horizontal: AppDimensions.horizontalPadding,
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(AppDimensions.borderRadius),
boxShadow: [
BoxShadow(
color: const Color(0x0D000000),
blurRadius: AppDimensions.cardShadowBlur,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
const Icon(Icons.location_on, color: AppColors.primary, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
location,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
),
),
],
),
);
}
/// 选择图片
Future<void> _pickImage(ReportCubit cubit) async {
final ImagePicker picker = ImagePicker();
try {
@@ -273,7 +240,6 @@ class _ReportPageContent extends StatelessWidget {
}
}
/// 选择视频
Future<void> _pickVideo(ReportCubit cubit) async {
final ImagePicker picker = ImagePicker();
try {
@@ -290,7 +256,6 @@ class _ReportPageContent extends StatelessWidget {
}
}
/// 预览媒体文件
void _previewMedia(BuildContext context, List<String> mediaFiles, int index) {
Navigator.push(
context,
@@ -301,11 +266,9 @@ class _ReportPageContent extends StatelessWidget {
);
}
/// 从相册选择
Future<void> _pickFromGallery(BuildContext context, ReportCubit cubit) async {
final ImagePicker picker = ImagePicker();
try {
// 显示选择图片或视频的选项
await showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
@@ -314,9 +277,7 @@ class _ReportPageContent extends StatelessWidget {
children: [
ListTile(
leading: const Icon(Icons.image),
title: Text(
AppLocalizations.of(context).translate('report.select_image'),
),
title: const Text('选择图片'),
onTap: () async {
Navigator.pop(context);
final XFile? image = await picker.pickImage(
@@ -332,9 +293,7 @@ class _ReportPageContent extends StatelessWidget {
),
ListTile(
leading: const Icon(Icons.video_library),
title: Text(
AppLocalizations.of(context).translate('report.select_video'),
),
title: const Text('选择视频'),
onTap: () async {
Navigator.pop(context);
final XFile? video = await picker.pickVideo(
@@ -354,123 +313,246 @@ class _ReportPageContent extends StatelessWidget {
}
}
/// 显示位置选择器
void _showLocationPicker(BuildContext context, ReportCubit cubit) {
final loc = AppLocalizations.of(context);
final locations = [
loc.translate('report.main_building_a'),
loc.translate('report.main_building_b'),
loc.translate('report.boiler_room'),
loc.translate('report.turbine_room'),
loc.translate('report.control_room'),
loc.translate('report.power_distribution_room'),
];
void _showSitePicker(
BuildContext context,
ReportCubit cubit,
ReportFormState currentState,
) {
if (currentState.sites.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('暂无场站数据')));
return;
}
showDialog(
showModalBottomSheet(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.translate('report.select_location')),
content: SingleChildScrollView(
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return Container(
padding: const EdgeInsets.all(14),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: locations.map((location) {
return ListTile(
title: Text(location),
onTap: () {
cubit.updateLocation(location);
Navigator.pop(context);
},
);
}).toList(),
children: [
const Text(
'选择场站',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 14),
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: currentState.sites.length,
itemBuilder: (context, index) {
final site = currentState.sites[index];
final isSelected = currentState.selectedSite?.id == site.id;
return ListTile(
title: Text(site.siteName),
subtitle:
site.siteCode != null && site.siteCode!.isNotEmpty
? Text('站点编码: ${site.siteCode}')
: null,
trailing: isSelected
? const Icon(Icons.check, color: Color(0xFF165DFF))
: null,
onTap: () {
cubit.selectSite(site);
Navigator.pop(context);
},
);
},
),
),
],
),
);
},
);
}
void _showDevicePicker(
BuildContext context,
ReportCubit cubit,
ReportFormState state,
) {
final selectedType = state.selectedReportType;
final selectedSite = state.selectedSite;
if (selectedSite == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先选择场站')));
return;
}
if (selectedType == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先选择上报类型')));
return;
}
bool isRobotType = selectedType == ReportType.mowerError;
bool isDroneType = selectedType == ReportType.uavError;
if (!isRobotType && !isDroneType) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('选择设备'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('设备一'),
onTap: () {
cubit.selectDevice('设备一', 'device_1');
Navigator.pop(context);
},
),
ListTile(
title: const Text('设备二'),
onTap: () {
cubit.selectDevice('设备二', 'device_2');
Navigator.pop(context);
},
),
ListTile(
title: const Text('设备三'),
onTap: () {
cubit.selectDevice('设备三', 'device_3');
Navigator.pop(context);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(loc.translate('report.cancel')),
),
],
),
);
}
);
return;
}
/// 显示设备选择器
void _showDevicePicker(BuildContext context, ReportCubit cubit) {
final loc = AppLocalizations.of(context);
final devices = [
loc.translate('report.boiler_1'),
loc.translate('report.boiler_2'),
loc.translate('report.turbine_1'),
loc.translate('report.generator_1'),
loc.translate('report.transformer_1'),
loc.translate('report.water_pump_1'),
];
Future<void> loadAndShowDevices() async {
await cubit.loadDeviceList(selectedSite.id);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.translate('report.select_device')),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: devices.map((device) {
return ListTile(
title: Text(device),
onTap: () {
cubit.selectDevice(device);
Navigator.pop(context);
},
);
}).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(loc.translate('report.cancel')),
),
],
),
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
final newState = cubit.state;
if (newState is! ReportFormState) return;
/// 显示成功对话框
void _showSuccessDialog(BuildContext context) {
final loc = AppLocalizations.of(context);
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(loc.translate('report.submit_success')),
content: Text(loc.translate('report.submit_success_message')),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
Navigator.pop(context); // 返回上一页
},
child: Text(loc.translate('report.confirm')),
),
],
),
);
}
List<Widget> deviceList = [];
/// 显示错误对话框
void _showErrorDialog(BuildContext context, String message) {
final loc = AppLocalizations.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.translate('report.submit_failed')),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(loc.translate('report.confirm')),
if (isRobotType) {
if (newState.robots.isEmpty) {
deviceList = [
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'暂无机器人数据',
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
),
),
),
];
} else {
deviceList = newState.robots
.map(
(robot) => ListTile(
title: Text(robot.name),
subtitle: robot.alias != null && robot.alias!.isNotEmpty
? Text(robot.alias!)
: null,
trailing: Text(robot.status),
onTap: () {
cubit.selectDevice(robot.name, robot.id);
Navigator.pop(context);
},
),
)
.toList();
}
} else if (isDroneType) {
if (newState.drones.isEmpty) {
deviceList = [
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'暂无无人机数据',
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
),
),
),
];
} else {
deviceList = newState.drones
.map(
(drone) => ListTile(
title: Text(drone.callsign),
subtitle: Text(drone.deviceSn),
onTap: () {
cubit.selectDevice(drone.callsign, drone.deviceSn);
Navigator.pop(context);
},
),
)
.toList();
}
}
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
],
),
);
builder: (context) {
return Container(
padding: const EdgeInsets.all(14),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isRobotType ? '选择机器人' : '选择无人机',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 14),
Expanded(
child: ListView(shrinkWrap: true, children: deviceList),
),
],
),
);
},
);
});
}
loadAndShowDevices();
}
}

View File

@@ -1,6 +1,9 @@
import 'package:equatable/equatable.dart';
import '../../domain/entities/report_entity.dart';
import '../constants/report_constants.dart';
import '../../../home/domain/entities/site_entity.dart';
import '../../../device_list/data/models/robot_data_model.dart';
import '../../../device_list/domain/entities/drone_station_entity.dart';
/// 上报页面状态抽象类
abstract class ReportState extends Equatable {
@@ -32,6 +35,11 @@ class ReportFailure extends ReportState {
List<Object?> get props => [message];
}
/// 设备列表加载状态
class ReportDevicesLoading extends ReportState {
const ReportDevicesLoading();
}
/// 表单数据状态
class ReportFormState extends ReportState {
final ReportEntity report;
@@ -39,7 +47,14 @@ class ReportFormState extends ReportState {
final ProblemLevel? selectedProblemLevel;
final String location;
final String? selectedDevice;
final String? selectedDeviceId;
final List<String> mediaFiles;
final List<SiteEntity> sites;
final SiteEntity? selectedSite;
final List<RobotDataModel> robots;
final List<DroneStationEntity> drones;
final String? errorMessage;
final String? successMessage;
const ReportFormState({
required this.report,
@@ -47,7 +62,14 @@ class ReportFormState extends ReportState {
this.selectedProblemLevel,
this.location = '江苏省苏州市吴中区',
this.selectedDevice,
this.selectedDeviceId,
this.mediaFiles = const [],
this.sites = const [],
this.selectedSite,
this.robots = const [],
this.drones = const [],
this.errorMessage,
this.successMessage,
});
ReportFormState copyWith({
@@ -56,7 +78,14 @@ class ReportFormState extends ReportState {
ProblemLevel? selectedProblemLevel,
String? location,
String? selectedDevice,
String? selectedDeviceId,
List<String>? mediaFiles,
List<SiteEntity>? sites,
SiteEntity? selectedSite,
List<RobotDataModel>? robots,
List<DroneStationEntity>? drones,
String? errorMessage,
String? successMessage,
}) {
return ReportFormState(
report: report ?? this.report,
@@ -64,7 +93,14 @@ class ReportFormState extends ReportState {
selectedProblemLevel: selectedProblemLevel ?? this.selectedProblemLevel,
location: location ?? this.location,
selectedDevice: selectedDevice ?? this.selectedDevice,
selectedDeviceId: selectedDeviceId ?? this.selectedDeviceId,
mediaFiles: mediaFiles ?? this.mediaFiles,
sites: sites ?? this.sites,
selectedSite: selectedSite ?? this.selectedSite,
robots: robots ?? this.robots,
drones: drones ?? this.drones,
errorMessage: errorMessage,
successMessage: successMessage,
);
}
@@ -75,6 +111,13 @@ class ReportFormState extends ReportState {
selectedProblemLevel,
location,
selectedDevice,
selectedDeviceId,
mediaFiles,
sites,
selectedSite,
robots,
drones,
errorMessage,
successMessage,
];
}

View File

@@ -40,27 +40,35 @@ class DeviceSelector extends StatelessWidget {
color: AppColors.textPrimary,
),
),
Row(
children: [
Text(
selectedDevice ??
AppLocalizations.of(
context,
).translate('report.please_select_device'),
style: TextStyle(
fontSize: 14,
color: selectedDevice != null
? AppColors.textPrimary
: AppColors.textHint,
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Text(
selectedDevice ??
AppLocalizations.of(
context,
).translate('report.please_select_device'),
style: TextStyle(
fontSize: 12,
color: selectedDevice != null
? AppColors.textPrimary
: AppColors.textHint,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 20,
color: AppColors.textHint,
),
],
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down,
size: 20,
color: AppColors.textHint,
),
],
),
),
],
),

View File

@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../constants/report_constants.dart';
/// 问题等级选择器
class LevelSelector extends StatelessWidget {
final ProblemLevel? selectedLevel;
final Function(ProblemLevel) onSelected;
@@ -15,7 +13,6 @@ class LevelSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Container(
margin: const EdgeInsets.symmetric(
horizontal: AppDimensions.horizontalPadding,
@@ -35,9 +32,9 @@ class LevelSelector extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.translate('report.problem_level'),
style: const TextStyle(
const Text(
'问题等级',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
@@ -54,8 +51,8 @@ class LevelSelector extends StatelessWidget {
margin: const EdgeInsets.symmetric(horizontal: 4),
height: AppDimensions.buttonHeight,
decoration: BoxDecoration(
color: isSelected && level == ProblemLevel.urgent
? AppColors.danger.withOpacity(0.1)
color: isSelected
? level.textColor.withOpacity(0.1)
: AppColors.background,
borderRadius: BorderRadius.circular(
AppDimensions.borderRadiusSmall,
@@ -69,7 +66,7 @@ class LevelSelector extends StatelessWidget {
),
child: Center(
child: Text(
loc.translate(level.labelKey),
level.label,
style: TextStyle(
fontSize: 14,
color: isSelected

Some files were not shown because too many files have changed in this diff Show More