diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d4391a78..f26fd0ce 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 294527d6..62646408 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,14 @@ + + + + + + + + 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] 添加调试日志 diff --git a/docs/UAV_VIDEO_COMPLETION_SUMMARY.md b/docs/UAV_VIDEO_COMPLETION_SUMMARY.md new file mode 100644 index 00000000..4afa2eed --- /dev/null +++ b/docs/UAV_VIDEO_COMPLETION_SUMMARY.md @@ -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` 模式处理错误 + +### 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` 统一处理错误 +- **默认值**:提供合理的默认参数,简化调用 +- **日志记录**:详细的日志输出,方便调试 +- **重试机制**:支持手动重试和自动刷新 + +## 快速开始 + +### 方式一:直接使用示例页面 +```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 即可显示实际视频画面。 diff --git a/docs/UAV_VIDEO_INTEGRATION_GUIDE.md b/docs/UAV_VIDEO_INTEGRATION_GUIDE.md new file mode 100644 index 00000000..ca35fd04 --- /dev/null +++ b/docs/UAV_VIDEO_INTEGRATION_GUIDE.md @@ -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(sl()), +); +sl.registerFactory( + () => 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(); + // 加载视频流 + _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( + 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` 模式统一处理错误,确保异常不会直接抛出。 + +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` - 注册依赖 diff --git a/docs/UAV_VIDEO_PAGE_INTEGRATION.md b/docs/UAV_VIDEO_PAGE_INTEGRATION.md new file mode 100644 index 00000000..ddb42337 --- /dev/null +++ b/docs/UAV_VIDEO_PAGE_INTEGRATION.md @@ -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(); + // 默认加载广角镜头的视频流 + _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( + 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( + 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 显示实际视频画面**,即可完整实现无人机实时视频监控功能! diff --git a/docs/UAV_VIDEO_USAGE_EXAMPLES.md b/docs/UAV_VIDEO_USAGE_EXAMPLES.md new file mode 100644 index 00000000..39406c70 --- /dev/null +++ b/docs/UAV_VIDEO_USAGE_EXAMPLES.md @@ -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 createState() => _MyCustomVideoPageState(); +} + +class _MyCustomVideoPageState extends State { + late DroneStationBloc _bloc; + UavVideoStreamEntity? _videoStream; + bool _isLoading = false; + String? _errorMessage; + UavLensType? _currentLensType; + + @override + void initState() { + super.initState(); + _bloc = sl(); + // 默认加载广角镜头 + _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( + 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( + 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 _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 cameraIndices; + + const MultiCameraVideoPage({ + super.key, + required this.droneSn, + required this.cameraIndices, + }); + + @override + State createState() => _MultiCameraVideoPageState(); +} + +class _MultiCameraVideoPageState extends State { + late DroneStationBloc _bloc; + int _currentCameraIndex = 0; + + @override + void initState() { + super.initState(); + _bloc = sl(); + _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( + 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 createState() => _AutoRefreshVideoPageState(); +} + +class _AutoRefreshVideoPageState extends State { + late DroneStationBloc _bloc; + Timer? _refreshTimer; + static const _tokenRefreshInterval = Duration(minutes: 55); // 每55分钟刷新一次(Token有效期约1小时) + + @override + void initState() { + super.initState(); + _bloc = sl(); + _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 _startRecording() async { + // 根据使用的 RTC SDK 调用相应的录制 API +} + +Future _stopRecording() async { + // 停止录制并保存文件 +} +``` + +### 添加截图功能 + +```dart +// TODO: 集成 RTC SDK 的截图功能 +Future _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. 联系开发团队 diff --git a/lib/components/device_status_modal.dart b/lib/components/device_status_modal.dart index 1ceb98a6..411be35a 100644 --- a/lib/components/device_status_modal.dart +++ b/lib/components/device_status_modal.dart @@ -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 { } Widget _buildCardContentView(DeviceStatusState state) { - if (_isDataTimeout || state is DeviceStatusInitial) { + if (state is DeviceStatusInitial) { return _noDataWidget(); } @@ -445,11 +445,9 @@ class _DeviceStatusModalState extends State { Widget build(BuildContext context) { return BlocBuilder( 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 { 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 { } Widget _buildChartContentView(DeviceStatusState state) { - if (_isDataTimeout || state is DeviceStatusInitial) { + if (state is DeviceStatusInitial) { return _noDataWidget(); } diff --git a/lib/core/bluetooth/ble_manager.dart b/lib/core/bluetooth/ble_manager.dart new file mode 100644 index 00000000..25553972 --- /dev/null +++ b/lib/core/bluetooth/ble_manager.dart @@ -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 _scanResults = {}; + final StreamController> _scanController = + StreamController.broadcast(); + final StreamController _packetController = + StreamController.broadcast(); + final StreamController _connectionController = + StreamController.broadcast(); + final StreamController _connectingController = + StreamController.broadcast(); + + StreamSubscription>? _scanSubscription; + StreamSubscription>? _readSubscription; + StreamSubscription? _adapterStateSubscription; + StreamSubscription? _deviceConnectionSubscription; + + /// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效 + int _scanGen = 0; + + /// 协商后的 MTU 值,用于分片写入 + int _negotiatedMtu = 23; + + /// 已收到的数据包存储(跨页面持久化) + final List receivedPacketStore = []; + static const int _maxStoredPackets = 100; + + void clearReceivedPackets() { + receivedPacketStore.clear(); + } + + bool get isConnected => _connectedDevice != null; + BluetoothDevice? get connectedDevice => _connectedDevice; + BluetoothDevice? get connectingDevice => _connectingDevice; + Stream> get scanResults => _scanController.stream; + Stream get packetStream => _packetController.stream; + Stream get adapterState => + FlutterBluePlus.adapterState; + + /// 连接状态变化流:连接成功时发出 device,断开时发出 null + Stream get connectionStream => _connectionController.stream; + + /// 连接中状态流:开始连接时发出 device,连接完成/失败时发出 null + Stream get connectingStream => _connectingController.stream; + + Future checkBluetooth() async { + final state = await FlutterBluePlus.adapterState.first; + return state == BluetoothAdapterState.on; + } + + Future 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 openBluetooth() async { + try { + await FlutterBluePlus.turnOn(); + } catch (e) { + await _goToSystemSettings(); + } + } + + Future _goToSystemSettings() async { + await openAppSettings(); + } + + Future _openLocationSettings() async { + await openAppSettings(); + } + + Future _checkLocationService() async { + final locStatus = await Permission.location.serviceStatus; + developer.log( + '[BLE] location service status: $locStatus', + name: 'BleManager', + ); + return locStatus == ServiceStatus.enabled; + } + + Future 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 refreshScan() async { + await stopScan(); + await Future.delayed(const Duration(milliseconds: 200)); + await startScan(continuous: true); + } + + Future stopScan() async { + _scanGen++; + developer.log('[BLE] stopScan, gen=$_scanGen', name: 'BleManager'); + _scanSubscription?.cancel(); + await FlutterBluePlus.stopScan(); + } + + /// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败 + Future 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 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 _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 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 bytes) { + if (bytes.isEmpty) return ''; + return bytes + .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(' '); + } + + Future sendCommand(int command, List 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 sendRawBytes(List bytes) async { + if (_writeCharacteristic == null) return; + await _writeCharacteristic!.write(bytes, withoutResponse: false); + } + + Future sendHeartbeat() async { + await sendCommand(0xFF, []); + } + + void dispose() { + _scanSubscription?.cancel(); + _readSubscription?.cancel(); + _adapterStateSubscription?.cancel(); + _deviceConnectionSubscription?.cancel(); + _scanController.close(); + _packetController.close(); + _connectionController.close(); + _connectingController.close(); + } +} diff --git a/lib/core/bluetooth/ble_protocol_decoder.dart b/lib/core/bluetooth/ble_protocol_decoder.dart new file mode 100644 index 00000000..9ccfd4fb --- /dev/null +++ b/lib/core/bluetooth/ble_protocol_decoder.dart @@ -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 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 = []; + + 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 _parseStatusFields(List 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 bytes) { + if (bytes.isEmpty) return ''; + return bytes + .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(' '); + } +} diff --git a/lib/core/bluetooth/bytes_util.dart b/lib/core/bluetooth/bytes_util.dart new file mode 100644 index 00000000..510450fd --- /dev/null +++ b/lib/core/bluetooth/bytes_util.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class BytesUtil { + static String bytesToHex(List 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 bytes) { + try { + return utf8.decode(bytes); + } catch (_) { + return String.fromCharCodes(bytes); + } + } + + static Uint8List stringToBytes(String str) { + return Uint8List.fromList(utf8.encode(str)); + } + + static List toByteList(Uint8List data) { + return List.from(data); + } + + static String bytesToHexSpaced(List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); + } +} diff --git a/lib/core/bluetooth/mc700_device_config.dart b/lib/core/bluetooth/mc700_device_config.dart new file mode 100644 index 00000000..97089415 --- /dev/null +++ b/lib/core/bluetooth/mc700_device_config.dart @@ -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 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 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; + } + } +} diff --git a/lib/core/bluetooth/protocol_parser.dart b/lib/core/bluetooth/protocol_parser.dart new file mode 100644 index 00000000..eaaf1b40 --- /dev/null +++ b/lib/core/bluetooth/protocol_parser.dart @@ -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 _buffer = []; + + /// 清空缓冲区 + void clear() { + _buffer.clear(); + } + + /// 获取缓冲区长度 + int get bufferLength => _buffer.length; + + /// 打包命令为字节帧(用于发送) + /// CRC16 覆盖 command + payload 确保整帧完整性 + static Uint8List pack(int command, List 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 data) { + _buffer.addAll(data); + } + + /// 从缓冲区解析所有完整的数据包 + /// 未完成的帧保留在缓冲区等待后续数据 + List parse() { + final List 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 appendAndParse(List data) { + append(data); + return parse(); + } + + /// CRC16-Modbus: polynomial=0x8005, init=0xFFFF, refIn/refOut=true + static int _crc16(List 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; + } +} diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart index 8c6ce35e..41b6f8b2 100644 --- a/lib/core/consts/http_api_consts.dart +++ b/lib/core/consts/http_api_consts.dart @@ -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"; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 29d078ee..9e6e7ed4 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -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 init() async { sl.registerLazySingleton(() => SentryLoggerImpl()); /// 1.4 --- Route Observer (路由监听器) --- - sl.registerLazySingleton(() => RouteObserver>()); + sl.registerLazySingleton( + () => RouteObserver>(), + ); /// 1.5 --- MQTT Data Sources --- sl.registerFactory( @@ -367,9 +384,7 @@ Future init() async { sl.registerLazySingleton( () => PauseFlightTaskUseCase(sl()), ); - sl.registerLazySingleton( - () => ReturnHomeUseCase(sl()), - ); + sl.registerLazySingleton(() => ReturnHomeUseCase(sl())); sl.registerFactory( () => DroneStationBloc(sl(), sl(), sl(), sl()), ); @@ -384,7 +399,7 @@ Future init() async { /// Alarm Center V2 sl.registerLazySingleton( - () => AlarmRemoteDataSourceImpl(), + () => AlarmRemoteDataSourceImpl(sl()), ); sl.registerLazySingleton(() => AlarmRepositoryImpl(sl())); sl.registerLazySingleton( @@ -393,13 +408,11 @@ Future init() async { sl.registerLazySingleton( () => GetAlarmCountUseCase(sl()), ); - sl.registerFactory( - () => AlarmCubit(getAlarmListUseCase: sl(), getAlarmCountUseCase: sl()), - ); + sl.registerFactory(() => AlarmCubit(getAlarmListUseCase: sl())); /// Alarm Detail V2 sl.registerLazySingleton( - () => AlarmDetailRemoteDataSourceImpl(), + () => AlarmDetailRemoteDataSourceImpl(sl()), ); sl.registerLazySingleton( () => AlarmDetailRepositoryImpl(sl()), @@ -410,11 +423,13 @@ Future init() async { sl.registerLazySingleton( () => ConfirmAlarmUseCase(sl()), ); + sl.registerLazySingleton(() => HandleAlarmUseCase(sl())); sl.registerLazySingleton(() => AIDiagnosisUseCase(sl())); sl.registerFactory( () => AlarmDetailCubit( getAlarmDetailUseCase: sl(), confirmAlarmUseCase: sl(), + handleAlarmUseCase: sl(), aiDiagnosisUseCase: sl(), ), ); @@ -565,5 +580,84 @@ Future 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( + () => WorkOrderRemoteDataSourceImpl(sl()), + ); + sl.registerLazySingleton( + () => WorkOrderRepositoryImpl(remoteDataSource: sl()), + ); + sl.registerLazySingleton( + () => GetWorkOrderListUseCase(sl()), + ); + sl.registerLazySingleton( + () => GetWorkOrderDetailUseCase(sl()), + ); + sl.registerLazySingleton( + () => DispatchWorkOrderUseCase(sl()), + ); + sl.registerLazySingleton( + () => SuspendWorkOrderUseCase(sl()), + ); + sl.registerLazySingleton( + () => CompleteWorkOrderUseCase(sl()), + ); + sl.registerLazySingleton( + () => StartWorkOrderUseCase(sl()), + ); + sl.registerFactory( + () => WorkOrderCubit( + getWorkOrderListUseCase: sl(), + getWorkOrderDetailUseCase: sl(), + dispatchWorkOrderUseCase: sl(), + suspendWorkOrderUseCase: sl(), + completeWorkOrderUseCase: sl(), + startWorkOrderUseCase: sl(), + ), + ); + + /// 12. 设备运行参数管理 (Device Run Param) + sl.registerLazySingleton( + () => DeviceRunParamRemoteDataSourceImpl(sl()), + ); + sl.registerLazySingleton( + () => DeviceRunParamRepositoryImpl(remoteDataSource: sl()), + ); + sl.registerLazySingleton( + () => GetDeviceRunParamUseCase(sl()), + ); + sl.registerLazySingleton( + () => SaveDeviceRunParamUseCase(sl()), + ); + + /// 13. 设备操作权限校验服务 (Device Permission Service) + sl.registerLazySingleton( + () => DevicePermissionService(sl()), + ); + + /// 14. 绑定智能装备 (Bind Device) + sl.registerLazySingleton( + () => BindDeviceDatasourceImpl(sl()), + ); + sl.registerLazySingleton( + () => BindDeviceRepositoryImpl(sl()), + ); + sl.registerLazySingleton(() => GetOrgListUseCase(sl())); + sl.registerLazySingleton( + () => GetSitesByOrgUseCase(sl()), + ); + sl.registerLazySingleton( + () => GetUsersBySiteUseCase(sl()), + ); + sl.registerLazySingleton( + () => BindDeviceV2UseCase(sl()), + ); + sl.registerLazySingleton( + () => IsDeviceAtSiteUseCase(sl()), + ); + sl.registerFactory( + () => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl()), + ); } diff --git a/lib/core/domain/entities/user_entity.dart b/lib/core/domain/entities/user_entity.dart index 61c4f31f..9a4cc452 100644 --- a/lib/core/domain/entities/user_entity.dart +++ b/lib/core/domain/entities/user_entity.dart @@ -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, + ); + } +} \ No newline at end of file diff --git a/lib/core/managers/drone_task_state_manager.dart b/lib/core/managers/drone_task_state_manager.dart index 321954b5..43e0af52 100644 --- a/lib/core/managers/drone_task_state_manager.dart +++ b/lib/core/managers/drone_task_state_manager.dart @@ -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 _currentTaskInfo = ValueNotifier(null); + /// 任务下发成功后,等待无人机 OSD 推送数据(实时信息) + final ValueNotifier _isWaitingForOsdPush = ValueNotifier(false); + + /// 任务下发成功后,等待无人机视频流 + final ValueNotifier _isWaitingForVideo = ValueNotifier(false); + + /// 无人机飞行轨迹点(跨页面持久化,退出视频页后不丢失) + final ValueNotifier> _trajectoryPoints = + ValueNotifier>([]); + + /// 超时定时器,避免一直转圈(推送/视频迟迟不到) + Timer? _osdWaitTimeoutTimer; + Timer? _videoWaitTimeoutTimer; + /// 监听无人机任务信息变化 ValueListenable get currentTaskInfo => _currentTaskInfo; + /// 监听“等待 OSD 推送”状态 + ValueListenable get isWaitingForOsdPush => _isWaitingForOsdPush; + + /// 监听“等待视频流”状态 + ValueListenable get isWaitingForVideo => _isWaitingForVideo; + + /// 监听无人机轨迹点 + ValueListenable> 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.from(_trajectoryPoints.value); + list.add(point); + // 性能优化:只保留最近 1000 个点 + if (list.length > 1000) { + list.removeAt(0); + } + _trajectoryPoints.value = list; + } + + /// 批量设置轨迹点(恢复历史轨迹时使用) + void setTrajectoryPoints(List points) { + _trajectoryPoints.value = List.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; } /// 检查是否有当前任务 diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 69824887..6906010f 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -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) { + final code = response.data['code']; + if (code == 401 || code == 403) { + print( + '>>> [DIO] 🚨🚨🚨 收到业务错误码 $code,触发 Token 过期处理!URL: ${response.requestOptions.uri},时间: ${DateTime.now()}', + ); + try { + sl().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().logout(); - } catch (ex) { - // 防止报错 - } + sl().tokenExpired(); + } catch (ex) {} } return handler.next(e); diff --git a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart index 4d86ec65..51d2079f 100644 --- a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart +++ b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart @@ -28,6 +28,8 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource { StreamSubscription? _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 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; 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(); } -} +} \ No newline at end of file diff --git a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart index de4ab186..29695cff 100644 --- a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart +++ b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart @@ -13,7 +13,7 @@ abstract class TaskMessageDataSource { Stream get taskArriveStream; Stream get realTimeMessageStream; - Future startListening({required String deviceId}); + Future startListening({required String deviceId, int? taskId}); Future stopListening(); } @@ -26,6 +26,7 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource { StreamSubscription? _subscription; String? _deviceId; + int? _taskId; TaskMessageDataSourceImpl(this.mqttClient); @@ -40,9 +41,9 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource { _realTimeMessageController.stream; @override - Future startListening({required String deviceId}) async { - if (_deviceId == deviceId && _subscription != null) { - debugPrint('[TaskMessageDataSource] already listening: $deviceId'); + Future 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) { diff --git a/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart b/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart index 49723915..9466bb3f 100644 --- a/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart +++ b/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart @@ -24,9 +24,9 @@ class TaskMessageRepositoryImpl implements TaskMessageRepository { Stream get taskStatusStream => dataSource.taskStatusStream; @override - Future> startListening({required String deviceId}) async { + Future> 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())); diff --git a/lib/core/network/mqtt/domain/models/mqtt_config.dart b/lib/core/network/mqtt/domain/models/mqtt_config.dart index 3bfb0e7a..49bf5051 100644 --- a/lib/core/network/mqtt/domain/models/mqtt_config.dart +++ b/lib/core/network/mqtt/domain/models/mqtt_config.dart @@ -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, diff --git a/lib/core/network/mqtt/domain/repositories/task_message_repository.dart b/lib/core/network/mqtt/domain/repositories/task_message_repository.dart index ab99c41b..25f1cacc 100644 --- a/lib/core/network/mqtt/domain/repositories/task_message_repository.dart +++ b/lib/core/network/mqtt/domain/repositories/task_message_repository.dart @@ -11,6 +11,6 @@ abstract class TaskMessageRepository { Stream get taskArriveStream; Stream get taskStatusStream; - Future> startListening({required String deviceId}); + Future> startListening({required String deviceId, int? taskId}); Future stopListening(); } diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart index 3acc1dc7..ae81427f 100644 --- a/lib/core/network/tcp/tcp_client.dart +++ b/lib/core/network/tcp/tcp_client.dart @@ -36,9 +36,15 @@ class TcpClient { final ILoggerService _logger = GetIt.I(); - // 新增:心跳定时器 + // 心跳超时定时器(收到服务端心跳后重置,超时则判定连接断开) 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? _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(); - 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(); + 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(); + _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; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 656a4269..fa500c85 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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, ], ); } diff --git a/lib/core/router/route_paths.dart b/lib/core/router/route_paths.dart index 981020f1..32970de6 100644 --- a/lib/core/router/route_paths.dart +++ b/lib/core/router/route_paths.dart @@ -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'; } diff --git a/lib/core/services/device_permission_service.dart b/lib/core/services/device_permission_service.dart new file mode 100644 index 00000000..a00436aa --- /dev/null +++ b/lib/core/services/device_permission_service.dart @@ -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 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) { + 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; + } + } +} diff --git a/lib/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart index 491572b5..ae014c52 100644 --- a/lib/features/auth/data/models/user_model.dart +++ b/lib/features/auth/data/models/user_model.dart @@ -12,19 +12,22 @@ class UserModel extends UserEntity implements BaseModel { super.avatar, super.email, super.phone, + super.roleKey, + super.siteId, }); factory UserModel.fromJson(Map 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, ); } -} +} \ No newline at end of file diff --git a/lib/features/auth/presentation/bloc/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit.dart index 39c3213b..08c885bf 100644 --- a/lib/features/auth/presentation/bloc/auth_cubit.dart +++ b/lib/features/auth/presentation/bloc/auth_cubit.dart @@ -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 { final ILoggerService _logger = GetIt.I(); StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期 - + // 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送 Completer? _loginVerificationCompleter; @@ -56,11 +64,57 @@ class AuthCubit extends Cubit { ) : super(AuthInitial()) { // Cubit 一启动就开始监听 TCP 的“自动逻辑” _listenToAuthResponse(); + // 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录 + tcp.onReconnectExhausted = _showReconnectFailedDialog; } /// App 启动时检查本地缓存 Future appStarted() async { final logger = GetIt.I() as SentryLoggerImpl; + + try { + final prefs = GetIt.I(); + + // 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 { ); 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 { } } + Future _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 && 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 loginSuccess(UserEntity user) async { await storage.saveUser(user); @@ -96,33 +193,33 @@ class AuthCubit extends Cubit { 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 { emit(AuthUnauthenticated()); } + /// 🔥 Token 过期处理:弹出提示后退出登录 + Future 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 { try { debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗'); _logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true); - + // 使用全局 navigatorKey 显示弹窗 final context = navigatorKey.currentContext; if (context != null) { @@ -190,9 +323,12 @@ class AuthCubit extends Cubit { ); } 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 { } } + /// 🔥 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(); - final deviceStatusBloc = GetIt.I(); - final siteCubit = GetIt.I(); + // 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等) + final remoteControlCubit = GetIt.I(); + remoteControlCubit.clearAll(); + debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态'); + _logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态'); - // 1. 清空设备列表和选中设备 + // 2. 清空设备列表和选中设备 + final devicesCubit = GetIt.I(); devicesCubit.emit(const DevicesState()); debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态'); _logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态'); - // 2. 清空设备实时状态 + // 3. 清空设备实时状态(图表数据等) + final deviceStatusBloc = GetIt.I(); 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.clearAll(); + debugPrint('✅ [AUTH] 已清空 SiteCubit 所有数据'); + _logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 所有数据'); + + // 5. 清空设备任务(taskPool、currentTask、currentTaskId 等) + final deviceTaskCubit = GetIt.I(); + deviceTaskCubit.clearAll(); + debugPrint('✅ [AUTH] 已清空 DeviceTaskCubit 状态'); + _logger.logWithLevel('✅ [AUTH] 已清空 DeviceTaskCubit 状态'); + + // 6. 清空权限请求弹窗状态 + final permissionRequestBloc = GetIt.I(); + permissionRequestBloc.clearAll(); + debugPrint('✅ [AUTH] 已清空 PermissionRequestBloc 状态'); + _logger.logWithLevel('✅ [AUTH] 已清空 PermissionRequestBloc 状态'); + + // 7. 清空我的页面数据(昵称等个人信息) + final myCubit = GetIt.I(); + myCubit.clearAll(); + debugPrint('✅ [AUTH] 已清空 MyCubit 状态'); + _logger.logWithLevel('✅ [AUTH] 已清空 MyCubit 状态'); + + // 8. 断开 MQTT 连接(避免新用户收到上个用户的实时推送) + try { + final droneOsdClient = GetIt.I(instanceName: 'droneOsdClient'); + if (droneOsdClient.isConnected) { + droneOsdClient.disconnect(); + debugPrint('✅ [AUTH] 已断开 droneOsdClient MQTT'); + } + } catch (_) {} + try { + final taskMessageClient = GetIt.I(instanceName: 'taskMessageClient'); + if (taskMessageClient.isConnected) { + taskMessageClient.disconnect(); + debugPrint('✅ [AUTH] 已断开 taskMessageClient MQTT'); + } + } catch (_) {} + + // 9. 清除 SharedPreferences 会话相关 key + final prefs = GetIt.I(); + 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 { /// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in) Future _waitForLoginVerification() async { _loginVerificationCompleter = Completer(); - + // 等待 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 { 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",不处理'); } diff --git a/lib/features/auth/presentation/bloc/login_cubit.dart b/lib/features/auth/presentation/bloc/login_cubit.dart index 44ccd8e2..a651938d 100644 --- a/lib/features/auth/presentation/bloc/login_cubit.dart +++ b/lib/features/auth/presentation/bloc/login_cubit.dart @@ -28,15 +28,16 @@ class LoginCubit extends Cubit { // 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())); diff --git a/lib/features/devices/data/datasources/device_task_datasource.dart b/lib/features/devices/data/datasources/device_task_datasource.dart index 361daac6..38c4d4c2 100644 --- a/lib/features/devices/data/datasources/device_task_datasource.dart +++ b/lib/features/devices/data/datasources/device_task_datasource.dart @@ -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; 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; 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; if (data['code'] == 200) { + _logger.logWithLevel('[recoveryTask] 结果: 成功, data=${jsonEncode(data['data'])}'); return data['data'] as Map? ?? {}; } 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; } } diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart index 3c60cfb3..2ca3eeb8 100644 --- a/lib/features/devices/presentation/bloc/device_status_bloc.dart +++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart @@ -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 { // 🔥 保存订阅引用,用于管理生命周期 StreamSubscription? _tcpSubscription; StreamSubscription? _mqttArriveSubscription; + StreamSubscription? _mqttStatusSubscription; // 🔥 节流相关:500ms节流控制0x02数据推送频率 Timer? _throttleTimer; @@ -34,6 +36,9 @@ class DeviceStatusBloc extends Bloc { RunningStatusEntity? _cachedStatus; GPSEntity? _cachedGps; + // 🔥 调试计数器:跟踪0x02收包序号,排查断断续续问题 + int _packetSeq = 0; + // 🔥 当前监听的设备ID String? _currentDeviceId; @@ -46,6 +51,9 @@ class DeviceStatusBloc extends Bloc { // 🔥 初始化MQTT到达点监听 _initMqttArriveListener(); + // 🔥 初始化MQTT任务状态监听(接收完成推送) + _initMqttStatusListener(); + // 保留事件处理(用于手动重置等场景) on(_handleReset); on(_handleDeviceStatusLoaded); @@ -89,23 +97,27 @@ class DeviceStatusBloc extends Bloc { // 🔥 关键修复:直接监听 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 { 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 { 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 { ); _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 { _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.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 { // 🔥 节流发射: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 { final devicesCubit = GetIt.I(); 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 { // 🔥 清理MQTT订阅 _mqttArriveSubscription?.cancel(); _mqttArriveSubscription = null; + _mqttStatusSubscription?.cancel(); + _mqttStatusSubscription = null; // 🔥 清理节流timer和缓存 _throttleTimer?.cancel(); diff --git a/lib/features/devices/presentation/bloc/device_task_cubit.dart b/lib/features/devices/presentation/bloc/device_task_cubit.dart index a15edd4e..c602cfd6 100644 --- a/lib/features/devices/presentation/bloc/device_task_cubit.dart +++ b/lib/features/devices/presentation/bloc/device_task_cubit.dart @@ -96,7 +96,7 @@ class DeviceTaskCubit extends Cubit { taskPool: taskList, currentTask: currentTask, currentTaskId: currentTask?.id, - activeTasks: activeTasks, // 🔥 保存所有活跃任务列表 + activeTasks: activeTasks, )); }, ); @@ -340,4 +340,12 @@ class DeviceTaskCubit extends Cubit { )); _logger.logWithLevel('🧹 清除当前任务'); } + + /// 🔥 退出登录时清空所有状态 + void clearAll() { + if (!isClosed) { + emit(const DeviceTaskState()); + } + _logger.logWithLevel('🧹 [DeviceTaskCubit] clearAll - 所有状态已重置'); + } } diff --git a/lib/features/devices/presentation/bloc/devices_cubit.dart b/lib/features/devices/presentation/bloc/devices_cubit.dart index 441dd877..166ba3c7 100644 --- a/lib/features/devices/presentation/bloc/devices_cubit.dart +++ b/lib/features/devices/presentation/bloc/devices_cubit.dart @@ -665,13 +665,14 @@ class DevicesCubit extends Cubit { /// 🔥 启动MQTT到达点监听(用于路径规划动画) /// [deviceId] - 目标设备ID,即targetDevice的deviceId - Future 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 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); diff --git a/lib/features/home/presentation/bloc/permission_request_bloc.dart b/lib/features/home/presentation/bloc/permission_request_bloc.dart index b48dc154..0265018f 100644 --- a/lib/features/home/presentation/bloc/permission_request_bloc.dart +++ b/lib/features/home/presentation/bloc/permission_request_bloc.dart @@ -28,6 +28,7 @@ class PermissionRequestBloc // 事件处理 on(_handleRequestReceived); on(_handleDialogDismissed); + on((event, emit) => emit(const PermissionRequestInitial())); } // 🔥 通过 NetMessageDispatcher 监听 TCP 0x12 指令,解析权限请求 @@ -116,4 +117,11 @@ class PermissionRequestBloc _permissionSubscription?.cancel(); _initPermissionListener(); } + + /// 🔥 退出登录时清空所有状态 + void clearAll() { + if (!isClosed) { + add(const PermissionClearAll()); + } + } } diff --git a/lib/features/home/presentation/bloc/permission_request_event.dart b/lib/features/home/presentation/bloc/permission_request_event.dart index 87bf692a..55bf1b99 100644 --- a/lib/features/home/presentation/bloc/permission_request_event.dart +++ b/lib/features/home/presentation/bloc/permission_request_event.dart @@ -32,3 +32,8 @@ class PermissionDialogDismissed extends PermissionRequestEvent { @override List get props => [agree, deviceId]; } + +/// 🔥 退出登录时清空所有状态 +class PermissionClearAll extends PermissionRequestEvent { + const PermissionClearAll(); +} diff --git a/lib/features/home/presentation/pages/route_plan_page.dart b/lib/features/home/presentation/pages/route_plan_page.dart index e2c3f96b..56ca5260 100644 --- a/lib/features/home/presentation/pages/route_plan_page.dart +++ b/lib/features/home/presentation/pages/route_plan_page.dart @@ -38,7 +38,7 @@ class _RoutePlanPageState extends State { final remoteControlState = context.watch().state; final targetDevice = remoteControlState.targetDevice; - debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}'); + // debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}'); // 检查是否有选中的设备 if (targetDevice == null) { diff --git a/lib/features/home/presentation/pages/running_status_page.dart b/lib/features/home/presentation/pages/running_status_page.dart index 8497e633..6ad9e72e 100644 --- a/lib/features/home/presentation/pages/running_status_page.dart +++ b/lib/features/home/presentation/pages/running_status_page.dart @@ -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 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 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 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 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 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 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)); }, diff --git a/lib/features/home/presentation/widgets/ImmersionHeader.dart b/lib/features/home/presentation/widgets/ImmersionHeader.dart index ce707a91..fddefb44 100644 --- a/lib/features/home/presentation/widgets/ImmersionHeader.dart +++ b/lib/features/home/presentation/widgets/ImmersionHeader.dart @@ -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'; diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index 3f716e2c..429fad14 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -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 { }); // 🔥 关键修复:开始监听路径规划指令应答 - _setupPathPlanningListener(); + // 🔥 已禁用:新流程使用 HTTP/MQTT 管理任务,不再需要 TCP 路径规划指令应答监听 + // 保留 TCP 监听会导致机器正常 TCP 0x01 响应触发 finishWork(),错误地将作业状态重置为 idle + // _setupPathPlanningListener(); // 监听地图移动事件,实时更新连线 _mapController.mapEventStream.listen((event) { @@ -309,6 +312,7 @@ class _MapPageEnterpriseState extends State { 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 { } else { prefs.remove(kSavedSelectedPlot); } + + // 🔥 5. 持久化 taskId(按 deviceId 隔离) + try { + final taskCubit = sl(); + final taskId = taskCubit.state.currentTaskId; + final deviceId = context.read().state.targetDevice?.deviceName; + if (taskId != null && deviceId != null) { + final existingJson = prefs.getString(kSavedDeviceTaskIds); + Map taskIdMap = {}; + if (existingJson != null) { + taskIdMap = jsonDecode(existingJson) as Map; + } + 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 _restoreTaskIdFromLocal(String deviceId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(kSavedDeviceTaskIds); + if (json != null) { + final taskIdMap = jsonDecode(json) as Map; + 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 _clearTaskIdFromLocal(String deviceId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(kSavedDeviceTaskIds); + if (json != null) { + final taskIdMap = jsonDecode(json) as Map; + 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 points) { if (points.isEmpty) return null; @@ -1823,8 +1885,8 @@ class _MapPageEnterpriseState extends State { _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 { 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 { } // 🔥 6. 先过滤出活跃任务,让用户选择 - debugPrint('🔍 [开始作业] 正在查询活跃任务...'); + debugPrint('══════════ [开始作业] 开始 ══════════'); + debugPrint('🔍 [开始作业] 步骤0: 查询活跃任务, deviceId=$deviceId'); final taskCubit = sl(); await taskCubit.fetchAndFilterTask(deviceId); @@ -2345,9 +2413,13 @@ class _MapPageEnterpriseState extends State { 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 { 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().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().execute( deviceId: deviceId, routeId: routeId, @@ -2392,50 +2467,80 @@ class _MapPageEnterpriseState extends State { ); 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().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().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 { _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 { } final taskCubit = sl(); - 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 { } final taskCubit = sl(); - 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 { debugPrint('[停止作业] cancelTask 返回成功'); taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务 debugPrint('[停止作业] clearCurrentTask 完成'); + await _clearTaskIdFromLocal(deviceId); // 🔥 同步清除本地持久化 + + // 🔥 停止MQTT到达点监听 + debugPrint('[停止作业] 停止MQTT到达点监听...'); + await context.read().stopListeningMqttArrive(); + debugPrint('[停止作业] MQTT到达点监听已停止'); + _showPageToast(message: "作业已停止", type: ToastType.success); debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令'); @@ -2613,9 +2748,21 @@ class _MapPageEnterpriseState extends State { } final taskCubit = sl(); - 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 { List _parseStartWorkListFromPathData( List>? 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 parsedJson; @@ -2679,31 +2826,31 @@ class _MapPageEnterpriseState extends State { try { final decoded = jsonDecode(nestedJsonRaw); if (decoded is! Map) { - debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []'); + // debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []'); return []; } parsedJson = decoded; } catch (_) { - debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []'); + // debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []'); return []; } } else if (nestedJsonRaw is Map) { 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 pathList = []; @@ -2728,15 +2875,15 @@ class _MapPageEnterpriseState extends State { 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 { // 🔥 核心修复:从 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 { ), ), 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 { DeviceStatusUpdated? updatedState; //有停止信号 if (isFinishWork) { + // 🔥 清除 taskId 持久化数据(任务已完成) + final finishDeviceId = context.read().state.targetDevice?.deviceName; + if (finishDeviceId != null) { + sl().clearCurrentTask(); + _clearTaskIdFromLocal(finishDeviceId); + debugPrint('🏁 [完成] 任务已到达终点,清除 taskId: deviceId=$finishDeviceId'); + } // 🔥 关键:用微任务延迟执行状态更新,避开构建阶段 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -3685,7 +3837,7 @@ class _MapPageEnterpriseState extends State { tracePoint?.clear(); gctracePoint?.clear(); }); - _showPageToast(message: "作业已停止", type: ToastType.error); + _showPageToast(message: "作业已完成", type: ToastType.success); // 延迟重置轨迹管理器 Future.delayed(const Duration(seconds: 1), () { diff --git a/lib/features/machine_details/presentation/pages/machine_details_page.dart b/lib/features/machine_details/presentation/pages/machine_details_page.dart index f98a5b9a..e260a3ca 100644 --- a/lib/features/machine_details/presentation/pages/machine_details_page.dart +++ b/lib/features/machine_details/presentation/pages/machine_details_page.dart @@ -349,7 +349,7 @@ class _MachineDetailsPageState extends State { streamUrl: _videoStreamUrl, showLeftPip: false, // 不显示悬浮小窗 showRightPip: false, - isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面 + mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment, // 🔥 根据视角切换画面 ) : Container( color: Colors.black87, diff --git a/lib/features/my/presentation/bloc/my_cubit.dart b/lib/features/my/presentation/bloc/my_cubit.dart index d8f0db92..2a89b553 100644 --- a/lib/features/my/presentation/bloc/my_cubit.dart +++ b/lib/features/my/presentation/bloc/my_cubit.dart @@ -45,4 +45,11 @@ class MyCubit extends Cubit { emit(state.copyWith(isLoading: true, errorMessage: '')); // 解绑逻辑(如需保留,需补充 UnbindDeviceUsecase 依赖注入) } + + /// 🔥 退出登录时清空所有状态(昵称等个人信息) + void clearAll() { + if (!isClosed) { + emit(const MyState()); + } + } } diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart index b0d55147..88edf44c 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -1070,4 +1070,28 @@ class RemoteControlCubit extends Cubit { 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 - 所有状态已重置为初始值'); + } + } diff --git a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart index 4b44f519..1dccb603 100644 --- a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart @@ -119,8 +119,6 @@ class _RightJoystickAreaState extends State { } void _triggerVibration() { - Vibration.hasVibrator().then((has) { - if (has ?? false) Vibration.vibrate(duration: 12); - }); + Vibration.vibrate(duration: 12); } } \ No newline at end of file diff --git a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart index cc2987e9..292d9df6 100644 --- a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart +++ b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart @@ -171,6 +171,14 @@ class _WebRTCLocalPlayerState extends State { 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( diff --git a/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart b/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart new file mode 100644 index 00000000..b7848b11 --- /dev/null +++ b/lib/features/v2/device_list/data/datasources/bind_device_datasource.dart @@ -0,0 +1,14 @@ +import '../../domain/entities/bind_device_entities.dart'; + +abstract class BindDeviceDatasource { + Future> getOrgList(); + Future> getSitesByOrgId(int orgId); + Future> getUsersBySiteId(int siteId); + Future isDeviceAtSite({required String deviceId, required int siteId}); + Future bindDevice({ + required List deviceIds, + required int orgId, + required int siteId, + required int userId, + }); +} diff --git a/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart new file mode 100644 index 00000000..0ba6d3cc --- /dev/null +++ b/lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart @@ -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 _getToken() async { + final token = sl().state.user?.token; + if (token != null) return token; + final user = await sl().getUser(); + return user?.token; + } + + @override + Future> 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 rows = data['rows'] ?? data['data'] ?? []; + return rows + .map((e) => OrgEntity.fromJson(e as Map)) + .toList(); + } + + @override + Future> 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 rows = data['rows'] ?? data['data'] ?? []; + return rows + .map((e) => SiteEntity.fromJson(e as Map)) + .toList(); + } + + @override + Future> 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 rows = data['rows'] ?? data['data'] ?? []; + return rows + .map((e) => UserSimpleEntity.fromJson(e as Map)) + .toList(); + } + + @override + Future 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 rows = data['rows'] ?? data['data'] ?? []; + return rows.any((e) => e['deviceId']?.toString() == deviceId); + } catch (e) { + return false; + } + } + + @override + Future bindDevice({ + required List 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'] ?? '绑定设备失败'); + } + } +} diff --git a/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart b/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart new file mode 100644 index 00000000..163cd694 --- /dev/null +++ b/lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart @@ -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>> getOrgList() async { + try { + final result = await _datasource.getOrgList(); + return Right(result); + } catch (e) { + return Left(Failure(e.toString())); + } + } + + @override + Future>> getSitesByOrgId(int orgId) async { + try { + final result = await _datasource.getSitesByOrgId(orgId); + return Right(result); + } catch (e) { + return Left(Failure(e.toString())); + } + } + + @override + Future>> getUsersBySiteId( + int siteId, + ) async { + try { + final result = await _datasource.getUsersBySiteId(siteId); + return Right(result); + } catch (e) { + return Left(Failure(e.toString())); + } + } + + @override + Future> 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> bindDevice({ + required List 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())); + } + } +} diff --git a/lib/features/v2/device_list/domain/entities/bind_device_entities.dart b/lib/features/v2/device_list/domain/entities/bind_device_entities.dart new file mode 100644 index 00000000..d1e2f154 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/bind_device_entities.dart @@ -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 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 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 json) { + return UserSimpleEntity( + id: _parseId(json['userId'] ?? json['id']), + name: json['nickName'] ?? json['name'] ?? json['username'] ?? '', + ); + } +} diff --git a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart index c561606d..3b003b0e 100644 --- a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart +++ b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart @@ -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? gatewayCameraList; final List? 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? gatewayCameraList; // 网关摄像头列表 final List? 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, diff --git a/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart b/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart new file mode 100644 index 00000000..1ff27434 --- /dev/null +++ b/lib/features/v2/device_list/domain/repositories/bind_device_repository.dart @@ -0,0 +1,19 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../../../core/error/failure.dart'; +import '../entities/bind_device_entities.dart'; + +abstract class BindDeviceRepository { + Future>> getOrgList(); + Future>> getSitesByOrgId(int orgId); + Future>> getUsersBySiteId(int siteId); + Future> isDeviceAtSite({ + required String deviceId, + required int siteId, + }); + Future> bindDevice({ + required List deviceIds, + required int orgId, + required int siteId, + required int userId, + }); +} diff --git a/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart b/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart new file mode 100644 index 00000000..4e564391 --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart @@ -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>> call() => _repository.getOrgList(); +} + +class GetSitesByOrgUseCase { + final BindDeviceRepository _repository; + GetSitesByOrgUseCase(this._repository); + Future>> call(int orgId) => + _repository.getSitesByOrgId(orgId); +} + +class GetUsersBySiteUseCase { + final BindDeviceRepository _repository; + GetUsersBySiteUseCase(this._repository); + Future>> call(int siteId) => + _repository.getUsersBySiteId(siteId); +} + +class BindDeviceV2UseCase { + final BindDeviceRepository _repository; + BindDeviceV2UseCase(this._repository); + Future> call({ + required List 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> call({ + required String deviceId, + required int siteId, + }) => _repository.isDeviceAtSite(deviceId: deviceId, siteId: siteId); +} diff --git a/lib/features/v2/device_list/domain/usecases/pause_flight_task_usecase.dart b/lib/features/v2/device_list/domain/usecases/pause_flight_task_usecase.dart new file mode 100644 index 00000000..0c592af7 --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/pause_flight_task_usecase.dart @@ -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>> execute({ + required String deviceSn, + }) async { + return await repository.pauseFlightTask(deviceSn: deviceSn); + } +} diff --git a/lib/features/v2/device_list/domain/usecases/return_home_usecase.dart b/lib/features/v2/device_list/domain/usecases/return_home_usecase.dart new file mode 100644 index 00000000..6a4894c7 --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/return_home_usecase.dart @@ -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>> execute({ + required String deviceSn, + }) async { + return await repository.returnHome(deviceSn: deviceSn); + } +} diff --git a/lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart b/lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart index a604f0d0..94ad5b36 100644 --- a/lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart +++ b/lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart @@ -43,27 +43,37 @@ class DeviceStatusBloc extends Bloc { DeviceStatusRefresh event, Emitter 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, // 🔥 标记需要显示弹窗 + )); } } diff --git a/lib/features/v2/device_list/presentation/bloc/device_status_event.dart b/lib/features/v2/device_list/presentation/bloc/device_status_event.dart index ef7aff3b..a1b3c89b 100644 --- a/lib/features/v2/device_list/presentation/bloc/device_status_event.dart +++ b/lib/features/v2/device_list/presentation/bloc/device_status_event.dart @@ -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 get props => [siteId]; } class DeviceStatusChangeType extends DeviceStatusEvent { diff --git a/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart b/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart new file mode 100644 index 00000000..4b904f4b --- /dev/null +++ b/lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart @@ -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 { + 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 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 _loadAllOrgs() async { + final result = await _getOrgListUseCase(); + result.fold( + (failure) => + emit(state.copyWith(errorMessage: failure.message, isLoading: false)), + (orgs) => emit(state.copyWith(orgList: orgs)), + ); + } + + Future _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 _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 _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 _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 _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 _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 = []; + final seenIds = {}; + 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 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 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 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; +} diff --git a/lib/features/v2/device_list/presentation/cubit/bind_device_state.dart b/lib/features/v2/device_list/presentation/cubit/bind_device_state.dart new file mode 100644 index 00000000..3852a2c0 --- /dev/null +++ b/lib/features/v2/device_list/presentation/cubit/bind_device_state.dart @@ -0,0 +1,68 @@ +import 'package:equatable/equatable.dart'; +import '../../domain/entities/bind_device_entities.dart'; + +class BindDeviceState extends Equatable { + final List orgList; + final List siteList; + final List 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? orgList, + List? siteList, + List? 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 get props => [ + orgList, + siteList, + userList, + selectedOrgId, + selectedSiteId, + selectedUserId, + isLoading, + isSubmitting, + errorMessage, + isSuccess, + ]; +} \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/pages/bind_device_page.dart b/lib/features/v2/device_list/presentation/pages/bind_device_page.dart new file mode 100644 index 00000000..a4303e0f --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/bind_device_page.dart @@ -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 createState() => _BindDevicePageState(); +} + +class _BindDevicePageState extends State { + late final BindDeviceCubit _cubit; + + @override + void initState() { + super.initState(); + _cubit = sl(); + _cubit.init(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _cubit, + child: BlocListener( + 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( + 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(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( + 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( + 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(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( + 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( + 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(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( + 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( + 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); + } +} diff --git a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart new file mode 100644 index 00000000..d8c32772 --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart @@ -0,0 +1,1573 @@ +import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import '../../../../../core/bluetooth/ble_manager.dart'; +import '../../../../../core/bluetooth/ble_protocol_decoder.dart'; +import '../../../../../core/bluetooth/mc700_device_config.dart'; +import '../../../../../core/bluetooth/protocol_parser.dart'; +import '../../../../../core/protocol/machine_protocol_constants.dart'; +import 'package:get_it/get_it.dart'; +import '../../../../../core/services/device_permission_service.dart'; + +class BleDeviceDetailPage extends StatefulWidget { + final BluetoothDevice device; + final ScanResult? scanResult; + + const BleDeviceDetailPage({super.key, required this.device, this.scanResult}); + + @override + State createState() => _BleDeviceDetailPageState(); +} + +class _BleDeviceDetailPageState extends State { + bool _isConnecting = false; + bool _isConnected = false; + List _services = []; + String? _resolvedName; + int _connectingCountdown = 15; + Timer? _connectingTimer; + + StreamSubscription? _connSubscription; + StreamSubscription? _connectingSubscription; + StreamSubscription? _packetSubscription; + final List<_ReceivedPacket> _receivedPackets = []; + /// 按命令类型分组的最新推送:command → 最新数据 + final Map _latestPushByCommand = {}; + bool _pushExpanded = false; // 数据推送默认折叠 + + /// 当前配置卡(0x05)的修改记录,新配置到来时清空 + Map _configModifications = {}; + bool _writeConfigLoading = false; + + /// 等待设备返回 0x06 写配置应答 + Completer? _writeRespCompleter; + /// 等待回读 0x05 应答,携带期望字段值用于对比 + Completer? _readbackCompleter; + Map? _expectedFieldValues; + + @override + void initState() { + super.initState(); + _initConnectionListener(); + _syncInitialState(); + } + + /// 监听 BleManager 的全局连接状态流 + void _initConnectionListener() { + _connSubscription = BleManager.instance.connectionStream.listen((device) { + if (!mounted) return; + if (device != null && device.remoteId == widget.device.remoteId) { + // 当前页面设备已连接 + setState(() { + _isConnected = true; + _isConnecting = false; + }); + _discoverServices(); + // 开始监听数据接收 + _startListeningPackets(); + } else if (device == null) { + // 有设备断开连接(BleManager 只维护一个连接,断开即当前设备断开) + setState(() { + _isConnected = false; + _isConnecting = false; + _services = []; + _receivedPackets.clear(); + _latestPushByCommand.clear(); + }); + BleManager.instance.clearReceivedPackets(); + _packetSubscription?.cancel(); + } + // device != null 但 remoteId 不匹配 = 其他设备连接,忽略不影响当前页面状态 + }); + + // 监听连接中状态(跨页面同步:列表页连接时详情页也能看到"连接中") + _connectingSubscription = BleManager.instance.connectingStream.listen(( + device, + ) { + if (!mounted) return; + if (device != null && device.remoteId == widget.device.remoteId) { + // 当前页面设备正在被连接 + setState(() => _isConnecting = true); + } else if (device == null) { + // 连接完成(成功或失败),清除连接中状态 + setState(() => _isConnecting = false); + } + }); + } + + /// 初始化时同步当前连接状态(包括已连接和连接中) + void _syncInitialState() { + final connectedDevice = BleManager.instance.connectedDevice; + if (connectedDevice != null && + connectedDevice.remoteId == widget.device.remoteId) { + setState(() { + _isConnected = true; + }); + _discoverServices(); + _startListeningPackets(); + // 恢复之前收到的数据 + final stored = BleManager.instance.receivedPacketStore; + if (stored.isNotEmpty) { + for (final p in stored) { + final parsed = BleProtocolDecoder.decode(p.command, p.payload); + final entry = _ReceivedPacket( + time: DateTime.now(), + command: p.command, + hex: _bytesToHex(p.payload.toList()), + bytes: p.payload.length, + isSent: false, + parsedFields: parsed.fields.isNotEmpty ? parsed.fields : null, + rawPayload: p.payload, + configEntity: parsed.configEntity, + ); + _receivedPackets.add(entry); + _latestPushByCommand[p.command] = entry; + } + } + } + // 检查是否正在被连接 + final connectingDevice = BleManager.instance.connectingDevice; + if (connectingDevice != null && + connectingDevice.remoteId == widget.device.remoteId) { + setState(() => _isConnecting = true); + } + } + + Future _discoverServices() async { + try { + final services = await widget.device.discoverServices(); + if (!mounted) return; + setState(() => _services = services); + // 尝试读取 GAP 设备名 + _tryReadGapDeviceName(services); + } catch (e) { + developer.log( + '[BLE] discoverServices error: $e', + name: 'BleDeviceDetail', + ); + } + } + + void _tryReadGapDeviceName(List services) { + try { + final gapService = services.where((s) { + final uuid = s.uuid.toString().toLowerCase(); + return uuid == '00001800-0000-1000-8000-00805f9b34fb' || uuid == '1800'; + }).firstOrNull; + if (gapService == null) return; + final nameChar = gapService.characteristics.where((c) { + final uuid = c.uuid.toString().toLowerCase(); + return uuid == '00002a00-0000-1000-8000-00805f9b34fb' || uuid == '2a00'; + }).firstOrNull; + if (nameChar == null || !nameChar.properties.read) return; + nameChar + .read() + .then((value) { + if (!mounted) return; + final gapName = String.fromCharCodes(value); + developer.log( + '[BLE] GAP device name: $gapName', + name: 'BleDeviceDetail', + ); + if (gapName.isNotEmpty) { + setState(() => _resolvedName = gapName); + } + }) + .catchError((_) {}); + } catch (_) {} + } + + Future _connect() async { + // 如果已连接其他设备,提示先断开 + final currentConnected = BleManager.instance.connectedDevice; + if (currentConnected != null && + currentConnected.remoteId != widget.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; + _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(widget.device); + + // 取消倒计时 + _connectingTimer?.cancel(); + _connectingTimer = null; + + if (!mounted) return; + setState(() => _isConnecting = false); + if (error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + } + + Future _disconnect() async { + await BleManager.instance.disconnect(); + } + + @override + void dispose() { + _connSubscription?.cancel(); + _connectingSubscription?.cancel(); + _connectingTimer?.cancel(); + _packetSubscription?.cancel(); + _writeRespCompleter?.complete(false); + _writeRespCompleter = null; + _readbackCompleter?.complete(null); + _readbackCompleter = null; + super.dispose(); + } + + void _startListeningPackets() { + _packetSubscription?.cancel(); + _packetSubscription = BleManager.instance.packetStream.listen((packet) { + if (!mounted) return; + final hex = packet.payload + .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(' '); + developer.log( + '[BLE Detail] 📩 0x${packet.command.toRadixString(16).toUpperCase().padLeft(2, '0')} ' + '${packet.payload.length}B: $hex', + name: 'BleDeviceDetail', + ); + final parsed = BleProtocolDecoder.decode(packet.command, packet.payload); + final entry = _ReceivedPacket( + time: DateTime.now(), + command: packet.command, + hex: hex, + bytes: packet.payload.length, + isSent: false, + parsedFields: parsed.fields.isNotEmpty ? parsed.fields : null, + rawPayload: packet.payload, + configEntity: parsed.configEntity, + ); + // 处理写配置应答:0x06 响应到达时通知等待中的 _sendWriteConfig + if (packet.command == MachineProtocolConstants.cmdWriteConfig) { + developer.log( + '[WriteConfig] 📥 收到 0x06 应答: ${packet.payload.length}B, ' + '首字节=0x${packet.payload.isNotEmpty ? packet.payload[0].toRadixString(16).padLeft(2, '0') : '??'} ' + '(0x01=成功, 0x00=失败)', + name: 'BleDeviceDetail', + ); + if (_writeRespCompleter != null && !_writeRespCompleter!.isCompleted) { + final ok = packet.payload.isNotEmpty && packet.payload[0] == 0x01; + developer.log( + '[WriteConfig] ${ok ? "✅ 设备应答: 写入成功" : "❌ 设备应答: 写入失败(首字节!=0x01)"}', + name: 'BleDeviceDetail', + ); + _writeRespCompleter!.complete(ok); + } else { + developer.log( + '[WriteConfig] ⚠️ 收到0x06应答但无等待中的Completer (completer=${_writeRespCompleter != null ? "未完成" : "null"})', + name: 'BleDeviceDetail', + ); + } + } + + // 设备推送 0x05 新配置 = 隐式确认写入成功 + if (packet.command == MachineProtocolConstants.cmdReadConfig) { + if (_writeRespCompleter != null && !_writeRespCompleter!.isCompleted) { + developer.log( + '[WriteConfig] 📥 收到 0x05 推送(设备已更新配置),视为写入确认', + name: 'BleDeviceDetail', + ); + _writeRespCompleter!.complete(true); + } + } + + // 处理回读应答:写配置成功后的自动回读 + if (packet.command == MachineProtocolConstants.cmdReadConfig) { + if (_readbackCompleter != null && !_readbackCompleter!.isCompleted) { + _readbackCompleter!.complete(packet.payload); + return; // 不在 setState 中处理,由 _sendWriteConfig 统一处理 + } + } + + setState(() { + _receivedPackets.insert(0, entry); + if (_receivedPackets.length > 100) { + _receivedPackets.removeLast(); + } + // 按命令类型更新:同类型覆盖,新类型新增 + _latestPushByCommand[packet.command] = entry; + // 新配置到来时清空修改记录(仅非回读模式时清空) + if (packet.command == MachineProtocolConstants.cmdReadConfig && + _readbackCompleter == null) { + _configModifications = {}; + } + }); + }); + } + + /// 发送指令并记录到通信日志 + Future _sendAndLog(int command, List payload) async { + await BleManager.instance.sendCommand(command, payload); + if (!mounted) return; + final hex = payload.isEmpty + ? '' + : payload + .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(' '); + setState(() { + _receivedPackets.insert( + 0, + _ReceivedPacket( + time: DateTime.now(), + command: command, + hex: hex, + bytes: payload.length, + isSent: true, + ), + ); + if (_receivedPackets.length > 100) { + _receivedPackets.removeLast(); + } + }); + } + + /// 发送读配置指令 (0x05) + Future _sendReadConfig() async { + await _sendAndLog(MachineProtocolConstants.cmdReadConfig, []); + } + + + String _bytesToHex(List bytes) { + if (bytes.isEmpty) return ''; + return bytes + .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(' '); + } + + String _commandName(int command) { + switch (command) { + case MachineProtocolConstants.cmdRemoteControl: return '远程遥控'; + case MachineProtocolConstants.cmdPathPlanning: return '路径规划'; + case MachineProtocolConstants.cmdStatusInfo: return '状态信息'; + case MachineProtocolConstants.cmdGetId: return '查询ID'; + case MachineProtocolConstants.cmdGetAuth: return '获取授权'; + case MachineProtocolConstants.cmdReadConfig: return '读配置'; + case MachineProtocolConstants.cmdWriteConfig: return '写配置'; + case MachineProtocolConstants.cmdObstacleAvoid: return '避障'; + case MachineProtocolConstants.cmdHeartbeat: return '心跳包'; + default: return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})'; + } + } + + @override + Widget build(BuildContext context) { + final device = widget.device; + final advData = widget.scanResult?.advertisementData; + // 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的 + final name = + _resolvedName ?? + (advData?.advName.isNotEmpty == true + ? advData!.advName + : (device.platformName.isNotEmpty + ? device.platformName + : (advData?.localName?.isNotEmpty == true + ? advData!.localName! + : (device.advName.isNotEmpty + ? device.advName + : '未知设备')))); + final scanResult = widget.scanResult; + + return Scaffold( + backgroundColor: const Color(0xFFF5F6F8), + appBar: AppBar( + backgroundColor: const Color(0xFF165DFF), + 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, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDeviceInfoCard(name, scanResult), + const SizedBox(height: 16), + _buildConnectionControl(), + if (_isConnected && _services.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildServicesList(), + ], + if (_isConnected) ...[ + const SizedBox(height: 16), + _buildCommandSection(), + const SizedBox(height: 16), + _buildDeviceConfigCard(), + const SizedBox(height: 16), + _buildCommLogSection(), + ], + ], + ), + ), + ); + } + + Widget _buildDeviceInfoCard(String name, ScanResult? scanResult) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF165DFF).withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.bluetooth, + color: Color(0xFF165DFF), + size: 28, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const SizedBox(height: 4), + Text( + '${widget.device.remoteId}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF86909C), + ), + ), + ], + ), + ), + _buildStatusBadge(), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFE5E6EB)), + const SizedBox(height: 12), + _buildInfoRow('设备名称', name), + _buildInfoRow('设备ID', '${widget.device.remoteId}'), + if (scanResult != null) ...[ + _buildInfoRow('信号强度', '${scanResult.rssi} dBm'), + _buildInfoRow( + '广播名称', + scanResult.device.advName.isEmpty + ? '-' + : scanResult.device.advName, + ), + ], + _buildInfoRow( + '连接状态', + _isConnected + ? '已连接' + : _isConnecting + ? '连接中 ${_connectingCountdown}s...' + : '未连接', + ), + ], + ), + ); + } + + Widget _buildStatusBadge() { + if (_isConnecting) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFF7D00).withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '连接中 ${_connectingCountdown}s', + style: const TextStyle(fontSize: 12, color: Color(0xFFFF7D00)), + ), + ); + } + if (_isConnected) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF00B42A).withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '已连接', + style: TextStyle(fontSize: 12, color: Color(0xFF00B42A)), + ), + ); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF86909C).withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '未连接', + style: TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 13, color: Color(0xFF1D2129)), + ), + ), + ], + ), + ); + } + + Widget _buildConnectionControl() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '操作', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isConnecting + ? null + : (_isConnected ? _disconnect : _connect), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _isConnected + ? const Color(0xFFF53F3F) + : const Color(0xFF165DFF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isConnecting + ? Text( + '连接中 ${_connectingCountdown}s', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ) + : Text(_isConnected ? '断开连接' : '连接设备'), + ), + ), + ], + ), + ); + } + + Widget _buildServicesList() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '服务列表', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const SizedBox(height: 12), + ..._services.map((service) => _buildServiceTile(service)), + ], + ), + ); + } + + Widget _buildServiceTile(BluetoothService service) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.widgets, size: 16, color: Color(0xFF165DFF)), + const SizedBox(width: 8), + Expanded( + child: Text( + service.uuid.toString().toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2129), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: service.isPrimary + ? const Color(0xFF165DFF).withOpacity(0.1) + : const Color(0xFF86909C).withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + service.isPrimary ? 'PRIMARY' : 'SECONDARY', + style: TextStyle( + fontSize: 10, + color: service.isPrimary + ? const Color(0xFF165DFF) + : const Color(0xFF86909C), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...service.characteristics.map( + (c) => Padding( + padding: const EdgeInsets.only(left: 24, top: 4), + child: Row( + children: [ + const Icon(Icons.code, size: 14, color: Color(0xFF00B42A)), + const SizedBox(width: 6), + Expanded( + child: Text( + c.uuid.toString().toUpperCase(), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF4E5969), + ), + ), + ), + _buildCharacteristicProps(c), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildCharacteristicProps(BluetoothCharacteristic c) { + final props = []; + if (c.properties.read) props.add('R'); + if (c.properties.write) props.add('W'); + if (c.properties.notify) props.add('N'); + if (c.properties.indicate) props.add('I'); + if (c.properties.broadcast) props.add('B'); + if (c.properties.writeWithoutResponse) props.add('WnR'); + + return Row( + mainAxisSize: MainAxisSize.min, + children: props + .map( + (p) => Container( + margin: const EdgeInsets.only(left: 2), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFF165DFF).withOpacity(0.1), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + p, + style: const TextStyle(fontSize: 10, color: Color(0xFF165DFF)), + ), + ), + ) + .toList(), + ); + } + + Widget _buildCommandSection() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '发送指令', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _sendReadConfig, + icon: const Icon(Icons.settings, size: 18), + label: const Text('读配置'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCommLogSection() { + final pushes = _latestPushByCommand.values.toList(); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '数据推送', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + if (pushes.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF165DFF), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${pushes.length}', + style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ], + const Spacer(), + if (_latestPushByCommand.isNotEmpty) + GestureDetector( + onTap: () { + setState(() { + _latestPushByCommand.clear(); + _receivedPackets.clear(); + }); + BleManager.instance.clearReceivedPackets(); + }, + child: const Text( + '清空', + style: TextStyle(fontSize: 13, color: Color(0xFF165DFF)), + ), + ), + ], + ), + const SizedBox(height: 12), + if (pushes.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text( + '等待接收数据...', + style: TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + ), + ) + else + Column( + children: pushes.map((p) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildPushCard(p), + )).toList(), + ), + ], + ), + ); + } + + Widget _buildPushCard(_ReceivedPacket p) { + final name = _commandName(p.command); + final colors = _cardColors(p.command); + final hasParsed = p.parsedFields != null && p.parsedFields!.isNotEmpty; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.bg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.border, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colors.badge, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '0x${p.command.toRadixString(16).toUpperCase().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 10, + fontFamily: 'monospace', + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.fg, + ), + ), + ), + Text( + '${p.time.hour.toString().padLeft(2, '0')}:' + '${p.time.minute.toString().padLeft(2, '0')}:' + '${p.time.second.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 11, color: Color(0xFFC9CDD4)), + ), + ], + ), + if (hasParsed) ...[ + const SizedBox(height: 8), + ...p.parsedFields!.map((f) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 68, + child: Text( + f.label, + style: const TextStyle(fontSize: 11, color: Color(0xFF86909C)), + ), + ), + Expanded( + child: Text( + f.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, color: Color(0xFF1D2129)), + ), + ), + ], + ), + )), + ] else if (p.hex.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + p.hex, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Color(0xFF4E5969), + ), + ), + ), + ], + ), + ); + } + + /// 可编辑的字段白名单:仅这四个增益属性可修改 + static const _editableFields = { + '左轮前进增益', + '左轮后退增益', + '右轮前进增益', + '右轮后退增益', + }; + + bool _isEditableField(String label) => _editableFields.contains(label); + + /// 设备配置独立卡片(0x05),仅在收到配置数据后显示 + Widget _buildDeviceConfigCard() { + final pkt = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig]; + if (pkt?.configEntity == null) return const SizedBox.shrink(); + + final entity = pkt!.configEntity!; + final fieldMap = entity.toFieldMap(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + '设备配置 (0x05)', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + ), + SizedBox( + height: 32, + child: ElevatedButton.icon( + onPressed: _writeConfigLoading + ? null + : () => _sendWriteConfig(pkt), + icon: _writeConfigLoading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.cloud_upload, size: 14), + label: Text( + _writeConfigLoading ? '写入中...' : '写配置', + style: const TextStyle(fontSize: 12), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFE5E6EB)), + const SizedBox(height: 8), + // 每个属性一行:label 左对齐,值右对齐,可换行 + ...fieldMap.entries.map((entry) { + final label = entry.key; + final currentValue = entry.value; + final mod = _configModifications[label]; + final isModified = mod != null; + final editable = _isEditableField(label); + + return InkWell( + onTap: () { + if (editable) { + _showEditDialog(label, currentValue); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('暂不支持更改'), + duration: Duration(seconds: 1), + ), + ); + } + }, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: editable + ? const Color(0xFF4E5969) + : const Color(0xFF86909C), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: isModified + ? RichText( + text: TextSpan( + style: const TextStyle(fontSize: 13), + children: [ + TextSpan( + text: mod.oldValue, + style: const TextStyle( + color: Color(0xFFC9CDD4), + decoration: TextDecoration.lineThrough, + ), + ), + const TextSpan( + text: ' | ', + style: TextStyle(color: Color(0xFFE5E6EB)), + ), + TextSpan( + text: mod.newValue, + style: const TextStyle( + color: Color(0xFF165DFF), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + : Text( + currentValue, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 13, + color: editable + ? const Color(0xFF165DFF) + : const Color(0xFF1D2129), + ), + ), + ), + ), + if (editable) + const Padding( + padding: EdgeInsets.only(left: 4), + child: Icon( + Icons.edit, + size: 14, + color: Color(0xFFC9CDD4), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ); + } + + void _showEditDialog(String label, String currentValue) { + final controller = TextEditingController(text: currentValue); + final formKey = GlobalKey(); + String? errorText; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: Text('修改 $label'), + content: Form( + key: formKey, + child: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: '输入新值', + border: const OutlineInputBorder(), + suffixText: '当前: $currentValue', + suffixStyle: const TextStyle(fontSize: 11, color: Color(0xFF86909C)), + errorText: errorText, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + TextButton( + onPressed: () { + final newValue = controller.text.trim(); + if (newValue.isEmpty || newValue == currentValue) { + Navigator.pop(ctx); + return; + } + final pkt = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig]; + final entity = pkt?.configEntity; + if (entity == null) { + Navigator.pop(ctx); + return; + } + final validationError = entity.setField(label, newValue); + if (validationError != null) { + setDialogState(() => errorText = validationError); + return; + } + setState(() { + _configModifications[label] = _FieldModification( + currentValue, + newValue, + ); + }); + Navigator.pop(ctx); + }, + child: const Text('确定'), + ), + ], + ), + ), + ); + } + + Future _sendWriteConfig(_ReceivedPacket p) async { + if (p.rawPayload == null || p.configEntity == null) return; + setState(() => _writeConfigLoading = true); + + try { + // 🔐 前置权限校验:使用读配置(0x05)中的芯片序列号(chipUid)作为设备ID + final readConfigPacket = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig]; + final chipUid = readConfigPacket?.configEntity?.chipUid ?? ''; + if (chipUid.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('未获取到设备序列号,请先读配置'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + setState(() => _writeConfigLoading = false); + return; + } + final permissionService = GetIt.I(); + final hasPermission = await permissionService.checkPermission(chipUid); + + if (!mounted) return; + if (!hasPermission) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('权限校验未通过,无法写入配置'), + backgroundColor: Colors.red, + duration: Duration(seconds: 2), + ), + ); + setState(() => _writeConfigLoading = false); + 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; + + // 0. 连接状态诊断 + developer.log( + '[WriteConfig] 🏥 连接状态: isConnected=${BleManager.instance.isConnected}, ' + 'device=${BleManager.instance.connectedDevice?.remoteId}', + name: 'BleDeviceDetail', + ); + + // 1. 序列化修改后的配置 + final configBytes = p.configEntity!.toBytes(p.rawPayload!); + developer.log( + '[WriteConfig] 📝 修改条目(${_configModifications.length}): ${_configModifications.entries.map((e) => '${e.key}: ${e.value.oldValue}→${e.value.newValue}').join(', ')}', + name: 'BleDeviceDetail', + ); + developer.log( + '[WriteConfig] 📤 payload=${configBytes.length}B ' + '首16B=${_bytesToHex(configBytes.take(16).toList())} ' + '尾16B=${_bytesToHex(configBytes.skip(configBytes.length - 16).toList())}', + name: 'BleDeviceDetail', + ); + + // 2. 发送 0x06 并等待设备应答 + _writeRespCompleter = Completer(); + developer.log('[WriteConfig] ⏳ 发送 0x06, 等待应答...', name: 'BleDeviceDetail'); + await BleManager.instance.sendCommand( + MachineProtocolConstants.cmdWriteConfig, + configBytes.toList(), + ); + + final writeOk = await _writeRespCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () => false, + ); + _writeRespCompleter = null; + + developer.log( + '[WriteConfig] ${writeOk ? '✅ 设备确认写入' : '⏰ 超时: 未收到显式确认,将回读验证'}', + name: 'BleDeviceDetail', + ); + + if (!mounted) return; + + // 3. 记录期望值,自动回读对比(无论是否收到显式确认都回读) + _expectedFieldValues = Map.from(p.configEntity!.toFieldMap()); + + // 4. 自动回读配置 + _readbackCompleter = Completer(); + await BleManager.instance.sendCommand( + MachineProtocolConstants.cmdReadConfig, + [], + ); + + final readbackPayload = await _readbackCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () => null, + ); + _readbackCompleter = null; + + if (!mounted) return; + + if (readbackPayload == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('回读超时,请手动读配置确认'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // 5. 解析回读数据并对比 + final newEntity = Mc700DeviceConfig.fromBytes(readbackPayload); + final newFieldMap = newEntity.toFieldMap(); + final expected = _expectedFieldValues!; + _expectedFieldValues = null; + + final mismatched = []; + for (final entry in expected.entries) { + final label = entry.key; + final expectedVal = entry.value; + final actualVal = newFieldMap[label] ?? ''; + if (expectedVal != actualVal) { + mismatched.add(label); + } + } + + if (mismatched.isNotEmpty) { + // 回滚:用回读值覆盖实体 + for (final label in mismatched) { + final actualVal = newFieldMap[label] ?? ''; + newEntity.setField(label, actualVal); + _configModifications.remove(label); + } + // 更新缓存的包数据 + final parsed = BleProtocolDecoder.decode( + MachineProtocolConstants.cmdReadConfig, + readbackPayload, + ); + if (mounted) { + setState(() { + final updatedEntry = _ReceivedPacket( + time: DateTime.now(), + command: MachineProtocolConstants.cmdReadConfig, + hex: _bytesToHex(readbackPayload.toList()), + bytes: readbackPayload.length, + isSent: false, + parsedFields: parsed.fields.isNotEmpty ? parsed.fields : null, + rawPayload: readbackPayload, + configEntity: newEntity, + ); + _latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = updatedEntry; + }); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '校验不一致: ${mismatched.join('、')},已恢复为设备实际值', + ), + backgroundColor: Colors.orange, + duration: const Duration(seconds: 3), + ), + ); + return; + } + + // 6. 全部一致:更新基准数据,清空修改记录 + if (mounted) { + final parsed = BleProtocolDecoder.decode( + MachineProtocolConstants.cmdReadConfig, + readbackPayload, + ); + setState(() { + final updatedEntry = _ReceivedPacket( + time: DateTime.now(), + command: MachineProtocolConstants.cmdReadConfig, + hex: _bytesToHex(readbackPayload.toList()), + bytes: readbackPayload.length, + isSent: false, + parsedFields: parsed.fields.isNotEmpty ? parsed.fields : null, + rawPayload: readbackPayload, + configEntity: newEntity, + ); + _latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = updatedEntry; + _configModifications = {}; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('写配置成功,已回读确认'), + backgroundColor: Color(0xFF00B42A), + ), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('写配置异常: $e'), + backgroundColor: Colors.red, + ), + ); + } finally { + _writeRespCompleter = null; + _readbackCompleter = null; + _expectedFieldValues = null; + if (mounted) { + setState(() => _writeConfigLoading = false); + } + } + } + + /// 卡片配色按命令类型区分 + _CardColors _cardColors(int command) { + const colors = [ + _CardColors( + bg: Color(0xFFF0F5FF), + border: Color(0xFFADC6FF), + badge: Color(0xFF165DFF), + fg: Color(0xFF165DFF), + ), + _CardColors( + bg: Color(0xFFF0FFF4), + border: Color(0xFFB7EB8F), + badge: Color(0xFF00B42A), + fg: Color(0xFF00B42A), + ), + _CardColors( + bg: Color(0xFFFFF7E6), + border: Color(0xFFFFD591), + badge: Color(0xFFFF7D00), + fg: Color(0xFFFF7D00), + ), + _CardColors( + bg: Color(0xFFFFF0F6), + border: Color(0xFFFFADD2), + badge: Color(0xFFF5319D), + fg: Color(0xFFF5319D), + ), + _CardColors( + bg: Color(0xFFF9F0FF), + border: Color(0xFFD3ADF7), + badge: Color(0xFF722ED1), + fg: Color(0xFF722ED1), + ), + ]; + return colors[command % colors.length]; + } +} + +class _CardColors { + final Color bg; + final Color border; + final Color badge; + final Color fg; + + const _CardColors({ + required this.bg, + required this.border, + required this.badge, + required this.fg, + }); +} + +class _ReceivedPacket { + final DateTime time; + final int command; + final String hex; + final int bytes; + final String? label; + final bool isSent; + final List? parsedFields; + final Uint8List? rawPayload; + final Mc700DeviceConfig? configEntity; + + const _ReceivedPacket({ + required this.time, + required this.command, + required this.hex, + required this.bytes, + this.label, + this.isSent = false, + this.parsedFields, + this.rawPayload, + this.configEntity, + }); +} + +/// 配置字段修改记录:旧值 → 新值 +class _FieldModification { + final String oldValue; + final String newValue; + const _FieldModification(this.oldValue, this.newValue); +} diff --git a/lib/features/v2/device_list/presentation/pages/create_task_page.dart b/lib/features/v2/device_list/presentation/pages/create_task_page.dart index b54952ff..0bbe568c 100644 --- a/lib/features/v2/device_list/presentation/pages/create_task_page.dart +++ b/lib/features/v2/device_list/presentation/pages/create_task_page.dart @@ -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 { 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 { 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 diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 2b44e1b6..49584b63 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -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 createState() => _DeviceStatusViewState(); +} + +class _DeviceStatusViewState extends State { + final _searchController = TextEditingController(); + + /// 🔥 监听电站切换:只要电站发生变化,就根据当前选中的标签栏自动刷新对应接口 + StreamSubscription? _siteSub; + + /// 当前缓存的 siteId,用于判断是否真的发生了变化 + int? _currentSiteId; + + @override + void initState() { + super.initState(); + _currentSiteId = sl().state.selectedSite?.id; + _siteSub = sl().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(); + 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().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().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().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()..add(RobotListLoadData(siteId: siteId)), child: BlocConsumer( @@ -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(); + 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().state.selectedSite; @@ -493,6 +690,7 @@ class DeviceStatusView extends StatelessWidget { } return BlocProvider( + key: key, create: (_) => sl()..add(DroneStationLoadData(selectedSite.id)), child: BlocConsumer( @@ -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( + 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 字段,使用默认值 diff --git a/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart index 15324398..d55eafbe 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_mission_control_page.dart @@ -30,7 +30,7 @@ class _DroneMissionControlPageState extends State { FlightTaskDetailEntity? _detailTask; // 详情数据 bool _isLoading = false; final Dio _dio = Dio(); - + // 🔥 任务状态管理 bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮) bool _isReturning = false; // 是否正在返航中 @@ -220,9 +220,7 @@ class _DroneMissionControlPageState extends State { print('🔍 [DroneMissionControl] 开始暂停任务, deviceSn: ${_detailTask!.sn}'); final useCase = GetIt.I(); - 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 { 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 { ), 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 { 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 { ), child: Text( _isReturning ? '已在返航' : '返航降落', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), ), ), ), diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index fd701e16..992ed994 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -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 { late DroneStationBloc _bloc; - // 无人机详情数据 UAVDetailEntity? _detail; String? _droneSn; - // 无人机状态轮询计时器 Timer? _droneStatusPollingTimer; - - // 🔥 标记是否已经初始化过(用于判断是否从其他页面返回) bool _hasInitialized = false; + late DroneOsdDataSource _osdDataSource; + StreamSubscription? _stationOsdSubscription; + final ValueNotifier> _stationHostData = + ValueNotifier>({}); + @override void initState() { super.initState(); @@ -46,20 +49,78 @@ class _DroneStationDetailPageState extends State { deviceSn: widget.station.deviceSn, ), ); - - // 🔥 标记已初始化 - _hasInitialized = true; - // 启动无人机状态轮询(每5秒刷新一次) - // 🔥 已禁用自动轮询,改为手动下拉刷新 - // _startDroneStatusPolling(); + _osdDataSource = sl(); + _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 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.from(parsed); } /// 🔥 页面重新激活时调用(从其他页面返回时) @override void didChangeDependencies() { super.didChangeDependencies(); - + // 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新) if (_hasInitialized && _bloc.state is UAVDetailLoaded) { debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据'); @@ -75,7 +136,7 @@ class _DroneStationDetailPageState extends State { /// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新) void _refreshData() { if (!mounted) return; - + debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据'); _bloc.add( UAVDetailLoad( @@ -87,9 +148,10 @@ class _DroneStationDetailPageState extends State { @override void dispose() { + _stationOsdSubscription?.cancel(); + _osdDataSource.stopListening(); + _stationHostData.dispose(); _bloc.close(); - // 🔥 已禁用自动轮询,无需停止 - // _droneStatusPollingTimer?.cancel(); super.dispose(); } @@ -216,27 +278,41 @@ class _DroneStationDetailPageState extends State { } 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>( + 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 { /// 处理下拉刷新 Future _handleRefresh() async { debugPrint('🔄 [DroneStationDetailPage] 开始下拉刷新'); - + // 🔥 创建一个 Completer 来等待 Bloc 状态更新 final completer = Completer(); - + // 监听 Bloc 状态变化 final subscription = _bloc.stream.listen((state) { if (state is UAVDetailLoaded || state is UAVDetailError) { @@ -265,7 +341,7 @@ class _DroneStationDetailPageState extends State { } } }); - + // 重新加载无人机详情 _bloc.add( UAVDetailLoad( @@ -273,7 +349,7 @@ class _DroneStationDetailPageState extends State { deviceSn: widget.station.deviceSn, ), ); - + // 🔥 等待数据加载完成(最多等待5秒) await completer.future.timeout( const Duration(seconds: 5), @@ -281,10 +357,10 @@ class _DroneStationDetailPageState extends State { debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时'); }, ); - + // 取消订阅 subscription.cancel(); - + debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成'); } @@ -343,7 +419,90 @@ class _DroneStationDetailPageState extends State { ); } - Widget _buildAirportStatusCard(UAVDetailEntity detail) { + Widget _buildAirportStatusCard( + UAVDetailEntity detail, + Map 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 { ), ), 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 { ), ], ), - 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 { ? '${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 { ); } + 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) { diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_status_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_status_page.dart index bc0cc9d9..faff0ff2 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_status_page.dart @@ -229,6 +229,18 @@ class _DroneStationStatusPageState extends State { 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 { color: Color(0xFF1D2129), ), ), - const SizedBox(height: 8), - _buildDroneBatteryRow(), ], ), ), diff --git a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart index c80b45b0..1bf0fcdc 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart @@ -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 { List _effectiveCameraList = []; // 实际使用的摄像头列表 List? _backupCameraList; // 备选摄像头列表(网关摄像头) + // 暂停状态 + bool _isPaused = false; + // 任务下发后等待视频流(用于显示“无人机已启动 视频获取中”toast) + bool _isWaitingForVideo = false; + // 火山引擎 RTC volc.RTCEngine? _rtcEngine; volc.RTCRoom? _rtcRoom; @@ -64,6 +74,9 @@ class _DroneVideoControlPageState extends State { List _trajectoryPoints = []; LatLng? _currentPosition; double? _currentHeading; + + /// 从全局管理器恢复的轨迹点(避免退出后丢失) + bool _hasRestoredTrajectory = false; StreamSubscription? _osdSubscription; DroneOsdDataSource? _droneOsdDataSource; @@ -72,6 +85,23 @@ class _DroneVideoControlPageState extends State { super.initState(); _bloc = sl(); + // 🔥 初始化"等待视频流"状态(任务下发后进入此页面时显示 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(); _startOsdListening(); @@ -123,11 +153,101 @@ class _DroneVideoControlPageState extends State { // 默认加载广角镜头 if (_currentCamera != null) { _loadVideoStream(UavLensType.wide); + } else { + // 摄像头列表为空(任务下发后无人机刚上线,详情接口尚未返回摄像头数据) + // 自动获取无人机详情,拿到摄像头列表 + _fetchDroneDetailAndLoadCamera(); + } + } + + /// 自动获取无人机详情,拿到摄像头列表 + /// 任务下发后无人机刚上线,传入的 cameraList 可能为空 + Future _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 jsonData = (response.data is String) + ? json.decode(response.data) + : Map.from(response.data); + + if (jsonData['code'] == 0 || jsonData['code'] == 200) { + final detailData = jsonData['data']; + final Map detailMap = (detailData is Map) + ? Map.from(detailData) + : {}; + + 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 { 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 { 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 { } /// 🔥 计算两点之间的距离(米) - 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 { ); _isLoading = false; }); + // 收到视频流,隐藏“无人机已启动 视频获取中”toast + droneTaskStateManager.markVideoReceived(); } }; @@ -664,58 +804,111 @@ class _DroneVideoControlPageState extends State { ), ], ), - body: BlocConsumer( - 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( + 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(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 { 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 { 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 { 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 { 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 { ); } - /// 🔥 摇杆控制(单独一行) - 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 _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 { 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 { 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 { 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 { 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, + ), ), ), ), diff --git a/lib/features/v2/device_list/presentation/pages/qr_scanner_page.dart b/lib/features/v2/device_list/presentation/pages/qr_scanner_page.dart new file mode 100644 index 00000000..ae147c42 --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/qr_scanner_page.dart @@ -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 createState() => _QrScannerPageState(); +} + +class _QrScannerPageState extends State { + final MobileScannerController _controller = MobileScannerController(); + bool _isScanned = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_isScanned) return; + + final List 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart b/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart new file mode 100644 index 00000000..551d1c8c --- /dev/null +++ b/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart @@ -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 createState() => _BluetoothScanModalState(); +} + +class _BluetoothScanModalState extends State { + BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown; + List _scanResults = []; + bool _isConnecting = false; + BluetoothDevice? _connectingDevice; + BluetoothDevice? _connectedDevice; + int _connectingCountdown = 15; + Timer? _connectingTimer; + bool _isStartingScan = false; + + StreamSubscription? _stateSubscription; + StreamSubscription>? _scanSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _connectingSubscription; + + @override + void initState() { + super.initState(); + _initBluetooth(); + } + + Future _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 _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 _refreshScan() async { + setState(() { + _scanResults = []; + }); + await BleManager.instance.refreshScan(); + if (!mounted) return; + } + + Future _openBluetooth() async { + await BleManager.instance.openBluetooth(); + } + + /// 排序:已连接设备排第一,有名称设备优先 + List _sortResults(List results) { + final mgrConnected = BleManager.instance.connectedDevice; + final sorted = List.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 _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 _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)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart index b448229e..d7af96f4 100644 --- a/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart @@ -1,20 +1,24 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../../../../core/di/injection.dart'; -import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart'; +import '../../../../../core/managers/drone_task_state_manager.dart'; import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart'; -/// 无人机 OSD 实时数据卡片(网格布局展示所有数据) class DroneOsdCard extends StatefulWidget { + final Stream droneOsdStream; final String deviceSn; final String gatewaySn; final bool isDroneOnline; + /// 手动刷新回调(点击刷新按钮时调用) + final VoidCallback? onRefresh; + const DroneOsdCard({ super.key, + required this.droneOsdStream, required this.deviceSn, required this.gatewaySn, required this.isDroneOnline, + this.onRefresh, }); @override @@ -22,129 +26,252 @@ class DroneOsdCard extends StatefulWidget { } class _DroneOsdCardState extends State { - late DroneOsdDataSource _dataSource; StreamSubscription? _subscription; - DroneOsdEntity? _currentOsd; + final List> _osdFields = []; + final Map _cachedValues = {}; - // OSD 数据字段列表 - List> _osdFields = []; + /// 是否正在等待任务下发后的无人机推送数据 + bool _isWaitingForPush = false; - // 缓存上次的有效值,避免闪烁 - Map _cachedValues = {}; + /// 是否已收到过有效的 OSD 数据 + /// 即使接口返回 isDroneOnline=false,只要 MQTT 推送了数据就说明无人机在线 + bool _hasReceivedData = false; + + /// 本地超时定时器,避免无人机离线时一直转圈 + Timer? _localWaitTimeoutTimer; @override void initState() { super.initState(); - _dataSource = sl(); - _startListening(); + _initFields(); + _isWaitingForPush = droneTaskStateManager.isWaitingForOsdPush.value; + droneTaskStateManager.isWaitingForOsdPush.addListener(_onWaitingChanged); + if (_isWaitingForPush) { + _startLocalWaitTimeout(); + } + _subscribeStream(); + } + + void _onWaitingChanged() { + if (!mounted) return; + final waiting = droneTaskStateManager.isWaitingForOsdPush.value; + if (waiting != _isWaitingForPush) { + setState(() { + _isWaitingForPush = waiting; + }); + if (waiting) { + _startLocalWaitTimeout(); + } else { + _localWaitTimeoutTimer?.cancel(); + } + } + } + + /// 启动本地超时:30 秒后若仍未收到推送,自动停止转圈 + void _startLocalWaitTimeout() { + _localWaitTimeoutTimer?.cancel(); + _localWaitTimeoutTimer = Timer(const Duration(seconds: 30), () { + if (mounted && _isWaitingForPush) { + setState(() { + _isWaitingForPush = false; + }); + } + }); } @override void didUpdateWidget(DroneOsdCard oldWidget) { super.didUpdateWidget(oldWidget); + // 同步全局等待状态(防止 State 被复用时状态不同步) + final globalWaiting = droneTaskStateManager.isWaitingForOsdPush.value; + if (globalWaiting != _isWaitingForPush) { + _isWaitingForPush = globalWaiting; + if (globalWaiting) { + _startLocalWaitTimeout(); + } else { + _localWaitTimeoutTimer?.cancel(); + } + } if (oldWidget.deviceSn != widget.deviceSn || oldWidget.gatewaySn != widget.gatewaySn || oldWidget.isDroneOnline != widget.isDroneOnline) { - _stopListening(); - _startListening(); + _subscription?.cancel(); + _subscribeStream(); } } @override void dispose() { - _stopListening(); + droneTaskStateManager.isWaitingForOsdPush.removeListener(_onWaitingChanged); + _localWaitTimeoutTimer?.cancel(); + _subscription?.cancel(); super.dispose(); } - void _startListening() { - if (!widget.isDroneOnline) return; + void _initFields() { + _osdFields.addAll([ + { + 'icon': Icons.height_rounded, + 'label': '飞行高度', + 'key': 'height', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.speed_rounded, + 'label': '飞行速度', + 'key': 'speed', + 'value': '未知', + 'color': const Color(0xFF722ED1), + }, + { + 'icon': Icons.trending_flat_rounded, + 'label': '水平速度', + 'key': 'horizontalSpeed', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.trending_up_rounded, + 'label': '垂直速度', + 'key': 'verticalSpeed', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.route_rounded, + 'label': '飞行距离', + 'key': 'distance', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.battery_full_rounded, + 'label': '电量', + 'key': 'battery', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.bolt_rounded, + 'label': '电池电压', + 'key': 'voltage', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.thermostat_rounded, + 'label': '电池温度', + 'key': 'batteryTemp', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.navigation_rounded, + 'label': '航向角', + 'key': 'heading', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.rotate_90_degrees_cw_rounded, + 'label': '俯仰角', + 'key': 'pitch', + 'value': '未知', + 'color': const Color(0xFF722ED1), + }, + { + 'icon': Icons.rotate_right_rounded, + 'label': '横滚角', + 'key': 'roll', + 'value': '未知', + 'color': const Color(0xFF722ED1), + }, + { + 'icon': Icons.satellite_rounded, + 'label': 'GPS卫星', + 'key': 'gps', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.satellite_alt_rounded, + 'label': 'RTK卫星', + 'key': 'rtk', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.home_rounded, + 'label': '无人机状态', + 'key': 'droneState', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.flight_rounded, + 'label': '飞行模式', + 'key': 'flightMode', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + ]); + } + + void _subscribeStream() { if (widget.deviceSn.isEmpty) return; - debugPrint('🔊 [DroneOsdCard] 开始监听无人机 OSD: ${widget.deviceSn}'); - - _dataSource.startListening( - deviceSn: widget.deviceSn, - gatewaySn: widget.gatewaySn, - ); - - _subscription = _dataSource.droneOsdStream.listen((osd) { + _subscription = widget.droneOsdStream.listen((osd) { if (!mounted) return; - - // 🔥 打印完整的 MQTT 原始数据(不做任何解析) - debugPrint('\n========== 📥 [无人机OSD] 完整原始数据 =========='); - debugPrint('${osd.rawData}'); - debugPrint('===========================================\n'); - - setState(() { - _currentOsd = osd; - _parseOsdFields(osd); - }); - - debugPrint('✅ [DroneOsdCard] UI 已更新,字段数: ${_osdFields.length}'); + _parseOsdFields(osd); }); } - void _stopListening() { - _subscription?.cancel(); - _subscription = null; - _dataSource.stopListening(); - debugPrint('⏹️ [DroneOsdCard] 停止监听无人机 OSD'); - } - - /// 解析无人机 OSD 数据为可展示的字段列表 void _parseOsdFields(DroneOsdEntity osd) { final data = osd.rawData; - - // 解析嵌套的 JSON 结构 - // 优先级:先尝试 drone 字段,如果不存在则使用 host 字段 final dataMap = data['data'] is Map ? data['data'] as Map : null; Map? droneData; if (dataMap != null) { - // 优先使用 drone 字段 if (dataMap['drone'] is Map) { droneData = dataMap['drone'] as Map; } else if (dataMap['host'] is Map) { - // 如果没有 drone 字段,尝试 host 字段(机场设备的数据结构) droneData = dataMap['host'] as Map; } } - if (droneData == null) { - debugPrint('⚠️ [DroneOsdCard] 无法解析 drone 或 host 数据'); - debugPrint('📋 [DroneOsdCard] 原始数据结构: ${data.keys.toList()}'); - if (dataMap != null) { - debugPrint('📋 [DroneOsdCard] data 结构: ${dataMap.keys.toList()}'); - } - return; + if (droneData == null) return; + + // 收到有效的无人机推送数据,标记已收到数据 + // 即使接口返回 isDroneOnline=false,MQTT 推送了数据说明无人机实际已在线 + _hasReceivedData = true; + + // 收到有效的无人机推送数据,停止本地“推送信息检测中”转圈 + // 注意:只清除本地状态,不清除全局状态 + // 因为本卡片在用户离开详情页期间仍可能收到旧 OSD 数据, + // 提前清除全局状态会导致用户返回后看不到转圈效果 + if (_isWaitingForPush) { + _localWaitTimeoutTimer?.cancel(); + setState(() { + _isWaitingForPush = false; + }); } - // 🔥 打印完整原始数据(用于调试) - debugPrint('📊 [DroneOsdCard] drone/host 数据键: ${droneData.keys.toList()}'); - debugPrint('📊 [DroneOsdCard] 完整原始数据: $droneData'); + final parsedValues = {}; + final parsedColors = {}; - // ========== 1. 基础飞行信息 ========== - // 无人机高度(height / altitude) double? height = (droneData['height'] as num?)?.toDouble() ?? (droneData['altitude'] as num?)?.toDouble(); - // 飞行速度(ground_speed) double? groundSpeed = (droneData['ground_speed'] as num?)?.toDouble(); - - // 飞行距离(flight_distance) double? flightDistance = (droneData['flight_distance'] as num?)?.toDouble(); - - // 飞行时间(flight_time) int? flightTime = droneData['flight_time'] as int?; - // ========== 2. 电池信息 ========== - // 电量百分比 - 支持两种结构:battery_percent 或 battery.capacity_percent final batteryMap = droneData['battery'] as Map?; int? batteryPercent = droneData['battery_percent'] as int? ?? batteryMap?['capacity_percent'] as int?; - // 电池电压(battery_voltage 或 battery.batteries[0].voltage) double? batteryVoltage = (droneData['battery_voltage'] as num?)?.toDouble(); if (batteryVoltage == null && batteryMap != null) { final batteries = batteryMap['batteries'] as List?; @@ -153,8 +280,10 @@ class _DroneOsdCardState extends State { batteryVoltage = (firstBattery?['voltage'] as num?)?.toDouble(); } } + if (batteryVoltage != null && batteryVoltage > 1000) { + batteryVoltage = batteryVoltage / 1000; + } - // 电池温度(battery_temperature 或 battery.batteries[0].temperature) double? batteryTemp = (droneData['battery_temperature'] as num?) ?.toDouble(); if (batteryTemp == null && batteryMap != null) { @@ -165,253 +294,137 @@ class _DroneOsdCardState extends State { } } - // ========== 3. 位置与姿态 ========== - // 纬度(latitude) - double? latitude = (droneData['latitude'] as num?)?.toDouble(); - - // 经度(longitude) - double? longitude = (droneData['longitude'] as num?)?.toDouble(); - - // 航向角(heading / attitude_head) double? heading = (droneData['heading'] as num?)?.toDouble() ?? (droneData['attitude_head'] as num?)?.toDouble(); - - // 俯仰角(pitch / attitude_pitch) double? pitch = (droneData['pitch'] as num?)?.toDouble() ?? (droneData['attitude_pitch'] as num?)?.toDouble(); - - // 横滚角(roll / attitude_roll) double? roll = (droneData['roll'] as num?)?.toDouble() ?? (droneData['attitude_roll'] as num?)?.toDouble(); - // ========== 4. GPS 状态 ========== - // GPS卫星数(gps_satellites) int? gpsSatellites = droneData['gps_satellites'] as int?; + int? rtkSatellites = droneData['rtk_satellites'] as int?; - // GPS信号质量(gps_quality) - int? gpsQuality = droneData['gps_quality'] as int?; + double? horizontalSpeed = (droneData['horizontal_speed'] as num?) + ?.toDouble(); + double? verticalSpeed = (droneData['vertical_speed'] as num?)?.toDouble(); - // ========== 5. 遥控信号 ========== - // 遥控信号强度(rc_signal_strength) - int? rcSignal = droneData['rc_signal_strength'] as int?; - - // 图传信号强度(video_signal_strength) - int? videoSignal = droneData['video_signal_strength'] as int?; - - // ========== 6. 飞行模式 ========== - // 飞行模式(flight_mode) - String? flightMode = droneData['flight_mode'] as String?; - - // ========== 7. 电机状态 ========== - // 电机状态(motor_status) - int? motorStatus = droneData['motor_status'] as int?; - - // ========== 8. 任务状态 ========== - // 任务进度(mission_progress) - int? missionProgress = droneData['mission_progress'] as int?; - - // 航点数量(waypoint_count) - int? waypointCount = droneData['waypoint_count'] as int?; - - // 当前航点(current_waypoint) - int? currentWaypoint = droneData['current_waypoint'] as int?; - - // 剩余航点(remaining_waypoints) - int? remainingWaypoints = droneData['remaining_waypoints'] as int?; - - debugPrint('✅ [DroneOsdCard] 解析结果:'); - debugPrint(' 高度: $height m'); - debugPrint(' 速度: $groundSpeed m/s'); - debugPrint(' 距离: $flightDistance m'); - debugPrint(' 时间: $flightTime s'); - debugPrint(' 电量: $batteryPercent%'); - debugPrint(' 电压: $batteryVoltage V'); - debugPrint(' GPS: $gpsSatellites 颗卫星'); - debugPrint(' 航向: $heading°'); - debugPrint(' 飞行模式: $flightMode'); - - // 构建展示字段列表(精选重要字段) - _osdFields = [ - { - 'icon': Icons.height, - 'label': '飞行高度', - 'key': 'height', - 'newValue': height != null ? '${height.toStringAsFixed(1)}m' : null, - 'color': const Color(0xFF165DFF), - }, - { - 'icon': Icons.speed, - 'label': '飞行速度', - 'key': 'speed', - 'newValue': groundSpeed != null - ? '${groundSpeed.toStringAsFixed(1)}m/s' - : null, - 'color': const Color(0xFF722ED1), - }, - { - 'icon': Icons.route, - 'label': '飞行距离', - 'key': 'distance', - 'newValue': flightDistance != null - ? '${flightDistance.toStringAsFixed(0)}m' - : null, - 'color': const Color(0xFF00B42A), - }, - { - 'icon': Icons.timer, - 'label': '飞行时间', - 'key': 'time', - 'newValue': flightTime != null - ? '${(flightTime / 60).toStringAsFixed(1)}min' - : null, - 'color': const Color(0xFF86909C), - }, - { - 'icon': Icons.battery_full_rounded, - 'label': '电量', - 'key': 'battery', - 'newValue': batteryPercent != null ? '$batteryPercent%' : null, - 'color': _getBatteryColor(batteryPercent), - }, - { - 'icon': Icons.bolt, - 'label': '电池电压', - 'key': 'voltage', - 'newValue': batteryVoltage != null - ? '${batteryVoltage.toStringAsFixed(1)}V' - : null, - 'color': const Color(0xFF00B42A), - }, - { - 'icon': Icons.thermostat, - 'label': '电池温度', - 'key': 'batteryTemp', - 'newValue': batteryTemp != null - ? '${batteryTemp.toStringAsFixed(0)}°C' - : null, - 'color': batteryTemp != null && batteryTemp > 50 - ? const Color(0xFFF53F3F) - : const Color(0xFF165DFF), - }, - { - 'icon': Icons.navigation, - 'label': '航向角', - 'key': 'heading', - 'newValue': heading != null ? '${heading.toStringAsFixed(0)}°' : null, - 'color': const Color(0xFF165DFF), - }, - { - 'icon': Icons.rotate_90_degrees_cw, - 'label': '俯仰角', - 'key': 'pitch', - 'newValue': pitch != null ? '${pitch.toStringAsFixed(1)}°' : null, - 'color': const Color(0xFF722ED1), - }, - { - 'icon': Icons.rotate_right, - 'label': '横滚角', - 'key': 'roll', - 'newValue': roll != null ? '${roll.toStringAsFixed(1)}°' : null, - 'color': const Color(0xFF722ED1), - }, - { - 'icon': Icons.satellite, - 'label': 'GPS卫星', - 'key': 'gps', - 'newValue': gpsSatellites != null ? '$gpsSatellites颗' : null, - 'color': gpsSatellites != null && gpsSatellites >= 6 - ? const Color(0xFF00B42A) - : const Color(0xFFFF7D00), - }, - // { - // 'icon': Icons.signal_cellular_alt, - // 'label': '遥控信号', - // 'key': 'rcSignal', - // 'newValue': rcSignal != null ? '$rcSignal%' : null, - // 'color': rcSignal != null && rcSignal > 80 - // ? const Color(0xFF00B42A) - // : rcSignal != null && rcSignal > 50 - // ? const Color(0xFFFF7D00) - // : const Color(0xFFF53F3F), - // }, - // { - // 'icon': Icons.video_label, - // 'label': '图传信号', - // 'key': 'videoSignal', - // 'newValue': videoSignal != null ? '$videoSignal%' : null, - // 'color': videoSignal != null && videoSignal > 80 - // ? const Color(0xFF00B42A) - // : videoSignal != null && videoSignal > 50 - // ? const Color(0xFFFF7D00) - // : const Color(0xFFF53F3F), - // }, - { - 'icon': Icons.flight, - 'label': '飞行模式', - 'key': 'flightMode', - 'newValue': flightMode, - 'color': const Color(0xFF165DFF), - }, - // { - // 'icon': Icons.radio_button_checked, - // 'label': '任务进度', - // 'key': 'mission', - // 'newValue': missionProgress != null ? '$missionProgress%' : null, - // 'color': const Color(0xFF00B42A), - // }, - // { - // 'icon': Icons.map, - // 'label': '航点', - // 'key': 'waypoint', - // 'newValue': (currentWaypoint != null && waypointCount != null) - // ? '$currentWaypoint/$waypointCount' - // : null, - // 'color': const Color(0xFF722ED1), - // }, - ]; - - // 应用缓存逻辑:有新值则更新缓存,否则使用旧值 - for (var field in _osdFields) { - final key = field['key'] as String; - final newValue = field['newValue'] as String?; - - if (newValue != null && newValue != '未知' && newValue.isNotEmpty) { - _cachedValues[key] = newValue; - field['value'] = newValue; - } else { - field['value'] = _cachedValues[key] ?? '未知'; + String? droneState = droneData['drone_state'] as String?; + if (droneState == null) { + final inHangar = droneData['in_hangar'] as int?; + if (inHangar != null) { + droneState = inHangar == 1 ? '在库' : '外出'; + } + } + if (droneState == null) { + final droneInDock = droneData['drone_in_dock'] as int?; + if (droneInDock != null) { + droneState = droneInDock == 1 ? '在库' : '外出'; } } - debugPrint('✅ [DroneOsdCard] 共解析 ${_osdFields.length} 个字段'); - } + String? flightMode = droneData['flight_mode'] as String?; - Color _getBatteryColor(dynamic battery) { - if (battery == null) return const Color(0xFF86909C); + if (height != null) { + parsedValues['height'] = '${height.toStringAsFixed(1)}m'; + parsedColors['height'] = const Color(0xFF165DFF); + } + if (groundSpeed != null) { + parsedValues['speed'] = '${groundSpeed.toStringAsFixed(1)}m/s'; + parsedColors['speed'] = const Color(0xFF722ED1); + } + if (horizontalSpeed != null) { + parsedValues['horizontalSpeed'] = + '${horizontalSpeed.toStringAsFixed(1)}m/s'; + } + if (verticalSpeed != null) { + parsedValues['verticalSpeed'] = '${verticalSpeed.toStringAsFixed(1)}m/s'; + parsedColors['verticalSpeed'] = verticalSpeed > 5 + ? const Color(0xFFF53F3F) + : const Color(0xFF00B42A); + } + if (flightDistance != null) { + parsedValues['distance'] = '${flightDistance.toStringAsFixed(0)}m'; + } + if (batteryPercent != null) { + parsedValues['battery'] = '$batteryPercent%'; + parsedColors['battery'] = batteryPercent > 50 + ? const Color(0xFF00B42A) + : batteryPercent > 20 + ? const Color(0xFFFF7D00) + : const Color(0xFFF53F3F); + } + if (batteryVoltage != null) { + parsedValues['voltage'] = '${batteryVoltage.toStringAsFixed(1)}V'; + } + if (batteryTemp != null) { + parsedValues['batteryTemp'] = '${batteryTemp.toStringAsFixed(0)}°C'; + parsedColors['batteryTemp'] = batteryTemp > 50 + ? const Color(0xFFF53F3F) + : batteryTemp > 40 + ? const Color(0xFFFF7D00) + : const Color(0xFF165DFF); + } + if (heading != null) { + parsedValues['heading'] = '${heading.toStringAsFixed(0)}°'; + } + if (pitch != null) { + parsedValues['pitch'] = '${pitch.toStringAsFixed(1)}°'; + } + if (roll != null) { + parsedValues['roll'] = '${roll.toStringAsFixed(1)}°'; + } + if (gpsSatellites != null) { + parsedValues['gps'] = '$gpsSatellites颗'; + parsedColors['gps'] = gpsSatellites >= 6 + ? const Color(0xFF00B42A) + : const Color(0xFFFF7D00); + } + if (rtkSatellites != null) { + parsedValues['rtk'] = '$rtkSatellites颗'; + parsedColors['rtk'] = rtkSatellites >= 4 + ? const Color(0xFF00B42A) + : const Color(0xFFFF7D00); + } + if (droneState != null) { + parsedValues['droneState'] = droneState; + parsedColors['droneState'] = droneState == '在库' + ? const Color(0xFF00B42A) + : const Color(0xFF165DFF); + } + if (flightMode != null) { + parsedValues['flightMode'] = flightMode; + } - final value = battery is num ? battery.toDouble() : 0.0; - if (value > 50) return const Color(0xFF00B42A); - if (value > 20) return const Color(0xFFFF7D00); - return const Color(0xFFF53F3F); + if (!mounted) return; + setState(() { + for (var field in _osdFields) { + final key = field['key'] as String; + if (parsedValues.containsKey(key)) { + _cachedValues[key] = parsedValues[key]!; + field['value'] = parsedValues[key]; + } else if (_cachedValues.containsKey(key)) { + field['value'] = _cachedValues[key]; + } + if (parsedColors.containsKey(key)) { + field['color'] = parsedColors[key]; + } + } + }); } @override Widget build(BuildContext context) { - if (!widget.isDroneOnline) { - return _buildOfflineCard(); + // 已收到 MQTT 推送数据 或 接口显示在线 → 显示数据网格卡片 + // 否则显示离线/等待卡片 + if (widget.isDroneOnline || _hasReceivedData) { + return _buildOsdGridCard(); } - - if (_osdFields.isEmpty) { - return _buildLoadingCard(); - } - - return _buildOsdGridCard(); + return _buildOfflineCard(); } - /// 离线状态卡片 Widget _buildOfflineCard() { return Container( padding: const EdgeInsets.all(16), @@ -428,58 +441,140 @@ class _DroneOsdCardState extends State { ), child: Row( children: [ - Icon(Icons.flight_land, size: 48, color: const Color(0xFF86909C)), + // 左侧图标:等待时转圈,离线时飞机降落 + if (_isWaitingForPush) + const SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Color(0xFF165DFF)), + ), + ) + else + const Icon( + Icons.flight_land_rounded, + size: 48, + color: Color(0xFF86909C), + ), const SizedBox(width: 16), + // 中间文字 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ Text( - '无人机离线', + _isWaitingForPush ? '正在获取' : '无人机离线', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, - color: Color(0xFF1D2129), + color: _isWaitingForPush + ? const Color(0xFF165DFF) + : const Color(0xFF1D2129), ), ), - SizedBox(height: 4), + const SizedBox(height: 4), Text( - '无人机实时数据不可用', - style: TextStyle(fontSize: 12, color: Color(0xFF86909C)), + _isWaitingForPush ? '推送信息检测中,稍等...' : '无人机实时数据不可用', + style: TextStyle( + fontSize: 12, + color: _isWaitingForPush + ? const Color(0xFF165DFF) + : const Color(0xFF86909C), + ), ), ], ), ), + // 右侧手动刷新按钮 + if (widget.onRefresh != null) + GestureDetector( + onTap: widget.onRefresh, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF165DFF).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.refresh_rounded, + size: 14, + color: Color(0xFF165DFF), + ), + SizedBox(width: 4), + Text( + '刷新', + style: TextStyle(fontSize: 12, color: Color(0xFF165DFF)), + ), + ], + ), + ), + ), ], ), ); } - /// 加载中卡片 - Widget _buildLoadingCard() { + /// 右上角状态指示:等待推送时显示转圈,否则显示绿点 + Widget _buildStatusIndicator() { + if (_isWaitingForPush) { + return const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFF165DFF)), + ), + ); + } return Container( - padding: const EdgeInsets.all(16), + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF00B42A), + shape: BoxShape.circle, + ), + ); + } + + /// 等待推送时的提示条 + Widget _buildWaitingHint() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Color(0x0D000000), - blurRadius: 8, - offset: Offset(0, 2), + color: const Color(0xFF165DFF).withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation(Color(0xFF165DFF)), + ), + ), + SizedBox(width: 6), + Text( + '推送信息检测中,稍等...', + style: TextStyle(fontSize: 11, color: Color(0xFF165DFF)), ), ], ), - child: const Center( - child: CircularProgressIndicator(color: Color(0xFF165DFF)), - ), ); } - /// OSD 网格布局卡片(核心功能) Widget _buildOsdGridCard() { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), @@ -494,37 +589,48 @@ class _DroneOsdCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 标题 - Row( - children: [ - Icon(Icons.flight, size: 20, color: const Color(0xFF165DFF)), - const SizedBox(width: 8), - const Text( - '无人机实时信息', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Color(0xFF1D2129), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + const Icon( + Icons.flight_rounded, + size: 18, + color: Color(0xFF165DFF), ), - ), - ], - ), - const SizedBox(height: 12), - - // 网格布局展示所有字段 - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 0.85, + const SizedBox(width: 6), + const Text( + '无人机实时信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const Spacer(), + _buildStatusIndicator(), + ], ), - itemCount: _osdFields.length, - itemBuilder: (context, index) { - final field = _osdFields[index]; - return _buildOsdGridItem(field); + ), + if (_isWaitingForPush) ...[ + const SizedBox(height: 6), + _buildWaitingHint(), + ], + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 6.0; + const columns = 3; + final itemWidth = + (constraints.maxWidth - spacing * (columns - 1)) / columns; + final items = _osdFields.map((field) { + return _buildOsdGridItem(field, itemWidth); + }).toList(); + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: items, + ); }, ), ], @@ -532,52 +638,53 @@ class _DroneOsdCardState extends State { ); } - /// OSD 网格项 - Widget _buildOsdGridItem(Map field) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: (field['color'] as Color).withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: (field['color'] as Color).withOpacity(0.2), - width: 1, + Widget _buildOsdGridItem(Map field, double width) { + final color = field['color'] as Color; + return SizedBox( + width: width, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.2), width: 1), ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 图标 - Icon( - field['icon'] as IconData, - color: field['color'] as Color, - size: 24, - ), - const SizedBox(height: 8), - - // 标签 - Text( - field['label'] as String, - style: const TextStyle(fontSize: 11, color: Color(0xFF86909C)), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - - // 数值 - Text( - field['value'] as String, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: field['color'] as Color, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(field['icon'] as IconData, color: color, size: 14), + const SizedBox(width: 2), + Expanded( + child: Text( + field['label'] as String, + style: const TextStyle( + fontSize: 9, + color: Color(0xFF86909C), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 3), + Text( + field['value'] as String, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ); } diff --git a/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart index 17eaa89e..99224d8a 100644 --- a/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/drone_station_osd_card.dart @@ -1,16 +1,15 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../../../../core/di/injection.dart'; -import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart'; import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart'; -/// 无人机机场 OSD 实时数据卡片(网格布局展示所有数据) class DroneStationOsdCard extends StatefulWidget { + final Stream stationOsdStream; final String gatewaySn; final bool isOnline; const DroneStationOsdCard({ super.key, + required this.stationOsdStream, required this.gatewaySn, required this.isOnline, }); @@ -20,370 +19,473 @@ class DroneStationOsdCard extends StatefulWidget { } class _DroneStationOsdCardState extends State { - late DroneOsdDataSource _dataSource; StreamSubscription? _subscription; - DroneOsdEntity? _currentOsd; - - // OSD 数据字段列表 - List> _osdFields = []; - - // 🔥 缓存上次的有效值,避免闪烁 - Map _cachedValues = {}; + final List> _osdFields = []; + final Map _cachedValues = {}; + bool _initialized = false; @override void initState() { super.initState(); - _dataSource = sl(); - _startListening(); + _initFields(); + _subscribeStream(); } @override void didUpdateWidget(DroneStationOsdCard oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.gatewaySn != widget.gatewaySn || + if (oldWidget.gatewaySn != widget.gatewaySn || oldWidget.isOnline != widget.isOnline) { - _stopListening(); - _startListening(); + _subscription?.cancel(); + _subscribeStream(); } } @override void dispose() { - _stopListening(); + _subscription?.cancel(); super.dispose(); } - void _startListening() { - if (!widget.isOnline) return; - - //debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}'); - - _dataSource.startListening( - deviceSn: '', // 机场不需要 deviceSn - gatewaySn: widget.gatewaySn, - ); - - _subscription = _dataSource.stationOsdStream.listen((osd) { - if (!mounted) return; - - // 🔥 打印完整的 MQTT 原始数据(不做任何解析) - debugPrint('\n========== 📥 [机场OSD] 完整原始数据 =========='); - debugPrint('${osd.rawData}'); - debugPrint('===========================================\n'); - - setState(() { - _currentOsd = osd; - _parseOsdFields(osd); - }); - - // debugPrint('✅ [DroneStationOsdCard] UI 已更新,字段数: ${_osdFields.length}'); - }); - } - - void _stopListening() { - _subscription?.cancel(); - _subscription = null; - _dataSource.stopListening(); - debugPrint('⏹️ [DroneStationOsdCard] 停止监听机场 OSD'); - } - - /// 解析 OSD 数据为可展示的字段列表 - void _parseOsdFields(DroneOsdEntity osd) { - final data = osd.rawData; - - // 解析嵌套的 JSON 结构 - final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null; - if (hostData == null || hostData is! Map) { - debugPrint('⚠️ [DroneStationOsdCard] 无法解析 host 数据'); - return; - } - - // 🔥 打印完整的 host 数据键,查看所有可用字段 - debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}'); - - // 🔥 打印完整原始数据(用于调试) - debugPrint('📊 [DroneStationOsdCard] 完整原始数据: $hostData'); - - // ========== 1. 电池相关 ========== - // 提取无人机电量(从 drone_battery_maintenance_info.batteries[0].capacity_percent) - double? batteryPercent; - final batteryInfo = hostData['drone_battery_maintenance_info']; - if (batteryInfo is Map && batteryInfo['batteries'] is List) { - final batteries = batteryInfo['batteries'] as List; - if (batteries.isNotEmpty && batteries[0] is Map) { - batteryPercent = (batteries[0]['capacity_percent'] as num?)?.toDouble(); - } - } - - // 提取备用电池温度(从 backup_battery.temperature) - double? backupBatteryTemp; - final backupBattery = hostData['backup_battery']; - if (backupBattery is Map) { - backupBatteryTemp = (backupBattery['temperature'] as num?)?.toDouble(); - } - - // 提取备用电池电压(从 backup_battery.voltage) - int? backupBatteryVoltage; - if (backupBattery is Map) { - backupBatteryVoltage = backupBattery['voltage'] as int?; - } - - // 提取备用电池开关状态(从 backup_battery.switch) - int? backupBatterySwitch; - if (backupBattery is Map) { - backupBatterySwitch = backupBattery['switch'] as int?; - } - - // ========== 2. 电源相关 ========== - // 提取交流输入功率(acdc_power_input) - double? acdcPower = (hostData['acdc_power_input'] as num?)?.toDouble(); - - // 提取供电电压(electric_supply_voltage) - int? supplyVoltage = hostData['electric_supply_voltage'] as int?; - - // 提取 PoE 链路状态(poe_link_status) - int? poeLinkStatus = hostData['poe_link_status'] as int?; - - // 提取 PoE 输出功率(poe_power_output) - double? poePowerOutput = (hostData['poe_power_output'] as num?)?.toDouble(); - - // ========== 3. 部署与维护 ========== - // 提取部署模式(deployment_mode) - int? deploymentMode = hostData['deployment_mode'] as int?; - - // 提取作业编号(job_number) - int? jobNumber = hostData['job_number'] as int?; - - // 提取云台 holder 状态(gimbal_holder_state) - int? gimbalHolderState = hostData['gimbal_holder_state'] as int?; - - // 提取维护状态(maintain_status) - String maintainStatus = '未知'; - final maintainStatusData = hostData['maintain_status']; - if (maintainStatusData is Map && maintainStatusData['maintain_status_array'] is List) { - final statusArray = maintainStatusData['maintain_status_array'] as List; - if (statusArray.isNotEmpty && statusArray[0] is Map) { - final firstStatus = statusArray[0] as Map; - final state = firstStatus['state']; - final maintainType = firstStatus['last_maintain_type']; - maintainStatus = '状态:$state 类型:$maintainType'; - } - } - - // ========== 4. 位置与坐标 ========== - // 提取相对备降点信息(relative_alternate_land_point) - String landPointInfo = '未知'; - final landPointData = hostData['relative_alternate_land_point']; - if (landPointData is Map) { - final lat = landPointData['latitude']; - final lon = landPointData['longitude']; - final safeHeight = landPointData['safe_land_height']; - final status = landPointData['status']; - landPointInfo = 'LAT:${lat?.toStringAsFixed(4)} LON:${lon?.toStringAsFixed(4)} H:${safeHeight}m S:$status'; - } - - // 提取自收敛坐标(self_converge_coordinate) - String convergeCoord = '未知'; - final convergeData = hostData['self_converge_coordinate']; - if (convergeData is Map) { - final height = convergeData['height']; - convergeCoord = 'H:${height}m'; - } - - // ========== 5. 网络与通信 ========== - // 提取 SDR 上行质量(sdr.up_quality) - int? sdrUpQuality; - final sdrData = hostData['sdr']; - if (sdrData is Map) { - sdrUpQuality = sdrData['up_quality'] as int?; - } - - // 提取 SDR 下行质量(sdr.down_quality) - int? sdrDownQuality; - if (sdrData is Map) { - sdrDownQuality = sdrData['down_quality'] as int?; - } - - // 提取 SDR 频段(sdr.frequency_band) - double? sdrFreqBand; - if (sdrData is Map) { - sdrFreqBand = (sdrData['frequency_band'] as num?)?.toDouble(); - } - - // ========== 6. 其他关键指标 ========== - // 提取累计时间(acc_time) - int? accTime = hostData['acc_time'] as int?; - - // 提取激活时间(activation_time) - int? activationTime = hostData['activation_time'] as int?; - - // 提取倾斜角度(tilt_angle.value) - double? tiltAngle; - final tiltAngleData = hostData['tilt_angle']; - if (tiltAngleData is Map && tiltAngleData['valid'] == 1) { - tiltAngle = (tiltAngleData['value'] as num?)?.toDouble(); - } - - /*debugPrint('✅ [DroneStationOsdCard] 解析结果:'); - debugPrint(' 无人机电量: $batteryPercent%'); - debugPrint(' 备用电池: ${backupBatteryTemp}°C / ${backupBatteryVoltage}mV / 开关:$backupBatterySwitch'); - debugPrint(' 交流功率: $acdcPower W'); - debugPrint(' 供电电压: $supplyVoltage V'); - debugPrint(' PoE状态: $poeLinkStatus / 功率: $poePowerOutput W'); - debugPrint(' 部署模式: $deploymentMode / 作业号: $jobNumber'); - debugPrint(' 云台状态: $gimbalHolderState'); - debugPrint(' 维护状态: $maintainStatus'); - debugPrint(' 备降点: $landPointInfo'); - debugPrint(' SDR上行: $sdrUpQuality% / 下行: $sdrDownQuality% / 频段: $sdrFreqBand GHz'); - debugPrint(' 倾斜角度: $tiltAngle°'); - debugPrint(' 累计时间: $accTime s');*/ - - // 构建展示字段列表(精选重要字段) - // 🔥 使用缓存机制:有值则更新并缓存,无值则使用上次缓存的值 - _osdFields = [ + void _initFields() { + _osdFields.addAll([ { - 'icon': Icons.battery_full_rounded, - 'label': '无人机电量', - 'key': 'battery', - 'newValue': batteryPercent != null ? '${batteryPercent.toInt()}%' : null, - 'color': _getBatteryColor(batteryPercent), - }, - { - 'icon': Icons.thermostat_rounded, - 'label': '备用电池温度', - 'key': 'backupTemp', - 'newValue': backupBatteryTemp != null ? '${backupBatteryTemp.toStringAsFixed(1)}°C' : null, + 'icon': Icons.ac_unit_rounded, + 'label': '空调状态', + 'key': 'acState', + 'value': '未知', 'color': const Color(0xFF165DFF), }, { - 'icon': Icons.bolt_rounded, - 'label': '备用电池电压', - 'key': 'backupVoltage', - 'newValue': backupBatteryVoltage != null ? '${(backupBatteryVoltage / 1000).toStringAsFixed(2)}V' : null, + 'icon': Icons.timer_rounded, + 'label': '空调计时', + 'key': 'acTime', + 'value': '未知', 'color': const Color(0xFF722ED1), }, { - 'icon': Icons.power_rounded, - 'label': '交流输入功率', - 'key': 'acdcPower', - 'newValue': acdcPower != null ? '${acdcPower.toStringAsFixed(1)} W' : null, + 'icon': Icons.door_sliding_rounded, + 'label': '机库舱门', + 'key': 'coverState', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.push_pin_rounded, + 'label': '推板状态', + 'key': 'putterState', + 'value': '未知', + 'color': const Color(0xFF722ED1), + }, + { + 'icon': Icons.lightbulb_rounded, + 'label': '补光灯', + 'key': 'lightState', + 'value': '未知', 'color': const Color(0xFFFF7D00), }, { - 'icon': Icons.electrical_services_rounded, - 'label': '供电电压', - 'key': 'supplyVoltage', - 'newValue': supplyVoltage != null ? '$supplyVoltage V' : null, - 'color': const Color(0xFF00B42A), - }, - { - 'icon': Icons.network_check_rounded, - 'label': 'PoE链路', - 'key': 'poeLink', - 'newValue': poeLinkStatus != null ? (poeLinkStatus == 1 ? '已连接' : '未连接') : null, - 'color': poeLinkStatus == 1 ? const Color(0xFF00B42A) : const Color(0xFF86909C), - }, - { - 'icon': Icons.settings_rounded, - 'label': '部署模式', - 'key': 'deployMode', - 'newValue': deploymentMode != null ? '模式$deploymentMode' : null, + 'icon': Icons.thermostat_rounded, + 'label': '机库温度', + 'key': 'cargoTemp', + 'value': '未知', 'color': const Color(0xFF165DFF), }, { - 'icon': Icons.work_outline_rounded, - 'label': '作业编号', - 'key': 'jobNumber', - 'newValue': jobNumber != null ? '#$jobNumber' : null, + 'icon': Icons.flight_rounded, + 'label': '无人机位置', + 'key': 'droneDock', + 'value': '未知', 'color': const Color(0xFF00B42A), }, { - 'icon': Icons.camera_roll_rounded, - 'label': '云台状态', - 'key': 'gimbalState', - 'newValue': gimbalHolderState != null ? (gimbalHolderState == 1 ? '已锁定' : '未锁定') : null, - 'color': gimbalHolderState == 1 ? const Color(0xFF00B42A) : const Color(0xFFF53F3F), + 'icon': Icons.battery_charging_full_rounded, + 'label': '充电状态', + 'key': 'chargeState', + 'value': '未知', + 'color': const Color(0xFFFF7D00), }, { - 'icon': Icons.rotate_right_rounded, - 'label': '倾斜角度', - 'key': 'tiltAngle', - 'newValue': tiltAngle != null ? '${tiltAngle.toStringAsFixed(2)}°' : null, + 'icon': Icons.water_drop_rounded, + 'label': '湿度', + 'key': 'humidity', + 'value': '未知', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.air_rounded, + 'label': '风速', + 'key': 'windSpeed', + 'value': '未知', 'color': const Color(0xFF722ED1), }, { - 'icon': Icons.signal_cellular_alt_rounded, - 'label': 'SDR上行质量', - 'key': 'sdrUp', - 'newValue': sdrUpQuality != null ? '$sdrUpQuality%' : null, - 'color': sdrUpQuality != null && sdrUpQuality > 80 - ? const Color(0xFF00B42A) - : sdrUpQuality != null && sdrUpQuality > 50 - ? const Color(0xFFFF7D00) - : const Color(0xFFF53F3F), + 'icon': Icons.umbrella_rounded, + 'label': '降雨', + 'key': 'rainfall', + 'value': '未知', + 'color': const Color(0xFF00B42A), }, { - 'icon': Icons.wifi_tethering_rounded, - 'label': 'SDR下行质量', - 'key': 'sdrDown', - 'newValue': sdrDownQuality != null ? '$sdrDownQuality%' : null, - 'color': sdrDownQuality != null && sdrDownQuality > 80 - ? const Color(0xFF00B42A) - : sdrDownQuality != null && sdrDownQuality > 50 - ? const Color(0xFFFF7D00) - : const Color(0xFFF53F3F), + 'icon': Icons.cloud_rounded, + 'label': '外部温度', + 'key': 'externalTemp', + 'value': '未知', + 'color': const Color(0xFF165DFF), }, { - 'icon': Icons.access_time_rounded, - 'label': '累计时间', - 'key': 'accTime', - 'newValue': accTime != null ? '${(accTime / 3600).toStringAsFixed(1)}h' : null, + 'icon': Icons.warning_amber_rounded, + 'label': '告警状态', + 'key': 'alarmState', + 'value': '无告警', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.sensors_rounded, + 'label': '急停状态', + 'key': 'emergencyStop', + 'value': '未触发', + 'color': const Color(0xFF00B42A), + }, + { + 'icon': Icons.volume_off_rounded, + 'label': '静音模式', + 'key': 'silentMode', + 'value': '关闭', 'color': const Color(0xFF86909C), }, { - 'icon': Icons.calendar_today_rounded, - 'label': '激活时间', - 'key': 'activationTime', - 'newValue': activationTime != null ? _formatTimestamp(activationTime) : null, + 'icon': Icons.mode_standby_rounded, + 'label': '运行模式', + 'key': 'modeCode', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.network_check_rounded, + 'label': '网络状态', + 'key': 'networkState', + 'value': '未知', 'color': const Color(0xFF86909C), }, - ]; - - // 🔥 应用缓存逻辑:有新值则更新缓存,否则使用旧值 - for (var field in _osdFields) { - final key = field['key'] as String; - final newValue = field['newValue'] as String?; - - if (newValue != null && newValue != '未知') { - // 有新值,更新缓存 - _cachedValues[key] = newValue; - field['value'] = newValue; - } else { - // 无新值,使用缓存值或默认"未知" - field['value'] = _cachedValues[key] ?? '未知'; + { + 'icon': Icons.gps_fixed_rounded, + 'label': '定位状态', + 'key': 'positionState', + 'value': '未知', + 'color': const Color(0xFF86909C), + }, + { + 'icon': Icons.storage_rounded, + 'label': '存储状态', + 'key': 'storageState', + 'value': '未知', + 'color': const Color(0xFF86909C), + }, + { + 'icon': Icons.north_rounded, + 'label': '航向角', + 'key': 'heading', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.height_rounded, + 'label': '海拔高度', + 'key': 'altitude', + 'value': '未知', + 'color': const Color(0xFF165DFF), + }, + { + 'icon': Icons.phone_android_rounded, + 'label': '设备型号', + 'key': 'deviceModel', + 'value': '未知', + 'color': const Color(0xFF86909C), + }, + { + 'icon': Icons.battery_saver_rounded, + 'label': '维护模式', + 'key': 'batteryStoreMode', + 'value': '关闭', + 'color': const Color(0xFF86909C), + }, + ]); + _initialized = true; + } + + void _subscribeStream() { + if (!widget.isOnline) return; + + _subscription = widget.stationOsdStream.listen((osd) { + if (!mounted) return; + _parseOsdFields(osd); + }); + } + + void _parseOsdFields(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 parsedValues = {}; + final parsedColors = {}; + + // 空调信息 + final acData = hostData['air_conditioner']; + if (acData is Map) { + final acState = + int.tryParse(acData['air_conditioner_state']?.toString() ?? '') ?? -1; + parsedValues['acState'] = acState == 1 + ? '开启' + : acState == 0 + ? '关闭' + : '未知'; + parsedColors['acState'] = acState == 1 + ? const Color(0xFF00B42A) + : acState == 0 + ? const Color(0xFF86909C) + : const Color(0xFF86909C); + + final switchTime = acData['switch_time']; + if (switchTime != null) { + parsedValues['acTime'] = '${switchTime}s'; } } - - //debugPrint('✅ [DroneStationOsdCard] 共解析 ${_osdFields.length} 个字段'); - } - Color _getBatteryColor(dynamic battery) { - if (battery == null) return const Color(0xFF86909C); - - final value = battery is num ? battery.toDouble() : 0.0; - if (value > 50) return const Color(0xFF00B42A); - if (value > 20) return const Color(0xFFFF7D00); - return const Color(0xFFF53F3F); - } + // 机库相关 + final coverState = + int.tryParse(hostData['cover_state']?.toString() ?? '') ?? -1; + parsedValues['coverState'] = coverState == 1 + ? '开启' + : coverState == 0 + ? '关闭' + : '未知'; + parsedColors['coverState'] = coverState == 1 + ? const Color(0xFFFF7D00) + : coverState == 0 + ? const Color(0xFF00B42A) + : const Color(0xFF86909C); - /// 格式化 Unix 时间戳(秒)为日期字符串 - String _formatTimestamp(int timestamp) { - try { - final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); - return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}'; - } catch (e) { - return '未知'; + final putterState = + int.tryParse(hostData['putter_state']?.toString() ?? '') ?? -1; + parsedValues['putterState'] = putterState == 1 + ? '动作中' + : putterState == 0 + ? '归位' + : '未知'; + parsedColors['putterState'] = putterState == 1 + ? const Color(0xFFFF7D00) + : putterState == 0 + ? const Color(0xFF00B42A) + : const Color(0xFF86909C); + + final lightState = + int.tryParse(hostData['supplement_light_state']?.toString() ?? '') ?? + -1; + parsedValues['lightState'] = lightState == 1 + ? '开启' + : lightState == 0 + ? '关闭' + : '未知'; + parsedColors['lightState'] = lightState == 1 + ? const Color(0xFFFF7D00) + : lightState == 0 + ? const Color(0xFF86909C) + : const Color(0xFF86909C); + + final cargoTemp = (hostData['temperature'] as num?)?.toDouble(); + if (cargoTemp != null) { + parsedValues['cargoTemp'] = '${cargoTemp.toStringAsFixed(1)}°C'; + parsedColors['cargoTemp'] = cargoTemp > 60 + ? const Color(0xFFF53F3F) + : cargoTemp > 40 + ? const Color(0xFFFF7D00) + : const Color(0xFF165DFF); } + + final droneDock = + int.tryParse(hostData['drone_in_dock']?.toString() ?? '') ?? -1; + parsedValues['droneDock'] = droneDock == 1 + ? '在库内' + : droneDock == 0 + ? '出库' + : '未知'; + parsedColors['droneDock'] = droneDock == 1 + ? const Color(0xFF00B42A) + : droneDock == 0 + ? const Color(0xFFFF7D00) + : const Color(0xFF86909C); + + // 无人机电量和充电状态 + final chargeStateData = hostData['drone_charge_state']; + if (chargeStateData is Map) { + final chargeState = + int.tryParse(chargeStateData['state']?.toString() ?? '') ?? -1; + parsedValues['chargeState'] = chargeState == 1 + ? '充电中' + : chargeState == 0 + ? '未充电' + : '未知'; + parsedColors['chargeState'] = chargeState == 1 + ? const Color(0xFFFF7D00) + : const Color(0xFF86909C); + } + + // 环境信息 + final humidity = (hostData['humidity'] as num?)?.toDouble(); + if (humidity != null) { + parsedValues['humidity'] = '${humidity.toStringAsFixed(0)}%'; + parsedColors['humidity'] = const Color(0xFF00B42A); + } + + final windSpeed = (hostData['wind_speed'] as num?)?.toDouble(); + if (windSpeed != null) { + parsedValues['windSpeed'] = '${windSpeed.toStringAsFixed(1)} m/s'; + parsedColors['windSpeed'] = windSpeed > 10 + ? const Color(0xFFF53F3F) + : windSpeed > 5 + ? const Color(0xFFFF7D00) + : const Color(0xFF722ED1); + } + + final rainfall = int.tryParse(hostData['rainfall']?.toString() ?? '') ?? -1; + parsedValues['rainfall'] = rainfall == 1 + ? '降雨中' + : rainfall == 0 + ? '无降雨' + : '未知'; + parsedColors['rainfall'] = rainfall == 1 + ? const Color(0xFF165DFF) + : const Color(0xFF00B42A); + + final externalTemp = (hostData['environment_temperature'] as num?) + ?.toDouble(); + if (externalTemp != null) { + parsedValues['externalTemp'] = '${externalTemp.toStringAsFixed(1)}°C'; + parsedColors['externalTemp'] = externalTemp > 40 + ? const Color(0xFFF53F3F) + : externalTemp > 25 + ? const Color(0xFFFF7D00) + : const Color(0xFF165DFF); + } + + // 告警状态 + final alarmState = + int.tryParse(hostData['alarm_state']?.toString() ?? '') ?? 0; + parsedValues['alarmState'] = alarmState != 0 ? '告警中' : '无告警'; + parsedColors['alarmState'] = alarmState != 0 + ? const Color(0xFFF53F3F) + : const Color(0xFF00B42A); + + // 急停状态 + final emergencyStop = + int.tryParse(hostData['emergency_stop_state']?.toString() ?? '') ?? 0; + parsedValues['emergencyStop'] = emergencyStop == 1 ? '已触发' : '未触发'; + parsedColors['emergencyStop'] = emergencyStop == 1 + ? const Color(0xFFF53F3F) + : const Color(0xFF00B42A); + + // 静音模式 + final silentMode = + int.tryParse(hostData['silent_mode']?.toString() ?? '') ?? 0; + parsedValues['silentMode'] = silentMode == 1 ? '开启' : '关闭'; + parsedColors['silentMode'] = silentMode == 1 + ? const Color(0xFFFF7D00) + : const Color(0xFF86909C); + + // 运行模式 + final modeCode = hostData['mode_code']; + if (modeCode != null) { + parsedValues['modeCode'] = '模式 $modeCode'; + } + + // 网络状态 + final networkState = hostData['network_state']; + if (networkState is Map) { + final type = networkState['type']; + final quality = networkState['quality']; + final typeStr = type == 2 ? '4G' : '网络'; + parsedValues['networkState'] = '$typeStr Q$quality'; + parsedColors['networkState'] = + (quality != null && + int.tryParse(quality.toString()) != null && + int.parse(quality.toString()) > 3) + ? const Color(0xFF00B42A) + : const Color(0xFF86909C); + } + + // 定位状态 + final positionState = hostData['position_state']; + if (positionState is Map) { + final isFixed = positionState['is_fixed']; + final gpsNum = positionState['gps_number']; + parsedValues['positionState'] = 'GPS:$gpsNum RTK:$isFixed'; + parsedColors['positionState'] = isFixed == '2' + ? const Color(0xFF00B42A) + : const Color(0xFFFF7D00); + } + + // 存储状态 + final storage = hostData['storage']; + if (storage is Map) { + final total = storage['total']; + final used = storage['used']; + if (total != null && used != null) { + final usedPercent = (used / total * 100).toStringAsFixed(0); + parsedValues['storageState'] = '$usedPercent%'; + parsedColors['storageState'] = + int.tryParse(usedPercent) != null && int.parse(usedPercent) > 80 + ? const Color(0xFFFF7D00) + : const Color(0xFF00B42A); + } + } + + // 航向角 + final heading = (hostData['heading'] as num?)?.toDouble(); + if (heading != null) { + parsedValues['heading'] = '${heading.toStringAsFixed(1)}°'; + } + + // 海拔高度 + final altitude = (hostData['height'] as num?)?.toDouble(); + if (altitude != null) { + parsedValues['altitude'] = '${altitude.toStringAsFixed(1)}m'; + } + + // 设备型号 + final subDevice = hostData['sub_device']; + if (subDevice is Map) { + final modelKey = subDevice['device_model_key']; + if (modelKey != null) { + parsedValues['deviceModel'] = modelKey.toString(); + } + } + + // 维护模式 + final batteryStoreMode = + int.tryParse(hostData['battery_store_mode']?.toString() ?? '') ?? 0; + parsedValues['batteryStoreMode'] = batteryStoreMode == 1 ? '开启' : '关闭'; + parsedColors['batteryStoreMode'] = batteryStoreMode == 1 + ? const Color(0xFFFF7D00) + : const Color(0xFF86909C); + + // 应用更新到字段列表 + if (!mounted) return; + setState(() { + for (var field in _osdFields) { + final key = field['key'] as String; + if (parsedValues.containsKey(key)) { + _cachedValues[key] = parsedValues[key]!; + field['value'] = parsedValues[key]; + } else if (_cachedValues.containsKey(key)) { + field['value'] = _cachedValues[key]; + } + if (parsedColors.containsKey(key)) { + field['color'] = parsedColors[key]; + } + } + }); } @override @@ -391,15 +493,9 @@ class _DroneStationOsdCardState extends State { if (!widget.isOnline) { return _buildOfflineCard(); } - - if (_osdFields.isEmpty) { - return _buildLoadingCard(); - } - return _buildOsdGridCard(); } - /// 离线状态卡片 Widget _buildOfflineCard() { return Container( padding: const EdgeInsets.all(16), @@ -416,7 +512,11 @@ class _DroneStationOsdCardState extends State { ), child: Row( children: [ - Icon(Icons.cloud_off_rounded, size: 48, color: const Color(0xFF86909C)), + const Icon( + Icons.cloud_off_rounded, + size: 48, + color: Color(0xFF86909C), + ), const SizedBox(width: 16), Expanded( child: Column( @@ -443,31 +543,9 @@ class _DroneStationOsdCardState extends State { ); } - /// 加载中卡片 - Widget _buildLoadingCard() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Color(0x0D000000), - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: const Center( - child: CircularProgressIndicator(color: Color(0xFF165DFF)), - ), - ); - } - - /// OSD 网格布局卡片(核心功能) Widget _buildOsdGridCard() { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), @@ -482,37 +560,51 @@ class _DroneStationOsdCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 标题 - Row( - children: [ - Icon(Icons.analytics_rounded, size: 20, color: const Color(0xFF165DFF)), - const SizedBox(width: 8), - const Text( - '实时数据', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Color(0xFF1D2129), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + const Icon( + Icons.analytics_rounded, + size: 18, + color: Color(0xFF165DFF), ), - ), - ], - ), - const SizedBox(height: 12), - - // 网格布局展示所有字段 - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 0.85, + const SizedBox(width: 6), + const Text( + '实时数据', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const Spacer(), + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF00B42A), + shape: BoxShape.circle, + ), + ), + ], ), - itemCount: _osdFields.length, - itemBuilder: (context, index) { - final field = _osdFields[index]; - return _buildOsdGridItem(field); + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 6.0; + const columns = 3; + final itemWidth = + (constraints.maxWidth - spacing * (columns - 1)) / columns; + final items = _osdFields.map((field) { + return _buildOsdGridItem(field, itemWidth); + }).toList(); + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: items, + ); }, ), ], @@ -520,55 +612,53 @@ class _DroneStationOsdCardState extends State { ); } - /// OSD 网格项 - Widget _buildOsdGridItem(Map field) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: (field['color'] as Color).withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: (field['color'] as Color).withOpacity(0.2), - width: 1, + Widget _buildOsdGridItem(Map field, double width) { + final color = field['color'] as Color; + return SizedBox( + width: width, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.2), width: 1), ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 图标 - Icon( - field['icon'] as IconData, - color: field['color'] as Color, - size: 24, - ), - const SizedBox(height: 8), - - // 标签 - Text( - field['label'] as String, - style: const TextStyle( - fontSize: 11, - color: Color(0xFF86909C), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(field['icon'] as IconData, color: color, size: 14), + const SizedBox(width: 2), + Expanded( + child: Text( + field['label'] as String, + style: const TextStyle( + fontSize: 9, + color: Color(0xFF86909C), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - - // 数值 - Text( - field['value'] as String, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: field['color'] as Color, + const SizedBox(height: 3), + Text( + field['value'] as String, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), ); } diff --git a/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart b/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart index d6bf856b..946a2a08 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_header_card.dart @@ -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 robot; const RobotHeaderCard({super.key, required this.robot}); @@ -58,20 +60,26 @@ class _RobotHeaderCardState extends State { 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 { ), 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), diff --git a/lib/features/v2/device_list/presentation/widgets/robot_status_bar.dart b/lib/features/v2/device_list/presentation/widgets/robot_status_bar.dart index a659c040..96536378 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_status_bar.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_status_bar.dart @@ -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 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()), + BlocProvider.value(value: GetIt.I()), + ], + 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( + stream: GetIt.I().stream, + initialData: GetIt.I().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, ), diff --git a/lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart b/lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart new file mode 100644 index 00000000..94e9c102 --- /dev/null +++ b/lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart @@ -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> getByDeviceId(String deviceId); + Future> save(Map params); +} \ No newline at end of file diff --git a/lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart b/lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart new file mode 100644 index 00000000..c41fe437 --- /dev/null +++ b/lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart @@ -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> 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; + final code = body['code']; + if (code != null && code.toString() == '200') { + final data = body['data'] as Map? ?? {}; + 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> save(Map params) async { + try { + final response = await _dio.post( + HttpApiConsts.deviceRunParamSave, + data: params, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + 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')); + } + } +} \ No newline at end of file diff --git a/lib/features/v2/device_run_param/data/models/device_run_param_model.dart b/lib/features/v2/device_run_param/data/models/device_run_param_model.dart new file mode 100644 index 00000000..53e5354d --- /dev/null +++ b/lib/features/v2/device_run_param/data/models/device_run_param_model.dart @@ -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 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.from(json), + ); + } + + Map toJson() { + return { + 'id': id, + 'deviceId': deviceId, + 'siteId': siteId, + 'orgId': orgId, + 'runSpeed': runSpeed, + 'leftForwardGain': leftForwardGain, + 'leftBackwardGain': leftBackwardGain, + 'rightForwardGain': rightForwardGain, + 'rightBackwardGain': rightBackwardGain, + }; + } +} \ No newline at end of file diff --git a/lib/features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart b/lib/features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart new file mode 100644 index 00000000..ce0e42e3 --- /dev/null +++ b/lib/features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart @@ -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> getByDeviceId( + String deviceId, + ) async { + final result = await remoteDataSource.getByDeviceId(deviceId); + return result.fold( + (failure) => left(failure), + (model) => right(model), + ); + } + + @override + Future> save(Map params) async { + return await remoteDataSource.save(params); + } +} \ No newline at end of file diff --git a/lib/features/v2/device_run_param/domain/entities/device_run_param_entity.dart b/lib/features/v2/device_run_param/domain/entities/device_run_param_entity.dart new file mode 100644 index 00000000..346c5bca --- /dev/null +++ b/lib/features/v2/device_run_param/domain/entities/device_run_param_entity.dart @@ -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 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, + }); +} diff --git a/lib/features/v2/device_run_param/domain/repositories/device_run_param_repository.dart b/lib/features/v2/device_run_param/domain/repositories/device_run_param_repository.dart new file mode 100644 index 00000000..aed2b2ea --- /dev/null +++ b/lib/features/v2/device_run_param/domain/repositories/device_run_param_repository.dart @@ -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> getByDeviceId(String deviceId); + Future> save(Map params); +} \ No newline at end of file diff --git a/lib/features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart b/lib/features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart new file mode 100644 index 00000000..fa8ae255 --- /dev/null +++ b/lib/features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart @@ -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> execute(String deviceId) async { + return await repository.getByDeviceId(deviceId); + } +} + +class SaveDeviceRunParamUseCase { + final DeviceRunParamRepository repository; + + SaveDeviceRunParamUseCase(this.repository); + + Future> execute(Map params) async { + return await repository.save(params); + } +} \ No newline at end of file diff --git a/lib/features/v2/device_run_param/presentation/pages/robot_param_settings_page.dart b/lib/features/v2/device_run_param/presentation/pages/robot_param_settings_page.dart new file mode 100644 index 00000000..2eb8b53b --- /dev/null +++ b/lib/features/v2/device_run_param/presentation/pages/robot_param_settings_page.dart @@ -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 robot; + + const RobotParamSettingsPage({super.key, required this.robot}); + + @override + State createState() => _RobotParamSettingsPageState(); +} + +class _RobotParamSettingsPageState extends State + 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 _loadData() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final useCase = GetIt.I(); + 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 _handleSave() async { + if (_isSaving || _param == null) return; + setState(() => _isSaving = true); + + try { + // 🔐 前置权限校验:只有 code=200 && data=true 才允许保存 + final permissionService = GetIt.I(); + 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().state.selectedSite?.id ?? 0; + final orgId = GetIt.I().state.user?.orgId ?? 0; + + final useCase = GetIt.I(); + final params = { + '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( + 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(Colors.white), + ), + ) + : const Text( + '保存', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ); + } +} diff --git a/lib/features/v2/home/data/datasources/site_datasource.dart b/lib/features/v2/home/data/datasources/site_datasource.dart index fedf544c..636b6eef 100644 --- a/lib/features/v2/home/data/datasources/site_datasource.dart +++ b/lib/features/v2/home/data/datasources/site_datasource.dart @@ -1,5 +1,5 @@ import '../../domain/entities/site_entity.dart'; abstract class SiteDataSource { - Future> getSiteList(int orgId); + Future> getSiteList(String userId); } diff --git a/lib/features/v2/home/data/datasources/site_datasource_impl.dart b/lib/features/v2/home/data/datasources/site_datasource_impl.dart index 69a99061..2514272d 100644 --- a/lib/features/v2/home/data/datasources/site_datasource_impl.dart +++ b/lib/features/v2/home/data/datasources/site_datasource_impl.dart @@ -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> getSiteList(int orgId) async { + Future> 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 = { - 'pageNum': 1, - 'pageSize': 9999, - }; - - if (orgId != 0) { - queryParams['orgId'] = orgId; - } - + print( + '🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}', + ); + + final queryParams = {'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().tokenExpired(); + } catch (e) {} + throw Exception('登录已过期,请重新登录'); + } + if (responseData['code'] != 200) { throw Exception(responseData['msg'] ?? '业务异常'); } - final List rows = responseData['rows'] ?? []; - + final List 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(); } } diff --git a/lib/features/v2/home/data/repositories/site_repository_impl.dart b/lib/features/v2/home/data/repositories/site_repository_impl.dart index 64403181..83f2f3bd 100644 --- a/lib/features/v2/home/data/repositories/site_repository_impl.dart +++ b/lib/features/v2/home/data/repositories/site_repository_impl.dart @@ -11,9 +11,9 @@ class SiteRepositoryImpl implements SiteRepository { SiteRepositoryImpl(this.dataSource); @override - Future>> getSiteList(int orgId) async { + Future>> 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())); diff --git a/lib/features/v2/home/domain/repositories/site_repository.dart b/lib/features/v2/home/domain/repositories/site_repository.dart index aaf9e97a..d0e884d2 100644 --- a/lib/features/v2/home/domain/repositories/site_repository.dart +++ b/lib/features/v2/home/domain/repositories/site_repository.dart @@ -5,5 +5,5 @@ import '../../../../../core/error/failure.dart'; import '../entities/site_entity.dart'; abstract class SiteRepository { - Future>> getSiteList(int orgId); + Future>> getSiteList(String userId); } diff --git a/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart b/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart index 29521b15..3680e49c 100644 --- a/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart +++ b/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart @@ -10,8 +10,8 @@ class GetSiteListUseCase { GetSiteListUseCase(this.repository); - // pageNum 和 pageSize 固定,orgId 从登录用户信息中获取 - Future>> call(int orgId) async { - return await repository.getSiteList(orgId); + // userId 从登录用户信息中获取 + Future>> call(String userId) async { + return await repository.getSiteList(userId); } } diff --git a/lib/features/v2/home/presentation/bloc/home_v2_bloc.dart b/lib/features/v2/home/presentation/bloc/home_v2_bloc.dart index 952c6b50..1cbdc4f6 100644 --- a/lib/features/v2/home/presentation/bloc/home_v2_bloc.dart +++ b/lib/features/v2/home/presentation/bloc/home_v2_bloc.dart @@ -14,7 +14,12 @@ class HomeV2Bloc extends Bloc { 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(_onLoadData); on(_onRefresh); on(_onToggleTrendType); @@ -28,26 +33,25 @@ class HomeV2Bloc extends Bloc { 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 sites = []; SiteEntity? selectedSite; - + siteResult.fold( (failure) { print('加载场站列表失败: ${failure.message}'); @@ -56,7 +60,7 @@ class HomeV2Bloc extends Bloc { sites = siteList; // 从全局 SiteCubit 获取之前选中的场站 final savedSelectedSite = siteCubit.state.selectedSite; - + // 尝试找到之前选中的场站 if (savedSelectedSite != null && siteList.isNotEmpty) { selectedSite = siteList.firstWhere( @@ -67,19 +71,21 @@ class HomeV2Bloc extends Bloc { // 没有选中过,默认选中第一个 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 { ) 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 sites = currentState.sites; SiteEntity? selectedSite = currentState.selectedSite; - + siteResult.fold( (failure) { print('刷新场站列表失败: ${failure.message}'); @@ -129,20 +134,22 @@ class HomeV2Bloc extends Bloc { } 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, + ), + ); }, ); } diff --git a/lib/features/v2/home/presentation/pages/home_v2_page.dart b/lib/features/v2/home/presentation/pages/home_v2_page.dart index 39854a41..f941a2d5 100644 --- a/lib/features/v2/home/presentation/pages/home_v2_page.dart +++ b/lib/features/v2/home/presentation/pages/home_v2_page.dart @@ -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 { 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 { color: Colors.white, child: Row( children: [ - Expanded( - child: InkWell( - onTap: _showPlantSelector, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: StreamBuilder( - stream: sl().stream, - builder: (context, snapshot) { - final siteState = - snapshot.data ?? - sl().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 { } // 🔥 Initial/Loading 状态显示加载指示器 - return const Center(child: CircularProgressIndicator(color: Color(0xFF165DFF))); + return const Center( + child: CircularProgressIndicator(color: Color(0xFF165DFF)), + ); }, ), ); diff --git a/lib/features/v2/report/data/datasources/report_remote_datasource_impl.dart b/lib/features/v2/report/data/datasources/report_remote_datasource_impl.dart index 530f630a..7c72810d 100644 --- a/lib/features/v2/report/data/datasources/report_remote_datasource_impl.dart +++ b/lib/features/v2/report/data/datasources/report_remote_datasource_impl.dart @@ -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> 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 = { + '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': [], + 'videoUrl': [], + }; + final workOrderJson = jsonEncode(workOrder); + print('[上报工单] workOrder JSON: $workOrderJson'); - // 模拟成功返回 - return right(true); + // 2. 分离图片和视频 + final imagePaths = []; + final videoPaths = []; + 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().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', + [], + 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', + [], + 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; + 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')); + } } } diff --git a/lib/features/v2/report/data/models/report_model.dart b/lib/features/v2/report/data/models/report_model.dart index 27a71f94..aefcf216 100644 --- a/lib/features/v2/report/data/models/report_model.dart +++ b/lib/features/v2/report/data/models/report_model.dart @@ -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? 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?)?.cast(), 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, diff --git a/lib/features/v2/report/di/report_di.dart b/lib/features/v2/report/di/report_di.dart index 04709b69..bfda02ba 100644 --- a/lib/features/v2/report/di/report_di.dart +++ b/lib/features/v2/report/di/report_di.dart @@ -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(), + appUserCubit: sl(), + userStorage: sl(), + ); } } diff --git a/lib/features/v2/report/domain/entities/report_entity.dart b/lib/features/v2/report/domain/entities/report_entity.dart index bbf3f15c..b9da3739 100644 --- a/lib/features/v2/report/domain/entities/report_entity.dart +++ b/lib/features/v2/report/domain/entities/report_entity.dart @@ -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? 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? 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, diff --git a/lib/features/v2/report/presentation/constants/report_constants.dart b/lib/features/v2/report/presentation/constants/report_constants.dart index 557a4c54..4c7b330a 100644 --- a/lib/features/v2/report/presentation/constants/report_constants.dart +++ b/lib/features/v2/report/presentation/constants/report_constants.dart @@ -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); } diff --git a/lib/features/v2/report/presentation/cubit/report_cubit.dart b/lib/features/v2/report/presentation/cubit/report_cubit.dart index 973f00aa..809f693f 100644 --- a/lib/features/v2/report/presentation/cubit/report_cubit.dart +++ b/lib/features/v2/report/presentation/cubit/report_cubit.dart @@ -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 { 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 _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 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 robots = []; + List drones = []; + + if (robotResponse.statusCode == 200 && + robotResponse.data['code'] == 200) { + final List rows = robotResponse.data['rows'] ?? []; + robots = rows.map((item) => RobotDataModel.fromJson(item)).toList(); + } + + if (droneResponse.statusCode == 200 && + droneResponse.data['code'] == 200) { + final List 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 { } } - /// 更新问题描述 void updateDescription(String description) { if (state is ReportFormState) { final currentState = state as ReportFormState; @@ -62,20 +192,18 @@ class ReportCubit extends Cubit { } } - /// 选择问题等级 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 { } } - /// 删除媒体文件 void removeMediaFile(int index) { if (state is ReportFormState) { final currentState = state as ReportFormState; @@ -105,43 +232,68 @@ class ReportCubit extends Cubit { } } - /// 提交上报 Future 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(); } } diff --git a/lib/features/v2/report/presentation/pages/report_page.dart b/lib/features/v2/report/presentation/pages/report_page.dart index 5b4fc39c..39ffe4df 100644 --- a/lib/features/v2/report/presentation/pages/report_page.dart +++ b/lib/features/v2/report/presentation/pages/report_page.dart @@ -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( 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().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().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(); 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().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(); @@ -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 _pickImage(ReportCubit cubit) async { final ImagePicker picker = ImagePicker(); try { @@ -273,7 +240,6 @@ class _ReportPageContent extends StatelessWidget { } } - /// 选择视频 Future _pickVideo(ReportCubit cubit) async { final ImagePicker picker = ImagePicker(); try { @@ -290,7 +256,6 @@ class _ReportPageContent extends StatelessWidget { } } - /// 预览媒体文件 void _previewMedia(BuildContext context, List mediaFiles, int index) { Navigator.push( context, @@ -301,11 +266,9 @@ class _ReportPageContent extends StatelessWidget { ); } - /// 从相册选择 Future _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 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 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(); } } diff --git a/lib/features/v2/report/presentation/states/report_state.dart b/lib/features/v2/report/presentation/states/report_state.dart index 826fb633..64e67f18 100644 --- a/lib/features/v2/report/presentation/states/report_state.dart +++ b/lib/features/v2/report/presentation/states/report_state.dart @@ -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 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 mediaFiles; + final List sites; + final SiteEntity? selectedSite; + final List robots; + final List 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? mediaFiles, + List? sites, + SiteEntity? selectedSite, + List? robots, + List? 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, ]; } diff --git a/lib/features/v2/report/presentation/widgets/device_selector.dart b/lib/features/v2/report/presentation/widgets/device_selector.dart index e70aebff..da297c4b 100644 --- a/lib/features/v2/report/presentation/widgets/device_selector.dart +++ b/lib/features/v2/report/presentation/widgets/device_selector.dart @@ -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, + ), + ], + ), ), ], ), diff --git a/lib/features/v2/report/presentation/widgets/level_selector.dart b/lib/features/v2/report/presentation/widgets/level_selector.dart index 162e850a..b674bd66 100644 --- a/lib/features/v2/report/presentation/widgets/level_selector.dart +++ b/lib/features/v2/report/presentation/widgets/level_selector.dart @@ -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 diff --git a/lib/features/v2/report/presentation/widgets/media_uploader.dart b/lib/features/v2/report/presentation/widgets/media_uploader.dart index 73e3ab0b..43b1505c 100644 --- a/lib/features/v2/report/presentation/widgets/media_uploader.dart +++ b/lib/features/v2/report/presentation/widgets/media_uploader.dart @@ -53,127 +53,131 @@ class MediaUploader extends StatelessWidget { ), ), const SizedBox(height: 12), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - // 拍照按钮 - _buildUploadButton( - icon: Icons.camera_alt, - label: loc.translate('report.take_photo'), - onTap: onCameraTap, - ), - const SizedBox(width: 8), - // 录像按钮 - _buildUploadButton( - icon: Icons.videocam, - label: loc.translate('report.record_video'), - onTap: onVideoTap, - ), - const SizedBox(width: 8), - // 已上传的媒体预览 - ...mediaFiles.asMap().entries.map((entry) { - final String filePath = entry.value; - final bool isNetworkImage = filePath.startsWith('http'); - final bool isVideo = _isVideoFile(filePath); + SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: Row( + children: [ + // 拍照按钮 + _buildUploadButton( + icon: Icons.camera_alt, + label: loc.translate('report.take_photo'), + onTap: onCameraTap, + ), + const SizedBox(width: 8), + // 录像按钮 + _buildUploadButton( + icon: Icons.videocam, + label: loc.translate('report.record_video'), + onTap: onVideoTap, + ), + const SizedBox(width: 8), + // 已上传的媒体预览 + ...mediaFiles.asMap().entries.map((entry) { + final String filePath = entry.value; + final bool isNetworkImage = filePath.startsWith('http'); + final bool isVideo = _isVideoFile(filePath); - return GestureDetector( - onTap: () => onTap?.call(entry.key), - child: Stack( - children: [ - Container( - width: 80, - height: 80, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppDimensions.borderRadiusSmall, - ), - color: AppColors.cardBackground, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular( - AppDimensions.borderRadiusSmall, - ), - child: isVideo - ? _buildVideoThumbnail(filePath) - : (isNetworkImage - ? Image.network( - filePath, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) { - return _buildPlaceholderIcon( - Icons.broken_image, - ); - }, - ) - : Image.file( - File(filePath), - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) { - return _buildPlaceholderIcon( - Icons.broken_image, - ); - }, - )), - ), - ), - Positioned( - top: 4, - right: 12, - child: GestureDetector( - onTap: () => onRemove(entry.key), - child: Container( - width: 20, - height: 20, - decoration: const BoxDecoration( - color: Colors.black54, - shape: BoxShape.circle, + return GestureDetector( + onTap: () => onTap?.call(entry.key), + child: Stack( + children: [ + Container( + width: 80, + height: 80, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppDimensions.borderRadiusSmall, ), - child: const Icon( - Icons.close, - size: 14, - color: Colors.white, + color: AppColors.cardBackground, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimensions.borderRadiusSmall, ), + child: isVideo + ? _buildVideoThumbnail(filePath) + : (isNetworkImage + ? Image.network( + filePath, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) { + return _buildPlaceholderIcon( + Icons.broken_image, + ); + }, + ) + : Image.file( + File(filePath), + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) { + return _buildPlaceholderIcon( + Icons.broken_image, + ); + }, + )), ), ), - ), - // 视频播放图标 - if (isVideo) Positioned( - child: Container( - width: 80, - height: 80, - margin: const EdgeInsets.only(right: 8), - alignment: Alignment.center, + top: 4, + right: 12, + child: GestureDetector( + onTap: () => onRemove(entry.key), child: Container( - padding: const EdgeInsets.all(8), + width: 20, + height: 20, decoration: const BoxDecoration( - color: Colors.black38, + color: Colors.black54, shape: BoxShape.circle, ), child: const Icon( - Icons.play_arrow, + Icons.close, + size: 14, color: Colors.white, - size: 24, ), ), ), ), - ], - ), - ); - }).toList(), - // 添加按钮 - _buildUploadButton( - icon: Icons.add, - label: '', - onTap: onAddFromGallery, - isAddButton: true, - ), - ], + // 视频播放图标 + if (isVideo) + Positioned( + child: Container( + width: 80, + height: 80, + margin: const EdgeInsets.only(right: 8), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Colors.black38, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ], + ), + ); + }).toList(), + // 添加按钮 + _buildUploadButton( + icon: Icons.add, + label: '', + onTap: onAddFromGallery, + isAddButton: true, + ), + ], + ), ), ), ], diff --git a/lib/features/v2/report/presentation/widgets/report_type_selector.dart b/lib/features/v2/report/presentation/widgets/report_type_selector.dart index 1d158837..f25692d8 100644 --- a/lib/features/v2/report/presentation/widgets/report_type_selector.dart +++ b/lib/features/v2/report/presentation/widgets/report_type_selector.dart @@ -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 ReportTypeSelector extends StatelessWidget { final ReportType? selectedType; final Function(ReportType) onSelected; @@ -19,22 +17,24 @@ class ReportTypeSelector extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: AppDimensions.horizontalPadding, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: ReportType.values.map((type) { - final isSelected = selectedType == type; - return Flexible( - flex: 1, - child: GestureDetector( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: Row( + children: ReportType.values.map((type) { + final isSelected = selectedType == type; + return GestureDetector( onTap: () => onSelected(type), child: Container( - margin: const EdgeInsets.symmetric(horizontal: 3), + margin: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.symmetric( - horizontal: 8, + horizontal: 16, vertical: 14, ), decoration: BoxDecoration( - color: AppColors.background, + color: isSelected + ? AppColors.primary.withOpacity(0.1) + : AppColors.background, borderRadius: BorderRadius.circular( AppDimensions.borderRadiusSmall, ), @@ -60,13 +60,13 @@ class ReportTypeSelector extends StatelessWidget { color: isSelected ? AppColors.primary : AppColors.textHint, - size: 26, + size: 24, ), const SizedBox(height: 6), Text( - AppLocalizations.of(context).translate(type.labelKey), + type.label, style: TextStyle( - fontSize: 11, + fontSize: 12, height: 1.2, color: isSelected ? AppColors.primary @@ -82,9 +82,9 @@ class ReportTypeSelector extends StatelessWidget { ], ), ), - ), - ); - }).toList(), + ); + }).toList(), + ), ), ); } diff --git a/lib/features/v2/site/presentation/cubit/site_cubit.dart b/lib/features/v2/site/presentation/cubit/site_cubit.dart index 5d5c176b..3bb39a80 100644 --- a/lib/features/v2/site/presentation/cubit/site_cubit.dart +++ b/lib/features/v2/site/presentation/cubit/site_cubit.dart @@ -4,23 +4,29 @@ import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../../../core/network/tcp/tcp_client.dart'; import '../../../home/domain/entities/site_entity.dart'; +import '../../../home/domain/usecases/get_site_list_usecase.dart'; +import '../../../../../core/app/app_user_cubit.dart'; class SiteState { final List sites; final SiteEntity? selectedSite; + final bool isLoading; const SiteState({ this.sites = const [], this.selectedSite, + this.isLoading = false, }); SiteState copyWith({ List? sites, SiteEntity? selectedSite, + bool? isLoading, }) { return SiteState( sites: sites ?? this.sites, selectedSite: selectedSite ?? this.selectedSite, + isLoading: isLoading ?? this.isLoading, ); } } @@ -28,9 +34,47 @@ class SiteState { class SiteCubit extends Cubit { final SharedPreferences sharedPreferences; static const String _selectedSiteIdKey = 'selected_site_id'; + bool _hasLoadedSites = false; SiteCubit(this.sharedPreferences) : super(const SiteState()); + /// 加载场站列表(首次调用时请求接口,之后不重复请求) + Future loadSites() async { + if (_hasLoadedSites) return; + _hasLoadedSites = true; + + emit(state.copyWith(isLoading: true)); + + try { + final userId = GetIt.I().state.user?.userId; + if (userId == null) { + emit(state.copyWith(isLoading: false)); + return; + } + + final result = await GetIt.I().call(userId); + result.fold( + (failure) { + debugPrint('❌ [SiteCubit] 加载场站列表失败: ${failure.message}'); + emit(state.copyWith(isLoading: false)); + }, + (sites) { + updateSites(sites); + emit(state.copyWith(isLoading: false)); + }, + ); + } catch (e) { + debugPrint('❌ [SiteCubit] 加载场站列表异常: $e'); + emit(state.copyWith(isLoading: false)); + } + } + + /// 强制重新加载场站列表 + Future reloadSites() async { + _hasLoadedSites = false; + await loadSites(); + } + /// 更新场站列表 void updateSites(List sites) { // 尝试恢复之前选中的场站 @@ -52,8 +96,10 @@ class SiteCubit extends Cubit { /// 选择场站(持久化) void selectSite(SiteEntity site) { debugPrint('🏭 [SiteCubit] ========== 切换场站 =========='); - debugPrint('🏭 [SiteCubit] 从 ${state.selectedSite?.siteName ?? "无"} 切换到 ${site.siteName}'); - + debugPrint( + '🏭 [SiteCubit] 从 ${state.selectedSite?.siteName ?? "无"} 切换到 ${site.siteName}', + ); + // 🔥 关键修复:只有切换到不同场站时才断开TCP if (state.selectedSite?.id != site.id) { final tcpClient = GetIt.I(); @@ -67,7 +113,7 @@ class SiteCubit extends Cubit { } else { debugPrint('✅ [SiteCubit] 相同场站,保持TCP连接状态'); } - + sharedPreferences.setInt(_selectedSiteIdKey, site.id); emit(state.copyWith(selectedSite: site)); debugPrint('✅ [SiteCubit] 场站切换完成'); @@ -81,6 +127,7 @@ class SiteCubit extends Cubit { /// 清空所有场站数据(退出登录时调用) void clearAll() { + _hasLoadedSites = false; sharedPreferences.remove(_selectedSiteIdKey); emit(const SiteState()); } diff --git a/lib/features/v2/site/presentation/widgets/site_selector_widget.dart b/lib/features/v2/site/presentation/widgets/site_selector_widget.dart new file mode 100644 index 00000000..6b08d414 --- /dev/null +++ b/lib/features/v2/site/presentation/widgets/site_selector_widget.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart'; + +class SiteSelectorWidget extends StatefulWidget { + final bool compact; + final bool enabled; + final void Function(SiteEntity site)? onSiteSelected; + + const SiteSelectorWidget({ + super.key, + this.compact = false, + this.enabled = true, + this.onSiteSelected, + }); + + @override + State createState() => _SiteSelectorWidgetState(); +} + +class _SiteSelectorWidgetState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + GetIt.I().loadSites(); + }); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: GetIt.I().stream, + builder: (context, snapshot) { + final siteState = snapshot.data ?? GetIt.I().state; + final selectedSite = siteState.selectedSite; + final sites = siteState.sites; + + if (widget.compact) { + return _buildCompact(context, selectedSite, sites); + } + return _buildNormal(context, selectedSite, sites); + }, + ); + } + + /// 紧凑模式(用于空间有限的场景) + Widget _buildCompact( + BuildContext context, + SiteEntity? selectedSite, + List sites, + ) { + const iconSize = 16.0; + const spacing = 2.0; + final text = selectedSite?.siteName ?? '请选择场站'; + + return InkWell( + onTap: widget.enabled ? () => _showSitePicker(context, sites) : null, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: LayoutBuilder( + builder: (context, constraints) { + final maxTextWidth = constraints.maxWidth - iconSize - spacing; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxTextWidth > 0 ? maxTextWidth : 0, + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2129), + ), + ), + ), + const SizedBox(width: spacing), + Icon( + Icons.keyboard_arrow_down, + size: iconSize, + color: widget.enabled + ? const Color(0xFF4E5969) + : const Color(0xFFC9CDD4), + ), + ], + ); + }, + ), + ), + ); + } + + /// 普通模式 + Widget _buildNormal( + BuildContext context, + SiteEntity? selectedSite, + List sites, + ) { + const iconSize = 18.0; + const spacing = 3.0; + final text = selectedSite?.siteName ?? '请选择场站'; + + return InkWell( + onTap: widget.enabled ? () => _showSitePicker(context, sites) : null, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: LayoutBuilder( + builder: (context, constraints) { + final maxTextWidth = constraints.maxWidth - iconSize - spacing; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxTextWidth > 0 ? maxTextWidth : 0, + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2129), + ), + ), + ), + const SizedBox(width: spacing), + Icon( + Icons.keyboard_arrow_down, + size: iconSize, + color: widget.enabled + ? const Color(0xFF4E5969) + : const Color(0xFFC9CDD4), + ), + ], + ); + }, + ), + ), + ); + } + + /// 弹出场站选择底部弹窗 + void _showSitePicker(BuildContext context, List sites) { + if (sites.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('暂无场站数据'))); + return; + } + + final siteCubit = GetIt.I(); + final currentSiteId = siteCubit.state.selectedSite?.id; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return Container( + padding: const EdgeInsets.all(16), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 标题 + Container( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + const Spacer(), + const Text( + '选择场站', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Icon( + Icons.close, + size: 22, + color: Color(0xFF86909C), + ), + ), + ], + ), + ), + const Divider(height: 1), + const SizedBox(height: 8), + // 场站列表 + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: sites.length, + itemBuilder: (context, index) { + final site = sites[index]; + final isSelected = currentSiteId == site.id; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFFE8F3FF) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 2, + ), + title: Text( + site.siteName, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected + ? const Color(0xFF165DFF) + : const Color(0xFF1D2129), + ), + ), + trailing: isSelected + ? const Icon( + Icons.check, + size: 20, + color: Color(0xFF165DFF), + ) + : null, + onTap: () { + siteCubit.selectSite(site); + widget.onSiteSelected?.call(site); + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart b/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart index 7412103f..c20997d8 100644 --- a/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart +++ b/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart @@ -5,8 +5,11 @@ abstract class AlarmDetailRemoteDataSource { /// 获取告警详情 Future getAlarmDetail(String alarmId); - /// 确认告警 - Future confirmAlarm(String alarmId); + /// 确认告警(处理中) + Future> confirmAlarm(String alarmId); + + /// 处理告警(已关闭) + Future> handleAlarm(Map handleData); /// AI诊断 Future aiDiagnosis(String alarmId); diff --git a/lib/features/v2/waring_center/data/datasources/alarm_dispatch_remote_datasource.dart b/lib/features/v2/waring_center/data/datasources/alarm_dispatch_remote_datasource.dart new file mode 100644 index 00000000..c37f6d55 --- /dev/null +++ b/lib/features/v2/waring_center/data/datasources/alarm_dispatch_remote_datasource.dart @@ -0,0 +1,8 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import '../../domain/entities/alarm_dispatch_entity.dart'; + +/// 告警派发远程数据源抽象 +abstract class AlarmDispatchRemoteDataSource { + Future> dispatchWorkOrder(AlarmDispatchEntity entity); +} diff --git a/lib/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart b/lib/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart index 09504dd6..a5852df2 100644 --- a/lib/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart +++ b/lib/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart @@ -2,9 +2,17 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_mod /// 告警远程数据源抽象 abstract class AlarmRemoteDataSource { - /// 获取告警列表 - Future> getAlarmList(); + /// 获取告警列表(支持分页) + Future> getAlarmList({ + int? siteId, + int? configId, + int? page, + int? pageSize, + }); /// 获取告警统计 - Future getAlarmCount(); + Future getAlarmCount({int? siteId}); + + /// 获取告警工单配置列表 + Future> getAlarmOrderConfigList(int siteId); } diff --git a/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart b/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart index ffb40ce3..39cf3f4a 100644 --- a/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart +++ b/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart @@ -1,55 +1,118 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_detail_model.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_handle_model.dart'; -/// 告警详情远程数据源实现(模拟数据) +/// 告警详情远程数据源实现 class AlarmDetailRemoteDataSourceImpl implements AlarmDetailRemoteDataSource { + AlarmDetailRemoteDataSourceImpl(this._dio); + + final Dio _dio; + @override Future getAlarmDetail(String alarmId) async { - // 模拟网络延迟 - await Future.delayed(const Duration(seconds: 1)); + final uri = '${HttpApiConsts.alarmDetail}/$alarmId'; + debugPrint('=== 告警详情请求 ==='); + debugPrint('uri: `$uri`'); - return AlarmDetailModel( - id: alarmId, - title: '逆变器离网告警', - level: '严重', - deviceInfo: '光伏区A / 逆变器 INV-001', - occurTime: '2025-05-19T09:24:30', - alarmStatus: '未处理', - recoverTime: null, - duration: '36分钟', - affectRange: '光伏区A(2.6 MW)', - description: '逆变器与电网失去连接,功率输出为0。', - suggestions: [ - '检查逆变器侧并网开关状态', - '检查电网电压及频率是否异常', - ], - historyData: HistoryDataModel( - power: [ - MetricPointModel(time: '08:24', value: 1050), - MetricPointModel(time: '08:54', value: 980), - MetricPointModel(time: '09:04', value: 1020), - MetricPointModel(time: '09:14', value: 990), - MetricPointModel(time: '09:19', value: 1010), - MetricPointModel(time: '09:24', value: 100), - MetricPointModel(time: '09:24:30', value: 0), - ], - voltage: [], - frequency: [], - ), + final response = await _dio.get(uri); + + debugPrint('statusCode: ${response.statusCode}'); + debugPrint('statusMessage: ${response.statusMessage}'); + debugPrint('Response Text: ${response.data}'); + debugPrint('=== 告警详情响应结束 ==='); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '获取告警详情失败'); + } + + return AlarmDetailModel.fromJson( + responseData['data'] as Map, ); } @override - Future confirmAlarm(String alarmId) async { - // 模拟网络延迟 - await Future.delayed(const Duration(seconds: 1)); - return true; + Future> confirmAlarm(String alarmId) async { + final requestData = AlarmHandleModel(alarmId: alarmId, handleStatus: 2); + + debugPrint('=== 确认告警请求 ==='); + debugPrint('uri: ${HttpApiConsts.alarmHandle}'); + debugPrint('requestData: ${requestData.toJson()}'); + + final response = await _dio.post( + HttpApiConsts.alarmHandle, + data: requestData.toJson(), + ); + + debugPrint('statusCode: ${response.statusCode}'); + debugPrint('statusMessage: ${response.statusMessage}'); + debugPrint('Response Data: ${response.data}'); + debugPrint('=== 确认告警响应结束 ==='); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '确认告警失败'); + } + + return responseData as Map; + } + + @override + Future> handleAlarm( + Map handleData, + ) async { + debugPrint('=== 处理告警请求 ==='); + debugPrint('uri: ${HttpApiConsts.alarmHandle}'); + debugPrint('requestData: $handleData'); + + final response = await _dio.post( + HttpApiConsts.alarmHandle, + data: handleData, + ); + + debugPrint('statusCode: ${response.statusCode}'); + debugPrint('statusMessage: ${response.statusMessage}'); + debugPrint('Response Data: ${response.data}'); + debugPrint('=== 处理告警响应结束 ==='); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '处理告警失败'); + } + + return responseData as Map; } @override Future aiDiagnosis(String alarmId) async { - // 模拟网络延迟 - await Future.delayed(const Duration(seconds: 2)); - return 'AI分析结果:\n\n根据历史数据分析,该告警可能是由于以下原因导致:\n1. 电网侧电压波动超过阈值\n2. 逆变器保护机制触发\n3. 并网开关异常断开\n\n建议优先检查并网开关状态和电网电压稳定性。'; + final response = await _dio.get( + '${HttpApiConsts.alarmDetail}/$alarmId/aiDiagnosis', + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? 'AI诊断失败'); + } + + return responseData['data'] as String? ?? ''; } } diff --git a/lib/features/v2/waring_center/data/datasources/impl/alarm_dispatch_remote_datasource_impl.dart b/lib/features/v2/waring_center/data/datasources/impl/alarm_dispatch_remote_datasource_impl.dart new file mode 100644 index 00000000..4949be1b --- /dev/null +++ b/lib/features/v2/waring_center/data/datasources/impl/alarm_dispatch_remote_datasource_impl.dart @@ -0,0 +1,171 @@ +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 '../../../domain/entities/alarm_dispatch_entity.dart'; +import '../alarm_dispatch_remote_datasource.dart'; + +/// 告警派发远程数据源实现 +class AlarmDispatchRemoteDataSourceImpl + implements AlarmDispatchRemoteDataSource { + @override + Future> dispatchWorkOrder( + AlarmDispatchEntity entity, + ) async { + print('========================================'); + print('[告警派发工单] 开始提交'); + print('[告警派发工单] 请求URL: ${HttpApiConsts.workOrderAdd}'); + + try { + // 格式化计划时间 + String formatDateTime(DateTime? dt) { + if (dt == null) return ''; + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } + + // 构造 workOrder JSON + final workOrder = { + 'siteId': entity.siteId, + 'siteName': entity.siteName ?? '', + 'sourceType': 7, // 7=告警关联 + 'orderType': entity.orderType ?? '', + 'deviceId': entity.deviceId ?? '', + 'deviceName': entity.deviceName ?? '', + 'taskDescription': entity.description ?? '', + 'priorityLevel': entity.problemLevel ?? 'INFO', + 'orderTitle': '${entity.orderType ?? '告警派发'} - ${entity.deviceName ?? ''}', + 'orderStatus': 1, // 1=待处理 + 'imgUrl': [], + 'videoUrl': [], + 'alarmId': entity.alarmId, + 'alarmNo': entity.alarmNo ?? '', + 'planStartTime': formatDateTime(entity.planStartTime), + 'planEndTime': formatDateTime(entity.planEndTime), + }; + final workOrderJson = jsonEncode(workOrder); + print('[告警派发工单] workOrder JSON: $workOrderJson'); + + // 分离图片和视频 + final imagePaths = []; + final videoPaths = []; + const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', '3gp']; + + if (entity.mediaUrls != null) { + for (final path in entity.mediaUrls!) { + final ext = path.split('.').last.toLowerCase(); + if (videoExts.contains(ext)) { + videoPaths.add(path); + } else { + imagePaths.add(path); + } + } + } + print('[告警派发工单] 图片: ${imagePaths.length}个, 视频: ${videoPaths.length}个'); + + // 创建 multipart 请求 + final request = http.MultipartRequest( + 'POST', + Uri.parse(HttpApiConsts.workOrderAdd), + ); + + // Authorization + final userToken = GetIt.I().state.user?.token; + if (userToken != null) { + request.headers['Authorization'] = 'Bearer $userToken'; + } + + // 图片文件 + if (imagePaths.isNotEmpty) { + final firstImage = imagePaths.first; + final file = File(firstImage); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + request.files.add(http.MultipartFile.fromBytes( + 'file', + bytes, + filename: firstImage.split('/').last, + contentType: http.MediaType('image', 'jpeg'), + )); + } + } else { + request.files.add(http.MultipartFile.fromBytes( + 'file', + [], + filename: 'empty.jpg', + contentType: http.MediaType('image', 'jpeg'), + )); + } + + // 视频文件 + if (videoPaths.isNotEmpty) { + final firstVideo = videoPaths.first; + final file = File(firstVideo); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + request.files.add(http.MultipartFile.fromBytes( + 'video', + bytes, + filename: firstVideo.split('/').last, + contentType: http.MediaType('video', 'mp4'), + )); + } + } else { + request.files.add(http.MultipartFile.fromBytes( + 'video', + [], + filename: 'empty.mp4', + contentType: http.MediaType('video', 'mp4'), + )); + } + + // workOrder JSON + request.files.add(http.MultipartFile.fromBytes( + 'workOrder', + utf8.encode(workOrderJson), + filename: '', + contentType: http.MediaType('application', 'json'), + )); + + print('[告警派发工单] 开始发送请求...'); + + final http.StreamedResponse response = await request.send(); + final String responseBody = await response.stream.bytesToString(); + + print('[告警派发工单] 响应状态码: ${response.statusCode}'); + print('[告警派发工单] 响应体: $responseBody'); + + if (response.statusCode == 200) { + final respJson = jsonDecode(responseBody) as Map; + final code = respJson['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}'), + ); + } + } catch (e) { + print('[告警派发工单] 异常: $e'); + print('========================================'); + return left(Failure('提交失败: $e')); + } + } +} diff --git a/lib/features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart b/lib/features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart index ebcbd7ae..af8d15fa 100644 --- a/lib/features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart +++ b/lib/features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart @@ -1,57 +1,56 @@ +import 'package:flutter/foundation.dart'; + +import 'package:dio/dio.dart'; +import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_model.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart'; -/// 告警远程数据源实现(模拟数据) +/// 告警远程数据源实现 class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource { - @override - Future> getAlarmList() async { - // 模拟网络延迟 - await Future.delayed(const Duration(seconds: 1)); + AlarmRemoteDataSourceImpl(this._dio); - return [ - AlarmModel( - id: '1', - title: '逆变器INV-001通讯异常', - area: 'A区', - device: 'INV-001', - time: '05-21 10:23', - level: AlarmLevel.danger, - status: AlarmStatus.unconfirmed, - aiDiagnosis: 'INV-001通讯异常可能由网络波动或采集器故障引起,建议检查网络连接。', - ), - AlarmModel( - id: '2', - title: '汇流箱SCB-003过温告警', - area: 'B区', - device: 'SCB-003', - time: '05-21 09:45', - level: AlarmLevel.warning, - status: AlarmStatus.unconfirmed, - ), - AlarmModel( - id: '3', - title: '组件串STR-024功率异常', - area: 'C区', - device: 'STR-024', - time: '05-21 08:32', - level: AlarmLevel.low, - status: AlarmStatus.confirmed, - ), - AlarmModel( - id: '4', - title: '环境辐照度传感器离线', - area: 'D区', - device: 'SEN-001', - time: '05-21 07:15', - level: AlarmLevel.info, - status: AlarmStatus.confirmed, - ), - ]; + final Dio _dio; + + @override + Future> getAlarmList({ + int? siteId, + int? configId, + int? page, + int? pageSize, + }) async { + final queryParams = {}; + if (siteId != null) queryParams['siteId'] = siteId; + if (configId != null) queryParams['configId'] = configId; + if (page != null) queryParams['page'] = page; + if (pageSize != null) queryParams['pageSize'] = pageSize; + + debugPrint('=== 获取告警列表请求 ==='); + debugPrint('uri: ${HttpApiConsts.alarmList}'); + debugPrint('queryParams: $queryParams'); + + final response = await _dio.get( + HttpApiConsts.alarmList, + queryParameters: queryParams, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '获取告警列表失败'); + } + + final List rows = responseData['rows'] ?? []; + return rows + .map((item) => AlarmModel.fromJson(item as Map)) + .toList(); } @override - Future getAlarmCount() async { + Future getAlarmCount({int? siteId}) async { // 模拟网络延迟 await Future.delayed(const Duration(milliseconds: 500)); @@ -62,4 +61,31 @@ class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource { todayNew: 12, ); } + + @override + Future> getAlarmOrderConfigList( + int siteId, + ) async { + final response = await _dio.post( + HttpApiConsts.alarmOrderConfigList, + data: {'siteId': siteId}, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '获取告警工单配置失败'); + } + + final List rows = responseData['rows'] ?? []; + return rows + .map( + (item) => + AlarmOrderConfigModel.fromJson(item as Map), + ) + .toList(); + } } diff --git a/lib/features/v2/waring_center/data/models/alarm_detail_model.dart b/lib/features/v2/waring_center/data/models/alarm_detail_model.dart index 4988f524..58ff6e90 100644 --- a/lib/features/v2/waring_center/data/models/alarm_detail_model.dart +++ b/lib/features/v2/waring_center/data/models/alarm_detail_model.dart @@ -16,6 +16,24 @@ class AlarmDetailModel { required this.description, required this.suggestions, required this.historyData, + this.alarmNo, + this.alarmType, + this.deviceId, + this.deviceType, + this.createTime, + this.updateTime, + this.handleUserId, + this.handleUserName, + this.handleTime, + this.handleRemark, + this.rootCause, + this.handleSuggestion, + this.siteId, + this.orgId, + this.notifySms, + this.notifyTelegram, + this.notifyEmail, + this.handleResult, }); final String id; @@ -30,29 +48,99 @@ class AlarmDetailModel { final String description; final List suggestions; final HistoryDataModel historyData; + final String? alarmNo; + final int? alarmType; + final String? deviceId; + final String? deviceType; + final String? createTime; + final String? updateTime; + final String? handleUserId; + final String? handleUserName; + final String? handleTime; + final String? handleRemark; + final String? rootCause; + final String? handleSuggestion; + final int? siteId; + final int? orgId; + final int? notifySms; + final int? notifyTelegram; + final int? notifyEmail; + final String? handleResult; factory AlarmDetailModel.fromJson(Map json) { return AlarmDetailModel( - id: json['id'] as String, - title: json['title'] as String, - level: json['level'] as String, - deviceInfo: json['deviceInfo'] as String, - occurTime: json['occurTime'] as String, - alarmStatus: json['alarmStatus'] as String, - recoverTime: json['recoverTime'] as String?, - duration: json['duration'] as String, - affectRange: json['affectRange'] as String, - description: json['description'] as String, - suggestions: (json['suggestions'] as List?) - ?.map((e) => e as String) - .toList() ?? - [], + id: json['id'].toString(), + title: json['alarmTitle']?.toString() ?? '', + level: json['alarmLevel']?.toString() ?? '', + deviceInfo: + (json['deviceName']?.toString() ?? + json['deviceId']?.toString() ?? + '') + + (json['deviceType'] != null ? ' (${json['deviceType']})' : ''), + occurTime: json['alarmTime']?.toString() ?? '', + alarmStatus: _parseStatus(json['handleStatus']), + recoverTime: json['recoverTime']?.toString(), + duration: json['duration']?.toString() ?? '', + affectRange: json['siteId'] != null ? '站点${json['siteId']}' : '', + description: json['alarmContent']?.toString() ?? '', + suggestions: _parseSuggestions(json['handleSuggestion']), historyData: HistoryDataModel.fromJson( - json['historyData'] as Map, + json['historyData'] as Map? ?? {}, ), + alarmNo: json['alarmNo']?.toString(), + alarmType: json['alarmType'] is int ? json['alarmType'] as int : null, + deviceId: json['deviceId']?.toString(), + deviceType: json['deviceType']?.toString(), + createTime: json['createTime']?.toString(), + updateTime: json['updateTime']?.toString(), + handleUserId: json['handleUserId']?.toString(), + handleUserName: json['handleUserName']?.toString(), + handleTime: json['handleTime']?.toString(), + handleRemark: json['handleRemark']?.toString(), + rootCause: json['rootCause']?.toString(), + handleSuggestion: json['handleSuggestion']?.toString(), + siteId: json['siteId'] is int ? json['siteId'] as int : null, + orgId: json['orgId'] is int ? json['orgId'] as int : null, + notifySms: json['notifySms'] is int ? json['notifySms'] as int : null, + notifyTelegram: json['notifyTelegram'] is int + ? json['notifyTelegram'] as int + : null, + notifyEmail: json['notifyEmail'] is int + ? json['notifyEmail'] as int + : null, + handleResult: json['handleResult']?.toString(), ); } + static String _parseStatus(dynamic status) { + if (status is int) { + switch (status) { + case 1: + return '待处理'; + case 2: + return '已关闭'; + case 3: + return '已关闭'; + case 4: + return '已忽略'; + default: + return '待处理'; + } + } + return status as String? ?? '待处理'; + } + + static List _parseSuggestions(dynamic suggestion) { + if (suggestion == null) return []; + if (suggestion is String) { + return suggestion.split('\n').where((s) => s.trim().isNotEmpty).toList(); + } + if (suggestion is List) { + return suggestion.map((e) => e.toString()).toList(); + } + return []; + } + AlarmDetailEntity toEntity() { return AlarmDetailEntity( id: id, @@ -67,23 +155,45 @@ class AlarmDetailModel { description: description, suggestions: suggestions, historyData: historyData.toEntity(), + alarmNo: alarmNo, + alarmType: alarmType, + deviceId: deviceId, + deviceType: deviceType, + createTime: createTime, + updateTime: updateTime, + handleUserId: handleUserId, + handleUserName: handleUserName, + handleTime: handleTime, + handleRemark: handleRemark, + rootCause: rootCause, + handleSuggestion: handleSuggestion, + siteId: siteId, + orgId: orgId, + notifySms: notifySms, + notifyTelegram: notifyTelegram, + notifyEmail: notifyEmail, + handleResult: handleResult, ); } AlarmLevel _parseLevel(String level) { - switch (level) { + final lowerLevel = level.toLowerCase(); + switch (lowerLevel) { + case 'danger': case '严重': - return AlarmLevel.danger; case '高危': return AlarmLevel.danger; + case 'warning': case '中危': return AlarmLevel.warning; + case 'low': case '低危': return AlarmLevel.low; + case 'info': case '提示': return AlarmLevel.info; default: - return AlarmLevel.info; + return AlarmLevel.warning; } } } @@ -102,15 +212,18 @@ class HistoryDataModel { factory HistoryDataModel.fromJson(Map json) { return HistoryDataModel( - power: (json['power'] as List?) + power: + (json['power'] as List?) ?.map((e) => MetricPointModel.fromJson(e as Map)) .toList() ?? [], - voltage: (json['voltage'] as List?) + voltage: + (json['voltage'] as List?) ?.map((e) => MetricPointModel.fromJson(e as Map)) .toList() ?? [], - frequency: (json['frequency'] as List?) + frequency: + (json['frequency'] as List?) ?.map((e) => MetricPointModel.fromJson(e as Map)) .toList() ?? [], @@ -128,10 +241,7 @@ class HistoryDataModel { /// 指标点模型 class MetricPointModel { - MetricPointModel({ - required this.time, - required this.value, - }); + MetricPointModel({required this.time, required this.value}); final String time; final num value; @@ -144,9 +254,6 @@ class MetricPointModel { } MetricDataPoint toEntity() { - return MetricDataPoint( - time: time, - value: value.toDouble(), - ); + return MetricDataPoint(time: time, value: value.toDouble()); } } diff --git a/lib/features/v2/waring_center/data/models/alarm_handle_model.dart b/lib/features/v2/waring_center/data/models/alarm_handle_model.dart new file mode 100644 index 00000000..40e2fd21 --- /dev/null +++ b/lib/features/v2/waring_center/data/models/alarm_handle_model.dart @@ -0,0 +1,25 @@ +class AlarmHandleModel { + AlarmHandleModel({ + required this.alarmId, + required this.handleStatus, + this.handleRemark, + this.rootCause, + this.handleResult, + }); + + final String alarmId; + final int handleStatus; + final String? handleRemark; + final String? rootCause; + final String? handleResult; + + Map toJson() { + return { + 'alarmId': alarmId, + 'handleStatus': handleStatus, + 'handleRemark': handleRemark, + 'rootCause': rootCause, + 'handleResult': handleResult, + }; + } +} \ No newline at end of file diff --git a/lib/features/v2/waring_center/data/models/alarm_model.dart b/lib/features/v2/waring_center/data/models/alarm_model.dart index 3eae3dc0..2d34d5d9 100644 --- a/lib/features/v2/waring_center/data/models/alarm_model.dart +++ b/lib/features/v2/waring_center/data/models/alarm_model.dart @@ -27,14 +27,15 @@ class AlarmModel { /// 从 JSON 创建 factory AlarmModel.fromJson(Map json) { return AlarmModel( - id: json['id'] as String, - title: json['title'] as String, - area: json['area'] as String, - device: json['device'] as String, - time: json['time'] as String, - level: _parseLevel(json['level'] as String), - status: _parseStatus(json['status'] as String), - aiDiagnosis: json['aiDiagnosis'] as String?, + id: json['id'].toString(), + title: json['alarmTitle']?.toString() ?? '', + area: json['siteId'] != null ? '站点${json['siteId']}' : '', + device: + json['deviceName']?.toString() ?? json['deviceId']?.toString() ?? '', + time: json['alarmTime']?.toString() ?? '', + level: _parseLevel(json['alarmLevel']?.toString() ?? ''), + status: _parseStatus(json['handleStatus']), + aiDiagnosis: json['aiDiagnosis']?.toString(), ); } @@ -81,7 +82,8 @@ class AlarmModel { } static AlarmLevel _parseLevel(String level) { - switch (level) { + final lowerLevel = level.toLowerCase(); + switch (lowerLevel) { case 'danger': return AlarmLevel.danger; case 'warning': @@ -91,21 +93,40 @@ class AlarmModel { case 'info': return AlarmLevel.info; default: - return AlarmLevel.info; + return AlarmLevel.warning; } } - static AlarmStatus _parseStatus(String status) { - switch (status) { - case 'unconfirmed': - return AlarmStatus.unconfirmed; - case 'confirmed': - return AlarmStatus.confirmed; - case 'recovered': - return AlarmStatus.recovered; - default: - return AlarmStatus.unconfirmed; + static AlarmStatus _parseStatus(dynamic status) { + if (status is int) { + switch (status) { + case 1: + return AlarmStatus.pending; + case 2: + return AlarmStatus.closed; + case 3: + return AlarmStatus.closed; + case 4: + return AlarmStatus.ignored; + default: + return AlarmStatus.pending; + } } + if (status is String) { + switch (status) { + case 'pending': + return AlarmStatus.pending; + case 'processing': + return AlarmStatus.closed; + case 'closed': + return AlarmStatus.closed; + case 'ignored': + return AlarmStatus.ignored; + default: + return AlarmStatus.pending; + } + } + return AlarmStatus.pending; } } @@ -153,3 +174,31 @@ class AlarmCountModel { ); } } + +/// 告警工单配置数据模型 +class AlarmOrderConfigModel { + AlarmOrderConfigModel({required this.id, required this.name, this.code}); + + final int id; + final String name; + final String? code; + + /// 从 JSON 创建 + factory AlarmOrderConfigModel.fromJson(Map json) { + return AlarmOrderConfigModel( + id: json['id'] as int, + name: json['name'] as String? ?? '', + code: json['code'] as String?, + ); + } + + /// 转换为 JSON + Map toJson() { + return {'id': id, 'name': name, 'code': code}; + } + + /// 转换为实体 + AlarmOrderConfigEntity toEntity() { + return AlarmOrderConfigEntity(id: id, name: name, code: code); + } +} diff --git a/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart b/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart index b6451fa4..644270c4 100644 --- a/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart +++ b/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart @@ -11,7 +11,9 @@ class AlarmDetailRepositoryImpl implements AlarmDetailRepository { final AlarmDetailRemoteDataSource _remoteDataSource; @override - Future> getAlarmDetail(String alarmId) async { + Future> getAlarmDetail( + String alarmId, + ) async { try { final model = await _remoteDataSource.getAlarmDetail(alarmId); return right(model.toEntity()); @@ -21,7 +23,9 @@ class AlarmDetailRepositoryImpl implements AlarmDetailRepository { } @override - Future> confirmAlarm(String alarmId) async { + Future>> confirmAlarm( + String alarmId, + ) async { try { final result = await _remoteDataSource.confirmAlarm(alarmId); return right(result); @@ -30,6 +34,18 @@ class AlarmDetailRepositoryImpl implements AlarmDetailRepository { } } + @override + Future>> handleAlarm( + Map handleData, + ) async { + try { + final result = await _remoteDataSource.handleAlarm(handleData); + return right(result); + } catch (e) { + return left(ServerFailure('处理告警失败: $e')); + } + } + @override Future> aiDiagnosis(String alarmId) async { try { diff --git a/lib/features/v2/waring_center/data/repositories/alarm_dispatch_repository_impl.dart b/lib/features/v2/waring_center/data/repositories/alarm_dispatch_repository_impl.dart new file mode 100644 index 00000000..4883bef4 --- /dev/null +++ b/lib/features/v2/waring_center/data/repositories/alarm_dispatch_repository_impl.dart @@ -0,0 +1,19 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import '../../domain/entities/alarm_dispatch_entity.dart'; +import '../../domain/usecases/dispatch_workorder_usecase.dart'; +import '../datasources/alarm_dispatch_remote_datasource.dart'; + +/// 告警派发仓储实现 +class AlarmDispatchRepositoryImpl implements AlarmDispatchRepository { + final AlarmDispatchRemoteDataSource remoteDataSource; + + AlarmDispatchRepositoryImpl({required this.remoteDataSource}); + + @override + Future> dispatchWorkOrder( + AlarmDispatchEntity entity, + ) async { + return await remoteDataSource.dispatchWorkOrder(entity); + } +} diff --git a/lib/features/v2/waring_center/data/repositories/alarm_repository_impl.dart b/lib/features/v2/waring_center/data/repositories/alarm_repository_impl.dart index 49b442cb..4b59a8c7 100644 --- a/lib/features/v2/waring_center/data/repositories/alarm_repository_impl.dart +++ b/lib/features/v2/waring_center/data/repositories/alarm_repository_impl.dart @@ -16,9 +16,18 @@ class AlarmRepositoryImpl implements AlarmRepository { @override Future>> getAlarmList({ AlarmFilterTab? filter, + int? siteId, + int? configId, + int? page, + int? pageSize, }) async { try { - final models = await _remoteDataSource.getAlarmList(); + final models = await _remoteDataSource.getAlarmList( + siteId: siteId, + configId: configId, + page: page, + pageSize: pageSize, + ); final entities = models.map((model) => model.toEntity()).toList(); // 根据筛选条件过滤 @@ -34,24 +43,46 @@ class AlarmRepositoryImpl implements AlarmRepository { } @override - Future> getAlarmCount() async { + Future> getAlarmCount({int? siteId}) async { try { - final model = await _remoteDataSource.getAlarmCount(); + final model = await _remoteDataSource.getAlarmCount(siteId: siteId); return right(model.toEntity()); } catch (e) { return left(core_error.ServerFailure('获取告警统计失败: $e')); } } + @override + Future>> getAlarmOrderConfigList( + int siteId, + ) async { + try { + final models = await _remoteDataSource.getAlarmOrderConfigList(siteId); + final entities = models.map((model) => model.toEntity()).toList(); + return right(entities); + } catch (e) { + return left(core_error.ServerFailure('获取告警工单配置失败: $e')); + } + } + /// 筛选告警 - List _filterAlarms(List alarms, AlarmFilterTab filter) { + List _filterAlarms( + List alarms, + AlarmFilterTab filter, + ) { switch (filter) { case AlarmFilterTab.unprocessed: - return alarms.where((alarm) => alarm.status == AlarmStatus.unconfirmed).toList(); + return alarms + .where((alarm) => alarm.status == AlarmStatus.pending) + .toList(); case AlarmFilterTab.confirmed: - return alarms.where((alarm) => alarm.status == AlarmStatus.confirmed).toList(); + return alarms + .where((alarm) => alarm.status == AlarmStatus.processing) + .toList(); case AlarmFilterTab.recovered: - return alarms.where((alarm) => alarm.status == AlarmStatus.recovered).toList(); + return alarms + .where((alarm) => alarm.status == AlarmStatus.closed) + .toList(); case AlarmFilterTab.all: return alarms; } diff --git a/lib/features/v2/waring_center/domain/entities/alarm_count_entity.dart b/lib/features/v2/waring_center/domain/entities/alarm_count_entity.dart index 15a59bec..bad2fd83 100644 --- a/lib/features/v2/waring_center/domain/entities/alarm_count_entity.dart +++ b/lib/features/v2/waring_center/domain/entities/alarm_count_entity.dart @@ -1,5 +1,21 @@ import 'package:equatable/equatable.dart'; +/// 告警工单配置实体 +class AlarmOrderConfigEntity extends Equatable { + const AlarmOrderConfigEntity({ + required this.id, + required this.name, + this.code, + }); + + final int id; + final String name; + final String? code; + + @override + List get props => [id, name, code]; +} + /// 告警统计实体 class AlarmCountEntity extends Equatable { const AlarmCountEntity({ diff --git a/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart b/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart index d10a4b65..984defa6 100644 --- a/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart +++ b/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart @@ -46,6 +46,24 @@ class AlarmDetailEntity extends Equatable { required this.description, required this.suggestions, required this.historyData, + this.alarmNo, + this.alarmType, + this.deviceId, + this.deviceType, + this.createTime, + this.updateTime, + this.handleUserId, + this.handleUserName, + this.handleTime, + this.handleRemark, + this.rootCause, + this.handleSuggestion, + this.siteId, + this.orgId, + this.notifySms, + this.notifyTelegram, + this.notifyEmail, + this.handleResult, }); /// 告警ID @@ -84,6 +102,60 @@ class AlarmDetailEntity extends Equatable { /// 历史指标数据 final HistoryMetrics historyData; + /// 告警编号 + final String? alarmNo; + + /// 告警类型 + final int? alarmType; + + /// 设备ID + final String? deviceId; + + /// 设备类型 + final String? deviceType; + + /// 创建时间 + final String? createTime; + + /// 更新时间 + final String? updateTime; + + /// 处理人ID + final String? handleUserId; + + /// 处理人名称 + final String? handleUserName; + + /// 处理时间 + final String? handleTime; + + /// 处理备注 + final String? handleRemark; + + /// 根因 + final String? rootCause; + + /// 处理建议(原始字符串) + final String? handleSuggestion; + + /// 站点ID + final int? siteId; + + /// 组织ID + final int? orgId; + + /// 是否短信通知 + final int? notifySms; + + /// 是否电报通知 + final int? notifyTelegram; + + /// 是否邮件通知 + final int? notifyEmail; + + /// 处理结果 + final String? handleResult; + @override List get props => [ id, @@ -98,6 +170,24 @@ class AlarmDetailEntity extends Equatable { description, suggestions, historyData, + alarmNo, + alarmType, + deviceId, + deviceType, + createTime, + updateTime, + handleUserId, + handleUserName, + handleTime, + handleRemark, + rootCause, + handleSuggestion, + siteId, + orgId, + notifySms, + notifyTelegram, + notifyEmail, + handleResult, ]; AlarmDetailEntity copyWith({ @@ -113,6 +203,24 @@ class AlarmDetailEntity extends Equatable { String? description, List? suggestions, HistoryMetrics? historyData, + String? alarmNo, + int? alarmType, + String? deviceId, + String? deviceType, + String? createTime, + String? updateTime, + String? handleUserId, + String? handleUserName, + String? handleTime, + String? handleRemark, + String? rootCause, + String? handleSuggestion, + int? siteId, + int? orgId, + int? notifySms, + int? notifyTelegram, + int? notifyEmail, + String? handleResult, }) { return AlarmDetailEntity( id: id ?? this.id, @@ -127,6 +235,24 @@ class AlarmDetailEntity extends Equatable { description: description ?? this.description, suggestions: suggestions ?? this.suggestions, historyData: historyData ?? this.historyData, + alarmNo: alarmNo ?? this.alarmNo, + alarmType: alarmType ?? this.alarmType, + deviceId: deviceId ?? this.deviceId, + deviceType: deviceType ?? this.deviceType, + createTime: createTime ?? this.createTime, + updateTime: updateTime ?? this.updateTime, + handleUserId: handleUserId ?? this.handleUserId, + handleUserName: handleUserName ?? this.handleUserName, + handleTime: handleTime ?? this.handleTime, + handleRemark: handleRemark ?? this.handleRemark, + rootCause: rootCause ?? this.rootCause, + handleSuggestion: handleSuggestion ?? this.handleSuggestion, + siteId: siteId ?? this.siteId, + orgId: orgId ?? this.orgId, + notifySms: notifySms ?? this.notifySms, + notifyTelegram: notifyTelegram ?? this.notifyTelegram, + notifyEmail: notifyEmail ?? this.notifyEmail, + handleResult: handleResult ?? this.handleResult, ); } } diff --git a/lib/features/v2/waring_center/domain/entities/alarm_dispatch_entity.dart b/lib/features/v2/waring_center/domain/entities/alarm_dispatch_entity.dart new file mode 100644 index 00000000..655227b4 --- /dev/null +++ b/lib/features/v2/waring_center/domain/entities/alarm_dispatch_entity.dart @@ -0,0 +1,89 @@ +/// 告警派发工单实体 +class AlarmDispatchEntity { + /// 告警ID + final String alarmId; + + /// 告警编号 + final String? alarmNo; + + /// 告警原始设备类型(如 UAV, MOWER),用于映射 orderType + final String? deviceType; + + /// 映射后的工单类型(如 UAV_ERROR, MOWER_ERROR) + final String? orderType; + + /// 场站ID + final int? siteId; + + /// 场站名称 + final String? siteName; + + /// 设备ID + final String? deviceId; + + /// 设备名称 + final String? deviceName; + + /// 任务描述 + final String? description; + + /// 问题等级 + final String? problemLevel; + + /// 媒体文件路径列表 + final List? mediaUrls; + + /// 计划开始时间 + final DateTime? planStartTime; + + /// 计划结束时间 + final DateTime? planEndTime; + + const AlarmDispatchEntity({ + required this.alarmId, + this.alarmNo, + this.deviceType, + this.orderType, + this.siteId, + this.siteName, + this.deviceId, + this.deviceName, + this.description, + this.problemLevel, + this.mediaUrls, + this.planStartTime, + this.planEndTime, + }); + + AlarmDispatchEntity copyWith({ + String? alarmId, + String? alarmNo, + String? deviceType, + String? orderType, + int? siteId, + String? siteName, + String? deviceId, + String? deviceName, + String? description, + String? problemLevel, + List? mediaUrls, + DateTime? planStartTime, + DateTime? planEndTime, + }) { + return AlarmDispatchEntity( + alarmId: alarmId ?? this.alarmId, + alarmNo: alarmNo ?? this.alarmNo, + deviceType: deviceType ?? this.deviceType, + orderType: orderType ?? this.orderType, + siteId: siteId ?? this.siteId, + siteName: siteName ?? this.siteName, + deviceId: deviceId ?? this.deviceId, + deviceName: deviceName ?? this.deviceName, + description: description ?? this.description, + problemLevel: problemLevel ?? this.problemLevel, + mediaUrls: mediaUrls ?? this.mediaUrls, + planStartTime: planStartTime ?? this.planStartTime, + planEndTime: planEndTime ?? this.planEndTime, + ); + } +} diff --git a/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart b/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart index 041ab7ac..81e0e11f 100644 --- a/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart +++ b/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart @@ -7,8 +7,13 @@ abstract class AlarmDetailRepository { /// 获取告警详情 Future> getAlarmDetail(String alarmId); - /// 确认告警 - Future> confirmAlarm(String alarmId); + /// 确认告警(处理中) + Future>> confirmAlarm(String alarmId); + + /// 处理告警(已关闭) + Future>> handleAlarm( + Map handleData, + ); /// AI诊断 Future> aiDiagnosis(String alarmId); diff --git a/lib/features/v2/waring_center/domain/repositories/alarm_repository.dart b/lib/features/v2/waring_center/domain/repositories/alarm_repository.dart index 1140574d..01016d61 100644 --- a/lib/features/v2/waring_center/domain/repositories/alarm_repository.dart +++ b/lib/features/v2/waring_center/domain/repositories/alarm_repository.dart @@ -6,12 +6,21 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constant /// 告警仓储接口 abstract class AlarmRepository { - /// 获取告警列表 + /// 获取告警列表(支持分页) /// [filter] 筛选条件 Future>> getAlarmList({ AlarmFilterTab? filter, + int? siteId, + int? configId, + int? page, + int? pageSize, }); /// 获取告警统计 - Future> getAlarmCount(); + Future> getAlarmCount({int? siteId}); + + /// 获取告警工单配置列表 + Future>> getAlarmOrderConfigList( + int siteId, + ); } diff --git a/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart b/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart index 1910f6d3..d719fd5c 100644 --- a/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart +++ b/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart @@ -8,11 +8,24 @@ class ConfirmAlarmUseCase { final AlarmDetailRepository _repository; - Future> call(String alarmId) async { + Future>> call(String alarmId) async { return await _repository.confirmAlarm(alarmId); } } +/// 处理告警用例 +class HandleAlarmUseCase { + HandleAlarmUseCase(this._repository); + + final AlarmDetailRepository _repository; + + Future>> call( + Map handleData, + ) async { + return await _repository.handleAlarm(handleData); + } +} + /// AI诊断用例 class AIDiagnosisUseCase { AIDiagnosisUseCase(this._repository); diff --git a/lib/features/v2/waring_center/domain/usecases/dispatch_workorder_usecase.dart b/lib/features/v2/waring_center/domain/usecases/dispatch_workorder_usecase.dart new file mode 100644 index 00000000..efdc8fd1 --- /dev/null +++ b/lib/features/v2/waring_center/domain/usecases/dispatch_workorder_usecase.dart @@ -0,0 +1,39 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import '../entities/alarm_dispatch_entity.dart'; + +/// 派发工单用例 +abstract class AlarmDispatchRepository { + Future> dispatchWorkOrder( + AlarmDispatchEntity entity, + ); +} + +class DispatchWorkOrderUseCase { + final AlarmDispatchRepository repository; + + DispatchWorkOrderUseCase(this.repository); + + Future> execute(AlarmDispatchEntity entity) async { + if (entity.siteId == null) { + return left(Failure('请选择场站')); + } + if (entity.orderType == null || entity.orderType!.isEmpty) { + return left(Failure('请选择上报类型')); + } + if (entity.deviceId == null || entity.deviceId!.isEmpty) { + return left(Failure('请选择设备')); + } + if (entity.description == null || entity.description!.isEmpty) { + return left(Failure('请填写问题描述')); + } + if (entity.problemLevel == null || entity.problemLevel!.isEmpty) { + return left(Failure('请选择问题等级')); + } + if (entity.planStartTime == null) { + return left(Failure('请选择计划开始时间')); + } + + return await repository.dispatchWorkOrder(entity); + } +} \ No newline at end of file diff --git a/lib/features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart b/lib/features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart index a4c3fafb..89cb084e 100644 --- a/lib/features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart +++ b/lib/features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart @@ -10,7 +10,7 @@ class GetAlarmCountUseCase { final AlarmRepository _repository; /// 执行用例 - Future> call() async { - return await _repository.getAlarmCount(); + Future> call({int? siteId}) async { + return await _repository.getAlarmCount(siteId: siteId); } } diff --git a/lib/features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart b/lib/features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart index 8df56574..446c5363 100644 --- a/lib/features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart +++ b/lib/features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart @@ -12,9 +12,21 @@ class GetAlarmListUseCase { /// 执行用例 /// [filter] 筛选条件 + /// [page] 页码 + /// [pageSize] 每页条数 Future>> call({ AlarmFilterTab? filter, + int? siteId, + int? configId, + int? page, + int? pageSize, }) async { - return await _repository.getAlarmList(filter: filter); + return await _repository.getAlarmList( + filter: filter, + siteId: siteId, + configId: configId, + page: page, + pageSize: pageSize, + ); } } diff --git a/lib/features/v2/waring_center/domain/usecases/get_alarm_order_config_usecase.dart b/lib/features/v2/waring_center/domain/usecases/get_alarm_order_config_usecase.dart new file mode 100644 index 00000000..0b66a774 --- /dev/null +++ b/lib/features/v2/waring_center/domain/usecases/get_alarm_order_config_usecase.dart @@ -0,0 +1,16 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_count_entity.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/domain/repositories/alarm_repository.dart'; + +/// 获取告警工单配置列表用例 +class GetAlarmOrderConfigUseCase { + GetAlarmOrderConfigUseCase(this._repository); + + final AlarmRepository _repository; + + /// 执行用例 + Future>> call(int siteId) async { + return await _repository.getAlarmOrderConfigList(siteId); + } +} diff --git a/lib/features/v2/waring_center/presentation/bloc/alarm_cubit.dart b/lib/features/v2/waring_center/presentation/bloc/alarm_cubit.dart index 66b6d26c..c5a30552 100644 --- a/lib/features/v2/waring_center/presentation/bloc/alarm_cubit.dart +++ b/lib/features/v2/waring_center/presentation/bloc/alarm_cubit.dart @@ -1,70 +1,232 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_count_entity.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_entity.dart'; -import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_state.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart'; -/// 告警中心 Cubit +const int _pageSize = 50; +const int _countPageSize = 500; + class AlarmCubit extends Cubit { AlarmCubit({ required GetAlarmListUseCase getAlarmListUseCase, - required GetAlarmCountUseCase getAlarmCountUseCase, - }) : _getAlarmListUseCase = getAlarmListUseCase, - _getAlarmCountUseCase = getAlarmCountUseCase, - super(AlarmInitial()); + }) : _getAlarmListUseCase = getAlarmListUseCase, + super(AlarmInitial()); final GetAlarmListUseCase _getAlarmListUseCase; - final GetAlarmCountUseCase _getAlarmCountUseCase; + int _loadRequestId = 0; + + int? get _siteId { + try { + return GetIt.I().state.selectedSite?.id; + } catch (_) { + return null; + } + } - /// 加载告警数据 Future loadAlarms({AlarmFilterTab? filter}) async { - // 如果不是初始加载,保留旧数据 final isInitialLoad = state is AlarmInitial; if (isInitialLoad) { emit(AlarmLoading()); } - try { - // 并行获取告警列表和统计 - final alarmsResult = await _getAlarmListUseCase(filter: filter); - final countResult = await _getAlarmCountUseCase(); + final siteId = _siteId; + final requestId = ++_loadRequestId; - final alarms = alarmsResult.fold( + try { + final alarmsResult = await _getAlarmListUseCase( + siteId: siteId, + page: 1, + pageSize: _pageSize, + ); + + final allAlarms = alarmsResult.fold( (failure) => [], (alarms) => alarms, ); - final count = countResult.fold( - (failure) => null, - (count) => count, + final selectedFilter = filter ?? AlarmFilterTab.all; + final filteredAlarms = _filterAlarms(allAlarms, selectedFilter); + + emit( + AlarmLoaded( + allAlarms: allAlarms, + alarms: filteredAlarms, + count: const AlarmCountEntity( + unprocessed: 0, + processing: 0, + confirmed: 0, + todayNew: 0, + ), + selectedFilter: selectedFilter, + currentPage: 1, + hasMore: allAlarms.length >= _pageSize, + isCountLoading: true, + ), ); - if (count == null) { - emit(const AlarmError('获取告警统计失败')); - return; - } - - emit(AlarmLoaded( - alarms: alarms, - count: count, - selectedFilter: filter ?? AlarmFilterTab.all, - )); + _loadAndCalculateCounts(siteId, requestId); } catch (e) { emit(AlarmError('加载告警数据失败: $e')); } } - /// 切换筛选标签 - Future changeFilter(AlarmFilterTab filter) async { - final currentState = state; - if (currentState is AlarmLoaded) { - emit(currentState.copyWith(selectedFilter: filter)); - await loadAlarms(filter: filter); + Future _loadAndCalculateCounts( + int? siteId, + int requestId, + ) async { + try { + final result = await _getAlarmListUseCase( + siteId: siteId, + page: 1, + pageSize: _countPageSize, + ); + + if (requestId != _loadRequestId) return; + + result.fold( + (failure) { + if (requestId != _loadRequestId) return; + final currentState = state; + if (currentState is AlarmLoaded) { + emit(currentState.copyWith( + isCountLoading: false, + count: const AlarmCountEntity( + unprocessed: 0, + processing: 0, + confirmed: 0, + todayNew: 0, + ), + )); + } + }, + (alarms) { + if (requestId != _loadRequestId) return; + final count = _calculateCounts(alarms); + final currentState = state; + if (currentState is AlarmLoaded) { + emit(currentState.copyWith( + isCountLoading: false, + count: count, + )); + } + }, + ); + } catch (e) { + if (requestId != _loadRequestId) return; + final currentState = state; + if (currentState is AlarmLoaded) { + emit(currentState.copyWith( + isCountLoading: false, + count: const AlarmCountEntity( + unprocessed: 0, + processing: 0, + confirmed: 0, + todayNew: 0, + ), + )); + } + } + } + + AlarmCountEntity _calculateCounts(List alarms) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + + int unprocessed = 0; + int confirmed = 0; + int todayNew = 0; + + for (final alarm in alarms) { + if (alarm.status == AlarmStatus.pending) { + unprocessed++; + } + if (alarm.status == AlarmStatus.closed) { + confirmed++; + } + + try { + final alarmTime = DateTime.parse(alarm.time); + final alarmDate = DateTime( + alarmTime.year, + alarmTime.month, + alarmTime.day, + ); + if (alarmDate == today) { + todayNew++; + } + } catch (_) {} + } + + return AlarmCountEntity( + unprocessed: unprocessed, + processing: 0, + confirmed: confirmed, + todayNew: todayNew, + ); + } + + Future loadMore() async { + final currentState = state; + if (currentState is! AlarmLoaded) return; + if (currentState.isLoadingMore || !currentState.hasMore) return; + + emit(currentState.copyWith(isLoadingMore: true)); + + final siteId = _siteId; + final nextPage = currentState.currentPage + 1; + + try { + final alarmsResult = await _getAlarmListUseCase( + siteId: siteId, + page: nextPage, + pageSize: _pageSize, + ); + + final newAlarms = alarmsResult.fold( + (failure) => [], + (alarms) => alarms, + ); + + final updatedAllAlarms = [ + ...currentState.allAlarms, + ...newAlarms, + ]; + final filteredAlarms = _filterAlarms( + updatedAllAlarms, + currentState.selectedFilter, + ); + + emit( + AlarmLoaded( + allAlarms: updatedAllAlarms, + alarms: filteredAlarms, + count: currentState.count, + selectedFilter: currentState.selectedFilter, + currentPage: nextPage, + hasMore: newAlarms.length >= _pageSize, + isLoadingMore: false, + isCountLoading: currentState.isCountLoading, + ), + ); + } catch (e) { + emit(currentState.copyWith(isLoadingMore: false)); + } + } + + void changeFilter(AlarmFilterTab filter) { + final currentState = state; + if (currentState is AlarmLoaded) { + final filteredAlarms = _filterAlarms(currentState.allAlarms, filter); + emit( + currentState.copyWith(alarms: filteredAlarms, selectedFilter: filter), + ); } } - /// 刷新数据 Future refresh() async { final currentState = state; if (currentState is AlarmLoaded) { @@ -73,4 +235,26 @@ class AlarmCubit extends Cubit { await loadAlarms(); } } -} + + List _filterAlarms( + List alarms, + AlarmFilterTab filter, + ) { + switch (filter) { + case AlarmFilterTab.unprocessed: + return alarms + .where((alarm) => alarm.status == AlarmStatus.pending) + .toList(); + case AlarmFilterTab.confirmed: + return alarms + .where((alarm) => alarm.status == AlarmStatus.processing) + .toList(); + case AlarmFilterTab.recovered: + return alarms + .where((alarm) => alarm.status == AlarmStatus.closed) + .toList(); + case AlarmFilterTab.all: + return alarms; + } + } +} \ No newline at end of file diff --git a/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart index 4205bae0..370a4e73 100644 --- a/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart +++ b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/get_alarm_detail_usecase.dart'; @@ -8,14 +9,17 @@ class AlarmDetailCubit extends Cubit { AlarmDetailCubit({ required GetAlarmDetailUseCase getAlarmDetailUseCase, required ConfirmAlarmUseCase confirmAlarmUseCase, + required HandleAlarmUseCase handleAlarmUseCase, required AIDiagnosisUseCase aiDiagnosisUseCase, - }) : _getAlarmDetailUseCase = getAlarmDetailUseCase, - _confirmAlarmUseCase = confirmAlarmUseCase, - _aiDiagnosisUseCase = aiDiagnosisUseCase, - super(AlarmDetailInitial()); + }) : _getAlarmDetailUseCase = getAlarmDetailUseCase, + _confirmAlarmUseCase = confirmAlarmUseCase, + _handleAlarmUseCase = handleAlarmUseCase, + _aiDiagnosisUseCase = aiDiagnosisUseCase, + super(AlarmDetailInitial()); final GetAlarmDetailUseCase _getAlarmDetailUseCase; final ConfirmAlarmUseCase _confirmAlarmUseCase; + final HandleAlarmUseCase _handleAlarmUseCase; final AIDiagnosisUseCase _aiDiagnosisUseCase; /// 加载告警详情 @@ -47,7 +51,12 @@ class AlarmDetailCubit extends Cubit { final currentState = state; if (currentState is! AlarmDetailLoaded) return; - emit(const AlarmDetailActionInProgress('确认告警')); + emit( + AlarmDetailActionInProgress( + '确认告警', + alarmDetail: currentState.alarmDetail, + ), + ); try { final result = await _confirmAlarmUseCase(alarmId); @@ -56,17 +65,16 @@ class AlarmDetailCubit extends Cubit { (failure) { emit(AlarmDetailError(failure.message)); }, - (success) { - if (success) { - emit( - currentState.copyWith( - isConfirmed: true, - alarmDetail: currentState.alarmDetail.copyWith( - alarmStatus: '已处理', - ), + (responseData) { + debugPrint('确认告警响应数据: $responseData'); + emit( + currentState.copyWith( + isConfirmed: true, + alarmDetail: currentState.alarmDetail.copyWith( + alarmStatus: '处理中', ), - ); - } + ), + ); }, ); } catch (e) { @@ -79,7 +87,12 @@ class AlarmDetailCubit extends Cubit { final currentState = state; if (currentState is! AlarmDetailLoaded) return; - emit(const AlarmDetailActionInProgress('AI诊断')); + emit( + AlarmDetailActionInProgress( + 'AI诊断', + alarmDetail: currentState.alarmDetail, + ), + ); try { final result = await _aiDiagnosisUseCase(alarmId); @@ -96,4 +109,40 @@ class AlarmDetailCubit extends Cubit { emit(AlarmDetailError('AI诊断失败: $e')); } } + + /// 处理告警(提交处理) + Future handleAlarm(Map handleData) async { + final currentState = state; + if (currentState is! AlarmDetailLoaded) return; + + emit( + AlarmDetailActionInProgress( + '提交处理', + alarmDetail: currentState.alarmDetail, + ), + ); + + try { + final result = await _handleAlarmUseCase(handleData); + + result.fold( + (failure) { + emit(AlarmDetailError(failure.message)); + }, + (responseData) { + debugPrint('处理告警响应数据: $responseData'); + emit( + currentState.copyWith( + alarmDetail: currentState.alarmDetail.copyWith( + alarmStatus: '已关闭', + ), + isHandleSuccess: true, + ), + ); + }, + ); + } catch (e) { + emit(AlarmDetailError('处理告警失败: $e')); + } + } } diff --git a/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart index 1a4aa367..680b8a1b 100644 --- a/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart +++ b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart @@ -32,27 +32,31 @@ class AlarmDetailLoaded extends AlarmDetailState { this.selectedMetric = MetricType.power, this.isConfirmed = false, this.aiDiagnosisResult, + this.isHandleSuccess = false, }); final AlarmDetailEntity alarmDetail; final MetricType selectedMetric; final bool isConfirmed; final String? aiDiagnosisResult; + final bool isHandleSuccess; @override - List get props => [alarmDetail, selectedMetric, isConfirmed, aiDiagnosisResult]; + List get props => [alarmDetail, selectedMetric, isConfirmed, aiDiagnosisResult, isHandleSuccess]; AlarmDetailLoaded copyWith({ AlarmDetailEntity? alarmDetail, MetricType? selectedMetric, bool? isConfirmed, String? aiDiagnosisResult, + bool? isHandleSuccess, }) { return AlarmDetailLoaded( alarmDetail: alarmDetail ?? this.alarmDetail, selectedMetric: selectedMetric ?? this.selectedMetric, isConfirmed: isConfirmed ?? this.isConfirmed, aiDiagnosisResult: aiDiagnosisResult ?? this.aiDiagnosisResult, + isHandleSuccess: isHandleSuccess ?? this.isHandleSuccess, ); } } @@ -69,10 +73,14 @@ class AlarmDetailError extends AlarmDetailState { /// 操作进行中状态 class AlarmDetailActionInProgress extends AlarmDetailState { - const AlarmDetailActionInProgress(this.action); + const AlarmDetailActionInProgress( + this.action, { + this.alarmDetail, + }); final String action; + final AlarmDetailEntity? alarmDetail; @override - List get props => [action]; + List get props => [action, alarmDetail]; } diff --git a/lib/features/v2/waring_center/presentation/bloc/alarm_state.dart b/lib/features/v2/waring_center/presentation/bloc/alarm_state.dart index 877344d9..d9812ea6 100644 --- a/lib/features/v2/waring_center/presentation/bloc/alarm_state.dart +++ b/lib/features/v2/waring_center/presentation/bloc/alarm_state.dart @@ -20,27 +20,56 @@ class AlarmLoading extends AlarmState {} /// 加载成功状态 class AlarmLoaded extends AlarmState { const AlarmLoaded({ + required this.allAlarms, required this.alarms, required this.count, this.selectedFilter = AlarmFilterTab.all, + this.currentPage = 1, + this.hasMore = true, + this.isLoadingMore = false, + this.isCountLoading = false, }); + final List allAlarms; final List alarms; final AlarmCountEntity count; final AlarmFilterTab selectedFilter; + final int currentPage; + final bool hasMore; + final bool isLoadingMore; + final bool isCountLoading; @override - List get props => [alarms, count, selectedFilter]; + List get props => [ + allAlarms, + alarms, + count, + selectedFilter, + currentPage, + hasMore, + isLoadingMore, + isCountLoading, + ]; AlarmLoaded copyWith({ + List? allAlarms, List? alarms, AlarmCountEntity? count, AlarmFilterTab? selectedFilter, + int? currentPage, + bool? hasMore, + bool? isLoadingMore, + bool? isCountLoading, }) { return AlarmLoaded( + allAlarms: allAlarms ?? this.allAlarms, alarms: alarms ?? this.alarms, count: count ?? this.count, selectedFilter: selectedFilter ?? this.selectedFilter, + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + isCountLoading: isCountLoading ?? this.isCountLoading, ); } } diff --git a/lib/features/v2/waring_center/presentation/constants/alarm_constants.dart b/lib/features/v2/waring_center/presentation/constants/alarm_constants.dart index 4c60d7f5..056bc224 100644 --- a/lib/features/v2/waring_center/presentation/constants/alarm_constants.dart +++ b/lib/features/v2/waring_center/presentation/constants/alarm_constants.dart @@ -90,14 +90,17 @@ enum AlarmLevel { /// 告警状态枚举 enum AlarmStatus { - /// 未确认 - unconfirmed('未确认'), + /// 待处理 + pending('待处理'), - /// 已确认 - confirmed('已确认'), + /// 处理中 + processing('处理中'), - /// 已恢复 - recovered('已恢复'); + /// 已关闭 + closed('已关闭'), + + /// 已忽略 + ignored('已忽略'); const AlarmStatus(this.label); diff --git a/lib/features/v2/waring_center/presentation/cubit/alarm_dispatch_cubit.dart b/lib/features/v2/waring_center/presentation/cubit/alarm_dispatch_cubit.dart new file mode 100644 index 00000000..e594b4ce --- /dev/null +++ b/lib/features/v2/waring_center/presentation/cubit/alarm_dispatch_cubit.dart @@ -0,0 +1,230 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/entities/alarm_dispatch_entity.dart'; +import '../../domain/entities/alarm_detail_entity.dart'; +import '../../domain/usecases/dispatch_workorder_usecase.dart'; +import '../states/alarm_dispatch_state.dart'; +import '../../../report/presentation/constants/report_constants.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'; + +/// deviceType → ReportType 映射配置(方便后续修改) +const Map deviceTypeToReportType = { + 'UAV': ReportType.uavError, + 'DRONE': ReportType.uavError, + 'MOWER': ReportType.mowerError, + 'ROBOT': ReportType.mowerError, +}; + +class AlarmDispatchCubit extends Cubit { + final DispatchWorkOrderUseCase dispatchUseCase; + final AlarmDetailEntity alarmDetail; + final Dio dio; + final AppUserCubit appUserCubit; + final UserStorage userStorage; + + AlarmDispatchCubit({ + required this.dispatchUseCase, + required this.alarmDetail, + required this.dio, + required this.appUserCubit, + required this.userStorage, + }) : super(AlarmDispatchLoading()) { + _init(); + } + + Future _init() async { + _initFromAlarm(); + await _loadSiteName(); + } + + /// 从告警数据初始化表单 + void _initFromAlarm() { + final deviceType = alarmDetail.deviceType?.toUpperCase() ?? ''; + final reportType = deviceTypeToReportType[deviceType] ?? ReportType.other; + final now = DateTime.now(); + + final entity = AlarmDispatchEntity( + alarmId: alarmDetail.id, + alarmNo: alarmDetail.alarmNo, + deviceType: alarmDetail.deviceType, + orderType: reportType.orderType, + siteId: alarmDetail.siteId, + siteName: null, + deviceId: alarmDetail.deviceId, + deviceName: alarmDetail.deviceInfo, + description: alarmDetail.description, + problemLevel: _mapAlarmLevelToProblemLevel(alarmDetail.level), + planStartTime: now, + planEndTime: now.add(const Duration(hours: 4)), + ); + + emit(AlarmDispatchFormState( + alarmId: alarmDetail.id, + alarmNo: alarmDetail.alarmNo, + entity: entity, + selectedReportType: reportType, + selectedDevice: alarmDetail.deviceInfo, + selectedDeviceId: alarmDetail.deviceId, + planStartTime: now, + planEndTime: now.add(const Duration(hours: 4)), + siteId: alarmDetail.siteId, + )); + } + + /// 加载场站列表,根据 siteId 找到场站名称 + Future _loadSiteName() async { + if (state is! AlarmDispatchFormState) return; + final currentState = state as AlarmDispatchFormState; + final siteId = currentState.siteId; + if (siteId == null) return; + + try { + final siteDataSource = SiteDataSourceImpl( + dio, + userStorage, + appUserCubit, + ); + final siteRepository = SiteRepositoryImpl(siteDataSource); + final getSiteListUseCase = GetSiteListUseCase(siteRepository); + + final user = appUserCubit.state.user; + if (user == null) return; + + final result = await getSiteListUseCase(user.userId); + result.fold( + (failure) {}, + (sites) { + final matches = sites.where((s) => s.id == siteId); + if (matches.isNotEmpty) { + final matchedSite = matches.first; + emit(currentState.copyWith( + siteName: matchedSite.siteName, + entity: currentState.entity.copyWith(siteName: matchedSite.siteName), + )); + } + }, + ); + } catch (e) { + // 加载失败不影响表单使用 + } + } + + /// 告警等级 → 问题等级映射 + String _mapAlarmLevelToProblemLevel(dynamic level) { + final label = level.label?.toUpperCase() ?? level.toString().toUpperCase(); + if (label.contains('DANGER') || label.contains('严重') || label.contains('高危')) { + return 'ERROR'; + } + if (label.contains('WARNING') || label.contains('中危')) { + return 'WARNING'; + } + return 'INFO'; + } + + /// 选择上报类型 + void selectReportType(ReportType type) { + final s = state as AlarmDispatchFormState; + emit(s.copyWith( + selectedReportType: type, + entity: s.entity.copyWith(orderType: type.orderType), + )); + } + + /// 选择问题等级 + void selectProblemLevel(ProblemLevel level) { + final s = state as AlarmDispatchFormState; + emit(s.copyWith( + selectedProblemLevel: level, + entity: s.entity.copyWith(problemLevel: level.level), + )); + } + + /// 更新描述 + void updateDescription(String description) { + final s = state as AlarmDispatchFormState; + emit(s.copyWith( + entity: s.entity.copyWith(description: description), + )); + } + + /// 更新设备 + void updateDevice(String deviceName, String deviceId) { + final s = state as AlarmDispatchFormState; + emit(s.copyWith( + selectedDevice: deviceName, + selectedDeviceId: deviceId, + entity: s.entity.copyWith(deviceId: deviceId, deviceName: deviceName), + )); + } + + /// 添加媒体文件 + void addMediaFile(String filePath) { + final s = state as AlarmDispatchFormState; + final updatedFiles = List.from(s.mediaFiles)..add(filePath); + emit(s.copyWith( + mediaFiles: updatedFiles, + entity: s.entity.copyWith(mediaUrls: updatedFiles), + )); + } + + /// 移除媒体文件 + void removeMediaFile(int index) { + final s = state as AlarmDispatchFormState; + final updatedFiles = List.from(s.mediaFiles)..removeAt(index); + emit(s.copyWith( + mediaFiles: updatedFiles, + entity: s.entity.copyWith(mediaUrls: updatedFiles), + )); + } + + /// 更新计划开始时间 + void updatePlanStartTime(DateTime time) { + final s = state as AlarmDispatchFormState; + emit(s.copyWith( + planStartTime: time, + entity: s.entity.copyWith(planStartTime: time), + )); + } + + /// 更新计划结束时间 + void updatePlanEndTime(DateTime time) { + final s = state as AlarmDispatchFormState; + emit(s.copyWith( + planEndTime: time, + entity: s.entity.copyWith(planEndTime: time), + )); + } + + /// 提交派发 + Future submitDispatch() async { + final s = state; + if (s is! AlarmDispatchFormState) return; + + emit(AlarmDispatchSubmitting()); + + final result = await dispatchUseCase.execute(s.entity); + + result.fold( + (failure) { + emit(s.copyWith(toastMessage: failure.message, isSuccess: false)); + }, + (success) { + emit(s.copyWith(toastMessage: '工单派发成功', isSuccess: true)); + }, + ); + } + + /// 清除 toast 消息 + void clearToast() { + final s = state; + if (s is AlarmDispatchFormState) { + emit(s.copyWith(toastMessage: null)); + } + } +} diff --git a/lib/features/v2/waring_center/presentation/pages/alarm_center_page.dart b/lib/features/v2/waring_center/presentation/pages/alarm_center_page.dart index 84fadf0a..4b8a5209 100644 --- a/lib/features/v2/waring_center/presentation/pages/alarm_center_page.dart +++ b/lib/features/v2/waring_center/presentation/pages/alarm_center_page.dart @@ -10,6 +10,7 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/widgets/ import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/widgets/alarm_item_card.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/widgets/alarm_tab_bar.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/widgets/ai_diagnosis_card.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/widgets/site_selector_widget.dart'; /// 告警中心页面 class AlarmCenterPage extends StatefulWidget { @@ -21,17 +22,33 @@ class AlarmCenterPage extends StatefulWidget { class _AlarmCenterPageState extends State { late AlarmCubit _cubit; + final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _cubit = context.read(); - // 首次加载数据 + _scrollController.addListener(_onScroll); + if (_cubit.state is AlarmInitial) { _cubit.loadAlarms(); } } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + if (currentScroll >= maxScroll - 200) { + _cubit.loadMore(); + } + } + @override Widget build(BuildContext context) { return AnnotatedRegion( @@ -88,35 +105,33 @@ class _AlarmCenterPageState extends State { await _cubit.refresh(); }, child: CustomScrollView( + controller: _scrollController, slivers: [ SliverPadding( padding: const EdgeInsets.only( top: AlarmDimensions.moduleSpacing, - bottom: 40, ), sliver: SliverList( delegate: SliverChildListDelegate([ - //const SizedBox(height: AlarmDimensions.moduleSpacing), - // 告警统计卡片 - AlarmCountCard(count: state.count), + AlarmCountCard(count: state.count, isCountLoading: state.isCountLoading), const SizedBox( height: AlarmDimensions.moduleSpacing, ), - // 告警列表 - ...state.alarms.map((alarm) { - return AlarmItemCard( - alarm: alarm, - onTap: () { - context.push( - '/alarm_center/detail/${alarm.id}', - ); - }, - ); - }).toList(), + if (state.alarms.isNotEmpty) + ...state.alarms.map((alarm) { + return AlarmItemCard( + alarm: alarm, + onTap: () async { + await context.push( + '/alarm_center/detail/${alarm.id}', + ); + _cubit.loadAlarms(); + }, + ); + }).toList(), const SizedBox( height: AlarmDimensions.moduleSpacing, ), - // AI 诊断建议(仅当有 AI 诊断内容时显示) if (state.alarms.any( (alarm) => alarm.aiDiagnosis != null, )) @@ -130,10 +145,10 @@ class _AlarmCenterPageState extends State { .aiDiagnosis ?? '', onViewDetails: () { - // TODO: 查看详情 debugPrint('查看 AI 诊断详情'); }, ), + _buildLoadMoreIndicator(state), ]), ), ), @@ -262,15 +277,24 @@ class _AlarmCenterPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ - Text( - AppLocalizations.of(context).translate('alarm_center.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('alarm_center.title'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF86909C), + ), + ), + ], ), ), - const Spacer(), // 铃铛图标(消息通知) Stack( children: [ @@ -323,6 +347,48 @@ class _AlarmCenterPageState extends State { ); } + Widget _buildLoadMoreIndicator(AlarmLoaded state) { + if (state.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Column( + children: [ + CircularProgressIndicator(strokeWidth: 2), + SizedBox(height: 8), + Text( + '加载中...', + style: TextStyle(fontSize: 12, color: AlarmColors.textTertiary), + ), + ], + ), + ), + ); + } + + if (!state.hasMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + '已加载全部数据', + style: TextStyle(fontSize: 12, color: AlarmColors.textTertiary), + ), + ), + ); + } + + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + '上滑加载更多', + style: TextStyle(fontSize: 12, color: AlarmColors.textTertiary), + ), + ), + ); + } + Widget _buildFilterOption(String title, List options) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart b/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart index f57ef1a8..ada0ce4a 100644 --- a/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart +++ b/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart @@ -12,10 +12,7 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/widgets/ class AlarmDetailPage extends StatefulWidget { final String alarmId; - const AlarmDetailPage({ - super.key, - required this.alarmId, - }); + const AlarmDetailPage({super.key, required this.alarmId}); @override State createState() => _AlarmDetailPageState(); @@ -23,6 +20,7 @@ class AlarmDetailPage extends StatefulWidget { class _AlarmDetailPageState extends State { late AlarmDetailCubit _cubit; + AlarmDetailLoaded? _lastLoadedState; @override void initState() { @@ -48,19 +46,14 @@ class _AlarmDetailPageState extends State { _showAIDiagnosisDialog(state.aiDiagnosisResult!); } - // 确认告警成功后返回 + // 确认告警成功后显示提示 if (state is AlarmDetailLoaded && state.isConfirmed) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('告警已确认'), + content: Text('告警已确认,正在处理中'), backgroundColor: AlarmColors.success, ), ); - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - context.pop(); - } - }); } }, builder: (context, state) { @@ -73,10 +66,19 @@ class _AlarmDetailPageState extends State { } if (state is AlarmDetailLoaded) { + _lastLoadedState = state; return _buildContent(state); } if (state is AlarmDetailActionInProgress) { + if (_lastLoadedState != null) { + return Stack( + children: [ + _buildContent(_lastLoadedState!), + _buildLoadingOverlay(state.action), + ], + ); + } return _buildLoadingOverlay(state.action); } @@ -106,7 +108,10 @@ class _AlarmDetailPageState extends State { centerTitle: true, actions: [ IconButton( - icon: const Icon(Icons.share_outlined, color: AlarmColors.textPrimary), + icon: const Icon( + Icons.share_outlined, + color: AlarmColors.textPrimary, + ), onPressed: () { // TODO: 分享功能 }, @@ -140,9 +145,7 @@ class _AlarmDetailPageState extends State { borderRadius: BorderRadius.circular(12), ), child: Center( - child: CircularProgressIndicator( - color: AlarmColors.primary, - ), + child: CircularProgressIndicator(color: AlarmColors.primary), ), ); } @@ -193,9 +196,7 @@ class _AlarmDetailPageState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator( - color: AlarmColors.primary, - ), + CircularProgressIndicator(color: AlarmColors.primary), const SizedBox(height: 16), Text( '$action...', @@ -229,15 +230,17 @@ class _AlarmDetailPageState extends State { // 告警详情信息列表 _buildAlarmInfoList(alarm), const SizedBox(height: 16), - // 指标趋势图 - MetricTrendChart( - historyData: alarm.historyData, - selectedMetric: state.selectedMetric, - onMetricChanged: (metric) => _cubit.changeMetricType(metric), - ), + // 指标趋势图(仅当有历史数据时显示) + if (_hasHistoryData(alarm.historyData)) + MetricTrendChart( + historyData: alarm.historyData, + selectedMetric: state.selectedMetric, + onMetricChanged: (metric) => _cubit.changeMetricType(metric), + ), const SizedBox(height: 16), - // 处理建议 - _buildSuggestionsCard(alarm.suggestions), + // 处理建议(仅当有建议时显示) + if (alarm.suggestions.isNotEmpty) + _buildSuggestionsCard(alarm.suggestions), const SizedBox(height: 100), // 为底部按钮留出空间 ], ), @@ -323,6 +326,61 @@ class _AlarmDetailPageState extends State { /// 告警详情信息列表 Widget _buildAlarmInfoList(AlarmDetailEntity alarm) { + final List infoRows = []; + + void addRow(String label, dynamic value, {Color? valueColor}) { + if (value == null || value == '' || value == 0) return; + final displayValue = value is bool + ? (value ? '是' : '否') + : value.toString(); + infoRows.add(_buildInfoRow(label, displayValue, valueColor: valueColor)); + } + + addRow('告警编号', alarm.alarmNo); + addRow( + '告警状态', + alarm.alarmStatus, + valueColor: alarm.alarmStatus == '待处理' + ? AlarmColors.danger + : AlarmColors.success, + ); + addRow('发生时间', alarm.occurTime); + addRow('恢复时间', alarm.recoverTime); + addRow('持续时长', alarm.duration); + addRow( + '设备名称', + alarm.deviceInfo.isNotEmpty ? alarm.deviceInfo : alarm.deviceId, + ); + addRow( + '告警类型', + alarm.deviceType != null ? _mapDeviceType(alarm.deviceType!) : null, + ); + addRow('告警描述', alarm.description); + addRow('创建时间', alarm.createTime); + addRow('更新时间', alarm.updateTime); + addRow('处理人', alarm.handleUserName); + addRow('处理时间', alarm.handleTime); + addRow('处理备注', alarm.handleRemark); + addRow('根因分析', alarm.rootCause); + addRow('处理建议', alarm.handleSuggestion); + addRow('处理结果', alarm.handleResult); + addRow( + '短信通知', + alarm.notifySms != null ? (alarm.notifySms == 1 ? '是' : '否') : null, + ); + addRow( + '电报通知', + alarm.notifyTelegram != null + ? (alarm.notifyTelegram == 1 ? '是' : '否') + : null, + ); + addRow( + '邮件通知', + alarm.notifyEmail != null ? (alarm.notifyEmail == 1 ? '是' : '否') : null, + ); + + if (infoRows.isEmpty) return const SizedBox.shrink(); + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -331,25 +389,55 @@ class _AlarmDetailPageState extends State { boxShadow: [AlarmDimensions.cardShadow], ), child: Column( - children: [ - _buildInfoRow('告警状态', alarm.alarmStatus, - valueColor: alarm.alarmStatus == '未处理' ? AlarmColors.danger : AlarmColors.success), - _buildDivider(), - _buildInfoRow('发生时间', alarm.occurTime), - _buildDivider(), - _buildInfoRow('恢复时间', alarm.recoverTime ?? '--'), - _buildDivider(), - _buildInfoRow('持续时长', alarm.duration), - _buildDivider(), - _buildInfoRow('影响范围', alarm.affectRange), - _buildDivider(), - _buildInfoRow('告警描述', alarm.description, isLast: true), - ], + children: infoRows + .asMap() + .entries + .map( + (entry) => [ + entry.value, + if (entry.key < infoRows.length - 1) _buildDivider(), + ], + ) + .expand((e) => e) + .toList(), ), ); } - Widget _buildInfoRow(String label, String value, {Color? valueColor, bool isLast = false}) { + String _mapDeviceType(String type) { + final lowerType = type.toLowerCase(); + switch (lowerType) { + case 'uav_fault': + case 'uav': + return '无人机故障'; + case 'mower_fault': + case 'mower': + return '割草机故障'; + case 'other_device_fault': + case 'other_device': + return '其他设备故障'; + case 'server_failure': + case 'server': + return '服务器故障'; + case 'system_error': + case 'system': + return '系统错误'; + default: + if (lowerType.contains('uav')) return '无人机故障'; + if (lowerType.contains('mower')) return '割草机故障'; + if (lowerType.contains('other_device')) return '其他设备故障'; + if (lowerType.contains('server')) return '服务器故障'; + if (lowerType.contains('system')) return '系统错误'; + return type; + } + } + + Widget _buildInfoRow( + String label, + String value, { + Color? valueColor, + bool isLast = false, + }) { return Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : 12), child: Row( @@ -381,11 +469,13 @@ class _AlarmDetailPageState extends State { } Widget _buildDivider() { - return const Divider( - height: 24, - thickness: 0.5, - color: Color(0xFFE5E6EB), - ); + return const Divider(height: 24, thickness: 0.5, color: Color(0xFFE5E6EB)); + } + + bool _hasHistoryData(HistoryMetrics historyData) { + return historyData.power.isNotEmpty || + historyData.voltage.isNotEmpty || + historyData.frequency.isNotEmpty; } /// 处理建议卡片 @@ -444,9 +534,10 @@ class _AlarmDetailPageState extends State { /// 底部按钮 Widget _buildBottomButtons(AlarmDetailLoaded state) { - final isConfirmed = state.isConfirmed; + final alarmStatus = state.alarmDetail.alarmStatus; final isInProgress = _cubit.state is AlarmDetailActionInProgress; - final isDisabled = isConfirmed || isInProgress; + final isPending = alarmStatus == '待处理'; + final isDisabled = !isPending || isInProgress; return Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), @@ -467,26 +558,68 @@ class _AlarmDetailPageState extends State { child: _buildButton( text: 'AI诊断', isPrimary: false, - isLoading: isInProgress && _cubit.state is AlarmDetailActionInProgress && - (_cubit.state as AlarmDetailActionInProgress).action == 'AI诊断', - isDisabled: isDisabled, - onPressed: isDisabled + isLoading: + isInProgress && + _cubit.state is AlarmDetailActionInProgress && + (_cubit.state as AlarmDetailActionInProgress).action == + 'AI诊断', + isDisabled: isInProgress, + onPressed: isInProgress ? null : () => _cubit.aiDiagnosis(widget.alarmId), ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), // 确认告警按钮 Expanded( child: _buildButton( - text: '确认告警', + text: isPending ? '确认告警' : '已确认', isPrimary: true, - isLoading: isInProgress && _cubit.state is AlarmDetailActionInProgress && - (_cubit.state as AlarmDetailActionInProgress).action == '确认告警', - isDisabled: isDisabled, - onPressed: isDisabled - ? null - : () => _cubit.confirmAlarm(widget.alarmId), + isLoading: + isInProgress && + _cubit.state is AlarmDetailActionInProgress && + (_cubit.state as AlarmDetailActionInProgress).action == + '确认告警', + isDisabled: true, + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('暂未开放'), + backgroundColor: AlarmColors.warning, + ), + ); + }, + ), + ), + const SizedBox(width: 12), + // 去处理按钮 + SizedBox( + width: 88, + height: 48, + child: ElevatedButton( + onPressed: () async { + await context.push( + '/alarm_center/detail/${widget.alarmId}/handle', + ); + _cubit.loadAlarmDetail(widget.alarmId); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AlarmColors.primary, + elevation: 0, + side: const BorderSide(color: AlarmColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + ), + child: Text( + alarmStatus == '已关闭' ? '已处理' : '去处理', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), ), ), ], @@ -510,9 +643,7 @@ class _AlarmDetailPageState extends State { foregroundColor: isPrimary ? Colors.white : AlarmColors.primary, elevation: 0, side: isPrimary ? null : const BorderSide(color: AlarmColors.primary), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: isLoading ? SizedBox( @@ -526,8 +657,8 @@ class _AlarmDetailPageState extends State { : Text( text, style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + fontSize: 13, + fontWeight: FontWeight.w500, ), ), ), @@ -542,25 +673,16 @@ class _AlarmDetailPageState extends State { return AlertDialog( title: const Text( 'AI诊断结果', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), content: SingleChildScrollView( child: Text( result, - style: const TextStyle( - fontSize: 14, - height: 1.6, - ), + style: const TextStyle(fontSize: 14, height: 1.6), ), ), actions: [ - TextButton( - onPressed: () => context.pop(), - child: const Text('关闭'), - ), + TextButton(onPressed: () => context.pop(), child: const Text('关闭')), ], ); }, diff --git a/lib/features/v2/waring_center/presentation/pages/alarm_dispatch_page.dart b/lib/features/v2/waring_center/presentation/pages/alarm_dispatch_page.dart new file mode 100644 index 00000000..9e19c32c --- /dev/null +++ b/lib/features/v2/waring_center/presentation/pages/alarm_dispatch_page.dart @@ -0,0 +1,560 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../domain/entities/alarm_detail_entity.dart'; +import '../../domain/usecases/dispatch_workorder_usecase.dart'; +import '../../data/datasources/alarm_dispatch_remote_datasource.dart'; +import '../../data/datasources/impl/alarm_dispatch_remote_datasource_impl.dart'; +import '../../data/repositories/alarm_dispatch_repository_impl.dart'; +import '../cubit/alarm_dispatch_cubit.dart'; +import '../states/alarm_dispatch_state.dart'; +import '../../../report/presentation/constants/report_constants.dart'; +import '../../../report/presentation/widgets/report_type_selector.dart'; +import '../../../report/presentation/widgets/device_selector.dart'; +import '../../../report/presentation/widgets/description_input.dart'; +import '../../../report/presentation/widgets/media_uploader.dart'; +import '../../../report/presentation/widgets/level_selector.dart'; +import '../../../report/presentation/pages/media_preview_page.dart'; +import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; +import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; + +/// 告警派发工单页面 +class AlarmDispatchPage extends StatelessWidget { + final AlarmDetailEntity alarmDetail; + + const AlarmDispatchPage({super.key, required this.alarmDetail}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => _createCubit(alarmDetail), + child: _AlarmDispatchPageContent(), + ); + } + + AlarmDispatchCubit _createCubit(AlarmDetailEntity alarmDetail) { + final sl = GetIt.I; + final datasource = AlarmDispatchRemoteDataSourceImpl(); + final repository = AlarmDispatchRepositoryImpl( + remoteDataSource: datasource, + ); + final useCase = DispatchWorkOrderUseCase(repository); + return AlarmDispatchCubit( + dispatchUseCase: useCase, + alarmDetail: alarmDetail, + dio: sl(), + appUserCubit: sl(), + userStorage: sl(), + ); + } +} + +class _AlarmDispatchPageContent extends StatelessWidget { + const _AlarmDispatchPageContent(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.cardBackground, + appBar: _buildAppBar(context), + body: BlocConsumer( + listener: (context, state) { + if (state is AlarmDispatchFormState && state.toastMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.toastMessage!), + backgroundColor: state.isSuccess ? Colors.green : Colors.red, + duration: const Duration(seconds: 2), + ), + ); + context.read().clearToast(); + if (state.isSuccess) { + context.pop(); + } + } + }, + builder: (context, state) { + if (state is AlarmDispatchSubmitting) { + return const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ); + } + + if (state is AlarmDispatchLoading) { + return const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ); + } + + if (state is AlarmDispatchFormState) { + return _buildForm(context, state); + } + + return const SizedBox.shrink(); + }, + ), + ); + } + + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + backgroundColor: AppColors.background, + elevation: 0, + title: const Text( + '告警派发工单', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, size: 20, color: Colors.black), + onPressed: () => context.pop(), + ), + actions: [ + TextButton( + onPressed: () => context.read().submitDispatch(), + child: const Text( + '提交', + style: TextStyle( + color: AppColors.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + Widget _buildForm(BuildContext context, AlarmDispatchFormState state) { + final cubit = context.read(); + + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppDimensions.moduleSpacing), + // 场站信息 + _buildSiteInfoCard(state), + const SizedBox(height: AppDimensions.moduleSpacing), + // 告警关联信息条 + _buildAlarmInfoBar(state), + const SizedBox(height: AppDimensions.moduleSpacing), + // 上报类型选择 + ReportTypeSelector( + selectedType: state.selectedReportType, + onSelected: (type) => cubit.selectReportType(type), + ), + const SizedBox(height: AppDimensions.moduleSpacing), + // 设备选择 + DeviceSelector( + selectedDevice: state.selectedDevice, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('设备已从告警自动关联,暂不支持修改'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + const SizedBox(height: AppDimensions.moduleSpacing), + // 问题描述 + DescriptionInput( + description: state.entity.description, + onChanged: (value) => cubit.updateDescription(value), + ), + const SizedBox(height: AppDimensions.moduleSpacing), + // 媒体上传 + MediaUploader( + mediaFiles: state.mediaFiles, + onCameraTap: () => _pickImage(cubit), + onVideoTap: () => _pickVideo(cubit), + onAddFromGallery: () => _pickFromGallery(context, cubit), + onTap: (index) { + _previewMedia(context, state.mediaFiles, index); + }, + onRemove: (index) => cubit.removeMediaFile(index), + ), + const SizedBox(height: AppDimensions.moduleSpacing), + // 计划时间 + _buildPlanTimeSection(context, state, cubit), + const SizedBox(height: AppDimensions.moduleSpacing), + // 问题等级 + LevelSelector( + selectedLevel: state.selectedProblemLevel, + onSelected: (level) => cubit.selectProblemLevel(level), + ), + const SizedBox(height: 40), + ], + ), + ); + } + + /// 场站信息卡片 + Widget _buildSiteInfoCard(AlarmDispatchFormState state) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: AppDimensions.horizontalPadding, + ), + padding: const EdgeInsets.all(14), + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.location_on, size: 18, color: AppColors.primary), + SizedBox(width: 6), + Text( + '场站信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + // _buildInfoLabel('场站ID'), + // const SizedBox(width: 8), + // Text( + // state.siteId != null ? '${state.siteId}' : '--', + // style: const TextStyle( + // fontSize: 14, + // color: AppColors.textPrimary, + // fontWeight: FontWeight.w500, + // ), + // ), + // const SizedBox(width: 24), + _buildInfoLabel('场站名称'), + const SizedBox(width: 8), + Expanded( + child: Text( + state.siteName ?? '加载中...', + style: TextStyle( + fontSize: 14, + color: state.siteName != null + ? AppColors.textPrimary + : AppColors.textHint, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _buildInfoLabel('告警编号'), + const SizedBox(width: 8), + Expanded( + child: Text( + state.alarmNo ?? state.alarmId, + style: const TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildInfoLabel(String label) { + return Text( + label, + style: const TextStyle(fontSize: 12, color: AppColors.textHint), + ); + } + + /// 告警关联信息条 + Widget _buildAlarmInfoBar(AlarmDispatchFormState state) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: AppDimensions.horizontalPadding, + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFFF7E6), + borderRadius: BorderRadius.circular(AppDimensions.borderRadiusSmall), + border: Border.all(color: const Color(0xFFFFD591)), + ), + child: Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFFA8C16), + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '关联告警: ${state.alarmNo ?? state.alarmId}', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF8C6E1F), + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.chevron_right, size: 18, color: Color(0xFFFA8C16)), + ], + ), + ); + } + + /// 计划时间区域 + Widget _buildPlanTimeSection( + BuildContext context, + AlarmDispatchFormState state, + AlarmDispatchCubit cubit, + ) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: AppDimensions.horizontalPadding, + ), + padding: const EdgeInsets.all(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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '计划时间', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 12), + _buildTimeRow( + label: '计划开始', + time: state.planStartTime, + onTap: () => _pickDateTime(context, (time) { + cubit.updatePlanStartTime(time); + }), + required: true, + ), + // const SizedBox(height: 8), + // _buildTimeRow( + // label: '计划结束', + // time: state.planEndTime, + // onTap: () => _pickDateTime(context, (time) { + // cubit.updatePlanEndTime(time); + // }), + // required: true, + // ), + ], + ), + ); + } + + Widget _buildTimeRow({ + required String label, + required DateTime? time, + required VoidCallback onTap, + required bool required, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(AppDimensions.borderRadiusSmall), + border: Border.all(color: AppColors.borderColor), + ), + child: Row( + children: [ + Text( + '$label${required ? ' *' : ''}', + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + const Spacer(), + Text( + time != null + ? '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} ' + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}' + : '请选择', + style: TextStyle( + fontSize: 14, + color: time != null + ? AppColors.textPrimary + : AppColors.textHint, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.calendar_today, + size: 16, + color: AppColors.textHint, + ), + ], + ), + ), + ); + } + + Future _pickDateTime( + BuildContext context, + Function(DateTime) onPicked, + ) async { + final date = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 30)), + lastDate: DateTime.now().add(const Duration(days: 365)), + helpText: '选择日期', + cancelText: '取消', + confirmText: '确定', + ); + if (date == null || !context.mounted) return; + + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + helpText: '选择时间', + cancelText: '取消', + confirmText: '确定', + ); + if (time == null) return; + + final dateTime = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + onPicked(dateTime); + } + + Future _pickImage(AlarmDispatchCubit cubit) async { + final picker = ImagePicker(); + try { + final image = await picker.pickImage( + source: ImageSource.camera, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + if (image != null) { + cubit.addMediaFile(image.path); + } + } catch (e) { + // ignore + } + } + + Future _pickVideo(AlarmDispatchCubit cubit) async { + final picker = ImagePicker(); + try { + final video = await picker.pickVideo( + source: ImageSource.camera, + maxDuration: const Duration(minutes: 5), + ); + if (video != null) { + cubit.addMediaFile(video.path); + } + } catch (e) { + // ignore + } + } + + void _previewMedia(BuildContext context, List mediaFiles, int index) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + MediaPreviewPage(mediaFiles: mediaFiles, initialIndex: index), + ), + ); + } + + Future _pickFromGallery( + BuildContext context, + AlarmDispatchCubit cubit, + ) async { + final picker = ImagePicker(); + try { + await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.image), + title: const Text('选择图片'), + onTap: () async { + Navigator.pop(context); + final image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1080, + imageQuality: 85, + ); + if (image != null) cubit.addMediaFile(image.path); + }, + ), + ListTile( + leading: const Icon(Icons.video_library), + title: const Text('选择视频'), + onTap: () async { + Navigator.pop(context); + final video = await picker.pickVideo( + source: ImageSource.gallery, + ); + if (video != null) cubit.addMediaFile(video.path); + }, + ), + ], + ), + ), + ); + } catch (e) { + // ignore + } + } +} diff --git a/lib/features/v2/waring_center/presentation/pages/alarm_handle_page.dart b/lib/features/v2/waring_center/presentation/pages/alarm_handle_page.dart new file mode 100644 index 00000000..5dbb6bee --- /dev/null +++ b/lib/features/v2/waring_center/presentation/pages/alarm_handle_page.dart @@ -0,0 +1,639 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/di/injection.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_detail_entity.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart'; + +class AlarmHandlePage extends StatelessWidget { + final String alarmId; + + const AlarmHandlePage({super.key, required this.alarmId}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => sl()..loadAlarmDetail(alarmId), + child: _AlarmHandlePageContent(alarmId: alarmId), + ); + } +} + +class _AlarmHandlePageContent extends StatefulWidget { + final String alarmId; + + const _AlarmHandlePageContent({required this.alarmId}); + + @override + State<_AlarmHandlePageContent> createState() => + _AlarmHandlePageContentState(); +} + +class _AlarmHandlePageContentState extends State<_AlarmHandlePageContent> { + final TextEditingController _handlerController = TextEditingController(); + final TextEditingController _remarkController = TextEditingController(); + final TextEditingController _rootCauseController = TextEditingController(); + String _handleStatus = '已关闭'; + String _handleResult = ''; + String _currentAlarmStatus = '待处理'; + int? _selectedActionIndex; + + final Map _handleResultMap = { + 'Handled': '已处理', + 'Restored': '已恢复', + 'Ignored': '已忽略', + 'UnableToHandle': '无法处理', + 'ManufacturerHandling': '厂家处理中', + 'FalseAlarm': '误报', + }; + + final List> _actionButtons = [ + { + 'label': '确认告警', + 'icon': Icons.check_circle_outline, + 'color': const Color(0xFF52C41A), + 'bgColor': const Color(0xFFF6FFED), + 'confirmedLabel': '已确认告警', + 'confirmedTip': '确认无需', + 'unconfirmedTip': '确认无需', + }, + { + 'label': '派发工单', + 'icon': Icons.file_copy_outlined, + 'color': const Color(0xFF1890FF), + 'bgColor': const Color(0xFFE6F7FF), + 'tip': '请填下方表格', + }, + { + 'label': '联系负责人', + 'icon': Icons.person_outline, + 'color': const Color(0xFFFA8C16), + 'bgColor': const Color(0xFFFFF7E6), + 'tip': '暂无功能', + }, + { + 'label': '拍照取证', + 'icon': Icons.camera_alt_outlined, + 'color': const Color(0xFF722ED1), + 'bgColor': const Color(0xFFF9F0FF), + 'tip': '暂不支持', + }, + { + 'label': '备注记录', + 'icon': Icons.edit_note_outlined, + 'color': const Color(0xFF13C2C2), + 'bgColor': const Color(0xFFE6FFFB), + 'tip': '请填下方表格', + }, + { + 'label': '更多操作', + 'icon': Icons.more_horiz, + 'color': const Color(0xFF8C8C8C), + 'bgColor': const Color(0xFFF5F5F5), + 'tip': '暂未开发', + }, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: _buildAppBar(), + body: BlocConsumer( + listener: (context, state) { + if (state is AlarmDetailLoaded && state.isHandleSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('处理成功'), + backgroundColor: Color(0xFF52C41A), + duration: Duration(seconds: 1), + ), + ); + Future.delayed(const Duration(seconds: 1), () { + context.pop(); + }); + } + }, + builder: (context, state) { + if (state is AlarmDetailLoaded) { + _currentAlarmStatus = state.alarmDetail.alarmStatus; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildAlarmInfoCard(state.alarmDetail), + const SizedBox(height: 16), + _buildActionButtons(), + const SizedBox(height: 16), + _buildFormCard(), + const SizedBox(height: 32), + ], + ), + ); + } + + if (state is AlarmDetailError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: Color(0xFFBFBFBF), + ), + const SizedBox(height: 16), + Text( + state.message, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF8C8C8C), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => context + .read() + .loadAlarmDetail(widget.alarmId), + icon: const Icon(Icons.refresh), + label: const Text('重试'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1890FF), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + ), + ], + ), + ); + } + + return const Center(child: CircularProgressIndicator()); + }, + ), + bottomNavigationBar: _buildBottomSubmitButton(), + ); + } + + AppBar _buildAppBar() { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black, size: 20), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + '告警处理', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + backgroundColor: Colors.white, + elevation: 0, + ); + } + + Widget _buildAlarmInfoCard(AlarmDetailEntity alarm) { + final levelColor = alarm.level == AlarmLevel.danger + ? const Color(0xFFFF4D4F) + : alarm.level == AlarmLevel.warning + ? const Color(0xFFFA8C16) + : alarm.level == AlarmLevel.low + ? const Color(0xFF1890FF) + : const Color(0xFF52C41A); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning, color: levelColor, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + alarm.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: levelColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + alarm.level.label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: levelColor, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '${alarm.alarmNo ?? alarm.id} | ${alarm.deviceInfo} | ${alarm.occurTime}', + style: const TextStyle(fontSize: 13, color: Color(0xFF8C8C8C)), + ), + ], + ), + ); + } + + Widget _buildActionButtons() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 1, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _actionButtons.length, + itemBuilder: (context, index) { + final button = _actionButtons[index]; + return _buildActionButton(button, index); + }, + ), + ); + } + + Widget _buildActionButton(Map button, int index) { + final isConfirmed = _currentAlarmStatus != '待处理'; + final isSelected = _selectedActionIndex == index; + String displayLabel = button['label'] as String; + + if (index == 0 && isConfirmed && button.containsKey('confirmedLabel')) { + displayLabel = button['confirmedLabel'] as String; + } + + return GestureDetector( + onTap: () => _onActionButtonTap(button, index), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected + ? (button['color'] as Color) + : (button['bgColor'] as Color), + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all(color: button['color'] as Color, width: 2) + : Border.all(color: Colors.transparent), + ), + child: Icon( + button['icon'] as IconData, + color: isSelected ? Colors.white : (button['color'] as Color), + size: 24, + ), + ), + const SizedBox(height: 8), + Text( + displayLabel, + style: TextStyle( + fontSize: 12, + color: button['color'] as Color, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ); + } + + void _onActionButtonTap(Map button, int index) { + setState(() { + _selectedActionIndex = index; + }); + + // 确认告警:暂未开放 + if (index == 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('暂未开放'), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + return; + } + + // 派发工单:跳转到告警派发页面 + if (index == 1) { + final cubitState = context.read().state; + if (cubitState is AlarmDetailLoaded) { + context.push( + '/alarm_center/detail/${widget.alarmId}/handle/dispatch', + extra: cubitState.alarmDetail, + ); + return; + } + } + + String tip = button['tip'] as String? ?? ''; + + if (tip.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tip), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } + } + + Widget _buildFormCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + _buildFormRow( + label: '处理状态', + child: _buildDropdown( + value: _handleStatus, + items: const ['已关闭'], + onChanged: (value) { + setState(() => _handleStatus = value!); + }, + ), + ), + _buildDivider(), + _buildFormRow(label: '处理结果', child: _buildHandleResultDropdown()), + _buildDivider(), + _buildFormRow( + label: '根因分析', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _rootCauseController, + maxLines: 4, + maxLength: 200, + decoration: const InputDecoration( + hintText: '请输入根因分析,最多200字', + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + isDense: false, + alignLabelWithHint: true, + ), + style: const TextStyle(fontSize: 14, color: Colors.black), + ), + Align( + alignment: Alignment.bottomRight, + child: Text( + '${_rootCauseController.text.length}/200', + style: const TextStyle( + fontSize: 12, + color: Color(0xFFBFBFBF), + ), + ), + ), + ], + ), + ), + _buildDivider(), + _buildFormRow( + label: '处理人', + child: TextField( + controller: _handlerController, + decoration: const InputDecoration( + hintText: '请输入处理人', + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + isDense: false, + ), + style: const TextStyle(fontSize: 14, color: Colors.black), + ), + ), + _buildDivider(), + _buildFormRow( + label: '备注说明', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _remarkController, + maxLines: 4, + maxLength: 200, + decoration: const InputDecoration( + hintText: '请输入处理备注,最多200字', + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + isDense: false, + alignLabelWithHint: true, + ), + style: const TextStyle(fontSize: 14, color: Colors.black), + ), + Align( + alignment: Alignment.bottomRight, + child: Text( + '${_remarkController.text.length}/200', + style: const TextStyle( + fontSize: 12, + color: Color(0xFFBFBFBF), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFormRow({required String label, required Widget child}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 72, + child: Text( + label, + style: const TextStyle(fontSize: 14, color: Color(0xFF8C8C8C)), + ), + ), + const SizedBox(width: 12), + Expanded(child: child), + ], + ), + ); + } + + Widget _buildDropdown({ + required String value, + required List items, + String? hintText, + required ValueChanged onChanged, + }) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + items: items.map((item) { + return DropdownMenuItem( + value: item, + child: Text( + item.isEmpty ? hintText ?? '' : item, + style: TextStyle( + fontSize: 14, + color: item.isEmpty ? const Color(0xFFBFBFBF) : Colors.black, + ), + ), + ); + }).toList(), + onChanged: onChanged, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + isDense: false, + ), + style: const TextStyle(fontSize: 14, color: Colors.black), + icon: const Icon(Icons.keyboard_arrow_down, color: Color(0xFFBFBFBF)), + ); + } + + Widget _buildHandleResultDropdown() { + return DropdownButtonFormField( + value: _handleResult.isEmpty ? null : _handleResult, + items: [ + const DropdownMenuItem( + value: '', + child: Text( + '请选择处理结果', + style: TextStyle(fontSize: 14, color: Color(0xFFBFBFBF)), + ), + ), + ..._handleResultMap.entries.map((entry) { + return DropdownMenuItem( + value: entry.key, + child: Text( + entry.value, + style: const TextStyle(fontSize: 14, color: Colors.black), + ), + ); + }).toList(), + ], + onChanged: (value) { + setState(() => _handleResult = value ?? ''); + }, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 12), + isDense: false, + ), + style: const TextStyle(fontSize: 14, color: Colors.black), + icon: const Icon(Icons.keyboard_arrow_down, color: Color(0xFFBFBFBF)), + ); + } + + Widget _buildDivider() { + return const Divider(height: 1, color: Color(0xFFF0F0F0)); + } + + Widget _buildBottomSubmitButton() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SizedBox( + height: 56, + child: ElevatedButton( + onPressed: _submitHandle, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1890FF), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '提交处理', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + ), + ), + ); + } + + void _submitHandle() { + int handleStatusValue = 2; + debugPrint('提交处理 - handleStatus: $handleStatusValue'); + + final handleData = { + 'alarmId': widget.alarmId, + 'handleStatus': handleStatusValue, + 'handleRemark': _remarkController.text, + 'rootCause': _rootCauseController.text, + 'handleResult': _handleResult, + }; + + context.read().handleAlarm(handleData); + } +} diff --git a/lib/features/v2/waring_center/presentation/routes/alarm_center_routes.dart b/lib/features/v2/waring_center/presentation/routes/alarm_center_routes.dart index 31ac769c..244175da 100644 --- a/lib/features/v2/waring_center/presentation/routes/alarm_center_routes.dart +++ b/lib/features/v2/waring_center/presentation/routes/alarm_center_routes.dart @@ -2,36 +2,59 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/features/v2/message_center/presentation/pages/message_center_page.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_detail_entity.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/pages/alarm_center_page.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/pages/alarm_detail_page.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/pages/alarm_handle_page.dart'; +import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/pages/alarm_dispatch_page.dart'; /// 告警中心路由配置 class AlarmCenterRoutes { static List get routes => [ + GoRoute( + path: '/alarm_center', + name: 'alarmCenter', + builder: (context, state) => const AlarmCenterPage(), + routes: [ GoRoute( - path: '/alarm_center', - name: 'alarmCenter', - builder: (context, state) => const AlarmCenterPage(), + path: 'detail/:alarmId', + name: 'alarmDetail', + builder: (context, state) { + final alarmId = state.pathParameters['alarmId']!; + return BlocProvider( + create: (_) => sl(), + child: AlarmDetailPage(alarmId: alarmId), + ); + }, routes: [ GoRoute( - path: 'detail/:alarmId', - name: 'alarmDetail', + path: 'handle', + name: 'alarmHandle', builder: (context, state) { final alarmId = state.pathParameters['alarmId']!; - return BlocProvider( - create: (_) => sl(), - child: AlarmDetailPage(alarmId: alarmId), - ); + return AlarmHandlePage(alarmId: alarmId); }, + routes: [ + GoRoute( + path: 'dispatch', + name: 'alarmDispatch', + builder: (context, state) { + final alarmDetail = state.extra as AlarmDetailEntity; + return AlarmDispatchPage(alarmDetail: alarmDetail); + }, + ), + ], ), ], ), - // 消息中心页路由 - GoRoute( - path: '/message_center', - name: 'messageCenter', - builder: (context, state) => const MessageCenterPage(), - ), - ]; + ], + ), + // 消息中心页路由 + GoRoute( + path: '/message_center', + name: 'messageCenter', + builder: (context, state) => const MessageCenterPage(), + ), + ]; } diff --git a/lib/features/v2/waring_center/presentation/states/alarm_dispatch_state.dart b/lib/features/v2/waring_center/presentation/states/alarm_dispatch_state.dart new file mode 100644 index 00000000..ccd961ca --- /dev/null +++ b/lib/features/v2/waring_center/presentation/states/alarm_dispatch_state.dart @@ -0,0 +1,129 @@ +import 'package:equatable/equatable.dart'; +import '../../domain/entities/alarm_dispatch_entity.dart'; +import '../../../report/presentation/constants/report_constants.dart'; + +/// 告警派发状态基类 +abstract class AlarmDispatchState extends Equatable { + const AlarmDispatchState(); + + @override + List get props => []; +} + +/// 加载中 +class AlarmDispatchLoading extends AlarmDispatchState {} + +/// 提交中 +class AlarmDispatchSubmitting extends AlarmDispatchState {} + +/// 表单状态 +class AlarmDispatchFormState extends AlarmDispatchState { + /// 关联的告警ID + final String alarmId; + + /// 告警编号 + final String? alarmNo; + + /// 派发实体 + final AlarmDispatchEntity entity; + + /// 选中的上报类型 + final ReportType? selectedReportType; + + /// 选中的问题等级 + final ProblemLevel? selectedProblemLevel; + + /// 选中的设备 + final String? selectedDevice; + + /// 选中的设备ID + final String? selectedDeviceId; + + /// 媒体文件列表 + final List mediaFiles; + + /// 计划开始时间 + final DateTime? planStartTime; + + /// 计划结束时间 + final DateTime? planEndTime; + + /// toast 消息(成功/失败) + final String? toastMessage; + + /// 场站ID(展示用) + final int? siteId; + + /// 场站名称(展示用) + final String? siteName; + + /// 是否为成功消息 + final bool isSuccess; + + const AlarmDispatchFormState({ + required this.alarmId, + this.alarmNo, + required this.entity, + this.selectedReportType, + this.selectedProblemLevel, + this.selectedDevice, + this.selectedDeviceId, + this.mediaFiles = const [], + this.planStartTime, + this.planEndTime, + this.toastMessage, + this.isSuccess = false, + this.siteId, + this.siteName, + }); + + AlarmDispatchFormState copyWith({ + AlarmDispatchEntity? entity, + ReportType? selectedReportType, + ProblemLevel? selectedProblemLevel, + String? selectedDevice, + String? selectedDeviceId, + List? mediaFiles, + DateTime? planStartTime, + DateTime? planEndTime, + String? toastMessage, + bool? isSuccess, + int? siteId, + String? siteName, + }) { + return AlarmDispatchFormState( + alarmId: alarmId, + alarmNo: alarmNo, + entity: entity ?? this.entity, + selectedReportType: selectedReportType ?? this.selectedReportType, + selectedProblemLevel: selectedProblemLevel ?? this.selectedProblemLevel, + selectedDevice: selectedDevice ?? this.selectedDevice, + selectedDeviceId: selectedDeviceId ?? this.selectedDeviceId, + mediaFiles: mediaFiles ?? this.mediaFiles, + planStartTime: planStartTime ?? this.planStartTime, + planEndTime: planEndTime ?? this.planEndTime, + toastMessage: toastMessage, + isSuccess: isSuccess ?? this.isSuccess, + siteId: siteId ?? this.siteId, + siteName: siteName ?? this.siteName, + ); + } + + @override + List get props => [ + alarmId, + alarmNo, + entity, + selectedReportType, + selectedProblemLevel, + selectedDevice, + selectedDeviceId, + mediaFiles, + planStartTime, + planEndTime, + toastMessage, + isSuccess, + siteId, + siteName, + ]; +} diff --git a/lib/features/v2/waring_center/presentation/widgets/alarm_count_card.dart b/lib/features/v2/waring_center/presentation/widgets/alarm_count_card.dart index 74956cc9..226a2298 100644 --- a/lib/features/v2/waring_center/presentation/widgets/alarm_count_card.dart +++ b/lib/features/v2/waring_center/presentation/widgets/alarm_count_card.dart @@ -3,11 +3,15 @@ import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_count_entity.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart'; -/// 告警统计卡片 class AlarmCountCard extends StatelessWidget { - const AlarmCountCard({super.key, required this.count}); + const AlarmCountCard({ + super.key, + required this.count, + this.isCountLoading = false, + }); final AlarmCountEntity count; + final bool isCountLoading; @override Widget build(BuildContext context) { @@ -25,9 +29,7 @@ class AlarmCountCard extends StatelessWidget { children: [ Expanded( child: _buildCountItem( - label: AppLocalizations.of( - context, - ).translate('alarm_center.unprocessed'), + label: '待处理', value: count.unprocessed, color: AlarmColors.danger, bgColor: AlarmColors.unprocessedBg, @@ -37,15 +39,7 @@ class AlarmCountCard extends StatelessWidget { _buildDivider(), Expanded( child: _buildCountItem( - label: '处理中', - value: count.processing, - color: AlarmColors.textPrimary, - ), - ), - _buildDivider(), - Expanded( - child: _buildCountItem( - label: '已确认', + label: '已关闭', value: count.confirmed, color: const Color(0xFF1D2129), ), @@ -88,14 +82,25 @@ class AlarmCountCard extends StatelessWidget { ), ), const SizedBox(height: 4), - Text( - value.toString(), - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: color, - ), - ), + isCountLoading + ? const SizedBox( + height: 24, + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + : Text( + value.toString(), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: color, + ), + ), ], ), ); diff --git a/lib/features/v2/waring_center/presentation/widgets/alarm_item_card.dart b/lib/features/v2/waring_center/presentation/widgets/alarm_item_card.dart index cc3ff72c..e57e8a72 100644 --- a/lib/features/v2/waring_center/presentation/widgets/alarm_item_card.dart +++ b/lib/features/v2/waring_center/presentation/widgets/alarm_item_card.dart @@ -85,12 +85,10 @@ class AlarmItemCard extends StatelessWidget { ), const Spacer(), Text( - _getStatusLabel(context, alarm.status), + alarm.status.label, style: TextStyle( fontSize: 12, - color: alarm.status == AlarmStatus.unconfirmed - ? AlarmColors.warning - : AlarmColors.textTertiary, + color: _getStatusColor(alarm.status), fontWeight: FontWeight.w500, ), ), @@ -98,9 +96,7 @@ class AlarmItemCard extends StatelessWidget { Icon( Icons.arrow_forward_ios, size: 12, - color: alarm.status == AlarmStatus.unconfirmed - ? AlarmColors.warning - : AlarmColors.textTertiary, + color: _getStatusColor(alarm.status), ), ], ), @@ -125,16 +121,14 @@ class AlarmItemCard extends StatelessWidget { } } - String _getStatusLabel(BuildContext context, AlarmStatus status) { + Color _getStatusColor(AlarmStatus status) { switch (status) { - case AlarmStatus.unconfirmed: - return AppLocalizations.of( - context, - ).translate('alarm_center.unconfirmed'); - case AlarmStatus.confirmed: - return AppLocalizations.of(context).translate('alarm_center.confirmed'); - case AlarmStatus.recovered: - return AppLocalizations.of(context).translate('alarm_center.recovered'); + case AlarmStatus.pending: + return AlarmColors.danger; + case AlarmStatus.processing: + case AlarmStatus.closed: + case AlarmStatus.ignored: + return AlarmColors.textTertiary; } } } diff --git a/lib/features/v2/waring_center/presentation/widgets/alarm_tab_bar.dart b/lib/features/v2/waring_center/presentation/widgets/alarm_tab_bar.dart index 18e40189..a8793f0b 100644 --- a/lib/features/v2/waring_center/presentation/widgets/alarm_tab_bar.dart +++ b/lib/features/v2/waring_center/presentation/widgets/alarm_tab_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart'; -/// 告警筛选标签栏 +/// 告警筛选标签栏 - 全部/未处理/已确认/已恢复 class AlarmTabBar extends StatelessWidget { const AlarmTabBar({ super.key, @@ -15,6 +15,14 @@ class AlarmTabBar extends StatelessWidget { final ValueChanged onFilterChanged; final Color backgroundColor; + // 待显示的标签列表(已注释掉"已确认"标签) + static const List _visibleTabs = [ + AlarmFilterTab.all, + AlarmFilterTab.unprocessed, + // AlarmFilterTab.confirmed, // 已确认 - 暂不显示 + AlarmFilterTab.recovered, + ]; + @override Widget build(BuildContext context) { return Container( @@ -23,44 +31,14 @@ class AlarmTabBar extends StatelessWidget { child: ListView.builder( scrollDirection: Axis.horizontal, padding: EdgeInsets.zero, - itemCount: AlarmFilterTab.values.length, + itemCount: _visibleTabs.length, itemBuilder: (context, index) { - final filter = AlarmFilterTab.values[index]; - final isSelected = selectedFilter == filter; - - return GestureDetector( + final filter = _visibleTabs[index]; + return _buildTabItem( + context, + label: _getFilterLabel(context, filter), + isSelected: selectedFilter == filter, onTap: () => onFilterChanged(filter), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _getFilterLabel(context, filter), - style: TextStyle( - fontSize: 15, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - color: isSelected - ? AlarmColors.primary - : AlarmColors.textSecondary, - ), - ), - const SizedBox(height: 4), - Container( - height: 2, - width: 20, - decoration: BoxDecoration( - color: isSelected - ? AlarmColors.primary - : Colors.transparent, - borderRadius: BorderRadius.circular(1), - ), - ), - ], - ), - ), ); }, ), @@ -72,13 +50,49 @@ class AlarmTabBar extends StatelessWidget { case AlarmFilterTab.all: return AppLocalizations.of(context).translate('alarm_center.all'); case AlarmFilterTab.unprocessed: - return AppLocalizations.of( - context, - ).translate('alarm_center.unprocessed'); + return '待处理'; case AlarmFilterTab.confirmed: return AppLocalizations.of(context).translate('alarm_center.confirmed'); case AlarmFilterTab.recovered: - return AppLocalizations.of(context).translate('alarm_center.recovered'); + return '已关闭(已恢复)'; } } + + Widget _buildTabItem( + BuildContext context, { + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? AlarmColors.primary + : AlarmColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Container( + height: 2, + width: 20, + decoration: BoxDecoration( + color: isSelected ? AlarmColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/features/v2/work_order/data/datasources/work_order_remote_datasource.dart b/lib/features/v2/work_order/data/datasources/work_order_remote_datasource.dart new file mode 100644 index 00000000..20fd17d0 --- /dev/null +++ b/lib/features/v2/work_order/data/datasources/work_order_remote_datasource.dart @@ -0,0 +1,19 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/data/models/work_order_model.dart'; + +abstract class WorkOrderRemoteDataSource { + Future>> getWorkOrderList( + Map params, + ); + + Future> getWorkOrderDetail(int id); + + Future> dispatchWorkOrder(Map params); + + Future> suspendWorkOrder(int id); + + Future> completeWorkOrder(Map params); + + Future> startWorkOrder(Map params); +} diff --git a/lib/features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart b/lib/features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart new file mode 100644 index 00000000..1cbaaee1 --- /dev/null +++ b/lib/features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.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 'package:maibu_satabot_v2/features/v2/work_order/data/models/work_order_model.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/data/datasources/work_order_remote_datasource.dart'; + +class WorkOrderRemoteDataSourceImpl implements WorkOrderRemoteDataSource { + WorkOrderRemoteDataSourceImpl(this._dio); + + final Dio _dio; + + @override + Future>> getWorkOrderList( + Map params, + ) async { + try { + final response = await _dio.get( + HttpApiConsts.workOrderList, + queryParameters: params, + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + if (code != null && code.toString() == '200') { + final rows = data['rows'] as List? ?? []; + final list = rows + .map( + (item) => WorkOrderModel.fromJson(item as Map), + ) + .toList(); + list.sort((a, b) { + final timeA = a.planStartTime ?? ''; + final timeB = b.planStartTime ?? ''; + return timeA.compareTo(timeB); + }); + return right(list); + } else { + return left(Failure(data['msg'] ?? '获取工单列表失败')); + } + } else { + return left(Failure('HTTP错误: ${response.statusCode}')); + } + } catch (e) { + return left(Failure('获取工单列表异常: $e')); + } + } + + @override + Future> getWorkOrderDetail(int id) async { + try { + final response = await _dio.get('${HttpApiConsts.workOrderDetail}/$id'); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + if (code != null && code.toString() == '200') { + final result = data['result'] as Map? ?? {}; + return right(WorkOrderModel.fromJson(result)); + } else { + return left(Failure(data['msg'] ?? '获取工单详情失败')); + } + } else { + return left(Failure('HTTP错误: ${response.statusCode}')); + } + } catch (e) { + return left(Failure('获取工单详情异常: $e')); + } + } + + @override + Future> dispatchWorkOrder( + Map params, + ) async { + try { + final response = await _dio.put( + HttpApiConsts.workOrderDispat, + data: params, + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + if (code != null && code.toString() == '200') { + return right(true); + } else { + return left(Failure(data['msg'] ?? '派发工单失败')); + } + } else { + return left(Failure('HTTP错误: ${response.statusCode}')); + } + } catch (e) { + return left(Failure('派发工单异常: $e')); + } + } + + @override + Future> suspendWorkOrder(int id) async { + try { + final response = await _dio.post('${HttpApiConsts.workOrderSuspend}/$id'); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + if (code != null && code.toString() == '200') { + return right(true); + } else { + return left(Failure(data['msg'] ?? '挂起工单失败')); + } + } else { + return left(Failure('HTTP错误: ${response.statusCode}')); + } + } catch (e) { + return left(Failure('挂起工单异常: $e')); + } + } + + @override + Future> completeWorkOrder( + Map params, + ) async { + try { + final formData = FormData.fromMap({ + 'workOrder': MultipartFile.fromString( + jsonEncode(params), + contentType: MediaType.parse('application/json'), + ), + }); + final response = await _dio.post( + HttpApiConsts.workOrderComplete, + data: formData, + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + if (code != null && code.toString() == '200') { + return right(true); + } else { + return left(Failure(data['msg'] ?? '完成工单失败')); + } + } else { + return left(Failure('HTTP错误: ${response.statusCode}')); + } + } catch (e) { + return left(Failure('完成工单异常: $e')); + } + } + + @override + Future> startWorkOrder( + Map params, + ) async { + try { + final response = await _dio.post( + HttpApiConsts.workOrderStart, + data: params, + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + if (code != null && code.toString() == '200') { + return right(true); + } else { + return left(Failure(data['msg'] ?? '开始执行工单失败')); + } + } else { + return left(Failure('HTTP错误: ${response.statusCode}')); + } + } catch (e) { + return left(Failure('开始执行工单异常: $e')); + } + } +} diff --git a/lib/features/v2/work_order/data/models/work_order_model.dart b/lib/features/v2/work_order/data/models/work_order_model.dart new file mode 100644 index 00000000..23c8467d --- /dev/null +++ b/lib/features/v2/work_order/data/models/work_order_model.dart @@ -0,0 +1,207 @@ +import 'package:maibu_satabot_v2/features/v2/work_order/domain/entities/work_order_entity.dart'; + +class WorkOrderModel { + final int? id; + final String? orderNo; + final String? orderTitle; + final int? sourceType; + final int? orderType; + final int? priorityLevel; + final int? deviceId; + final String? deviceName; + final int? deviceType; + final int? siteId; + final String? siteName; + final int? orgId; + final int? assigneeId; + final String? assigneeName; + final String? collaboratorIds; + final String? collaboratorNames; + final String? planStartTime; + final String? planEndTime; + final String? deadlineTime; + final String? actualStartTime; + final String? actualEndTime; + final int? orderStatus; + final int? alarmId; + final String? alarmNo; + final String? taskDescription; + final String? aiSuggestion; + final String? requiredEquipment; + final double? estimatedImpact; + final String? handleResult; + final String? handleRemark; + + WorkOrderModel({ + this.id, + this.orderNo, + this.orderTitle, + this.sourceType, + this.orderType, + this.priorityLevel, + this.deviceId, + this.deviceName, + this.deviceType, + this.siteId, + this.siteName, + this.orgId, + this.assigneeId, + this.assigneeName, + this.collaboratorIds, + this.collaboratorNames, + this.planStartTime, + this.planEndTime, + this.deadlineTime, + this.actualStartTime, + this.actualEndTime, + this.orderStatus, + this.alarmId, + this.alarmNo, + this.taskDescription, + this.aiSuggestion, + this.requiredEquipment, + this.estimatedImpact, + this.handleResult, + this.handleRemark, + }); + + factory WorkOrderModel.fromJson(Map json) { + return WorkOrderModel( + id: json['id'] as int?, + orderNo: json['orderNo'] as String?, + orderTitle: json['orderTitle'] as String?, + sourceType: json['sourceType'] as int?, + orderType: json['orderType'] as int?, + priorityLevel: json['priorityLevel'] as int?, + deviceId: json['deviceId'] as int?, + deviceName: json['deviceName'] as String?, + deviceType: json['deviceType'] as int?, + siteId: json['siteId'] as int?, + siteName: json['siteName'] as String?, + orgId: json['orgId'] as int?, + assigneeId: json['assigneeId'] as int?, + assigneeName: json['assigneeName'] as String?, + collaboratorIds: json['collaboratorIds'] as String?, + collaboratorNames: json['collaboratorNames'] as String?, + planStartTime: json['planStartTime'] as String?, + planEndTime: json['planEndTime'] as String?, + deadlineTime: json['deadlineTime'] as String?, + actualStartTime: json['actualStartTime'] as String?, + actualEndTime: json['actualEndTime'] as String?, + orderStatus: json['orderStatus'] as int?, + alarmId: json['alarmId'] as int?, + alarmNo: json['alarmNo'] as String?, + taskDescription: json['taskDescription'] as String?, + aiSuggestion: json['aiSuggestion'] as String?, + requiredEquipment: json['requiredEquipment'] as String?, + estimatedImpact: json['estimatedImpact'] as double?, + handleResult: json['handleResult'] as String?, + handleRemark: json['handleRemark'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'orderNo': orderNo, + 'orderTitle': orderTitle, + 'sourceType': sourceType, + 'orderType': orderType, + 'priorityLevel': priorityLevel, + 'deviceId': deviceId, + 'deviceName': deviceName, + 'deviceType': deviceType, + 'siteId': siteId, + 'siteName': siteName, + 'orgId': orgId, + 'assigneeId': assigneeId, + 'assigneeName': assigneeName, + 'collaboratorIds': collaboratorIds, + 'collaboratorNames': collaboratorNames, + 'planStartTime': planStartTime, + 'planEndTime': planEndTime, + 'deadlineTime': deadlineTime, + 'actualStartTime': actualStartTime, + 'actualEndTime': actualEndTime, + 'orderStatus': orderStatus, + 'alarmId': alarmId, + 'alarmNo': alarmNo, + 'taskDescription': taskDescription, + 'aiSuggestion': aiSuggestion, + 'requiredEquipment': requiredEquipment, + 'estimatedImpact': estimatedImpact, + 'handleResult': handleResult, + 'handleRemark': handleRemark, + }; + } + + WorkOrderEntity toEntity() { + return WorkOrderEntity( + id: id, + orderNo: orderNo, + orderTitle: orderTitle, + sourceType: sourceType, + orderType: orderType, + priorityLevel: priorityLevel, + deviceId: deviceId, + deviceName: deviceName, + deviceType: deviceType, + siteId: siteId, + siteName: siteName, + orgId: orgId, + assigneeId: assigneeId, + assigneeName: assigneeName, + collaboratorIds: collaboratorIds, + collaboratorNames: collaboratorNames, + planStartTime: planStartTime, + planEndTime: planEndTime, + deadlineTime: deadlineTime, + actualStartTime: actualStartTime, + actualEndTime: actualEndTime, + orderStatus: orderStatus, + alarmId: alarmId, + alarmNo: alarmNo, + taskDescription: taskDescription, + aiSuggestion: aiSuggestion, + requiredEquipment: requiredEquipment, + estimatedImpact: estimatedImpact, + handleResult: handleResult, + handleRemark: handleRemark, + ); + } + + factory WorkOrderModel.fromEntity(WorkOrderEntity entity) { + return WorkOrderModel( + id: entity.id, + orderNo: entity.orderNo, + orderTitle: entity.orderTitle, + sourceType: entity.sourceType, + orderType: entity.orderType, + priorityLevel: entity.priorityLevel, + deviceId: entity.deviceId, + deviceName: entity.deviceName, + deviceType: entity.deviceType, + siteId: entity.siteId, + siteName: entity.siteName, + orgId: entity.orgId, + assigneeId: entity.assigneeId, + assigneeName: entity.assigneeName, + collaboratorIds: entity.collaboratorIds, + collaboratorNames: entity.collaboratorNames, + planStartTime: entity.planStartTime, + planEndTime: entity.planEndTime, + deadlineTime: entity.deadlineTime, + actualStartTime: entity.actualStartTime, + actualEndTime: entity.actualEndTime, + orderStatus: entity.orderStatus, + alarmId: entity.alarmId, + alarmNo: entity.alarmNo, + taskDescription: entity.taskDescription, + aiSuggestion: entity.aiSuggestion, + requiredEquipment: entity.requiredEquipment, + estimatedImpact: entity.estimatedImpact, + handleResult: entity.handleResult, + handleRemark: entity.handleRemark, + ); + } +} diff --git a/lib/features/v2/work_order/data/repositories/work_order_repository_impl.dart b/lib/features/v2/work_order/data/repositories/work_order_repository_impl.dart new file mode 100644 index 00000000..332bd847 --- /dev/null +++ b/lib/features/v2/work_order/data/repositories/work_order_repository_impl.dart @@ -0,0 +1,57 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/entities/work_order_entity.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/repositories/work_order_repository.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/data/datasources/work_order_remote_datasource.dart'; + +class WorkOrderRepositoryImpl implements WorkOrderRepository { + final WorkOrderRemoteDataSource remoteDataSource; + + WorkOrderRepositoryImpl({required this.remoteDataSource}); + + @override + Future>> getWorkOrderList( + Map params, + ) async { + final result = await remoteDataSource.getWorkOrderList(params); + return result.fold( + (failure) => left(failure), + (models) => right(models.map((m) => m.toEntity()).toList()), + ); + } + + @override + Future> getWorkOrderDetail(int id) async { + final result = await remoteDataSource.getWorkOrderDetail(id); + return result.fold( + (failure) => left(failure), + (model) => right(model.toEntity()), + ); + } + + @override + Future> dispatchWorkOrder( + Map params, + ) async { + return await remoteDataSource.dispatchWorkOrder(params); + } + + @override + Future> suspendWorkOrder(int id) async { + return await remoteDataSource.suspendWorkOrder(id); + } + + @override + Future> completeWorkOrder( + Map params, + ) async { + return await remoteDataSource.completeWorkOrder(params); + } + + @override + Future> startWorkOrder( + Map params, + ) async { + return await remoteDataSource.startWorkOrder(params); + } +} diff --git a/lib/features/v2/work_order/di/work_order_di.dart b/lib/features/v2/work_order/di/work_order_di.dart new file mode 100644 index 00000000..26c9b52c --- /dev/null +++ b/lib/features/v2/work_order/di/work_order_di.dart @@ -0,0 +1,43 @@ +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/data/datasources/work_order_remote_datasource.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/data/repositories/work_order_repository_impl.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/repositories/work_order_repository.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/usecases/work_order_usecases.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/presentation/cubit/work_order_cubit.dart'; + +class WorkOrderDependencyInjector { + static WorkOrderCubit createWorkOrderCubit() { + final sl = GetIt.I; + final WorkOrderRemoteDataSource remoteDataSource = + WorkOrderRemoteDataSourceImpl(sl()); + + final WorkOrderRepository repository = WorkOrderRepositoryImpl( + remoteDataSource: remoteDataSource, + ); + + final GetWorkOrderListUseCase getWorkOrderListUseCase = + GetWorkOrderListUseCase(repository); + final GetWorkOrderDetailUseCase getWorkOrderDetailUseCase = + GetWorkOrderDetailUseCase(repository); + final DispatchWorkOrderUseCase dispatchWorkOrderUseCase = + DispatchWorkOrderUseCase(repository); + final SuspendWorkOrderUseCase suspendWorkOrderUseCase = + SuspendWorkOrderUseCase(repository); + final CompleteWorkOrderUseCase completeWorkOrderUseCase = + CompleteWorkOrderUseCase(repository); + final StartWorkOrderUseCase startWorkOrderUseCase = StartWorkOrderUseCase( + repository, + ); + + return WorkOrderCubit( + getWorkOrderListUseCase: getWorkOrderListUseCase, + getWorkOrderDetailUseCase: getWorkOrderDetailUseCase, + dispatchWorkOrderUseCase: dispatchWorkOrderUseCase, + suspendWorkOrderUseCase: suspendWorkOrderUseCase, + completeWorkOrderUseCase: completeWorkOrderUseCase, + startWorkOrderUseCase: startWorkOrderUseCase, + ); + } +} diff --git a/lib/features/v2/work_order/domain/entities/work_order_entity.dart b/lib/features/v2/work_order/domain/entities/work_order_entity.dart new file mode 100644 index 00000000..7ee4c046 --- /dev/null +++ b/lib/features/v2/work_order/domain/entities/work_order_entity.dart @@ -0,0 +1,167 @@ +import 'package:equatable/equatable.dart'; + +class WorkOrderEntity extends Equatable { + final int? id; + final String? orderNo; + final String? orderTitle; + final int? sourceType; + final int? orderType; + final int? priorityLevel; + final int? deviceId; + final String? deviceName; + final int? deviceType; + final int? siteId; + final String? siteName; + final int? orgId; + final int? assigneeId; + final String? assigneeName; + final String? collaboratorIds; + final String? collaboratorNames; + final String? planStartTime; + final String? planEndTime; + final String? deadlineTime; + final String? actualStartTime; + final String? actualEndTime; + final int? orderStatus; + final int? alarmId; + final String? alarmNo; + final String? taskDescription; + final String? aiSuggestion; + final String? requiredEquipment; + final double? estimatedImpact; + final String? handleResult; + final String? handleRemark; + + const WorkOrderEntity({ + this.id, + this.orderNo, + this.orderTitle, + this.sourceType, + this.orderType, + this.priorityLevel, + this.deviceId, + this.deviceName, + this.deviceType, + this.siteId, + this.siteName, + this.orgId, + this.assigneeId, + this.assigneeName, + this.collaboratorIds, + this.collaboratorNames, + this.planStartTime, + this.planEndTime, + this.deadlineTime, + this.actualStartTime, + this.actualEndTime, + this.orderStatus, + this.alarmId, + this.alarmNo, + this.taskDescription, + this.aiSuggestion, + this.requiredEquipment, + this.estimatedImpact, + this.handleResult, + this.handleRemark, + }); + + WorkOrderEntity copyWith({ + int? id, + String? orderNo, + String? orderTitle, + int? sourceType, + int? orderType, + int? priorityLevel, + int? deviceId, + String? deviceName, + int? deviceType, + int? siteId, + String? siteName, + int? orgId, + int? assigneeId, + String? assigneeName, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + String? actualStartTime, + String? actualEndTime, + int? orderStatus, + int? alarmId, + String? alarmNo, + String? taskDescription, + String? aiSuggestion, + String? requiredEquipment, + double? estimatedImpact, + String? handleResult, + String? handleRemark, + }) { + return WorkOrderEntity( + id: id ?? this.id, + orderNo: orderNo ?? this.orderNo, + orderTitle: orderTitle ?? this.orderTitle, + sourceType: sourceType ?? this.sourceType, + orderType: orderType ?? this.orderType, + priorityLevel: priorityLevel ?? this.priorityLevel, + deviceId: deviceId ?? this.deviceId, + deviceName: deviceName ?? this.deviceName, + deviceType: deviceType ?? this.deviceType, + siteId: siteId ?? this.siteId, + siteName: siteName ?? this.siteName, + orgId: orgId ?? this.orgId, + assigneeId: assigneeId ?? this.assigneeId, + assigneeName: assigneeName ?? this.assigneeName, + collaboratorIds: collaboratorIds ?? this.collaboratorIds, + collaboratorNames: collaboratorNames ?? this.collaboratorNames, + planStartTime: planStartTime ?? this.planStartTime, + planEndTime: planEndTime ?? this.planEndTime, + deadlineTime: deadlineTime ?? this.deadlineTime, + actualStartTime: actualStartTime ?? this.actualStartTime, + actualEndTime: actualEndTime ?? this.actualEndTime, + orderStatus: orderStatus ?? this.orderStatus, + alarmId: alarmId ?? this.alarmId, + alarmNo: alarmNo ?? this.alarmNo, + taskDescription: taskDescription ?? this.taskDescription, + aiSuggestion: aiSuggestion ?? this.aiSuggestion, + requiredEquipment: requiredEquipment ?? this.requiredEquipment, + estimatedImpact: estimatedImpact ?? this.estimatedImpact, + handleResult: handleResult ?? this.handleResult, + handleRemark: handleRemark ?? this.handleRemark, + ); + } + + @override + List get props => [ + id, + orderNo, + orderTitle, + sourceType, + orderType, + priorityLevel, + deviceId, + deviceName, + deviceType, + siteId, + siteName, + orgId, + assigneeId, + assigneeName, + collaboratorIds, + collaboratorNames, + planStartTime, + planEndTime, + deadlineTime, + actualStartTime, + actualEndTime, + orderStatus, + alarmId, + alarmNo, + taskDescription, + aiSuggestion, + requiredEquipment, + estimatedImpact, + handleResult, + handleRemark, + ]; +} \ No newline at end of file diff --git a/lib/features/v2/work_order/domain/repositories/work_order_repository.dart b/lib/features/v2/work_order/domain/repositories/work_order_repository.dart new file mode 100644 index 00000000..beaed188 --- /dev/null +++ b/lib/features/v2/work_order/domain/repositories/work_order_repository.dart @@ -0,0 +1,19 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/entities/work_order_entity.dart'; + +abstract class WorkOrderRepository { + Future>> getWorkOrderList( + Map params, + ); + + Future> getWorkOrderDetail(int id); + + Future> dispatchWorkOrder(Map params); + + Future> suspendWorkOrder(int id); + + Future> completeWorkOrder(Map params); + + Future> startWorkOrder(Map params); +} diff --git a/lib/features/v2/work_order/domain/usecases/work_order_usecases.dart b/lib/features/v2/work_order/domain/usecases/work_order_usecases.dart new file mode 100644 index 00000000..4e7225bc --- /dev/null +++ b/lib/features/v2/work_order/domain/usecases/work_order_usecases.dart @@ -0,0 +1,142 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:maibu_satabot_v2/core/error/failure.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/entities/work_order_entity.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/repositories/work_order_repository.dart'; + +class GetWorkOrderListUseCase { + final WorkOrderRepository repository; + + GetWorkOrderListUseCase(this.repository); + + Future>> execute({ + String? orderNo, + String? orderTitle, + int? sourceType, + int? orderType, + int? priorityLevel, + int? assigneeId, + int? deviceId, + int? siteId, + int? orgId, + int? orderStatus, + String? startTime, + String? endTime, + String? keyword, + int pageNum = 1, + int pageSize = 10000, + }) async { + final params = { + if (orderNo != null) 'orderNo': orderNo, + if (orderTitle != null) 'orderTitle': orderTitle, + if (sourceType != null) 'sourceType': sourceType, + if (orderType != null) 'orderType': orderType, + if (priorityLevel != null) 'priorityLevel': priorityLevel, + if (assigneeId != null) 'assigneeId': assigneeId, + if (deviceId != null) 'deviceId': deviceId, + if (siteId != null) 'siteId': siteId, + if (orgId != null) 'orgId': orgId, + if (orderStatus != null) 'orderStatus': orderStatus, + if (startTime != null) 'startTime': startTime, + if (endTime != null) 'endTime': endTime, + if (keyword != null) 'keyword': keyword, + 'pageNum': pageNum, + 'pageSize': pageSize, + }; + return await repository.getWorkOrderList(params); + } +} + +class GetWorkOrderDetailUseCase { + final WorkOrderRepository repository; + + GetWorkOrderDetailUseCase(this.repository); + + Future> execute(int id) async { + return await repository.getWorkOrderDetail(id); + } +} + +class DispatchWorkOrderUseCase { + final WorkOrderRepository repository; + + DispatchWorkOrderUseCase(this.repository); + + Future> execute({ + required int orderId, + required int assigneeId, + required String assigneeName, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + }) async { + final params = { + 'orderId': orderId, + 'assigneeId': assigneeId, + 'assigneeName': assigneeName, + if (collaboratorIds != null) 'collaboratorIds': collaboratorIds, + if (collaboratorNames != null) 'collaboratorNames': collaboratorNames, + if (planStartTime != null) 'planStartTime': planStartTime, + if (planEndTime != null) 'planEndTime': planEndTime, + if (deadlineTime != null) 'deadlineTime': deadlineTime, + }; + return await repository.dispatchWorkOrder(params); + } +} + +class SuspendWorkOrderUseCase { + final WorkOrderRepository repository; + + SuspendWorkOrderUseCase(this.repository); + + Future> execute(int id) async { + return await repository.suspendWorkOrder(id); + } +} + +class CompleteWorkOrderUseCase { + final WorkOrderRepository repository; + + CompleteWorkOrderUseCase(this.repository); + + Future> execute({ + required int id, + String? handleResult, + String? failureCause, + String? handleMeasures, + String? completeRemark, + String? completeTime, + List? images, + }) async { + final params = { + 'id': id, + if (handleResult != null) 'handleResult': handleResult, + if (failureCause != null) 'failureCause': failureCause, + if (handleMeasures != null) 'handleMeasures': handleMeasures, + if (completeRemark != null) 'completeRemark': completeRemark, + if (completeTime != null) 'completeTime': completeTime, + if (images != null && images.isNotEmpty) 'images': images, + }; + return await repository.completeWorkOrder(params); + } +} + +class StartWorkOrderUseCase { + final WorkOrderRepository repository; + + StartWorkOrderUseCase(this.repository); + + Future> execute({ + required List ids, + String? startTime, + String? deviceId, + }) async { + final params = { + 'ids': ids, + if (startTime != null) 'startTime': startTime, + if (deviceId != null) 'deviceId': deviceId, + }; + return await repository.startWorkOrder(params); + } +} diff --git a/lib/features/v2/work_order/presentation/cubit/work_order_cubit.dart b/lib/features/v2/work_order/presentation/cubit/work_order_cubit.dart new file mode 100644 index 00000000..fdbfb555 --- /dev/null +++ b/lib/features/v2/work_order/presentation/cubit/work_order_cubit.dart @@ -0,0 +1,150 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/entities/work_order_entity.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/usecases/work_order_usecases.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/presentation/states/work_order_state.dart'; + +class WorkOrderCubit extends Cubit { + final GetWorkOrderListUseCase _getWorkOrderListUseCase; + final GetWorkOrderDetailUseCase _getWorkOrderDetailUseCase; + final DispatchWorkOrderUseCase _dispatchWorkOrderUseCase; + final SuspendWorkOrderUseCase _suspendWorkOrderUseCase; + final CompleteWorkOrderUseCase _completeWorkOrderUseCase; + final StartWorkOrderUseCase _startWorkOrderUseCase; + + WorkOrderCubit({ + required GetWorkOrderListUseCase getWorkOrderListUseCase, + required GetWorkOrderDetailUseCase getWorkOrderDetailUseCase, + required DispatchWorkOrderUseCase dispatchWorkOrderUseCase, + required SuspendWorkOrderUseCase suspendWorkOrderUseCase, + required CompleteWorkOrderUseCase completeWorkOrderUseCase, + required StartWorkOrderUseCase startWorkOrderUseCase, + }) : _getWorkOrderListUseCase = getWorkOrderListUseCase, + _getWorkOrderDetailUseCase = getWorkOrderDetailUseCase, + _dispatchWorkOrderUseCase = dispatchWorkOrderUseCase, + _suspendWorkOrderUseCase = suspendWorkOrderUseCase, + _completeWorkOrderUseCase = completeWorkOrderUseCase, + _startWorkOrderUseCase = startWorkOrderUseCase, + super(WorkOrderInitial()); + + Future loadWorkOrderList({ + String? orderNo, + String? orderTitle, + int? sourceType, + int? orderType, + int? priorityLevel, + int? assigneeId, + int? deviceId, + int? siteId, + int? orgId, + int? orderStatus, + String? startTime, + String? endTime, + String? keyword, + }) async { + emit(WorkOrderLoading()); + final result = await _getWorkOrderListUseCase.execute( + orderNo: orderNo, + orderTitle: orderTitle, + sourceType: sourceType, + orderType: orderType, + priorityLevel: priorityLevel, + assigneeId: assigneeId, + deviceId: deviceId, + siteId: siteId, + orgId: orgId, + orderStatus: orderStatus, + startTime: startTime, + endTime: endTime, + keyword: keyword, + pageNum: 1, + pageSize: 10000, + ); + result.fold( + (failure) => emit(WorkOrderError(failure.message)), + (orders) => emit(WorkOrderListLoaded(orders)), + ); + } + + Future loadWorkOrderDetail(int id) async { + emit(WorkOrderLoading()); + final result = await _getWorkOrderDetailUseCase.execute(id); + result.fold( + (failure) => emit(WorkOrderError(failure.message)), + (order) => emit(WorkOrderDetailLoaded(order)), + ); + } + + Future dispatchWorkOrder({ + required int orderId, + required int assigneeId, + required String assigneeName, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + }) async { + emit(const WorkOrderActionInProgress('派发工单')); + final result = await _dispatchWorkOrderUseCase.execute( + orderId: orderId, + assigneeId: assigneeId, + assigneeName: assigneeName, + collaboratorIds: collaboratorIds, + collaboratorNames: collaboratorNames, + planStartTime: planStartTime, + planEndTime: planEndTime, + deadlineTime: deadlineTime, + ); + result.fold( + (failure) => emit(WorkOrderError(failure.message)), + (success) => emit(const WorkOrderActionSuccess('派发工单成功')), + ); + } + + Future suspendWorkOrder(int id) async { + emit(const WorkOrderActionInProgress('挂起工单')); + final result = await _suspendWorkOrderUseCase.execute(id); + result.fold( + (failure) => emit(WorkOrderError(failure.message)), + (success) => emit(const WorkOrderActionSuccess('挂起工单成功')), + ); + } + + Future completeWorkOrder({ + required int id, + String? completeRemark, + String? completeTime, + }) async { + emit(const WorkOrderActionInProgress('完成工单')); + final result = await _completeWorkOrderUseCase.execute( + id: id, + completeRemark: completeRemark, + completeTime: completeTime, + ); + result.fold( + (failure) => emit(WorkOrderError(failure.message)), + (success) => emit(const WorkOrderActionSuccess('完成工单成功')), + ); + } + + Future startWorkOrder({ + required List ids, + String? startTime, + String? deviceId, + }) async { + emit(const WorkOrderActionInProgress('开始执行工单')); + final result = await _startWorkOrderUseCase.execute( + ids: ids, + startTime: startTime, + deviceId: deviceId, + ); + result.fold( + (failure) => emit(WorkOrderError(failure.message)), + (success) => emit(const WorkOrderActionSuccess('开始执行工单成功')), + ); + } + + void refresh() { + emit(WorkOrderInitial()); + } +} diff --git a/lib/features/v2/work_order/presentation/states/work_order_state.dart b/lib/features/v2/work_order/presentation/states/work_order_state.dart new file mode 100644 index 00000000..51d45b2c --- /dev/null +++ b/lib/features/v2/work_order/presentation/states/work_order_state.dart @@ -0,0 +1,58 @@ +import 'package:equatable/equatable.dart'; +import 'package:maibu_satabot_v2/features/v2/work_order/domain/entities/work_order_entity.dart'; + +abstract class WorkOrderState extends Equatable { + const WorkOrderState(); + + @override + List get props => []; +} + +class WorkOrderInitial extends WorkOrderState {} + +class WorkOrderLoading extends WorkOrderState {} + +class WorkOrderListLoaded extends WorkOrderState { + final List orders; + + const WorkOrderListLoaded(this.orders); + + @override + List get props => [orders]; +} + +class WorkOrderDetailLoaded extends WorkOrderState { + final WorkOrderEntity order; + + const WorkOrderDetailLoaded(this.order); + + @override + List get props => [order]; +} + +class WorkOrderActionInProgress extends WorkOrderState { + final String action; + + const WorkOrderActionInProgress(this.action); + + @override + List get props => [action]; +} + +class WorkOrderActionSuccess extends WorkOrderState { + final String action; + + const WorkOrderActionSuccess(this.action); + + @override + List get props => [action]; +} + +class WorkOrderError extends WorkOrderState { + final String message; + + const WorkOrderError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/features/v2/work_order/work_order.dart b/lib/features/v2/work_order/work_order.dart new file mode 100644 index 00000000..fa822e04 --- /dev/null +++ b/lib/features/v2/work_order/work_order.dart @@ -0,0 +1,4 @@ +export 'presentation/states/work_order_state.dart'; +export 'presentation/cubit/work_order_cubit.dart'; +export 'domain/entities/work_order_entity.dart'; +export 'di/work_order_di.dart'; \ No newline at end of file diff --git a/lib/features/v2/workorder/data/datasources/workorder_remote_datasource.dart b/lib/features/v2/workorder/data/datasources/workorder_remote_datasource.dart index 10f31abb..3c861139 100644 --- a/lib/features/v2/workorder/data/datasources/workorder_remote_datasource.dart +++ b/lib/features/v2/workorder/data/datasources/workorder_remote_datasource.dart @@ -1,12 +1,35 @@ import '../models/workorder_model.dart'; -/// 工单远程数据源抽象类 abstract class WorkOrderRemoteDataSource { - /// 获取工单列表 Future> getWorkOrderList({ - String? status, + required int page, + required int pageSize, + int? siteId, + int? orgId, }); - /// 获取工单统计 Future getWorkOrderCount(); + + Future getWorkOrderDetail( + String orderId, { + int? siteId, + int? orgId, + }); + + Future>> fetchUsers({ + required int orgId, + required int siteId, + }); + + Future dispatchWorkOrder({ + required String orderId, + required String assigneeId, + required String assigneeName, + String? dispatchRemark, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + }); } diff --git a/lib/features/v2/workorder/data/datasources/workorder_remote_datasource_impl.dart b/lib/features/v2/workorder/data/datasources/workorder_remote_datasource_impl.dart index 9644b898..4a0cf00c 100644 --- a/lib/features/v2/workorder/data/datasources/workorder_remote_datasource_impl.dart +++ b/lib/features/v2/workorder/data/datasources/workorder_remote_datasource_impl.dart @@ -1,87 +1,145 @@ +import 'package:dio/dio.dart'; +import '../../../../../core/consts/http_api_consts.dart'; import '../../../../../core/consts/workorder_consts.dart'; import 'workorder_remote_datasource.dart'; import '../models/workorder_model.dart'; -/// 工单远程数据源实现(模拟数据) class WorkOrderRemoteDataSourceImpl implements WorkOrderRemoteDataSource { + WorkOrderRemoteDataSourceImpl(this._dio); + + final Dio _dio; + @override - Future> getWorkOrderList({String? status}) async { - // 模拟网络延迟 - await Future.delayed(const Duration(seconds: 1)); + Future> getWorkOrderList({ + required int page, + required int pageSize, + int? siteId, + int? orgId, + }) async { + final queryParameters = {'page': page, 'pageSize': pageSize}; + if (siteId != null) { + queryParameters['siteId'] = siteId; + } + if (orgId != null) { + queryParameters['orgId'] = orgId; + } + final response = await _dio.get( + HttpApiConsts.workOrderList, + queryParameters: queryParameters, + ); - // 模拟数据 - final allOrders = [ - WorkOrderModel( - id: '1', - title: '逆变器通讯故障处理', - orderNo: 'WO-20250521001', - priority: WorkOrderPriority.high, - executor: null, - createTime: DateTime(2025, 5, 21, 10, 12), - completeTime: null, - location: 'A区 / INV-001', - status: WorkOrderStatus.pending, - progress: null, - ), - WorkOrderModel( - id: '2', - title: '组件清洗作业', - orderNo: 'WO-20250521002', - priority: WorkOrderPriority.medium, - executor: '张工', - createTime: DateTime(2025, 5, 21, 9, 30), - completeTime: null, - location: 'B区', - status: WorkOrderStatus.executing, - progress: 60.0, - ), - WorkOrderModel( - id: '3', - title: '汇流箱巡检', - orderNo: 'WO-20250520098', - priority: WorkOrderPriority.low, - executor: '李工', - createTime: DateTime(2025, 5, 20, 14, 20), - completeTime: DateTime(2025, 5, 20, 16, 32), - location: 'C区', - status: WorkOrderStatus.completed, - progress: 100.0, - ), - ]; - - // 根据状态筛选 - if (status != null && status != 'all') { - return allOrders - .where((order) => _statusToString(order.status) == status) - .toList(); + final responseData = response.data; + final int code = responseData['code'] ?? -1; + if (code != 0 && code != 200) { + throw Exception('获取工单列表失败: code=$code'); } - return allOrders; + final List rows = responseData['rows'] ?? []; + return rows + .map((item) => WorkOrderModel.fromJson(item as Map)) + .toList(); } @override Future getWorkOrderCount() async { - // 模拟网络延迟 - await Future.delayed(const Duration(milliseconds: 500)); - - // 模拟统计数据 - return WorkOrderCountModel( - pendingCount: 12, - executingCount: 8, - todayCompletedCount: 18, + final response = await _dio.get(HttpApiConsts.workOrderCount); + final responseData = response.data; + final int code = responseData['code'] ?? -1; + if (code != 0 && code != 200) { + throw Exception('获取工单统计失败: code=$code'); + } + return WorkOrderCountModel.fromJson( + responseData['data'] as Map, ); } - String _statusToString(WorkOrderStatus status) { - switch (status) { - case WorkOrderStatus.pending: - return 'pending'; - case WorkOrderStatus.executing: - return 'executing'; - case WorkOrderStatus.completed: - return 'completed'; - case WorkOrderStatus.all: - return 'all'; + @override + Future getWorkOrderDetail( + String orderId, { + int? siteId, + int? orgId, + }) async { + final queryParameters = {}; + if (siteId != null) { + queryParameters['siteId'] = siteId; + } + if (orgId != null) { + queryParameters['orgId'] = orgId; + } + final response = await _dio.get( + '${HttpApiConsts.workOrderDetail}/$orderId', + queryParameters: queryParameters, + ); + + final responseData = response.data; + final int code = responseData['code'] ?? -1; + if (code != 0 && code != 200) { + throw Exception('获取工单详情失败: code=$code'); + } + + final data = responseData['data']; + if (data == null) { + throw Exception('工单详情数据为空'); + } + + return WorkOrderModel.fromJson(data as Map); + } + + @override + Future>> fetchUsers({ + required int orgId, + required int siteId, + }) async { + final response = await _dio.get( + HttpApiConsts.systemUserList, + queryParameters: { + 'pageNum': 1, + 'pageSize': 99999, + 'orgId': orgId, + 'siteId': siteId, + }, + ); + + final responseData = response.data; + final int code = responseData['code'] ?? -1; + if (code != 0 && code != 200) { + throw Exception('获取人员列表失败: code=$code'); + } + + final List rows = + responseData['rows'] ?? responseData['data'] ?? []; + return rows.cast>(); + } + + @override + Future dispatchWorkOrder({ + required String orderId, + required String assigneeId, + required String assigneeName, + String? dispatchRemark, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + }) async { + final data = { + 'orderId': orderId, + 'assigneeId': assigneeId, + 'assigneeName': assigneeName, + 'collaboratorIds': collaboratorIds ?? '', + 'collaboratorNames': collaboratorNames ?? '', + 'planStartTime': planStartTime ?? '', + 'planEndTime': planEndTime ?? '', + 'deadlineTime': deadlineTime ?? '', + }; + + final response = await _dio.post(HttpApiConsts.workOrderDispat, data: data); + + final responseData = response.data; + final int code = responseData['code'] ?? -1; + if (code != 0 && code != 200) { + throw Exception('转派工单失败: ${responseData['msg'] ?? '未知错误'}'); } } } diff --git a/lib/features/v2/workorder/data/models/workorder_model.dart b/lib/features/v2/workorder/data/models/workorder_model.dart index f9512962..64d9c49f 100644 --- a/lib/features/v2/workorder/data/models/workorder_model.dart +++ b/lib/features/v2/workorder/data/models/workorder_model.dart @@ -1,5 +1,140 @@ import '../../../../../core/consts/workorder_consts.dart'; import '../../domain/entities/workorder_entity.dart'; +import 'dart:developer' as developer; + +/// 设备对象数据模型 +class DeviceObjectModel { + final String deviceName; + final String deviceType; + final String assetCode; + + DeviceObjectModel({ + required this.deviceName, + required this.deviceType, + required this.assetCode, + }); + + factory DeviceObjectModel.fromJson(Map json) { + return DeviceObjectModel( + deviceName: json['deviceName'] as String? ?? '', + deviceType: json['deviceType'] as String? ?? '', + assetCode: json['assetCode'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'deviceName': deviceName, + 'deviceType': deviceType, + 'assetCode': assetCode, + }; + } + + DeviceObjectEntity toEntity() { + return DeviceObjectEntity( + deviceName: deviceName, + deviceType: deviceType, + assetCode: assetCode, + ); + } +} + +/// 地点数据模型 +class LocationModel { + final String stationName; + final String area; + final String region; + final String detailAddress; + + LocationModel({ + required this.stationName, + required this.area, + required this.region, + required this.detailAddress, + }); + + factory LocationModel.fromJson(Map json) { + return LocationModel( + stationName: json['stationName'] as String? ?? '', + area: json['area'] as String? ?? '', + region: json['region'] as String? ?? '', + detailAddress: json['detailAddress'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'stationName': stationName, + 'area': area, + 'region': region, + 'detailAddress': detailAddress, + }; + } + + LocationEntity toEntity() { + return LocationEntity( + stationName: stationName, + area: area, + region: region, + detailAddress: detailAddress, + ); + } +} + +/// 时限要求数据模型 +class TimeLimitModel { + final String expectedCompleteTime; + final String remainingTime; + + TimeLimitModel({ + required this.expectedCompleteTime, + required this.remainingTime, + }); + + factory TimeLimitModel.fromJson(Map json) { + return TimeLimitModel( + expectedCompleteTime: json['expectedCompleteTime'] as String? ?? '', + remainingTime: json['remainingTime'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'expectedCompleteTime': expectedCompleteTime, + 'remainingTime': remainingTime, + }; + } + + TimeLimitEntity toEntity() { + return TimeLimitEntity( + expectedCompleteTime: expectedCompleteTime, + remainingTime: remainingTime, + ); + } +} + +/// 附件数据模型 +class AttachmentModel { + final String fileName; + final String url; + + AttachmentModel({required this.fileName, required this.url}); + + factory AttachmentModel.fromJson(Map json) { + return AttachmentModel( + fileName: json['fileName'] as String? ?? '', + url: json['url'] as String? ?? '', + ); + } + + Map toJson() { + return {'fileName': fileName, 'url': url}; + } + + AttachmentEntity toEntity() { + return AttachmentEntity(fileName: fileName, url: url); + } +} /// 工单数据模型 class WorkOrderModel { @@ -13,6 +148,34 @@ class WorkOrderModel { final String location; final WorkOrderStatus status; final double? progress; + final String? source; + final DeviceObjectModel? deviceObject; + final LocationModel? locationDetail; + final TimeLimitModel? timeLimit; + final String? description; + final List? attachments; + final String? orderType; + final String? alarmId; + final String? alarmNo; + final String? handleResult; + final String? handleRemark; + final String? planStartTime; + final String? planEndTime; + final String? siteName; + final String? deviceId; + final String? deviceName; + final String? deviceType; + final String? assigneeName; + final String? aiSuggestion; + final String? requiredEquipment; + final String? estimatedImpact; + final String? taskDescription; + final String? actualStartTime; + final String? actualEndTime; + final String? deadlineTime; + final int? sourceType; + final List? videoUrls; + final String? updateTime; WorkOrderModel({ required this.id, @@ -25,27 +188,118 @@ class WorkOrderModel { required this.location, required this.status, this.progress, + this.source, + this.deviceObject, + this.locationDetail, + this.timeLimit, + this.description, + this.attachments, + this.orderType, + this.alarmId, + this.alarmNo, + this.handleResult, + this.handleRemark, + this.planStartTime, + this.planEndTime, + this.siteName, + this.deviceId, + this.deviceName, + this.deviceType, + this.assigneeName, + this.aiSuggestion, + this.requiredEquipment, + this.estimatedImpact, + this.taskDescription, + this.actualStartTime, + this.actualEndTime, + this.deadlineTime, + this.sourceType, + this.videoUrls, + this.updateTime, }); - /// 从 JSON 创建 factory WorkOrderModel.fromJson(Map json) { + final attachments = _parseAttachments(json); + final videoUrls = _parseVideoUrls(json); + + developer.log( + 'WorkOrderModel.fromJson keys: ${json.keys.toList()}', + name: 'WorkOrder', + ); + developer.log( + 'attachments: ${attachments?.length ?? 0}', + name: 'WorkOrder', + ); + developer.log('videoUrls: ${videoUrls?.length ?? 0}', name: 'WorkOrder'); + return WorkOrderModel( - id: json['id'] as String, - title: json['title'] as String, - orderNo: json['orderNo'] as String, - priority: _parsePriority(json['priority'] as String), - executor: json['executor'] as String?, - createTime: DateTime.parse(json['createTime'] as String), - completeTime: json['completeTime'] != null - ? DateTime.parse(json['completeTime'] as String) + id: (json['id'] as dynamic)?.toString() ?? '', + title: json['orderTitle'] as String? ?? '', + orderNo: json['orderNo'] as String? ?? '', + priority: _parsePriority(json['priorityLevel'] as String? ?? 'medium'), + executor: json['assigneeName'] as String?, + createTime: DateTime.parse( + json['createTime'] as String? ?? '2026-01-01 00:00:00', + ), + completeTime: json['actualEndTime'] != null + ? DateTime.parse(json['actualEndTime'] as String) : null, - location: json['location'] as String, - status: _parseStatus(json['status'] as String), - progress: json['progress'] as double?, + location: json['siteName'] as String? ?? '', + status: _parseStatusFromInt(json['orderStatus'] as int? ?? 1), + progress: (json['progress'] as dynamic)?.toDouble(), + source: _parseSourceType(json['sourceType'] as int? ?? 0), + deviceObject: json['deviceName'] != null + ? DeviceObjectModel( + deviceName: json['deviceName'] as String? ?? '', + deviceType: json['deviceType'] as String? ?? '', + assetCode: json['deviceId'] as String? ?? '', + ) + : null, + locationDetail: json['siteName'] != null + ? LocationModel( + stationName: json['siteName'] as String? ?? '', + area: json['area'] as String? ?? '', + region: json['region'] as String? ?? '', + detailAddress: json['detailAddress'] as String? ?? '', + ) + : null, + timeLimit: json['planEndTime'] != null + ? TimeLimitModel( + expectedCompleteTime: json['planEndTime'] as String? ?? '', + remainingTime: '', + ) + : null, + description: json['taskDescription'] as String?, + attachments: attachments, + orderType: json['orderType'] as String?, + alarmId: (json['alarmId'] as dynamic)?.toString(), + alarmNo: json['alarmNo'] as String?, + handleResult: json['handleResult'] as String?, + handleRemark: json['handleRemark'] as String?, + planStartTime: json['planStartTime'] as String?, + planEndTime: json['planEndTime'] as String?, + siteName: json['siteName'] as String?, + deviceId: json['deviceId'] as String?, + deviceName: json['deviceName'] as String?, + deviceType: json['deviceType'] as String?, + assigneeName: + json['assigneeName'] as String? ?? + json['executorName'] as String? ?? + json['executor'] as String? ?? + json['assignee'] as String?, + aiSuggestion: json['aiSuggestion'] as String?, + requiredEquipment: json['requiredEquipment'] as String?, + estimatedImpact: json['estimatedImpact'] as String?, + taskDescription: json['taskDescription'] as String?, + actualStartTime: json['actualStartTime'] as String?, + actualEndTime: json['actualEndTime'] as String?, + deadlineTime: json['deadlineTime'] as String?, + sourceType: json['sourceType'] as int?, + videoUrls: videoUrls, + updateTime: json['updateTime'] as String?, ); } - /// 转换为 JSON Map toJson() { return { 'id': id, @@ -58,10 +312,15 @@ class WorkOrderModel { 'location': location, 'status': _statusToString(status), 'progress': progress, + 'source': source, + 'deviceObject': deviceObject?.toJson(), + 'locationDetail': locationDetail?.toJson(), + 'timeLimit': timeLimit?.toJson(), + 'description': description, + 'attachments': attachments?.map((e) => e.toJson()).toList(), }; } - /// 转换为实体 WorkOrderEntity toEntity() { return WorkOrderEntity( id: id, @@ -74,10 +333,37 @@ class WorkOrderModel { location: location, status: status, progress: progress, + source: source, + deviceObject: deviceObject?.toEntity(), + locationDetail: locationDetail?.toEntity(), + timeLimit: timeLimit?.toEntity(), + description: description, + attachments: attachments?.map((e) => e.toEntity()).toList(), + orderType: orderType, + alarmId: alarmId, + alarmNo: alarmNo, + handleResult: handleResult, + handleRemark: handleRemark, + planStartTime: planStartTime, + planEndTime: planEndTime, + siteName: siteName, + deviceId: deviceId, + deviceName: deviceName, + deviceType: deviceType, + assigneeName: assigneeName, + aiSuggestion: aiSuggestion, + requiredEquipment: requiredEquipment, + estimatedImpact: estimatedImpact, + taskDescription: taskDescription, + actualStartTime: actualStartTime, + actualEndTime: actualEndTime, + deadlineTime: deadlineTime, + sourceType: sourceType, + videoUrls: videoUrls, + updateTime: updateTime, ); } - /// 从实体创建 factory WorkOrderModel.fromEntity(WorkOrderEntity entity) { return WorkOrderModel( id: entity.id, @@ -90,17 +376,217 @@ class WorkOrderModel { location: entity.location, status: entity.status, progress: entity.progress, + source: entity.source, + deviceObject: entity.deviceObject != null + ? DeviceObjectModel( + deviceName: entity.deviceObject!.deviceName, + deviceType: entity.deviceObject!.deviceType, + assetCode: entity.deviceObject!.assetCode, + ) + : null, + locationDetail: entity.locationDetail != null + ? LocationModel( + stationName: entity.locationDetail!.stationName, + area: entity.locationDetail!.area, + region: entity.locationDetail!.region, + detailAddress: entity.locationDetail!.detailAddress, + ) + : null, + timeLimit: entity.timeLimit != null + ? TimeLimitModel( + expectedCompleteTime: entity.timeLimit!.expectedCompleteTime, + remainingTime: entity.timeLimit!.remainingTime, + ) + : null, + description: entity.description, + attachments: entity.attachments + ?.map((e) => AttachmentModel(fileName: e.fileName, url: e.url)) + .toList(), + orderType: entity.orderType, + alarmId: entity.alarmId, + alarmNo: entity.alarmNo, + handleResult: entity.handleResult, + handleRemark: entity.handleRemark, + planStartTime: entity.planStartTime, + planEndTime: entity.planEndTime, + siteName: entity.siteName, + deviceId: entity.deviceId, + deviceName: entity.deviceName, + deviceType: entity.deviceType, + assigneeName: entity.assigneeName, + aiSuggestion: entity.aiSuggestion, + requiredEquipment: entity.requiredEquipment, + estimatedImpact: entity.estimatedImpact, + taskDescription: entity.taskDescription, + actualStartTime: entity.actualStartTime, + actualEndTime: entity.actualEndTime, + deadlineTime: entity.deadlineTime, + sourceType: entity.sourceType, + videoUrls: entity.videoUrls, + updateTime: entity.updateTime, ); } + static List? _parseAttachments(Map json) { + const fieldNames = [ + 'imgUrl', + 'imageUrl', + 'imageUrls', + 'photos', + 'images', + 'attachments', + 'files', + 'media', + ]; + for (final field in fieldNames) { + final dynamic value = json[field]; + if (value == null) continue; + + if (value is String && value.isNotEmpty) { + return [AttachmentModel(fileName: '', url: value)]; + } + if (value is List) { + final list = []; + for (final item in value) { + if (item is String && item.isNotEmpty) { + list.add(AttachmentModel(fileName: '', url: item)); + } else if (item is Map) { + final type = + (item['type']?.toString() ?? + item['mediaType']?.toString() ?? + '') + .toLowerCase(); + if (type == 'video' || type == 'video/mp4') continue; + final url = + item['url']?.toString() ?? item['fileUrl']?.toString() ?? ''; + final fileName = + item['fileName']?.toString() ?? item['name']?.toString() ?? ''; + if (url.isNotEmpty) { + list.add(AttachmentModel(fileName: fileName, url: url)); + } + } + } + if (list.isNotEmpty) return list; + } + if (value is Map) { + final dynamic nestedImages = + value['images'] ?? + value['photos'] ?? + value['imgUrl'] ?? + value['imageUrls']; + if (nestedImages != null) { + if (nestedImages is List) { + final list = []; + for (final item in nestedImages) { + if (item is String && item.isNotEmpty) { + list.add(AttachmentModel(fileName: '', url: item)); + } else if (item is Map) { + final url = + item['url']?.toString() ?? + item['fileUrl']?.toString() ?? + ''; + final fileName = + item['fileName']?.toString() ?? + item['name']?.toString() ?? + ''; + if (url.isNotEmpty) + list.add(AttachmentModel(fileName: fileName, url: url)); + } + } + if (list.isNotEmpty) return list; + } else if (nestedImages is String && nestedImages.isNotEmpty) { + return [AttachmentModel(fileName: '', url: nestedImages)]; + } + } + } + } + return null; + } + + static List? _parseVideoUrls(Map json) { + const fieldNames = ['videoUrl', 'videoUrls', 'videos', 'videoList']; + for (final field in fieldNames) { + final dynamic value = json[field]; + if (value == null) continue; + + if (value is String && value.isNotEmpty) { + return [value]; + } + if (value is List) { + final list = []; + for (final item in value) { + if (item is String && item.isNotEmpty) { + list.add(item); + } else if (item is Map) { + final url = + item['url']?.toString() ?? item['fileUrl']?.toString() ?? ''; + if (url.isNotEmpty) list.add(url); + } + } + if (list.isNotEmpty) return list; + } + } + + final dynamic attachments = json['attachments']; + if (attachments is List) { + final list = []; + for (final item in attachments) { + if (item is Map) { + final type = + (item['type']?.toString() ?? item['mediaType']?.toString() ?? '') + .toLowerCase(); + if (type == 'video' || type == 'video/mp4') { + final url = + item['url']?.toString() ?? item['fileUrl']?.toString() ?? ''; + if (url.isNotEmpty) list.add(url); + } + } + } + if (list.isNotEmpty) return list; + } + + final dynamic media = json['media']; + if (media is Map) { + final dynamic nestedVideos = + media['videos'] ?? media['videoUrls'] ?? media['videoUrl']; + if (nestedVideos != null) { + if (nestedVideos is List) { + final list = []; + for (final item in nestedVideos) { + if (item is String && item.isNotEmpty) list.add(item); + if (item is Map) { + final url = + item['url']?.toString() ?? item['fileUrl']?.toString() ?? ''; + if (url.isNotEmpty) list.add(url); + } + } + if (list.isNotEmpty) return list; + } else if (nestedVideos is String && nestedVideos.isNotEmpty) { + return [nestedVideos]; + } + } + } + + return null; + } + static WorkOrderPriority _parsePriority(String value) { - switch (value) { + switch (value.toUpperCase()) { + case 'HIGH': case 'high': return WorkOrderPriority.high; + case 'MEDIUM': case 'medium': return WorkOrderPriority.medium; + case 'LOW': case 'low': return WorkOrderPriority.low; + case 'ERROR': + return WorkOrderPriority.high; + case 'WARNING': + return WorkOrderPriority.medium; + case 'INFO': + return WorkOrderPriority.low; default: return WorkOrderPriority.medium; } @@ -130,6 +616,40 @@ class WorkOrderModel { } } + static WorkOrderStatus _parseStatusFromInt(int value) { + switch (value) { + case 1: + return WorkOrderStatus.pending; + case 2: + return WorkOrderStatus.executing; + case 3: + return WorkOrderStatus.completed; + default: + return WorkOrderStatus.pending; + } + } + + static String _parseSourceType(int value) { + switch (value) { + case 1: + return '手动创建'; + case 2: + return '计划任务'; + case 3: + return '定期维护'; + case 4: + return '巡检发现'; + case 5: + return '设备上报'; + case 6: + return '客户反馈'; + case 7: + return '告警联动'; + default: + return '未知来源'; + } + } + static String _statusToString(WorkOrderStatus status) { switch (status) { case WorkOrderStatus.pending: @@ -156,7 +676,6 @@ class WorkOrderCountModel { required this.todayCompletedCount, }); - /// 从 JSON 创建 factory WorkOrderCountModel.fromJson(Map json) { return WorkOrderCountModel( pendingCount: json['pendingCount'] as int, @@ -165,7 +684,6 @@ class WorkOrderCountModel { ); } - /// 转换为 JSON Map toJson() { return { 'pendingCount': pendingCount, @@ -174,7 +692,6 @@ class WorkOrderCountModel { }; } - /// 转换为实体 WorkOrderCountEntity toEntity() { return WorkOrderCountEntity( pendingCount: pendingCount, diff --git a/lib/features/v2/workorder/data/repositories/workorder_repository_impl.dart b/lib/features/v2/workorder/data/repositories/workorder_repository_impl.dart index f8834beb..0a8fb076 100644 --- a/lib/features/v2/workorder/data/repositories/workorder_repository_impl.dart +++ b/lib/features/v2/workorder/data/repositories/workorder_repository_impl.dart @@ -1,4 +1,3 @@ -import '../../../../../core/consts/workorder_consts.dart'; import '../../../../../core/error/failure.dart'; import '../../../../../core/error/workorder_failure.dart'; import '../../domain/repositories/workorder_repository.dart'; @@ -6,7 +5,6 @@ import '../../domain/entities/workorder_entity.dart'; import '../datasources/workorder_remote_datasource.dart'; import 'package:fpdart/fpdart.dart'; - class WorkOrderRepositoryImpl implements WorkOrderRepository { final WorkOrderRemoteDataSource remoteDataSource; @@ -14,11 +12,17 @@ class WorkOrderRepositoryImpl implements WorkOrderRepository { @override Future>> getWorkOrderList({ - WorkOrderStatus? status, + required int page, + required int pageSize, + int? siteId, + int? orgId, }) async { try { final models = await remoteDataSource.getWorkOrderList( - status: status != null ? _statusToString(status) : null, + page: page, + pageSize: pageSize, + siteId: siteId, + orgId: orgId, ); final entities = models.map((model) => model.toEntity()).toList(); return Right(entities); @@ -26,7 +30,7 @@ class WorkOrderRepositoryImpl implements WorkOrderRepository { return Left(UnknownFailure(message: '获取工单列表失败: $e')); } } - + @override Future> getWorkOrderCount() async { try { @@ -38,22 +42,66 @@ class WorkOrderRepositoryImpl implements WorkOrderRepository { } @override - Future>> filterWorkOrders({ - required WorkOrderStatus status, + Future> getWorkOrderDetail( + String orderId, { + int? siteId, + int? orgId, }) async { - return getWorkOrderList(status: status); + try { + final model = await remoteDataSource.getWorkOrderDetail( + orderId, + siteId: siteId, + orgId: orgId, + ); + return Right(model.toEntity()); + } catch (e) { + return Left(UnknownFailure(message: '获取工单详情失败: $e')); + } } - String _statusToString(WorkOrderStatus status) { - switch (status) { - case WorkOrderStatus.pending: - return 'pending'; - case WorkOrderStatus.executing: - return 'executing'; - case WorkOrderStatus.completed: - return 'completed'; - case WorkOrderStatus.all: - return 'all'; + @override + Future>>> fetchUsers({ + required int orgId, + required int siteId, + }) async { + try { + final result = await remoteDataSource.fetchUsers( + orgId: orgId, + siteId: siteId, + ); + return Right(result); + } catch (e) { + return Left(UnknownFailure(message: '获取人员列表失败: $e')); + } + } + + @override + Future> dispatchWorkOrder({ + required String orderId, + required String assigneeId, + required String assigneeName, + String? dispatchRemark, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + }) async { + try { + await remoteDataSource.dispatchWorkOrder( + orderId: orderId, + assigneeId: assigneeId, + assigneeName: assigneeName, + dispatchRemark: dispatchRemark, + collaboratorIds: collaboratorIds, + collaboratorNames: collaboratorNames, + planStartTime: planStartTime, + planEndTime: planEndTime, + deadlineTime: deadlineTime, + ); + return const Right(null); + } catch (e) { + return Left(UnknownFailure(message: '转派工单失败: $e')); } } } diff --git a/lib/features/v2/workorder/domain/entities/workorder_entity.dart b/lib/features/v2/workorder/domain/entities/workorder_entity.dart index 98326c29..ce44f428 100644 --- a/lib/features/v2/workorder/domain/entities/workorder_entity.dart +++ b/lib/features/v2/workorder/domain/entities/workorder_entity.dart @@ -1,5 +1,64 @@ +import 'package:equatable/equatable.dart'; import '../../../../../core/consts/workorder_consts.dart'; +/// 设备对象实体 +class DeviceObjectEntity extends Equatable { + final String deviceName; + final String deviceType; + final String assetCode; + + const DeviceObjectEntity({ + required this.deviceName, + required this.deviceType, + required this.assetCode, + }); + + @override + List get props => [deviceName, deviceType, assetCode]; +} + +/// 地点实体 +class LocationEntity extends Equatable { + final String stationName; + final String area; + final String region; + final String detailAddress; + + const LocationEntity({ + required this.stationName, + required this.area, + required this.region, + required this.detailAddress, + }); + + @override + List get props => [stationName, area, region, detailAddress]; +} + +/// 时限要求实体 +class TimeLimitEntity extends Equatable { + final String expectedCompleteTime; + final String remainingTime; + + const TimeLimitEntity({ + required this.expectedCompleteTime, + required this.remainingTime, + }); + + @override + List get props => [expectedCompleteTime, remainingTime]; +} + +/// 附件实体 +class AttachmentEntity extends Equatable { + final String fileName; + final String url; + + const AttachmentEntity({required this.fileName, required this.url}); + + @override + List get props => [fileName, url]; +} /// 工单详情实体 class WorkOrderEntity { @@ -13,6 +72,34 @@ class WorkOrderEntity { final String location; final WorkOrderStatus status; final double? progress; + final String? source; + final DeviceObjectEntity? deviceObject; + final LocationEntity? locationDetail; + final TimeLimitEntity? timeLimit; + final String? description; + final List? attachments; + final String? orderType; + final String? alarmId; + final String? alarmNo; + final String? handleResult; + final String? handleRemark; + final String? planStartTime; + final String? planEndTime; + final String? siteName; + final String? deviceId; + final String? deviceName; + final String? deviceType; + final String? assigneeName; + final String? aiSuggestion; + final String? requiredEquipment; + final String? estimatedImpact; + final String? taskDescription; + final String? actualStartTime; + final String? actualEndTime; + final String? deadlineTime; + final int? sourceType; + final List? videoUrls; + final String? updateTime; WorkOrderEntity({ required this.id, @@ -25,6 +112,34 @@ class WorkOrderEntity { required this.location, required this.status, this.progress, + this.source, + this.deviceObject, + this.locationDetail, + this.timeLimit, + this.description, + this.attachments, + this.orderType, + this.alarmId, + this.alarmNo, + this.handleResult, + this.handleRemark, + this.planStartTime, + this.planEndTime, + this.siteName, + this.deviceId, + this.deviceName, + this.deviceType, + this.assigneeName, + this.aiSuggestion, + this.requiredEquipment, + this.estimatedImpact, + this.taskDescription, + this.actualStartTime, + this.actualEndTime, + this.deadlineTime, + this.sourceType, + this.videoUrls, + this.updateTime, }); } diff --git a/lib/features/v2/workorder/domain/repositories/workorder_repository.dart b/lib/features/v2/workorder/domain/repositories/workorder_repository.dart index b7024957..94f25275 100644 --- a/lib/features/v2/workorder/domain/repositories/workorder_repository.dart +++ b/lib/features/v2/workorder/domain/repositories/workorder_repository.dart @@ -1,20 +1,37 @@ -import '../../../../../core/consts/workorder_consts.dart'; import '../../../../../core/error/failure.dart'; import '../entities/workorder_entity.dart'; import 'package:fpdart/fpdart.dart'; -/// 工单仓储接口 abstract class WorkOrderRepository { - /// 获取工单列表 Future>> getWorkOrderList({ - WorkOrderStatus? status, + required int page, + required int pageSize, + int? siteId, + int? orgId, }); - /// 获取工单统计 Future> getWorkOrderCount(); - /// 筛选工单 - Future>> filterWorkOrders({ - required WorkOrderStatus status, + Future> getWorkOrderDetail( + String orderId, { + int? siteId, + int? orgId, + }); + + Future>>> fetchUsers({ + required int orgId, + required int siteId, + }); + + Future> dispatchWorkOrder({ + required String orderId, + required String assigneeId, + required String assigneeName, + String? dispatchRemark, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, }); } diff --git a/lib/features/v2/workorder/domain/usecases/get_workorder_detail_usecase.dart b/lib/features/v2/workorder/domain/usecases/get_workorder_detail_usecase.dart new file mode 100644 index 00000000..951b48d8 --- /dev/null +++ b/lib/features/v2/workorder/domain/usecases/get_workorder_detail_usecase.dart @@ -0,0 +1,14 @@ +import '../repositories/workorder_repository.dart'; +import '../entities/workorder_entity.dart'; +import '../../../../../core/error/failure.dart'; +import 'package:fpdart/fpdart.dart'; + +class GetWorkOrderDetailUseCase { + final WorkOrderRepository repository; + + GetWorkOrderDetailUseCase({required this.repository}); + + Future> call(String orderId, {int? siteId, int? orgId}) { + return repository.getWorkOrderDetail(orderId, siteId: siteId, orgId: orgId); + } +} diff --git a/lib/features/v2/workorder/domain/usecases/get_workorder_list_usecase.dart b/lib/features/v2/workorder/domain/usecases/get_workorder_list_usecase.dart index 8f629d2e..18d85b92 100644 --- a/lib/features/v2/workorder/domain/usecases/get_workorder_list_usecase.dart +++ b/lib/features/v2/workorder/domain/usecases/get_workorder_list_usecase.dart @@ -11,9 +11,17 @@ class GetWorkOrderListUseCase { GetWorkOrderListUseCase({required this.repository}); Future>> execute({ - WorkOrderStatus? status, + required int page, + required int pageSize, + int? siteId, + int? orgId, }) async { - return await repository.getWorkOrderList(status: status); + return await repository.getWorkOrderList( + page: page, + pageSize: pageSize, + siteId: siteId, + orgId: orgId, + ); } } diff --git a/lib/features/v2/workorder/domain/usecases/transfer_workorder_usecase.dart b/lib/features/v2/workorder/domain/usecases/transfer_workorder_usecase.dart new file mode 100644 index 00000000..c9519e87 --- /dev/null +++ b/lib/features/v2/workorder/domain/usecases/transfer_workorder_usecase.dart @@ -0,0 +1,46 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../../core/error/failure.dart'; +import '../repositories/workorder_repository.dart'; + +class FetchUsersUseCase { + final WorkOrderRepository repository; + + FetchUsersUseCase(this.repository); + + Future>>> execute({ + required int orgId, + required int siteId, + }) async { + return await repository.fetchUsers(orgId: orgId, siteId: siteId); + } +} + +class DispatchWorkOrderUseCase { + final WorkOrderRepository repository; + + DispatchWorkOrderUseCase(this.repository); + + Future> execute({ + required String orderId, + required String assigneeId, + required String assigneeName, + String? dispatchRemark, + String? collaboratorIds, + String? collaboratorNames, + String? planStartTime, + String? planEndTime, + String? deadlineTime, + }) async { + return await repository.dispatchWorkOrder( + orderId: orderId, + assigneeId: assigneeId, + assigneeName: assigneeName, + dispatchRemark: dispatchRemark, + collaboratorIds: collaboratorIds, + collaboratorNames: collaboratorNames, + planStartTime: planStartTime, + planEndTime: planEndTime, + deadlineTime: deadlineTime, + ); + } +} diff --git a/lib/features/v2/workorder/presentation/cubit/transfer_cubit.dart b/lib/features/v2/workorder/presentation/cubit/transfer_cubit.dart new file mode 100644 index 00000000..bb46fe56 --- /dev/null +++ b/lib/features/v2/workorder/presentation/cubit/transfer_cubit.dart @@ -0,0 +1,133 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/usecases/transfer_workorder_usecase.dart'; +import '../../../../../core/error/failure.dart'; + +class TransferState extends Equatable { + final bool isLoading; + final bool isSubmitting; + final List> users; + final String? selectedUserId; + final String? selectedUserName; + final String? remark; + final String? errorMessage; + final bool? isSuccess; + + const TransferState({ + this.isLoading = false, + this.isSubmitting = false, + this.users = const [], + this.selectedUserId, + this.selectedUserName, + this.remark, + this.errorMessage, + this.isSuccess, + }); + + TransferState copyWith({ + bool? isLoading, + bool? isSubmitting, + List>? users, + String? selectedUserId, + String? selectedUserName, + String? remark, + String? errorMessage, + bool? isSuccess, + }) { + return TransferState( + isLoading: isLoading ?? this.isLoading, + isSubmitting: isSubmitting ?? this.isSubmitting, + users: users ?? this.users, + selectedUserId: selectedUserId ?? this.selectedUserId, + selectedUserName: selectedUserName ?? this.selectedUserName, + remark: remark ?? this.remark, + errorMessage: errorMessage, + isSuccess: isSuccess, + ); + } + + @override + List get props => [ + isLoading, + isSubmitting, + users, + selectedUserId, + selectedUserName, + remark, + errorMessage, + isSuccess, + ]; +} + +class TransferCubit extends Cubit { + final FetchUsersUseCase fetchUsersUseCase; + final DispatchWorkOrderUseCase dispatchUseCase; + + TransferCubit({ + required this.fetchUsersUseCase, + required this.dispatchUseCase, + }) : super(const TransferState()); + + Future loadUsers({required int orgId, required int siteId}) async { + emit(state.copyWith(isLoading: true, errorMessage: null)); + + final result = await fetchUsersUseCase.execute( + orgId: orgId, + siteId: siteId, + ); + + result.fold( + (failure) { + emit(state.copyWith(isLoading: false, errorMessage: failure.message)); + }, + (users) { + emit(state.copyWith(isLoading: false, users: users)); + }, + ); + } + + void selectUser(String userId, String userName) { + emit(state.copyWith(selectedUserId: userId, selectedUserName: userName)); + } + + void updateRemark(String remark) { + emit(state.copyWith(remark: remark)); + } + + Future submitTransfer({required String workOrderId}) async { + if (state.selectedUserId == null || state.selectedUserId!.isEmpty) { + emit(state.copyWith(errorMessage: '请选择接收人', isSuccess: false)); + return false; + } + + emit(state.copyWith(isSubmitting: true, errorMessage: null)); + + final result = await dispatchUseCase.execute( + orderId: workOrderId, + assigneeId: state.selectedUserId!, + assigneeName: state.selectedUserName ?? '', + dispatchRemark: state.remark, + ); + + return result.fold( + (failure) { + emit( + state.copyWith( + isSubmitting: false, + errorMessage: failure.message, + isSuccess: false, + ), + ); + return false; + }, + (_) { + emit(state.copyWith(isSubmitting: false, isSuccess: true)); + return true; + }, + ); + } + + void reset() { + emit(const TransferState()); + } +} diff --git a/lib/features/v2/workorder/presentation/cubit/workorder_cubit.dart b/lib/features/v2/workorder/presentation/cubit/workorder_cubit.dart index 031d0310..9e4b69bc 100644 --- a/lib/features/v2/workorder/presentation/cubit/workorder_cubit.dart +++ b/lib/features/v2/workorder/presentation/cubit/workorder_cubit.dart @@ -1,5 +1,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import '../../../../../core/app/app_user_cubit.dart'; import '../../../../../core/consts/workorder_consts.dart'; import '../../../../../core/error/failure.dart'; import '../../../../../core/error/workorder_failure.dart'; @@ -22,17 +26,58 @@ class WorkOrderLoading extends WorkOrderState {} class WorkOrderLoaded extends WorkOrderState { final List workOrders; + final List allWorkOrders; final WorkOrderCountEntity? count; final WorkOrderStatus currentStatus; + final int currentPage; + final bool hasMore; + final bool isLoadingMore; + final String? errorMessage; const WorkOrderLoaded({ required this.workOrders, + required this.allWorkOrders, this.count, this.currentStatus = WorkOrderStatus.pending, + this.currentPage = 1, + this.hasMore = true, + this.isLoadingMore = false, + this.errorMessage, }); @override - List get props => [workOrders, count, currentStatus]; + List get props => [ + workOrders, + allWorkOrders, + count, + currentStatus, + currentPage, + hasMore, + isLoadingMore, + errorMessage, + ]; + + WorkOrderLoaded copyWith({ + List? workOrders, + List? allWorkOrders, + WorkOrderCountEntity? count, + WorkOrderStatus? currentStatus, + int? currentPage, + bool? hasMore, + bool? isLoadingMore, + String? errorMessage, + }) { + return WorkOrderLoaded( + workOrders: workOrders ?? this.workOrders, + allWorkOrders: allWorkOrders ?? this.allWorkOrders, + count: count ?? this.count, + currentStatus: currentStatus ?? this.currentStatus, + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: errorMessage ?? this.errorMessage, + ); + } } class WorkOrderError extends WorkOrderState { @@ -48,42 +93,114 @@ class WorkOrderError extends WorkOrderState { class WorkOrderCubit extends Cubit { final GetWorkOrderListUseCase getWorkOrderListUseCase; - final GetWorkOrderCountUseCase getWorkOrderCountUseCase; - WorkOrderCubit({ - required this.getWorkOrderListUseCase, - required this.getWorkOrderCountUseCase, - }) : super(WorkOrderInitial()); + WorkOrderCubit({required this.getWorkOrderListUseCase}) + : super(WorkOrderInitial()); + + static const int _pageSize = 100; + + List _filterWorkOrders( + List allWorkOrders, + WorkOrderStatus status, + ) { + if (status == WorkOrderStatus.all) { + return allWorkOrders; + } + return allWorkOrders.where((order) => order.status == status).toList(); + } /// 初始加载 Future loadInitialData() async { emit(WorkOrderLoading()); try { - // 并行加载工单列表和统计数据 - final results = await Future.wait([ - getWorkOrderListUseCase.execute(status: WorkOrderStatus.pending), - getWorkOrderCountUseCase.execute(), - ]); + final siteId = GetIt.I().state.selectedSite?.id; + final orgId = GetIt.I().state.user?.orgId; - final listResult = results[0] as Either>; - final countResult = results[1] as Either; + List allWorkOrders = []; + String? errorMessage; - listResult.fold( - (Failure failure) => emit(WorkOrderError(failure: failure)), - (workOrders) { - countResult.fold( - (Failure failure) => emit(WorkOrderError(failure: failure)), - (count) => emit(WorkOrderLoaded( - workOrders: workOrders, - count: count, - currentStatus: WorkOrderStatus.pending, - )), - ); - }, + final listResult = await getWorkOrderListUseCase.execute( + page: 1, + pageSize: _pageSize, + siteId: siteId, + orgId: orgId, + ); + + listResult.fold((Failure failure) { + debugPrint('❌ [WorkOrder] 获取工单列表失败: ${failure.message}'); + errorMessage = '暂无数据'; + }, (List orders) => allWorkOrders = orders); + + emit( + WorkOrderLoaded( + workOrders: _filterWorkOrders(allWorkOrders, WorkOrderStatus.pending), + allWorkOrders: allWorkOrders, + count: null, + currentStatus: WorkOrderStatus.pending, + currentPage: 1, + hasMore: allWorkOrders.length >= _pageSize, + errorMessage: errorMessage, + ), ); } catch (e) { - emit(WorkOrderError(failure: UnknownFailure(message: '加载失败: $e'))); + debugPrint('❌ [WorkOrder] 加载工单异常: $e'); + emit( + WorkOrderLoaded( + workOrders: [], + allWorkOrders: [], + count: null, + currentStatus: WorkOrderStatus.pending, + currentPage: 1, + hasMore: false, + errorMessage: '暂无数据', + ), + ); + } + } + + /// 加载更多 + Future loadMore() async { + final currentState = state; + if (currentState is WorkOrderLoaded) { + if (!currentState.hasMore || currentState.isLoadingMore) return; + + emit(currentState.copyWith(isLoadingMore: true)); + + try { + final siteId = GetIt.I().state.selectedSite?.id; + final orgId = GetIt.I().state.user?.orgId; + final result = await getWorkOrderListUseCase.execute( + page: currentState.currentPage + 1, + pageSize: _pageSize, + siteId: siteId, + orgId: orgId, + ); + + result.fold( + (Failure failure) => emit(WorkOrderError(failure: failure)), + (newWorkOrders) { + final updatedAllWorkOrders = [ + ...currentState.allWorkOrders, + ...newWorkOrders, + ]; + emit( + currentState.copyWith( + allWorkOrders: updatedAllWorkOrders, + workOrders: _filterWorkOrders( + updatedAllWorkOrders, + currentState.currentStatus, + ), + currentPage: currentState.currentPage + 1, + hasMore: newWorkOrders.length >= _pageSize, + isLoadingMore: false, + ), + ); + }, + ); + } catch (e) { + emit(currentState.copyWith(isLoadingMore: false)); + } } } @@ -93,22 +210,12 @@ class WorkOrderCubit extends Cubit { final currentState = state as WorkOrderLoaded; if (currentState.currentStatus == status) return; - emit(WorkOrderLoading()); - - try { - final result = await getWorkOrderListUseCase.execute(status: status); - - result.fold( - (Failure failure) => emit(WorkOrderError(failure: failure)), - (workOrders) => emit(WorkOrderLoaded( - workOrders: workOrders, - count: currentState.count, - currentStatus: status, - )), - ); - } catch (e) { - emit(WorkOrderError(failure: UnknownFailure(message: '切换失败: $e'))); - } + emit( + currentState.copyWith( + workOrders: _filterWorkOrders(currentState.allWorkOrders, status), + currentStatus: status, + ), + ); } } diff --git a/lib/features/v2/workorder/presentation/cubit/workorder_detail_cubit.dart b/lib/features/v2/workorder/presentation/cubit/workorder_detail_cubit.dart new file mode 100644 index 00000000..885a0c57 --- /dev/null +++ b/lib/features/v2/workorder/presentation/cubit/workorder_detail_cubit.dart @@ -0,0 +1,67 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:get_it/get_it.dart'; +import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; +import '../../../../../core/app/app_user_cubit.dart'; +import '../../../../../core/error/failure.dart'; +import '../../../../../core/error/workorder_failure.dart'; +import '../../domain/usecases/get_workorder_detail_usecase.dart'; +import '../../domain/entities/workorder_entity.dart'; +import 'package:fpdart/fpdart.dart'; + +abstract class WorkOrderDetailState extends Equatable { + const WorkOrderDetailState(); + + @override + List get props => []; +} + +class WorkOrderDetailInitial extends WorkOrderDetailState {} + +class WorkOrderDetailLoading extends WorkOrderDetailState {} + +class WorkOrderDetailLoaded extends WorkOrderDetailState { + final WorkOrderEntity workOrder; + + const WorkOrderDetailLoaded({required this.workOrder}); + + @override + List get props => [workOrder]; +} + +class WorkOrderDetailError extends WorkOrderDetailState { + final Failure failure; + + const WorkOrderDetailError({required this.failure}); + + @override + List get props => [failure]; +} + +class WorkOrderDetailCubit extends Cubit { + final GetWorkOrderDetailUseCase getWorkOrderDetailUseCase; + + WorkOrderDetailCubit({required this.getWorkOrderDetailUseCase}) + : super(WorkOrderDetailInitial()); + + Future loadWorkOrderDetail(String orderId) async { + emit(WorkOrderDetailLoading()); + + try { + final siteId = GetIt.I().state.selectedSite?.id; + final orgId = GetIt.I().state.user?.orgId; + final result = await getWorkOrderDetailUseCase( + orderId, + siteId: siteId, + orgId: orgId, + ); + + result.fold( + (Failure failure) => emit(WorkOrderDetailError(failure: failure)), + (workOrder) => emit(WorkOrderDetailLoaded(workOrder: workOrder)), + ); + } catch (e) { + emit(WorkOrderDetailError(failure: UnknownFailure(message: '加载失败: $e'))); + } + } +} diff --git a/lib/features/v2/workorder/presentation/pages/transfer_dialog.dart b/lib/features/v2/workorder/presentation/pages/transfer_dialog.dart new file mode 100644 index 00000000..1b268b39 --- /dev/null +++ b/lib/features/v2/workorder/presentation/pages/transfer_dialog.dart @@ -0,0 +1,335 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import '../../../../../core/app/app_user_cubit.dart'; +import '../../../../../core/network/dio_client.dart'; +import '../../../site/presentation/cubit/site_cubit.dart'; +import '../../domain/usecases/transfer_workorder_usecase.dart'; +import '../../data/repositories/workorder_repository_impl.dart'; +import '../../data/datasources/workorder_remote_datasource_impl.dart'; +import '../cubit/transfer_cubit.dart'; + +class TransferDialog extends StatelessWidget { + final String workOrderId; + + const TransferDialog({super.key, required this.workOrderId}); + + @override + Widget build(BuildContext context) { + final appUserCubit = GetIt.I(); + final user = appUserCubit.state.user; + final orgId = user?.orgId ?? 0; + final siteId = GetIt.I().state.selectedSite?.id ?? 0; + + final dio = DioClient.create(); + final remoteDataSource = WorkOrderRemoteDataSourceImpl(dio); + final repository = WorkOrderRepositoryImpl( + remoteDataSource: remoteDataSource, + ); + final fetchUsersUseCase = FetchUsersUseCase(repository); + final dispatchUseCase = DispatchWorkOrderUseCase(repository); + + return BlocProvider( + create: (_) => TransferCubit( + fetchUsersUseCase: fetchUsersUseCase, + dispatchUseCase: dispatchUseCase, + )..loadUsers(orgId: orgId, siteId: siteId), + child: _TransferDialogContent(workOrderId: workOrderId), + ); + } +} + +class _TransferDialogContent extends StatefulWidget { + final String workOrderId; + + const _TransferDialogContent({required this.workOrderId}); + + @override + State<_TransferDialogContent> createState() => _TransferDialogContentState(); +} + +class _TransferDialogContentState extends State<_TransferDialogContent> { + late TextEditingController _remarkController; + + @override + void initState() { + super.initState(); + _remarkController = TextEditingController(); + } + + @override + void dispose() { + _remarkController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state.isSuccess == true) { + Navigator.of(context).pop(true); + } + if (state.errorMessage != null && state.isSuccess == false) { + _showErrorDialog(context, state.errorMessage!); + } + }, + builder: (context, state) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: 16), + _buildUserList(state), + const SizedBox(height: 12), + _buildRemarkInput(context), + const SizedBox(height: 16), + _buildSubmitButton(context, state), + ], + ), + ); + }, + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + children: [ + const Expanded( + child: Text( + '转派工单', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2129), + ), + ), + ), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon(Icons.close, size: 24, color: Color(0xFF86909C)), + ), + ], + ); + } + + Widget _buildUserList(TransferState state) { + if (state.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ); + } + + if (state.users.isEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Text( + '暂无可转派人员', + style: TextStyle(color: Color(0xFF86909C), fontSize: 14), + ), + ), + ); + } + + return Container( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: state.users.length, + itemBuilder: (context, index) { + final user = state.users[index]; + final userId = (user['userId'] ?? user['id'] ?? '').toString(); + final userName = (user['nickName'] ?? user['username'] ?? '') + .toString(); + final isSelected = state.selectedUserId == userId; + + return GestureDetector( + onTap: () => + context.read().selectUser(userId, userName), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFFE8F3FF) + : const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected + ? const Color(0xFF165DFF) + : Colors.transparent, + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: isSelected + ? const Color(0xFF165DFF) + : const Color(0xFFC9CDD4), + child: Text( + userName.isNotEmpty ? userName[0] : '?', + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + userName.isNotEmpty ? userName : '未知用户', + style: const TextStyle( + fontSize: 15, + color: Color(0xFF1D2129), + ), + ), + ), + if (isSelected) + const Icon( + Icons.check_circle, + color: Color(0xFF165DFF), + size: 22, + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildRemarkInput(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '派发备注(选填)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2129), + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: _remarkController, + maxLines: 3, + maxLength: 200, + onChanged: (value) => + context.read().updateRemark(value), + decoration: const InputDecoration( + hintText: '请输入派发备注', + border: InputBorder.none, + contentPadding: EdgeInsets.all(12), + isDense: true, + ), + ), + ), + ], + ); + } + + Widget _buildSubmitButton(BuildContext context, TransferState state) { + return SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: state.isSubmitting + ? null + : () { + context.read().submitTransfer( + workOrderId: widget.workOrderId, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + elevation: 0, + padding: EdgeInsets.zero, + 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: 16, fontWeight: FontWeight.w500), + ), + ), + ); + } + + void _showErrorDialog(BuildContext context, String message) { + showDialog( + context: context, + builder: (_) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, 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), + Text( + message, + style: const TextStyle(fontSize: 14, color: Color(0xFF86909C)), + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + 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)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/v2/workorder/presentation/pages/workorder_detail_page.dart b/lib/features/v2/workorder/presentation/pages/workorder_detail_page.dart new file mode 100644 index 00000000..0cdb7d6b --- /dev/null +++ b/lib/features/v2/workorder/presentation/pages/workorder_detail_page.dart @@ -0,0 +1,1108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:video_player/video_player.dart'; +import '../../../../../core/consts/workorder_consts.dart'; +import '../cubit/workorder_detail_cubit.dart'; +import '../../domain/entities/workorder_entity.dart'; +import 'transfer_dialog.dart'; + +class WorkOrderDetailPage extends StatefulWidget { + final String orderId; + + const WorkOrderDetailPage({super.key, required this.orderId}); + + @override + State createState() => _WorkOrderDetailPageState(); +} + +class _WorkOrderDetailPageState extends State { + late WorkOrderDetailCubit _cubit; + + @override + void initState() { + super.initState(); + _cubit = context.read(); + _cubit.loadWorkOrderDetail(widget.orderId); + } + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: WorkOrderColors.pageBackground, + appBar: _buildAppBar(), + body: BlocBuilder( + builder: (context, state) { + if (state is WorkOrderDetailLoading || + state is WorkOrderDetailInitial) { + return _buildLoadingView(); + } + + if (state is WorkOrderDetailError) { + return _buildErrorView(state.failure.message); + } + + if (state is WorkOrderDetailLoaded) { + return _buildContent(state.workOrder); + } + + return const SizedBox(); + }, + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: WorkOrderColors.primaryText), + onPressed: () => context.pop(), + ), + title: const Text( + '工单详情', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon( + Icons.more_horiz, + color: WorkOrderColors.primaryText, + ), + onPressed: () {}, + ), + ], + ); + } + + Widget _buildLoadingView() { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(WorkOrderColors.primary), + ), + ); + } + + Widget _buildErrorView(String message) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: WorkOrderColors.auxiliaryText, + ), + const SizedBox(height: 16), + Text( + message, + style: TextStyle( + fontSize: 14, + color: WorkOrderColors.auxiliaryText, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => _cubit.loadWorkOrderDetail(widget.orderId), + style: ElevatedButton.styleFrom( + backgroundColor: WorkOrderColors.primary, + foregroundColor: Colors.white, + ), + child: const Text('重试'), + ), + ], + ), + ); + } + + Widget _buildContent(WorkOrderEntity workOrder) { + return Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildOrderHeader(workOrder), + const SizedBox(height: 16), + _buildBasicInfoCard(workOrder), + const SizedBox(height: 16), + _buildDeviceCard(workOrder), + const SizedBox(height: 16), + _buildTimeCard(workOrder), + const SizedBox(height: 16), + _buildDescriptionCard(workOrder), + const SizedBox(height: 16), + _buildHandleCard(workOrder), + const SizedBox(height: 16), + _buildAttachmentsCard(workOrder), + const SizedBox(height: 100), + ], + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomButtons(workOrder), + ), + ], + ); + } + + Widget _buildOrderHeader(WorkOrderEntity workOrder) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '工单编号', + style: TextStyle( + fontSize: 13, + color: WorkOrderColors.auxiliaryText, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + workOrder.orderNo, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + ), + if (workOrder.priority == WorkOrderPriority.high) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFFF4D4F), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '紧急', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + workOrder.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(height: 16), + _buildInfoRow( + '状态', + _getStatusLabel(workOrder.status), + valueColor: _getStatusColor(workOrder.status), + ), + _buildInfoRow( + '优先级', + _getPriorityLabel(workOrder.priority), + valueColor: _getPriorityColor(workOrder.priority), + ), + _buildInfoRow('来源', workOrder.source ?? '--'), + _buildInfoRow( + '工单类型', + _getOrderTypeLabel(workOrder.orderType), + isLast: true, + ), + ], + ), + ); + } + + Widget _buildBasicInfoCard(WorkOrderEntity workOrder) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '基本信息', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(height: 12), + _buildInfoRow('场站名称', workOrder.siteName ?? '--'), + if (workOrder.assigneeName != null && + workOrder.assigneeName!.isNotEmpty) + _buildInfoRow('负责人', workOrder.assigneeName!), + _buildInfoRow('AI建议', workOrder.aiSuggestion ?? '--'), + _buildInfoRow('所需设备', workOrder.requiredEquipment ?? '--'), + _buildInfoRow( + '预计影响', + workOrder.estimatedImpact ?? '--', + isLast: true, + ), + ], + ), + ); + } + + Widget _buildDeviceCard(WorkOrderEntity workOrder) { + final deviceLabel = workOrder.deviceName?.isNotEmpty == true + ? workOrder.deviceName! + : (workOrder.deviceId ?? '--'); + final typeLabel = _mapDeviceType( + workOrder.orderType ?? '', + workOrder.deviceType ?? '', + ); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '设备信息', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(height: 12), + _buildInfoRow('设备名称', deviceLabel), + _buildInfoRow('设备类型', typeLabel, isLast: true), + ], + ), + ); + } + + Widget _buildTimeCard(WorkOrderEntity workOrder) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '时间信息', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(height: 12), + _buildInfoRow('创建时间', _formatDateTime(workOrder.createTime)), + _buildInfoRow('计划开始时间', workOrder.planStartTime ?? '--'), + _buildInfoRow('计划结束时间', workOrder.planEndTime ?? '--'), + _buildInfoRow('实际开始时间', workOrder.actualStartTime ?? '--'), + _buildInfoRow('实际结束时间', workOrder.actualEndTime ?? '--'), + _buildInfoRow('截止时间', workOrder.deadlineTime ?? '--'), + _buildInfoRow('更新时间', workOrder.updateTime ?? '--', isLast: true), + ], + ), + ); + } + + Widget _buildDescriptionCard(WorkOrderEntity workOrder) { + if (workOrder.taskDescription == null || + workOrder.taskDescription!.isEmpty) { + return const SizedBox(); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '任务描述', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(height: 12), + Text( + workOrder.taskDescription!, + style: TextStyle( + fontSize: 14, + color: WorkOrderColors.secondaryText, + height: 1.6, + ), + ), + ], + ), + ); + } + + Widget _buildHandleCard(WorkOrderEntity workOrder) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '处理信息', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(height: 12), + _buildInfoRow('处理结果', _getHandleResultLabel(workOrder.handleResult)), + _buildInfoRow('处理备注', workOrder.handleRemark ?? '--', isLast: true), + ], + ), + ); + } + + Widget _buildAttachmentsCard(WorkOrderEntity workOrder) { + final hasImages = + workOrder.attachments != null && workOrder.attachments!.isNotEmpty; + final hasVideos = + workOrder.videoUrls != null && workOrder.videoUrls!.isNotEmpty; + + if (!hasImages && !hasVideos) { + return const SizedBox(); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: WorkOrderColors.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '附件', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: WorkOrderColors.primaryText, + ), + ), + const SizedBox(width: 8), + Text( + '(${hasImages ? workOrder.attachments!.length : 0}张图片${hasVideos ? ' ${workOrder.videoUrls!.length}个视频' : ''})', + style: TextStyle( + fontSize: 14, + color: WorkOrderColors.auxiliaryText, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + if (hasImages) + ...workOrder.attachments!.map((attachment) { + return _buildImageItem(attachment); + }).toList(), + if (hasVideos) + ...workOrder.videoUrls!.map((videoUrl) { + return _buildVideoItem(videoUrl); + }).toList(), + ], + ), + ], + ), + ); + } + + Widget _buildImageItem(AttachmentEntity attachment) { + return GestureDetector( + onTap: () => _previewAttachment(attachment.url, true), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: attachment.url.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + attachment.url, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.image_outlined, + size: 32, + color: WorkOrderColors.auxiliaryText, + ), + const SizedBox(height: 4), + Text( + attachment.fileName.isNotEmpty + ? attachment.fileName + : '图片', + style: TextStyle( + fontSize: 11, + color: WorkOrderColors.auxiliaryText, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ); + }, + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.image_outlined, + size: 32, + color: WorkOrderColors.auxiliaryText, + ), + const SizedBox(height: 4), + Text( + attachment.fileName.isNotEmpty ? attachment.fileName : '图片', + style: TextStyle( + fontSize: 11, + color: WorkOrderColors.auxiliaryText, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + Widget _buildVideoItem(String videoUrl) { + return GestureDetector( + onTap: () => _previewAttachment(videoUrl, false), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.video_library_outlined, + size: 32, + color: WorkOrderColors.primary, + ), + const SizedBox(height: 4), + const Text( + '视频', + style: TextStyle( + fontSize: 11, + color: WorkOrderColors.auxiliaryText, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + void _previewAttachment(String url, bool isImage) { + Navigator.push( + context, + PageRouteBuilder( + opaque: false, + pageBuilder: (context, animation, secondaryAnimation) { + return isImage + ? _buildImagePreviewPage(url) + : _buildVideoPreviewPage(url); + }, + ), + ); + } + + Widget _buildImagePreviewPage(String url) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: InteractiveViewer( + child: Image.network( + url, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.broken_image, + color: Colors.white, + size: 64, + ); + }, + ), + ), + ), + ); + } + + Widget _buildVideoPreviewPage(String url) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: VideoPreviewWidget(url: url), + ); + } + + Widget _buildBottomButtons(WorkOrderEntity workOrder) { + if (workOrder.status == WorkOrderStatus.completed) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF2F3F5), + foregroundColor: const Color(0xFF86909C), + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '已完成', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + SizedBox( + width: 70, + height: 48, + child: ElevatedButton( + onPressed: () { + _showTransferDialog(workOrder); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: WorkOrderColors.primary, + elevation: 0, + side: const BorderSide(color: WorkOrderColors.primary), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '转派', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: () { + context.push( + '/workorder/detail/$widget.orderId/onsite', + extra: workOrder, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: WorkOrderColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '接单', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 70, + height: 48, + child: ElevatedButton( + onPressed: () { + context.push('/workorder/detail/$widget.orderId/process'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: WorkOrderColors.primary, + elevation: 0, + side: const BorderSide(color: WorkOrderColors.primary), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '更多', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + } + + void _showTransferDialog(WorkOrderEntity workOrder) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => TransferDialog(workOrderId: workOrder.id), + ).then((success) { + if (success == true) { + _cubit.loadWorkOrderDetail(widget.orderId); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('转派成功'))); + } + }); + } + + Widget _buildInfoRow( + String label, + String value, { + Color? valueColor, + bool isLast = false, + }) { + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + color: WorkOrderColors.auxiliaryText, + ), + ), + const Spacer(), + Expanded( + flex: 2, + child: Text( + value, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: valueColor ?? WorkOrderColors.primaryText, + ), + ), + ), + ], + ), + ); + } + + String _getPriorityLabel(WorkOrderPriority priority) { + switch (priority) { + case WorkOrderPriority.high: + return '紧急'; + case WorkOrderPriority.medium: + return '中等'; + case WorkOrderPriority.low: + return '低'; + } + } + + Color _getPriorityColor(WorkOrderPriority priority) { + switch (priority) { + case WorkOrderPriority.high: + return const Color(0xFFFF4D4F); + case WorkOrderPriority.medium: + return const Color(0xFFFF7D00); + case WorkOrderPriority.low: + return WorkOrderColors.auxiliaryText; + } + } + + String _getStatusLabel(WorkOrderStatus status) { + switch (status) { + case WorkOrderStatus.pending: + return '待处理'; + case WorkOrderStatus.executing: + return '处理中'; + case WorkOrderStatus.completed: + return '已完成'; + case WorkOrderStatus.all: + return '全部'; + } + } + + Color _getStatusColor(WorkOrderStatus status) { + switch (status) { + case WorkOrderStatus.pending: + return const Color(0xFFFF7D00); + case WorkOrderStatus.executing: + return WorkOrderColors.primary; + case WorkOrderStatus.completed: + return const Color(0xFF00B42A); + case WorkOrderStatus.all: + return WorkOrderColors.auxiliaryText; + } + } + + String _getOrderTypeLabel(String? orderType) { + if (orderType == null || orderType.isEmpty) return '--'; + final upper = orderType.toUpperCase(); + switch (upper) { + case 'MOWER_ERROR': + return '割草机故障'; + case 'UAV_ERROR': + return '无人机故障'; + case 'OTHER_DEVICE_FAULT': + return '其他设备故障'; + case 'SERVER_FAILURE': + return '服务器故障'; + case 'SYSTEM_ERROR': + return '系统错误'; + case 'INVERTER_ERROR': + return '逆变器故障'; + case 'PANEL_ERROR': + return '组件故障'; + case 'CLEANING': + return '清洗作业'; + case 'MAINTENANCE': + return '定期维护'; + case 'INSPECTION': + return '巡检任务'; + default: + if (upper.contains('UAV')) return '无人机故障'; + if (upper.contains('MOWER') || upper.contains('ROBOT')) return '割草机故障'; + if (upper.contains('OTHER')) return '其他设备故障'; + if (upper.contains('SERVER')) return '服务器故障'; + if (upper.contains('SYSTEM')) return '系统错误'; + return orderType; + } + } + + String _mapDeviceType(String orderType, String deviceType) { + String? prefix; + + if (orderType.isNotEmpty) { + final parts = orderType.split('_'); + if (parts.isNotEmpty && parts.first.isNotEmpty) { + prefix = parts.first; + } + } + + if (prefix == null || prefix!.isEmpty) { + if (deviceType.isNotEmpty) { + prefix = deviceType; + } + } + + if (prefix == null || prefix!.isEmpty) return '--'; + + final lower = prefix!.toLowerCase(); + if (lower.contains('uav') || lower.contains('drone')) return '无人机'; + if (lower.contains('mower') || lower.contains('robot')) return '割草机'; + if (lower.contains('other')) return '其他设备'; + if (lower.contains('server')) return '服务器'; + if (lower.contains('system')) return '系统'; + + return prefix!; + } + + String _getHandleResultLabel(String? handleResult) { + if (handleResult == null || handleResult.isEmpty) return '--'; + switch (handleResult.toUpperCase()) { + case 'HANDLED': + return '已处理'; + case 'RESTORED': + return '已恢复'; + case 'IGNORED': + return '已忽略'; + case 'UNABLETOHANDLE': + case 'UNABLE_TO_HANDLE': + return '无法处理'; + case 'MANUFACTURERHANDLING': + case 'MANUFACTURER_HANDLING': + return '厂家处理中'; + case 'FALSEALARM': + case 'FALSE_ALARM': + return '误报'; + default: + return handleResult; + } + } + + String _formatDateTime(DateTime dateTime) { + return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } +} + +class VideoPreviewWidget extends StatefulWidget { + final String url; + + const VideoPreviewWidget({super.key, required this.url}); + + @override + State createState() => _VideoPreviewWidgetState(); +} + +class _VideoPreviewWidgetState extends State { + VideoPlayerController? _controller; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _initVideo(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + Future _initVideo() async { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.url)); + try { + await _controller!.initialize(); + setState(() { + _isLoading = false; + }); + _controller!.play(); + } catch (e) { + debugPrint('视频加载失败: $e'); + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (_controller == null || !_controller!.value.isInitialized) { + return const Center( + child: Icon( + Icons.video_library_outlined, + color: Colors.white, + size: 64, + ), + ); + } + + return Center( + child: AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: Stack( + alignment: Alignment.center, + children: [ + VideoPlayer(_controller!), + VideoProgressIndicator( + _controller!, + allowScrubbing: true, + colors: const VideoProgressColors( + playedColor: Colors.white, + bufferedColor: Colors.white38, + backgroundColor: Colors.white12, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: VideoControlsWidget(controller: _controller!), + ), + ], + ), + ), + ); + } +} + +class VideoControlsWidget extends StatefulWidget { + final VideoPlayerController controller; + + const VideoControlsWidget({super.key, required this.controller}); + + @override + State createState() => _VideoControlsWidgetState(); +} + +class _VideoControlsWidgetState extends State { + bool _isPlaying = true; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onVideoChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onVideoChanged); + super.dispose(); + } + + void _onVideoChanged() { + setState(() { + _isPlaying = widget.controller.value.isPlaying; + }); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + _isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 32, + ), + onPressed: () { + if (_isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + }, + ), + ], + ); + } +} diff --git a/lib/features/v2/workorder/presentation/pages/workorder_on_site_page.dart b/lib/features/v2/workorder/presentation/pages/workorder_on_site_page.dart new file mode 100644 index 00000000..2fa78871 --- /dev/null +++ b/lib/features/v2/workorder/presentation/pages/workorder_on_site_page.dart @@ -0,0 +1,992 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:video_player/video_player.dart'; +import '../../../../../core/consts/workorder_consts.dart'; +import '../../domain/entities/workorder_entity.dart'; +import '../../../work_order/domain/usecases/work_order_usecases.dart'; + +class WorkOrderOnSitePage extends StatefulWidget { + final WorkOrderEntity workOrder; + final String orderId; + + const WorkOrderOnSitePage({ + super.key, + required this.workOrder, + required this.orderId, + }); + + @override + State createState() => _WorkOrderOnSitePageState(); +} + +class _WorkOrderOnSitePageState extends State { + bool _isOperating = false; + WorkOrderStatus? _currentStatus; + bool _dataChanged = false; + + Future _handleStartWork() async { + if (_isOperating) return; + setState(() => _isOperating = true); + + try { + final useCase = GetIt.I(); + final workOrder = widget.workOrder; + final ids = [int.parse(workOrder.id)]; + + final result = await useCase.execute( + ids: ids, + deviceId: workOrder.deviceId, + startTime: DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + ); + + if (!mounted) return; + result.fold( + (failure) => _showSnackBar('开工失败: ${failure.message}', isError: true), + (success) { + setState(() { + _currentStatus = WorkOrderStatus.executing; + _dataChanged = true; + }); + _showSnackBar('开工成功'); + }, + ); + } catch (e) { + if (mounted) _showSnackBar('开工异常: $e', isError: true); + } finally { + if (mounted) setState(() => _isOperating = false); + } + } + + Future _handleSuspendWork() async { + if (_isOperating) return; + setState(() => _isOperating = true); + + try { + final useCase = GetIt.I(); + final id = int.parse(widget.workOrder.id); + + final result = await useCase.execute(id); + + if (!mounted) return; + result.fold( + (failure) => _showSnackBar('暂停失败: ${failure.message}', isError: true), + (success) { + setState(() { + _currentStatus = WorkOrderStatus.pending; + _dataChanged = true; + }); + _showSnackBar('已暂停'); + }, + ); + } catch (e) { + if (mounted) _showSnackBar('暂停异常: $e', isError: true); + } finally { + if (mounted) setState(() => _isOperating = false); + } + } + + 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), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Color(0xFF1F2329)), + onPressed: () => context.pop(_dataChanged), + ), + title: const Text( + '现场执行', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2329), + ), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: () {}, + child: const Text( + '更多', + style: TextStyle(fontSize: 15, color: Color(0xFF1890FF)), + ), + ), + ], + ), + body: _buildContent(widget.workOrder), + bottomNavigationBar: _buildBottomButtons(context), + ), + ); + } + + Widget _buildContent(WorkOrderEntity workOrder) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildWorkOrderInfoCard(workOrder), + const SizedBox(height: 16), + _buildChecklistCard(), + const SizedBox(height: 16), + _buildOnSiteRecordCard(context, workOrder), + const SizedBox(height: 80), + ], + ), + ); + } + + Widget _buildWorkOrderInfoCard(WorkOrderEntity workOrder) { + final deviceName = + workOrder.deviceName ?? workOrder.deviceObject?.deviceName ?? '--'; + final location = + workOrder.locationDetail?.detailAddress ?? workOrder.location ?? '--'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 工单编号 + 紧急标签 + Row( + children: [ + Expanded( + child: Text( + workOrder.orderNo, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2329), + ), + ), + ), + if (workOrder.priority == WorkOrderPriority.high) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFFF4D4F), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '紧急', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildInfoRowWithValueColor( + '状态', + _getStatusLabel(_currentStatus ?? workOrder.status), + valueColor: _getStatusColor(_currentStatus ?? workOrder.status), + ), + _buildInfoRowWithValueColor( + '优先级', + _getPriorityLabel(workOrder.priority), + valueColor: _getPriorityColor(workOrder.priority), + ), + _buildInfoRow('来源', workOrder.source ?? '--'), + _buildInfoRow('工单类型', _getOrderTypeLabel(workOrder.orderType)), + _buildInfoRow('设备', deviceName, showArrow: true, multiline: true), + _buildInfoRow('当前位置', location, showLocation: true, isLast: true), + ], + ), + ); + } + + Widget _buildInfoRow( + String label, + String value, { + bool showArrow = false, + bool showLocation = false, + bool isLast = false, + bool multiline = false, + }) { + if (multiline) { + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF1F2329), + ), + ), + ), + if (showArrow) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.chevron_right, + size: 18, + color: Color(0xFFBBBBBB), + ), + ), + ], + ), + ], + ), + ); + } + + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + Row( + children: [ + Text( + value, + style: const TextStyle(fontSize: 13, color: Color(0xFF1F2329)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (showLocation) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.location_on, + size: 16, + color: Color(0xFF1890FF), + ), + ), + if (showArrow) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.chevron_right, + size: 18, + color: Color(0xFFBBBBBB), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildInfoRowWithValueColor( + String label, + String value, { + Color? valueColor, + bool isLast = false, + }) { + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + Text( + value, + style: TextStyle( + fontSize: 13, + color: valueColor ?? const Color(0xFF1F2329), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + String _getStatusLabel(WorkOrderStatus status) { + switch (status) { + case WorkOrderStatus.pending: + return '待处理'; + case WorkOrderStatus.executing: + return '处理中'; + case WorkOrderStatus.completed: + return '已完成'; + case WorkOrderStatus.all: + return '全部'; + } + } + + Color _getStatusColor(WorkOrderStatus status) { + switch (status) { + case WorkOrderStatus.pending: + return const Color(0xFFFF7D00); + case WorkOrderStatus.executing: + return const Color(0xFF1890FF); + case WorkOrderStatus.completed: + return const Color(0xFF00B42A); + case WorkOrderStatus.all: + return const Color(0xFF86909C); + } + } + + String _getPriorityLabel(WorkOrderPriority priority) { + switch (priority) { + case WorkOrderPriority.high: + return '紧急'; + case WorkOrderPriority.medium: + return '中等'; + case WorkOrderPriority.low: + return '低'; + } + } + + Color _getPriorityColor(WorkOrderPriority priority) { + switch (priority) { + case WorkOrderPriority.high: + return const Color(0xFFFF4D4F); + case WorkOrderPriority.medium: + return const Color(0xFFFF7D00); + case WorkOrderPriority.low: + return const Color(0xFF86909C); + } + } + + String _getOrderTypeLabel(String? orderType) { + if (orderType == null || orderType.isEmpty) return '--'; + switch (orderType.toUpperCase()) { + case 'MOWER_ERROR': + return '割草机故障'; + case 'INVERTER_ERROR': + return '逆变器故障'; + case 'PANEL_ERROR': + return '组件故障'; + case 'CLEANING': + return '清洗作业'; + case 'MAINTENANCE': + return '定期维护'; + case 'INSPECTION': + return '巡检任务'; + default: + return orderType; + } + } + + Widget _buildChecklistCard() { + final checklist = [ + _CheckItem(id: 1, title: '现场安全确认', checked: true), + _CheckItem(id: 2, title: '设备外观检查', checked: true), + _CheckItem(id: 3, title: '通讯线路检查', checked: true), + _CheckItem(id: 4, title: '通讯模块更换', checked: false, current: true), + _CheckItem(id: 5, title: '参数配置与调试', checked: false), + _CheckItem(id: 6, title: '功能测试', checked: false), + _CheckItem(id: 7, title: '现场清理与恢复', checked: false), + ]; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '检查项清单 (5/7)', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2329), + ), + ), + const SizedBox(height: 16), + ...checklist.map((item) => _buildCheckItem(item)), + ], + ), + ); + } + + Widget _buildCheckItem(_CheckItem item) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${item.id}. ${item.title}', + style: TextStyle( + fontSize: 14, + color: item.checked + ? const Color(0xFF52C41A) + : item.current + ? const Color(0xFF1890FF) + : const Color(0xFF1F2329), + ), + ), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: item.checked + ? const Color(0xFF52C41A) + : item.current + ? const Color(0xFF1890FF) + : const Color(0xFFD9D9D9), + width: 2, + ), + color: item.checked + ? const Color(0xFF52C41A) + : item.current + ? const Color(0xFFE6F7FF) + : Colors.transparent, + ), + child: item.checked + ? const Icon(Icons.check, size: 12, color: Colors.white) + : item.current + ? Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF1890FF), + ), + ) + : Container(), + ), + ], + ), + ); + } + + Widget _buildOnSiteRecordCard( + BuildContext context, + WorkOrderEntity workOrder, + ) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '现场记录', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2329), + ), + ), + const SizedBox(height: 16), + _buildAudioRecorder(), + const SizedBox(height: 20), + _buildPhotoGallery(context, workOrder), + ], + ), + ); + } + + Widget _buildAudioRecorder() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + decoration: BoxDecoration( + color: const Color(0xFFF5F7FA), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.mic_none, size: 24, color: Color(0xFFBBBBBB)), + const SizedBox(width: 12), + const Text( + '录音备注', + style: TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + ], + ), + ); + } + + Widget _buildPhotoGallery(BuildContext context, WorkOrderEntity workOrder) { + final hasImages = + workOrder.attachments != null && workOrder.attachments!.isNotEmpty; + final hasVideos = + workOrder.videoUrls != null && workOrder.videoUrls!.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '现场照片', + style: TextStyle(fontSize: 13, color: Color(0xFF86909C)), + ), + const SizedBox(width: 8), + Text( + '(${hasImages ? workOrder.attachments!.length : 0}张图片${hasVideos ? ' ${workOrder.videoUrls!.length}个视频' : ''})', + style: const TextStyle(fontSize: 13, color: Color(0xFF1890FF)), + ), + ], + ), + const SizedBox(height: 12), + if (!hasImages && !hasVideos) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text( + '暂无照片或视频', + style: TextStyle(fontSize: 13, color: Color(0xFFBBBBBB)), + ), + ) + else + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 4, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + children: [ + if (hasImages) + ...workOrder.attachments!.map((attachment) { + return _buildMediaItem(attachment.url, true, context); + }).toList(), + if (hasVideos) + ...workOrder.videoUrls!.map((videoUrl) { + return _buildMediaItem(videoUrl, false, context); + }).toList(), + _buildCameraButton(), + ], + ), + ], + ); + } + + Widget _buildMediaItem(String url, bool isImage, BuildContext context) { + return GestureDetector( + onTap: () => _previewMedia(url, isImage), + child: Container( + width: double.infinity, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: const Color(0xFFF7F8FA), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: isImage + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.image_outlined, + size: 24, + color: Color(0xFFBBBBBB), + ); + }, + ), + ) + : const Center( + child: Icon( + Icons.video_library_outlined, + size: 24, + color: Color(0xFF1890FF), + ), + ), + ), + ); + } + + void _previewMedia(String url, bool isImage) { + Navigator.push( + context, + PageRouteBuilder( + opaque: false, + pageBuilder: (context, animation, secondaryAnimation) { + return isImage + ? _buildImagePreviewPage(url) + : _buildVideoPreviewPage(url); + }, + ), + ); + } + + Widget _buildImagePreviewPage(String url) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: InteractiveViewer( + child: Image.network( + url, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.broken_image, + color: Colors.white, + size: 64, + ); + }, + ), + ), + ), + ); + } + + Widget _buildVideoPreviewPage(String url) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: VideoPreviewWidget(url: url), + ); + } + + Widget _buildCameraButton() { + return Container( + width: double.infinity, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD9D9D9), width: 1), + ), + child: const Icon(Icons.camera_alt, size: 24, color: Color(0xFFBBBBBB)), + ); + } + + Widget _buildBottomButtons(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: _isOperating ? null : _handleStartWork, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF52C41A), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isOperating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text( + '开工', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: _isOperating ? null : _handleSuspendWork, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFAAD14), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isOperating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text( + '暂停', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: () async { + final receiptChanged = await context.push( + '/workorder/detail/${widget.orderId}/receipt', + extra: widget.workOrder, + ); + if (receiptChanged == true) { + setState(() => _dataChanged = true); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1890FF), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '完成', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ), + ), + ], + ), + ); + } +} + +class VideoPreviewWidget extends StatefulWidget { + final String url; + + const VideoPreviewWidget({super.key, required this.url}); + + @override + State createState() => _VideoPreviewWidgetState(); +} + +class _VideoPreviewWidgetState extends State { + VideoPlayerController? _controller; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _initVideo(); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + Future _initVideo() async { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.url)); + try { + await _controller!.initialize(); + setState(() { + _isLoading = false; + }); + _controller!.play(); + } catch (e) { + debugPrint('视频加载失败: $e'); + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (_controller == null || !_controller!.value.isInitialized) { + return const Center( + child: Icon( + Icons.video_library_outlined, + color: Colors.white, + size: 64, + ), + ); + } + + return Center( + child: AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: Stack( + alignment: Alignment.center, + children: [ + VideoPlayer(_controller!), + VideoProgressIndicator( + _controller!, + allowScrubbing: true, + colors: const VideoProgressColors( + playedColor: Colors.white, + bufferedColor: Colors.white38, + backgroundColor: Colors.white12, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: VideoControlsWidget(controller: _controller!), + ), + ], + ), + ), + ); + } +} + +class VideoControlsWidget extends StatefulWidget { + final VideoPlayerController controller; + + const VideoControlsWidget({super.key, required this.controller}); + + @override + State createState() => _VideoControlsWidgetState(); +} + +class _VideoControlsWidgetState extends State { + bool _isPlaying = true; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onVideoChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onVideoChanged); + super.dispose(); + } + + void _onVideoChanged() { + setState(() { + _isPlaying = widget.controller.value.isPlaying; + }); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + _isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 32, + ), + onPressed: () { + if (_isPlaying) { + widget.controller.pause(); + } else { + widget.controller.play(); + } + }, + ), + ], + ); + } +} + +class _CheckItem { + final int id; + final String title; + final bool checked; + final bool current; + + _CheckItem({ + required this.id, + required this.title, + required this.checked, + this.current = false, + }); +} diff --git a/lib/features/v2/workorder/presentation/pages/workorder_page.dart b/lib/features/v2/workorder/presentation/pages/workorder_page.dart index 99e24398..27c91aa4 100644 --- a/lib/features/v2/workorder/presentation/pages/workorder_page.dart +++ b/lib/features/v2/workorder/presentation/pages/workorder_page.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; +import 'package:maibu_satabot_v2/core/network/dio_client.dart'; import '../../../../../core/consts/workorder_consts.dart'; +import '../../../site/presentation/widgets/site_selector_widget.dart'; import '../cubit/workorder_cubit.dart'; import '../widgets/workorder_tabbar.dart'; import '../widgets/workorder_count_card.dart'; @@ -12,44 +15,74 @@ import '../../data/repositories/workorder_repository_impl.dart'; import '../../data/datasources/workorder_remote_datasource_impl.dart'; /// 工单任务主页面 -class WorkOrderPage extends StatelessWidget { +class WorkOrderPage extends StatefulWidget { const WorkOrderPage({Key? key}) : super(key: key); @override - Widget build(BuildContext context) { - // 创建 Repository 和 UseCases - final remoteDataSource = WorkOrderRemoteDataSourceImpl(); + State createState() => _WorkOrderPageState(); +} + +class _WorkOrderPageState extends State { + final ScrollController _scrollController = ScrollController(); + late final WorkOrderCubit _cubit; + + @override + void initState() { + super.initState(); + + final dio = DioClient.create(); + final remoteDataSource = WorkOrderRemoteDataSourceImpl(dio); final repository = WorkOrderRepositoryImpl( remoteDataSource: remoteDataSource, ); - final getWorkOrderListUseCase = GetWorkOrderListUseCase( - repository: repository, - ); - final getWorkOrderCountUseCase = GetWorkOrderCountUseCase( - repository: repository, + _cubit = WorkOrderCubit( + getWorkOrderListUseCase: GetWorkOrderListUseCase(repository: repository), ); - return BlocProvider( - create: (context) { - final cubit = WorkOrderCubit( - getWorkOrderListUseCase: getWorkOrderListUseCase, - getWorkOrderCountUseCase: getWorkOrderCountUseCase, - ); - cubit.loadInitialData(); - return cubit; - }, + _scrollController.addListener(_onScroll); + _cubit.loadInitialData(); + } + + @override + void dispose() { + _scrollController.dispose(); + _cubit.close(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels == + _scrollController.position.maxScrollExtent) { + _cubit.loadMore(); + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, child: AnnotatedRegion( value: SystemUiOverlayStyle.dark, child: Scaffold( backgroundColor: const Color(0xFFF7F8FA), - body: BlocBuilder( + body: BlocConsumer( + listener: (context, state) { + if (state is WorkOrderLoaded && state.errorMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage!), + duration: const Duration(seconds: 3), + ), + ); + } + }, builder: (context, state) { if (state is WorkOrderLoading) { return _buildLoadingView(); } else if (state is WorkOrderLoaded) { return _buildContentView(context, state); } else if (state is WorkOrderError) { - return _buildErrorView(context, state); + return _buildEmptyView(context); } return _buildLoadingView(); }, @@ -67,37 +100,24 @@ class WorkOrderPage extends StatelessWidget { ); } - Widget _buildErrorView(BuildContext context, WorkOrderError state) { + Widget _buildEmptyView(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.error_outline, + Icons.inbox_outlined, size: 48, color: AppConstants.auxiliaryTextColor, ), const SizedBox(height: 16), Text( - state.failure.message, + '无数据', style: TextStyle( fontSize: 14, color: AppConstants.auxiliaryTextColor, ), ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - context.read().loadInitialData(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppConstants.primaryColor, - foregroundColor: Colors.white, - ), - child: Text( - AppLocalizations.of(context).translate('work_order_v2.retry'), - ), - ), ], ), ); @@ -107,34 +127,33 @@ class WorkOrderPage extends StatelessWidget { return SafeArea( child: Column( children: [ - // 导航栏 _buildAppBar(context), - // 标签栏 _buildTabBar(context, state), - - // 内容区域 Expanded( child: RefreshIndicator( onRefresh: () async { await context.read().refreshData(); }, child: ListView( + controller: _scrollController, children: [ - // 统计卡片 if (state.count != null) WorkOrderCountCard(count: state.count!), - - // 工单列表 - ...state.workOrders.map( - (workOrder) => WorkOrderItemCard( - workOrder: workOrder, - onTap: () { - // TODO: 跳转到工单详情页 - debugPrint('点击工单: ${workOrder.title}'); - }, + if (state.workOrders.isEmpty) _buildEmptyView(context), + if (state.workOrders.isNotEmpty) + ...state.workOrders.map( + (workOrder) => WorkOrderItemCard( + workOrder: workOrder, + onTap: () async { + await context.push( + '/workorder/detail/${workOrder.id}', + ); + context.read().loadInitialData(); + }, + ), ), - ), - + if (state.workOrders.isNotEmpty) + _buildLoadMoreIndicator(state), const SizedBox(height: 20), ], ), @@ -151,16 +170,32 @@ class WorkOrderPage extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ - Text( - AppLocalizations.of(context).translate('work_order_v2.title'), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + 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('work_order_v2.title'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF86909C), + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => context.push('/workorder/report'), + child: const Icon( + Icons.bar_chart, + size: 24, color: Color(0xFF1D2129), ), ), - const Spacer(), - Icon(Icons.add_circle_outline, size: 24, color: Color(0xFF1D2129)), ], ), ); @@ -174,4 +209,27 @@ class WorkOrderPage extends StatelessWidget { }, ); } + + Widget _buildLoadMoreIndicator(WorkOrderLoaded state) { + if (state.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } else if (state.hasMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('上滑加载更多', style: TextStyle(color: Color(0xFF86909C))), + ), + ); + } else { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('已加载全部数据', style: TextStyle(color: Color(0xFF86909C))), + ), + ); + } + } } diff --git a/lib/features/v2/workorder/presentation/pages/workorder_process_page.dart b/lib/features/v2/workorder/presentation/pages/workorder_process_page.dart new file mode 100644 index 00000000..536ab491 --- /dev/null +++ b/lib/features/v2/workorder/presentation/pages/workorder_process_page.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class WorkOrderProcessPage extends StatelessWidget { + final String orderId; + + const WorkOrderProcessPage({super.key, required this.orderId}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Color(0xFF1F2329)), + onPressed: () => context.pop(), + ), + title: const Text( + '工单流程', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2329), + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.share, color: Color(0xFF86909C)), + onPressed: () {}, + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildProcessTimeline(), + const SizedBox(height: 24), + _buildExecutionRecords(), + const SizedBox(height: 32), + ], + ), + ), + bottomNavigationBar: _buildBottomIndicator(), + ); + } + + Widget _buildProcessTimeline() { + final processes = [ + _ProcessStep( + status: ProcessStatus.completed, + title: '创建', + time: '2025-05-21 09:12', + details: ['系统创建工单(告警联动)', '创建人:系统'], + ), + _ProcessStep( + status: ProcessStatus.completed, + title: '派发', + time: '2025-05-21 09:15', + details: ['派发给:张工(运维班组)', '派发人:调度员-李明'], + ), + _ProcessStep( + status: ProcessStatus.completed, + title: '接单', + time: '2025-05-21 09:17', + details: ['张工已接单'], + ), + _ProcessStep( + status: ProcessStatus.completed, + title: '处理中', + time: '2025-05-21 09:35', + details: ['现场处理进行中'], + ), + _ProcessStep( + status: ProcessStatus.pending, + title: '验收', + time: '', + details: ['待验收'], + ), + _ProcessStep( + status: ProcessStatus.pending, + title: '关闭', + time: '', + details: ['待关闭'], + ), + ]; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: processes.asMap().entries.map((entry) { + final index = entry.key; + final step = entry.value; + final isLast = index == processes.length - 1; + final nextStatus = isLast ? null : processes[index + 1].status; + + return _buildProcessItem(step, nextStatus, isLast); + }).toList(), + ), + ); + } + + Widget _buildProcessItem( + _ProcessStep step, + ProcessStatus? nextStatus, + bool isLast, + ) { + final showLine = !isLast; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + _buildStatusIcon(step.status, 0), + if (showLine) _buildProcessLine(step.status, nextStatus!), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + step.title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: step.status == ProcessStatus.completed + ? const Color(0xFF1F2329) + : const Color(0xFFBBBBBB), + ), + ), + if (step.time.isNotEmpty) ...[ + const SizedBox(width: 12), + Text( + step.time, + style: TextStyle( + fontSize: 12, + color: step.status == ProcessStatus.completed + ? const Color(0xFF86909C) + : const Color(0xFFBBBBBB), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + ...step.details.map( + (detail) => Text( + detail, + style: TextStyle( + fontSize: 12, + color: step.status == ProcessStatus.completed + ? const Color(0xFF86909C) + : const Color(0xFFBBBBBB), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ], + ); + } + + Widget _buildStatusIcon(ProcessStatus status, int index) { + Widget icon; + Color iconColor; + Color backgroundColor; + + switch (status) { + case ProcessStatus.completed: + icon = const Icon(Icons.check, size: 16); + iconColor = const Color(0xFF52C41A); + backgroundColor = const Color(0xFFF6FFED); + break; + case ProcessStatus.current: + icon = const Icon(Icons.check, size: 16); + iconColor = const Color(0xFF1890FF); + backgroundColor = const Color(0xFFE6F7FF); + break; + case ProcessStatus.pending: + icon = Container(); + iconColor = const Color(0xFFBBBBBB); + backgroundColor = const Color(0xFFFAFAFA); + break; + } + + return Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: iconColor, width: 2), + ), + child: icon, + ); + } + + Widget _buildProcessLine( + ProcessStatus currentStatus, + ProcessStatus nextStatus, + ) { + Color lineColor; + if (currentStatus == ProcessStatus.completed && + nextStatus == ProcessStatus.completed) { + lineColor = const Color(0xFF52C41A); + } else if (currentStatus == ProcessStatus.completed && + nextStatus == ProcessStatus.current) { + lineColor = const Color(0xFF1890FF); + } else { + lineColor = const Color(0xFFE8E8E8); + } + + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + height: 28, + width: 2, + color: lineColor, + ); + } + + Widget _buildExecutionRecords() { + final records = [ + _ExecutionRecord(time: '2025-05-21 09:20', action: '到达现场'), + _ExecutionRecord(time: '2025-05-21 09:35', action: '现场定位打卡'), + _ExecutionRecord(time: '2025-05-21 09:35', action: '开始处理'), + _ExecutionRecord(time: '2025-05-21 10:05', action: '更换通讯模块'), + _ExecutionRecord(time: '2025-05-21 10:05', action: '暂停处理'), + _ExecutionRecord(time: '2025-05-21 10:05', action: '等待备件到货'), + _ExecutionRecord(time: '2025-05-21 11:10', action: '继续处理'), + _ExecutionRecord(time: '2025-05-21 11:10', action: '更换完成,调试中'), + ]; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '执行记录', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2329), + ), + ), + const SizedBox(height: 16), + ...records.map((record) => _buildRecordItem(record)), + ], + ), + ); + } + + Widget _buildRecordItem(_ExecutionRecord record) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Text( + record.time, + style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), + const SizedBox(width: 32), + Expanded( + child: Text( + record.action, + style: const TextStyle(fontSize: 13, color: Color(0xFF1F2329)), + ), + ), + ], + ), + ); + } + + Widget _buildBottomIndicator() { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SizedBox( + height: 48, + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Color(0xFF1890FF), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: const Center( + child: const Text( + '工单流程', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ); + } +} + +enum ProcessStatus { completed, current, pending } + +class _ProcessStep { + final ProcessStatus status; + final String title; + final String time; + final List details; + + _ProcessStep({ + required this.status, + required this.title, + required this.time, + required this.details, + }); +} + +class _ExecutionRecord { + final String time; + final String action; + + _ExecutionRecord({required this.time, required this.action}); +} diff --git a/lib/features/v2/workorder/presentation/pages/workorder_receipt_page.dart b/lib/features/v2/workorder/presentation/pages/workorder_receipt_page.dart new file mode 100644 index 00000000..4109b981 --- /dev/null +++ b/lib/features/v2/workorder/presentation/pages/workorder_receipt_page.dart @@ -0,0 +1,541 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import '../../domain/entities/workorder_entity.dart'; +import '../../../work_order/domain/usecases/work_order_usecases.dart'; + +class WorkOrderReceiptPage extends StatefulWidget { + final WorkOrderEntity workOrder; + final String orderId; + + WorkOrderReceiptPage({ + super.key, + required this.workOrder, + required this.orderId, + }); + + @override + State createState() => _WorkOrderReceiptPageState(); +} + +class _WorkOrderReceiptPageState extends State { + bool _isSubmitting = false; + + // ============ 表单状态 ============ + int _selectedResultIndex = 0; // 0=已解决, 1=部分解决, 2=未解决 + final _faultCauseController = TextEditingController(text: '通讯模块故障'); + final _measureController = TextEditingController(text: '更换通讯模块并重启设备,恢复通讯。'); + final _sparePartController = TextEditingController(text: '通讯模块(型号:COM-485)'); + final _remarkController = TextEditingController(); + + // 下拉选项 + final _faultCauseOptions = ['通讯模块故障', '电源故障', '传感器异常', '机械故障', '网络连接异常']; + final _sparePartOptions = ['通讯模块(型号:COM-485)', '电源模块(型号:PWR-220)', '传感器模块(型号:SEN-001)']; + + @override + void dispose() { + _faultCauseController.dispose(); + _measureController.dispose(); + _sparePartController.dispose(); + _remarkController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF7F8FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0.5, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)), + onPressed: () => context.pop(), + ), + title: const Text( + '工单回执', + style: TextStyle( + color: Color(0xFF1D2129), + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: const Text( + '提交', + style: TextStyle( + color: Color(0xFF165DFF), + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 8), + _buildOrderNumberCard(), + const SizedBox(height: 8), + _buildResultCard(), + const SizedBox(height: 8), + _buildDropdownInputCard( + label: '故障原因', + controller: _faultCauseController, + options: _faultCauseOptions, + required: true, + ), + const SizedBox(height: 8), + _buildTextInputCard( + label: '处理措施', + controller: _measureController, + maxLines: 3, + ), + const SizedBox(height: 8), + _buildDropdownInputCard( + label: '备件使用', + controller: _sparePartController, + options: _sparePartOptions, + ), + const SizedBox(height: 8), + _buildPhotoCard(), + const SizedBox(height: 8), + _buildSignatureCard(), + const SizedBox(height: 8), + _buildTextInputCard( + label: '备注说明', + controller: _remarkController, + hintText: '请填写备注信息(选填)', + maxLines: 3, + ), + const SizedBox(height: 100), + ], + ), + ), + ), + bottomNavigationBar: _buildBottomBar(context), + ); + } + + // ============ 提交 ============ + Future _handleSubmit() async { + if (_isSubmitting) return; + setState(() => _isSubmitting = true); + + try { + final useCase = GetIt.I(); + const results = ['已解决', '部分解决', '未解决']; + final id = int.parse(widget.workOrder.id); + + final result = await useCase.execute( + id: id, + handleResult: results[_selectedResultIndex], + failureCause: _faultCauseController.text.isNotEmpty + ? _faultCauseController.text + : null, + handleMeasures: _measureController.text.isNotEmpty + ? _measureController.text + : null, + completeRemark: _remarkController.text.isNotEmpty + ? _remarkController.text + : null, + completeTime: DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()), + ); + + if (!mounted) return; + result.fold( + (failure) { + setState(() => _isSubmitting = false); + _showSnackBar('提交失败: ${failure.message}', isError: true); + }, + (success) { + setState(() => _isSubmitting = false); + _showSnackBar('提交成功'); + context.pop(true); + }, + ); + } catch (e) { + if (mounted) { + setState(() => _isSubmitting = 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), + ), + ); + } + + // ============ 工单编号 ============ + Widget _buildOrderNumberCard() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + color: Colors.white, + child: Row( + children: [ + const Text( + '工单编号', + style: TextStyle(color: Color(0xFF4E5969), fontSize: 14), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.workOrder.orderNo, + style: const TextStyle( + color: Color(0xFF1D2129), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + // ============ 处理结果 ============ + Widget _buildResultCard() { + const results = ['已解决', '部分解决', '未解决']; + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('处理结果', required: true), + const SizedBox(height: 12), + Row( + children: List.generate(results.length, (index) { + final selected = _selectedResultIndex == index; + return Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 10), + child: GestureDetector( + onTap: () => setState(() => _selectedResultIndex = index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: selected ? const Color(0xFF165DFF) : Colors.white, + border: Border.all( + color: selected ? const Color(0xFF165DFF) : const Color(0xFFE5E6EB), + ), + ), + child: Text( + results[index], + style: TextStyle( + fontSize: 13, + color: selected ? Colors.white : const Color(0xFF4E5969), + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + ), + ), + ), + ), + ); + }), + ), + ], + ), + ); + } + + // ============ 输入+下拉卡片 ============ + Widget _buildDropdownInputCard({ + required String label, + required TextEditingController controller, + required List options, + bool required = false, + }) { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel(label, required: required), + const SizedBox(height: 10), + TextField( + controller: controller, + style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + filled: true, + fillColor: const Color(0xFFF7F8FA), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFE5E6EB)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFE5E6EB)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFF165DFF)), + ), + isDense: true, + suffixIcon: PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.keyboard_arrow_down, color: Color(0xFF86909C), size: 20), + onSelected: (value) { + controller.text = value; + }, + itemBuilder: (_) => options.map((o) => PopupMenuItem(value: o, child: Text(o, style: const TextStyle(fontSize: 14)))).toList(), + ), + ), + ), + ], + ), + ); + } + + // ============ 纯文本输入卡片 ============ + Widget _buildTextInputCard({ + required String label, + required TextEditingController controller, + String? hintText, + int maxLines = 1, + }) { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel(label), + const SizedBox(height: 10), + TextField( + controller: controller, + maxLines: maxLines, + style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129), height: 1.5), + decoration: InputDecoration( + hintText: hintText, + hintStyle: const TextStyle(color: Color(0xFFC9CDD4), fontSize: 14), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + filled: true, + fillColor: const Color(0xFFF7F8FA), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFE5E6EB)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFE5E6EB)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFF165DFF)), + ), + isDense: true, + ), + ), + ], + ), + ); + } + + // ============ 现场照片 ============ + Widget _buildPhotoCard() { + final hasImages = widget.workOrder.attachments != null && + widget.workOrder.attachments!.isNotEmpty; + final hasVideos = widget.workOrder.videoUrls != null && + widget.workOrder.videoUrls!.isNotEmpty; + + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('现场照片', required: true), + const SizedBox(height: 12), + if (!hasImages && !hasVideos) + const Text( + '暂无照片或视频', + style: TextStyle(fontSize: 13, color: Color(0xFFBBBBBB)), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (hasImages) + ...widget.workOrder.attachments!.map((attachment) { + return _buildPhotoThumbNet(attachment.url); + }), + if (hasVideos) + ...widget.workOrder.videoUrls!.map((url) { + return _buildVideoThumb(url); + }), + ], + ), + ], + ), + ); + } + + Widget _buildPhotoThumbNet(String url) { + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network(url, width: 72, height: 72, fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 72, + height: 72, + color: const Color(0xFFF7F8FA), + child: const Icon(Icons.broken_image, color: Color(0xFFBBBBBB)), + ); + }, + ), + ); + } + + Widget _buildVideoThumb(String url) { + return Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: const Icon(Icons.video_library_outlined, size: 24, color: Color(0xFF1890FF)), + ); + } + + Widget _buildAddPhotoBtn() { + return GestureDetector( + onTap: () { + // TODO: 选择/拍照上传 + }, + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: const Icon(Icons.add, color: Color(0xFFC9CDD4), size: 28), + ), + ); + } + + // ============ 客户/验收签名 ============ + Widget _buildSignatureCard() { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildLabel('客户/验收签名', required: true), + GestureDetector( + onTap: () { + // TODO: 清除签名 + }, + child: const Text( + '清空', + style: TextStyle(color: Color(0xFF165DFF), fontSize: 13), + ), + ), + ], + ), + const SizedBox(height: 10), + Container( + height: 100, + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE5E6EB)), + ), + child: const Center( + child: Text( + '李建国', + style: TextStyle(fontSize: 24, color: Color(0xFF1D2129), fontFamily: 'KaiTi'), + ), + ), + ), + ], + ), + ); + } + + // ============ 标签 ============ + Widget _buildLabel(String text, {bool required = false}) { + return Row( + children: [ + if (required) + const Text( + '* ', + style: TextStyle(color: Color(0xFFF53F3F), fontSize: 14), + ), + Text( + text, + style: const TextStyle( + color: Color(0xFF4E5969), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } + + // ============ 底部按钮 ============ + Widget _buildBottomBar(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: 12 + MediaQuery.of(context).padding.bottom, + ), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFE5E6EB), width: 0.5)), + ), + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: _isSubmitting ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: _isSubmitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + '提交并完成', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ), + ); + } +} diff --git a/lib/features/v2/workorder/presentation/pages/workorder_report_page.dart b/lib/features/v2/workorder/presentation/pages/workorder_report_page.dart new file mode 100644 index 00000000..32d7e9e6 --- /dev/null +++ b/lib/features/v2/workorder/presentation/pages/workorder_report_page.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:go_router/go_router.dart'; + +class WorkOrderReportPage extends StatefulWidget { + const WorkOrderReportPage({super.key}); + + @override + State createState() => _WorkOrderReportPageState(); +} + +class _WorkOrderReportPageState extends State { + // ============ 模拟数据 ============ + static const _totalOrders = 58; + static const _completedOrders = 46; + static const _completionRate = 79.3; + + static const _typeData = [ + (label: '故障处理', value: 48.0, color: Color(0xFF4A90D9)), + (label: '运维维护', value: 28.0, color: Color(0xFF00B42A)), + (label: '设备更换', value: 14.0, color: Color(0xFFFF7D00)), + (label: '用户报修', value: 10.0, color: Color(0xFFF53F3F)), + ]; + + static const _trendDays = ['05-19', '05-20', '05-21', '05-22', '05-23', '05-24', '05-25']; + static const _createdData = [8, 14, 10, 17, 12, 10, 5]; + static const _completedData = [5, 12, 9, 15, 10, 8, 4]; + + static const _topPendingList = [ + (title: '逆变器通讯异常', area: '逆变器', remainHours: 2), + (title: '稽查温度偏高', area: '开压站区', remainHours: 8), + (title: '汇流箱熔断器更换', area: '2k 方阵', remainHours: 18), + (title: '变压柜电流异常', area: '1k 方阵', remainHours: 24), + (title: '数据采集器离线', area: '中心站区', remainHours: 36), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF7F8FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0.5, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)), + onPressed: () => context.pop(), + ), + title: const Text( + '移动报表概览', + style: TextStyle( + color: Color(0xFF1D2129), + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + actions: [ + TextButton( + onPressed: () {}, + child: const Text( + '更多', + style: TextStyle( + color: Color(0xFF165DFF), + fontSize: 14, + ), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildTimeRange(), + const SizedBox(height: 16), + _buildMetricCards(), + const SizedBox(height: 16), + _buildTypeDistribution(), + const SizedBox(height: 16), + _buildTrendChart(), + const SizedBox(height: 16), + _buildPendingTop5(), + const SizedBox(height: 24), + _buildBottomActions(), + const SizedBox(height: 100), + ], + ), + ), + ); + } + + // ============ 时间范围 ============ + Widget _buildTimeRange() { + return Row( + children: [ + const Icon(Icons.calendar_today, size: 16, color: Color(0xFF4E5969)), + const SizedBox(width: 6), + const Text( + '本周 05-19 ~ 05-25', + style: TextStyle(fontSize: 14, color: Color(0xFF4E5969)), + ), + const Spacer(), + GestureDetector( + onTap: () {}, + child: const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF86909C)), + ), + ], + ); + } + + // ============ 三个指标卡片 ============ + Widget _buildMetricCards() { + return Row( + children: [ + Expanded(child: _metricCard('个人工单总数', '$_totalOrders', '单', const Color(0xFFE8F3FF), const Color(0xFF165DFF))), + const SizedBox(width: 10), + Expanded(child: _metricCard('已完成', '$_completedOrders', '单', const Color(0xFFE8FFEA), const Color(0xFF00B42A))), + const SizedBox(width: 10), + Expanded(child: _metricCard('完成率', '$_completionRate', '%', const Color(0xFFFFF3E0), const Color(0xFFFF7D00))), + ], + ); + } + + Widget _metricCard(String title, String value, String unit, Color bgColor, Color valueColor) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 12, color: Color(0xFF86909C))), + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: valueColor), + ), + TextSpan( + text: ' $unit', + style: TextStyle(fontSize: 13, color: valueColor), + ), + ], + ), + ), + ], + ), + ); + } + + // ============ 工单类型分布(环形图)============ + Widget _buildTypeDistribution() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('工单类型分布', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1D2129))), + const SizedBox(height: 16), + Row( + children: [ + // 环形图 + SizedBox( + width: 140, + height: 140, + child: PieChart( + PieChartData( + centerSpaceRadius: 32, + sectionsSpace: 3, + sections: _typeData.map((d) { + return PieChartSectionData( + value: d.value, + color: d.color, + radius: 18, + showTitle: false, + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 20), + // 图例 + Expanded( + child: Column( + children: _typeData.map((d) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: d.color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(d.label, style: const TextStyle(fontSize: 13, color: Color(0xFF4E5969)))), + Text( + '${d.value.toStringAsFixed(0)}%', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1D2129)), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ], + ), + ); + } + + // ============ 本周趋势(柱状图)============ + Widget _buildTrendChart() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('本周趋势', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1D2129))), + const Spacer(), + _legendDot(const Color(0xFF165DFF), '创建数'), + const SizedBox(width: 12), + _legendDot(const Color(0xFF00B42A), '完成数'), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: 24, + barGroups: List.generate(_trendDays.length, (i) { + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: _createdData[i].toDouble(), + color: const Color(0xFF165DFF), + width: 10, + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + ), + BarChartRodData( + toY: _completedData[i].toDouble(), + color: const Color(0xFF00B42A), + width: 10, + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + ), + ], + ); + }), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (value % 6 == 0) { + return Text('${value.toInt()}', style: const TextStyle(fontSize: 11, color: Color(0xFF86909C))); + } + return const SizedBox.shrink(); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final i = value.toInt(); + if (i >= 0 && i < _trendDays.length) { + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(_trendDays[i], style: const TextStyle(fontSize: 10, color: Color(0xFF86909C))), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 6, + getDrawingHorizontalLine: (value) => FlLine( + color: const Color(0xFFF0F0F0), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData(enabled: false), + ), + ), + ), + ], + ), + ); + } + + Widget _legendDot(Color color, String label) { + return Row( + children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF86909C))), + ], + ); + } + + // ============ 待处理工单 TOP5 ============ + Widget _buildPendingTop5() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('待处理工单 TOP5', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1D2129))), + const SizedBox(height: 12), + ...List.generate(_topPendingList.length, (i) { + final item = _topPendingList[i]; + final isUrgent = item.remainHours <= 8; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: i < 3 ? const Color(0xFF165DFF) : const Color(0xFFC9CDD4), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + '${i + 1}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: i < 3 ? Colors.white : const Color(0xFF86909C), + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129))), + const SizedBox(height: 2), + Text(item.area, style: const TextStyle(fontSize: 12, color: Color(0xFF86909C))), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isUrgent ? const Color(0xFFFFECE8) : const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '剩余${item.remainHours}h', + style: TextStyle( + fontSize: 12, + color: isUrgent ? const Color(0xFFF53F3F) : const Color(0xFF86909C), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ); + } + + // ============ 底部操作按钮 ============ + Widget _buildBottomActions() { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.download, size: 18), + label: const Text('导出报表', style: TextStyle(fontSize: 14)), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF165DFF), + side: const BorderSide(color: Color(0xFF165DFF)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.share, size: 18), + label: const Text('分享', style: TextStyle(fontSize: 14)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ); + } + +} diff --git a/lib/features/v2/workorder/presentation/routes/workorder_routes.dart b/lib/features/v2/workorder/presentation/routes/workorder_routes.dart new file mode 100644 index 00000000..10854586 --- /dev/null +++ b/lib/features/v2/workorder/presentation/routes/workorder_routes.dart @@ -0,0 +1,87 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:maibu_satabot_v2/core/network/dio_client.dart'; +import '../../domain/entities/workorder_entity.dart'; +import '../../domain/usecases/get_workorder_detail_usecase.dart'; +import '../../data/repositories/workorder_repository_impl.dart'; +import '../../data/datasources/workorder_remote_datasource_impl.dart'; +import '../cubit/workorder_detail_cubit.dart'; +import '../pages/workorder_page.dart'; +import '../pages/workorder_detail_page.dart'; +import '../pages/workorder_process_page.dart'; +import '../pages/workorder_on_site_page.dart'; +import '../pages/workorder_receipt_page.dart'; +import '../pages/workorder_report_page.dart'; + +class WorkOrderRoutes { + static List get routes => [ + GoRoute( + path: '/workorder', + name: 'workOrder', + builder: (context, state) => const WorkOrderPage(), + routes: [ + GoRoute( + path: 'report', + name: 'workOrderReport', + builder: (context, state) => const WorkOrderReportPage(), + ), + GoRoute( + path: 'detail/:orderId', + name: 'workOrderDetail', + builder: (context, state) { + final orderId = state.pathParameters['orderId']!; + final dio = DioClient.create(); + final remoteDataSource = WorkOrderRemoteDataSourceImpl(dio); + final repository = WorkOrderRepositoryImpl( + remoteDataSource: remoteDataSource, + ); + final getWorkOrderDetailUseCase = GetWorkOrderDetailUseCase( + repository: repository, + ); + + return BlocProvider( + create: (_) => WorkOrderDetailCubit( + getWorkOrderDetailUseCase: getWorkOrderDetailUseCase, + ), + child: WorkOrderDetailPage(orderId: orderId), + ); + }, + routes: [ + GoRoute( + path: 'process', + name: 'workOrderProcess', + builder: (context, state) { + final orderId = state.pathParameters['orderId']!; + return WorkOrderProcessPage(orderId: orderId); + }, + ), + GoRoute( + path: 'onsite', + name: 'workOrderOnSite', + builder: (context, state) { + final orderId = state.pathParameters['orderId']!; + final workOrder = state.extra as WorkOrderEntity; + return WorkOrderOnSitePage( + workOrder: workOrder, + orderId: orderId, + ); + }, + ), + GoRoute( + path: 'receipt', + name: 'workOrderReceipt', + builder: (context, state) { + final orderId = state.pathParameters['orderId']!; + final workOrder = state.extra as WorkOrderEntity; + return WorkOrderReceiptPage( + workOrder: workOrder, + orderId: orderId, + ); + }, + ), + ], + ), + ], + ), + ]; +} diff --git a/lib/features/v2/workorder/presentation/widgets/workorder_tabbar.dart b/lib/features/v2/workorder/presentation/widgets/workorder_tabbar.dart index 78dc3443..3a67c7f8 100644 --- a/lib/features/v2/workorder/presentation/widgets/workorder_tabbar.dart +++ b/lib/features/v2/workorder/presentation/widgets/workorder_tabbar.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../../../../core/consts/workorder_consts.dart'; +import '../../domain/entities/workorder_entity.dart'; /// 工单标签栏组件 +/// 展示待处理/执行中/已完成/全部四个静态标签 class WorkOrderTabBar extends StatelessWidget { final WorkOrderStatus currentStatus; final Function(WorkOrderStatus) onTabChanged; @@ -15,25 +17,40 @@ class WorkOrderTabBar extends StatelessWidget { @override Widget build(BuildContext context) { - final tabs = [ + return Container( + height: 44, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 4, + itemBuilder: (context, index) { + final tabs = _getStaticTabs(context); + final tab = tabs[index]; + return _buildTabItem( + context, + label: tab['label'] as String, + isSelected: currentStatus == tab['status'], + showBadge: tab['badge'] as bool, + onTap: () => onTabChanged(tab['status'] as WorkOrderStatus), + ); + }, + ), + ); + } + + List> _getStaticTabs(BuildContext context) { + return [ { - 'label': AppLocalizations.of( - context, - ).translate('work_order_v2.pending'), + 'label': AppLocalizations.of(context).translate('work_order_v2.pending'), 'status': WorkOrderStatus.pending, 'badge': false, }, { - 'label': AppLocalizations.of( - context, - ).translate('work_order_v2.executing'), + 'label': AppLocalizations.of(context).translate('work_order_v2.executing'), 'status': WorkOrderStatus.executing, 'badge': true, }, { - 'label': AppLocalizations.of( - context, - ).translate('work_order_v2.completed'), + 'label': AppLocalizations.of(context).translate('work_order_v2.completed'), 'status': WorkOrderStatus.completed, 'badge': false, }, @@ -43,69 +60,64 @@ class WorkOrderTabBar extends StatelessWidget { 'badge': true, }, ]; + } - return Container( - height: 44, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: tabs.length, - itemBuilder: (context, index) { - final tab = tabs[index]; - final isSelected = currentStatus == tab['status']; - - return GestureDetector( - onTap: () => onTabChanged(tab['status'] as WorkOrderStatus), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Text( - tab['label'] as String, - style: TextStyle( - fontSize: 15, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - color: isSelected - ? AppConstants.primaryColor - : AppConstants.secondaryTextColor, - ), - ), - if (tab['badge'] as bool) - Positioned( - right: -8, - top: -4, - child: Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - ), - ), - ], + Widget _buildTabItem( + BuildContext context, { + required String label, + required bool isSelected, + required VoidCallback onTap, + bool showBadge = false, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? AppConstants.primaryColor + : AppConstants.secondaryTextColor, ), - if (isSelected) - Container( - margin: const EdgeInsets.only(top: 4), - width: 24, - height: 3, - decoration: BoxDecoration( - color: AppConstants.primaryColor, - borderRadius: BorderRadius.circular(1.5), + ), + if (showBadge) + Positioned( + right: -8, + top: -4, + child: Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, ), ), - ], - ), + ), + ], ), - ); - }, + if (isSelected) + Container( + margin: const EdgeInsets.only(top: 4), + width: 24, + height: 3, + decoration: BoxDecoration( + color: AppConstants.primaryColor, + borderRadius: BorderRadius.circular(1.5), + ), + ), + ], + ), ), ); } -} +} \ No newline at end of file diff --git a/lib/features/v2/workorder/workorder.dart b/lib/features/v2/workorder/workorder.dart index dd3aa69d..1f595d44 100644 --- a/lib/features/v2/workorder/workorder.dart +++ b/lib/features/v2/workorder/workorder.dart @@ -1,7 +1,4 @@ /// WorkOrder 模块导出文件 -/// -/// 使用方式: -/// import 'package:your_app/features/v2/workorder/workorder.dart'; // Constants export '../../../core/consts/workorder_consts.dart'; @@ -15,6 +12,7 @@ export 'domain/repositories/workorder_repository.dart'; // Domain - UseCases export 'domain/usecases/get_workorder_list_usecase.dart'; +export 'domain/usecases/get_workorder_detail_usecase.dart'; // Data - Models export 'data/models/workorder_model.dart'; @@ -28,6 +26,7 @@ export 'data/repositories/workorder_repository_impl.dart'; // Presentation - Cubit export 'presentation/cubit/workorder_cubit.dart'; +export 'presentation/cubit/workorder_detail_cubit.dart'; // Presentation - Widgets export 'presentation/widgets/workorder_tabbar.dart'; @@ -36,3 +35,4 @@ export 'presentation/widgets/workorder_item_card.dart'; // Presentation - Pages export 'presentation/pages/workorder_page.dart'; +export 'presentation/pages/workorder_detail_page.dart'; \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 43f440fa..10490243 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_patcher/flutter_patcher.dart'; import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/core/infrastructure/logging/app_bloc_observer.dart'; import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart'; @@ -80,16 +81,18 @@ class MyApp extends StatelessWidget { create: (_) { final user = sl().state.user; final devicesCubit = sl(); - + // 🔥 修复:检查是否已有 targetDevice,如果有则不需要加载设备列表 final remoteControlCubit = sl(); if (user != null && remoteControlCubit.state.targetDevice == null) { debugPrint('📱 [Main] 无 targetDevice,开始加载设备列表'); devicesCubit.fetchAllDevices(user.username); } else if (user != null) { - debugPrint('✅ [Main] 已有 targetDevice: ${remoteControlCubit.state.targetDevice!.deviceName},跳过设备列表加载'); + debugPrint( + '✅ [Main] 已有 targetDevice: ${remoteControlCubit.state.targetDevice!.deviceName},跳过设备列表加载', + ); } - + return devicesCubit; }, ), @@ -105,9 +108,7 @@ class MyApp extends StatelessWidget { BlocProvider( create: (_) => sl(), ), - BlocProvider( - create: (_) => sl(), - ), + BlocProvider(create: (_) => sl()), ], child: BlocBuilder( bloc: localeCubit, @@ -143,10 +144,24 @@ class _LifecycleListener extends StatefulWidget { class _LifecycleListenerState extends State<_LifecycleListener> with WidgetsBindingObserver { + String? _currentSessionId; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _initSession(); + } + + Future _initSession() async { + final prefs = sl(); + final savedCurrentId = prefs.getString('current_session_id'); + if (savedCurrentId != null) { + _currentSessionId = savedCurrentId; + debugPrint('📱 [生命周期] 恢复会话ID: $savedCurrentId'); + } else { + debugPrint('📱 [生命周期] 首次启动,等待 appStarted 初始化会话'); + } } @override @@ -157,9 +172,59 @@ class _LifecycleListenerState extends State<_LifecycleListener> @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - debugPrint('📱 [生命周期] 应用恢复到前台'); - _handleResume(); + if (state == AppLifecycleState.paused) { + _handleAppPaused(); + } else if (state == AppLifecycleState.resumed) { + _handleAppResumed(); + } else if (state == AppLifecycleState.detached) { + _handleAppDetached(); + } + } + + Future _handleAppPaused() async { + final prefs = sl(); + await prefs.setBool('pending_kill_logout', true); + if (_currentSessionId != null) { + await prefs.setString('saved_session_id', _currentSessionId!); + } + debugPrint('📱 [生命周期] 应用进入后台,已保存会话ID和杀后台标记'); + } + + Future _handleAppResumed() async { + final prefs = sl(); + final savedSessionId = prefs.getString('saved_session_id'); + final currentSessionId = + prefs.getString('current_session_id') ?? _currentSessionId; + + if (currentSessionId != null && savedSessionId == currentSessionId) { + debugPrint('📱 [生命周期] 应用正常恢复(会话ID匹配),清除杀后台标记'); + await prefs.setBool('pending_kill_logout', false); + await prefs.remove('saved_session_id'); + } else if (savedSessionId != null && savedSessionId != currentSessionId) { + debugPrint('📱 [生命周期] 应用从后台恢复但会话ID不匹配,设置杀后台标记'); + await prefs.setBool('pending_kill_logout', true); + } else { + debugPrint('📱 [生命周期] 应用恢复(未进入过后台),清除杀后台标记'); + await prefs.setBool('pending_kill_logout', false); + } + + _handleResume(); + } + + Future _handleAppDetached() async { + final prefs = sl(); + final savedSessionId = prefs.getString('saved_session_id'); + final currentSessionId = + prefs.getString('current_session_id') ?? _currentSessionId; + + if (savedSessionId != null && + currentSessionId != null && + savedSessionId == currentSessionId) { + debugPrint('📱 [生命周期] 应用在后台被杀(detached),保留杀后台标记'); + } else { + debugPrint('📱 [生命周期] 应用在前台被杀(detached),清除杀后台标记'); + await prefs.setBool('pending_kill_logout', false); + await prefs.remove('saved_session_id'); } } @@ -167,16 +232,13 @@ class _LifecycleListenerState extends State<_LifecycleListener> try { final authCubit = context.read(); if (authCubit.state is! AuthAuthenticated) return; - - // 🔥 关键修复:只有在用户已选择设备时才重连 TCP - // 否则创建一个无用的 TCP 连接,服务端会残留 session, - // 导致后续 connectBySwitch() 复用该连接时触发 have_logged_in + final remoteControlCubit = sl(); if (remoteControlCubit.state.targetDevice == null) { debugPrint('📱 [生命周期] 未选择设备,跳过 TCP 重连(避免创建无用连接)'); return; } - + await Future.delayed(const Duration(milliseconds: 500)); await authCubit.reconnectAfterResume(); } catch (e) { diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 95857373..3752d622 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import agora_rtc_engine import connectivity_plus import device_info_plus import file_selector_macos +import flutter_blue_plus_darwin import flutter_webrtc import geolocator_apple import iris_method_channel @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) IrisMethodChannelPlugin.register(with: registry.registrar(forPlugin: "IrisMethodChannelPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 55f995b8..251afdf1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "9.2.0" + bluez: + dependency: transitive + description: + name: bluez + sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.3" boolean_selector: dependency: transitive description: @@ -405,6 +413,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "9.1.1" + flutter_blue_plus: + dependency: "direct main" + description: + name: flutter_blue_plus + sha256: "2ff21c8aa1a6798f13519782627759f6f1703afac11fc1d25877f4a45bfd426d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.11" + flutter_blue_plus_android: + dependency: transitive + description: + name: flutter_blue_plus_android + sha256: "5f1db477d442974c516196718e2ab66e3601e9a2de271bddde9368c1a85e6e64" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.3" + flutter_blue_plus_darwin: + dependency: transitive + description: + name: flutter_blue_plus_darwin + sha256: bf41a4a07978b4c86a344c0c6e7388ff69f2b442a8d943b8da6d7f01a5c435bc + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.3" + flutter_blue_plus_linux: + dependency: transitive + description: + name: flutter_blue_plus_linux + sha256: "79387947c27d04fce505916d168a1f8b7a89846d22d11a659970aba316459622" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.3" + flutter_blue_plus_platform_interface: + dependency: transitive + description: + name: flutter_blue_plus_platform_interface + sha256: "9378ed463673ab51e7ab72cf4bad3633b134182ca184ddcc598d6f7474ada993" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.3" + flutter_blue_plus_web: + dependency: transitive + description: + name: flutter_blue_plus_web + sha256: "62670fd0072e9424661170c3439eb2784679e0cb8420907ae2fe979aab8eed71" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.3" + flutter_blue_plus_winrt: + dependency: transitive + description: + name: flutter_blue_plus_winrt + sha256: "0000b2d818e6f79ad07764206fdd8afb69426fd44453c6254c35828fd16aa09f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.20" flutter_highlight: dependency: transitive description: @@ -917,7 +981,7 @@ packages: source: hosted version: "2.0.0" mobile_scanner: - dependency: "direct dev" + dependency: "direct main" description: name: mobile_scanner sha256: "1b60b8f9d4ce0cb0e7d7bc223c955d083a0737bee66fa1fcfe5de48225e0d5b3" diff --git a/pubspec.yaml b/pubspec.yaml index 9c3e3d17..6e9a56b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -120,6 +120,9 @@ dependencies: webview_flutter: ^4.4.2 # 将此行移到这里 geolocator: ^13.0.1 + + # ===== 蓝牙 ===== + flutter_blue_plus: ^2.3.11 flutter_map: ^6.1.0 # 建议使用最新稳定版 latlong2: ^0.9.0 # 处理经纬度的依赖 #coord_convert: ^1.0.0 @@ -136,7 +139,7 @@ dependencies: markdown_widget: ^2.0.0 # 产品参数用的 markdown 渲染 flutter_markdown: ^0.7.1 - #qr_code_scanner: ^1.0.1 # 用于扫描二维码 + mobile_scanner: ^3.4.1 vibration: ^3.1.8 flutter_patcher: ^0.1.2 # Add flutter_patcher here open_file: ^3.3.2 # 打开文件(安装 APK) @@ -163,8 +166,6 @@ dev_dependencies: flutter_launcher_icons: ^0.13.1 - mobile_scanner: ^3.4.1 # 请根据实际需求选择最新版本 - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test_run.log b/test_run.log new file mode 100644 index 00000000..730e3fc3 Binary files /dev/null and b/test_run.log differ diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 62596ac4..71a40e41 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterBluePlusPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterBluePlusPlugin")); FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); GeolocatorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 10317e20..09e2268e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST agora_rtc_engine connectivity_plus file_selector_windows + flutter_blue_plus_winrt flutter_webrtc geolocator_windows iris_method_channel