From 1a4d0aedfddd33bc2e12da92eeccd4b4b376a4cd Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Sat, 11 Jul 2026 16:46:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=92=8C=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=A7=84=E5=88=92=E6=96=B0=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/consts/http_api_consts.dart | 3 + lib/core/consts/tcp_consts.dart | 6 +- lib/core/di/injection.dart | 14 +- lib/core/env/env_config.dart | 13 +- lib/core/network/tcp/tcp_client.dart | 30 +- .../datasources/device_task_datasource.dart | 9 +- .../impl/device_http_datasource_impl.dart | 12 +- .../impl/path_http_datasource_impl.dart | 73 +- .../models/device_work_area_param_model.dart | 4 +- .../data/models/work_record_entity.dart | 9 +- .../device_hostriry_work_impl.dart | 2 +- .../generate_path_repository_Impl.dart | 346 +++++--- .../route_planning_repository_impl.dart | 85 +- .../domain/repositories/path_repository.dart | 7 +- .../usecases/create_device_task_usecase.dart | 8 +- .../presentation/bloc/device_task_cubit.dart | 51 +- .../presentation/bloc/devices_cubit.dart | 17 +- .../pages/running_status_page.dart | 27 +- .../widgets/map/testmap_pages.dart | 771 ++++++++++++++---- .../my/repository/my_repository_impl.dart | 2 +- .../bloc/remote_control_cubit.dart | 63 ++ .../bloc/remote_control_state.dart | 5 + .../pages/remote_control_page.dart | 51 +- .../widgets/left_joystick_area.dart | 46 ++ .../widgets/right_joystick_area.dart | 50 +- .../widgets/webrtc/webrtc_local_player.dart | 176 ++-- .../datasources/drone_station_datasource.dart | 10 + .../drone_station_datasource_impl.dart | 84 ++ .../drone_station_repository_impl.dart | 24 + .../drone_station_repository.dart | 10 + .../float_bar/view/float_bar_widget.dart | 56 +- .../presentation/pages/create_task_page.dart | 55 +- .../pages/drone_mission_control_page.dart | 169 +++- .../pages/drone_station_detail_page.dart | 66 +- .../pages/drone_video_control_page.dart | 520 +++++++++++- .../pages/robot_control_page.dart | 33 +- .../presentation/pages/robot_list_page.dart | 5 +- .../presentation/widgets/drone_osd_card.dart | 85 +- .../widgets/drone_station_item_card.dart | 13 +- .../widgets/drone_station_osd_card.dart | 12 +- .../widgets/robot_header_card.dart | 100 ++- .../presentation/widgets/robot_item_card.dart | 10 +- lib/main.dart | 7 +- 43 files changed, 2454 insertions(+), 685 deletions(-) diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart index dc69d281..8c6ce35e 100644 --- a/lib/core/consts/http_api_consts.dart +++ b/lib/core/consts/http_api_consts.dart @@ -58,4 +58,7 @@ class HttpApiConsts { // 获取无人机详情 static const String getUAVDetail = "$baseUrl/iot/UAV/getUAVDetail"; + + // 飞行任务命令控制(暂停、返航等) + static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand"; } diff --git a/lib/core/consts/tcp_consts.dart b/lib/core/consts/tcp_consts.dart index 071a6471..bb60fdfa 100644 --- a/lib/core/consts/tcp_consts.dart +++ b/lib/core/consts/tcp_consts.dart @@ -1,4 +1,6 @@ +import '../env/env_config.dart'; + class TCPConsts { - static const String TCP_IP = "1.95.137.212"; - static const int TCP_PORT = 59016; + static String get TCP_IP => EnvConfig.tcpIp; + static int get TCP_PORT => EnvConfig.tcpPort; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 28fa2f41..29d078ee 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/infrastructure/logging/sentry_logger_impl.dart'; @@ -90,6 +91,8 @@ import '../../features/v2/device_list/domain/usecases/get_drone_station_list_use import '../../features/v2/device_list/domain/usecases/get_video_stream_usecase.dart'; import '../../features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart'; import '../../features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart'; +import '../../features/v2/device_list/domain/usecases/pause_flight_task_usecase.dart'; +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'; @@ -192,7 +195,10 @@ Future init() async { /// 1.3 Log工具Sentry sl.registerLazySingleton(() => SentryLoggerImpl()); - /// 1.4 --- MQTT Data Sources --- + /// 1.4 --- Route Observer (路由监听器) --- + sl.registerLazySingleton(() => RouteObserver>()); + + /// 1.5 --- MQTT Data Sources --- sl.registerFactory( () => DroneOsdDataSourceImpl(sl(instanceName: 'droneOsdClient')), @@ -358,6 +364,12 @@ Future init() async { sl.registerLazySingleton( () => UpdateFlightTaskStatusUseCase(sl()), ); + sl.registerLazySingleton( + () => PauseFlightTaskUseCase(sl()), + ); + sl.registerLazySingleton( + () => ReturnHomeUseCase(sl()), + ); sl.registerFactory( () => DroneStationBloc(sl(), sl(), sl(), sl()), ); diff --git a/lib/core/env/env_config.dart b/lib/core/env/env_config.dart index 6a682e47..c6bee51b 100644 --- a/lib/core/env/env_config.dart +++ b/lib/core/env/env_config.dart @@ -1,7 +1,7 @@ class EnvConfig { static const String environment = String.fromEnvironment( 'ENV', - defaultValue: 'dev', + defaultValue: 'prod', ); static String get sentryDsn { @@ -13,4 +13,15 @@ class EnvConfig { } static bool get isProduction => environment == 'prod'; + + /// TCP 服务器配置 + static String get tcpIp => '1.95.137.212'; + + static int get tcpPort { + // 测试服: 59016, 生产服: 9001 + if (environment == 'prod') { + return 9001; + } + return 9001; // TCP 端口 + } } diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart index 551f515d..3acc1dc7 100644 --- a/lib/core/network/tcp/tcp_client.dart +++ b/lib/core/network/tcp/tcp_client.dart @@ -833,21 +833,24 @@ class TcpClient { return; } - isUserSwitch = true; - _isSwitching = true; // 🔥 关键修复:防止旧 socket 的 onDone/onError 触发重连 - _connecting = true; // 🔥 防止 connect() 并发创建新连接 - debugPrint('🔌 [TCP-connectBySwitch] 设置 _connecting=true'); - - // 🔥 关键修复:如果 TCP 已连接,先断开旧连接再创建新连接 - // 原因:旧连接可能是 _handleResume() 创建的“空连接”(没有关联设备), - // 服务端残留的 session 与当前连接不匹配,导致推送 have_logged_in + // 🔥 核心优化:如果 TCP 已连接,复用现有连接,只调 HTTP switchDevice + // 不再销毁重建,避免推送空窗期导致延迟 if (_socket != null) { - debugPrint('🔌 [TCP-connectBySwitch] 检测到已有连接,先断开再创建新连接(确保服务端 session 干净)'); - stopHeartbeat(); - _socket!.destroy(); - _socket = null; - _isSwitching = false; // 防止 onDone 触发重连 + debugPrint('✅ [TCP-connectBySwitch] 已有连接,复用现有连接,只调 HTTP switchDevice: $deviceName'); + _logger.logWithLevel('✅ [TCP-connectBySwitch] 复用连接,HTTP切换设备: $deviceName', shouldLog: true); + try { + await switchDeviceUseCase.deviceRepository.switchDevice("app", deviceName); + debugPrint('✅ [TCP-connectBySwitch] HTTP切换成功: $deviceName'); + } catch (e) { + debugPrint('❌ [TCP-connectBySwitch] HTTP切换失败: $e'); + _logger.logWithLevel('❌ [TCP-connectBySwitch] HTTP切换失败: $e', shouldLog: true); + } + return; } + + isUserSwitch = true; + _connecting = true; // 🔥 防止 connect() 并发创建新连接 + debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建'); // debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条 _logger.logWithLevel('🔌被动 [TCP] 开始连接:$host:$port'); @@ -995,6 +998,7 @@ class TcpClient { _lastAuthPacketSentAt = DateTime.now(); // 🔥 记录发送 0x03 的时间 _socket!.add(builder.takeBytes()); + await _socket!.flush(); // 🔥 关键修复:强制flush,确保认证包立即到达服务端 //debugPrint('🔑 被动[TCP] 已发送认证包 (0x03): $authString'); _logger.logWithLevel('🔑 被动[TCP] 已发送认证包 (0x03): $authString'); diff --git a/lib/features/devices/data/datasources/device_task_datasource.dart b/lib/features/devices/data/datasources/device_task_datasource.dart index fbc0aaab..361daac6 100644 --- a/lib/features/devices/data/datasources/device_task_datasource.dart +++ b/lib/features/devices/data/datasources/device_task_datasource.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; +import '../../../../core/consts/http_api_consts.dart'; import '../../../../core/logging/i_logger_service.dart'; import '../models/device_task_model.dart'; @@ -50,7 +51,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { int pageSize = 99999999, }) async { try { - final url = 'http://1.95.137.212:59015/iot/deviceTask/deviceTaskPool'; + final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/deviceTaskPool'; final response = await dio.get( url, queryParameters: { @@ -89,7 +90,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { required int siteId, }) async { try { - final url = 'http://1.95.137.212:59015/iot/deviceTask/cancelTask'; + final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask'; final response = await dio.post( url, data: { @@ -124,7 +125,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { required int siteId, }) async { try { - final url = 'http://1.95.137.212:59015/iot/deviceTask/pauseTask'; + final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask'; final response = await dio.post( url, data: { @@ -159,7 +160,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { required int siteId, }) async { try { - final url = 'http://1.95.137.212:59015/iot/deviceTask/recoveryTask'; + final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask'; final response = await dio.post( url, data: { diff --git a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart index 096d644a..0d5148a3 100644 --- a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart +++ b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart @@ -34,7 +34,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { logger.logWithLevel( '用户点击bindDevice方法API 请求开始', level: 'INFO', - data: {'url': 'https://serviceri.satabot.com/iot/device/bindDevice'}, + data: {'url': 'http://1.95.137.212:8081/iot/device/bindDevice'}, ); var response = await dio.post( HttpApiConsts.bindDevice, @@ -44,7 +44,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { '请求详细参数', level: 'DEBUG', data: { - 'url': 'https://serviceri.satabot.com/iot/device/bindDevice', + 'url': 'http://1.95.137.212:8081/iot/device/bindDevice', 'deviceId': deviceId, 'deviceAlias': deviceAlias, }, @@ -122,7 +122,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { logger.logWithLevel( '用户点击switchDevice方法API 请求开始', level: 'INFO', - data: {'url': 'https://serviceri.satabot.com/iot/device/switchDevice'}, + data: {'url': 'http://1.95.137.212:8081/iot/device/switchDevice'}, ); var response = await dio.post( HttpApiConsts.switchDevice, @@ -138,7 +138,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { '请求详细参数', level: 'DEBUG', data: { - 'url': 'https://serviceri.satabot.com/iot/device/switchDevice', + 'url': 'http://1.95.137.212:8081/iot/device/switchDevice', 'platform': platform, 'deviceId': deviceId, }, @@ -219,12 +219,12 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { '请求详细参数', level: 'DEBUG', data: { - 'url': 'https://serviceri.satabot.com/iot/device/userDevice', + 'url': 'http://1.95.137.212:8081/iot/device/userDevice', 'tenantName': deviceame, }, ); final response = await dio.get( - 'https://serviceri.satabot.com/iot/device/userDevice', + 'http://1.95.137.212:8081/iot/device/userDevice', queryParameters: {'tenantName': deviceame}, // options: Options( // headers: { diff --git a/lib/features/devices/data/datasources/impl/path_http_datasource_impl.dart b/lib/features/devices/data/datasources/impl/path_http_datasource_impl.dart index c2e52623..e823c3a6 100644 --- a/lib/features/devices/data/datasources/impl/path_http_datasource_impl.dart +++ b/lib/features/devices/data/datasources/impl/path_http_datasource_impl.dart @@ -21,66 +21,83 @@ class PathHttpDatasourceImpl implements PathHttpDatasource { @override Future> generatePathRaw({required Map body}) async { try { - _logger.logWithLevel( '请求体类型:${body.runtimeType}',level: 'INFO'); + _logger.logWithLevel( '璇锋眰浣撶被鍨嬶細${body.runtimeType}',level: 'INFO'); - // 2. 格式化打印JSON(带缩进,清晰展示嵌套结构) + // 2. 鏍煎紡鍖栨墦鍗癑SON锛堝甫缂╄繘锛屾竻鏅板睍绀哄祵濂楃粨鏋勶級 final jsonString = const JsonEncoder.withIndent(' ').convert(body); final _jsonString = jsonEncode(body); - // print('完整JSON请求体:\n$jsonString'); - _logger.logWithLevel( '请求体:$_jsonString',level: 'INFO'); + // print('瀹屾暣JSON璇锋眰浣擄細\n$jsonString'); + _logger.logWithLevel( '璇锋眰浣擄細$_jsonString',level: 'INFO'); - // 3. 可选:单独打印holes的JSON(重点关注) + // 3. 鍙€夛細鍗曠嫭鎵撳嵃holes鐨凧SON锛堥噸鐐瑰叧娉級 //final holesJson = body['holes'] as List; - //print('=== 单独打印holes的JSON ==='); + //print('=== 鍗曠嫭鎵撳嵃holes鐨凧SON ==='); //if (holesJson.isEmpty) { - // print('holes 为空数组'); + // print('holes 涓虹┖鏁扮粍'); //} else { // for (int i = 0; i < holesJson.length; i++) { - // print('第${i + 1}组hole:\n${const JsonEncoder.withIndent(' ').convert(holesJson[i])}'); + // print('绗?{i + 1}缁刪ole锛歕n${const JsonEncoder.withIndent(' ').convert(holesJson[i])}'); // } //} } catch (e) { - // 防止JSON序列化失败导致崩溃 - //print('打印请求体失败:$e'); - _logger.logWithLevel( '打印请求体失败:$e',level: 'ERROR'); - // 降级打印原始body(虽然格式乱,但能看基础数据) - // print('原始body数据:$body'); - _logger.logWithLevel( '原始body数据:$body',level: 'ERROR'); + // 闃叉JSON搴忓垪鍖栧け璐ュ鑷村穿婧? + //print('鎵撳嵃璇锋眰浣撳け璐ワ細$e'); + _logger.logWithLevel( '鎵撳嵃璇锋眰浣撳け璐ワ細$e',level: 'ERROR'); + // 闄嶇骇鎵撳嵃鍘熷body锛堣櫧鐒舵牸寮忎贡锛屼絾鑳界湅鍩虹鏁版嵁锛? + // print('鍘熷body鏁版嵁锛?body'); + _logger.logWithLevel( '鍘熷body鏁版嵁锛?body',level: 'ERROR'); } final token = (await _userStorage.getUser())?.token; if (token == null) throw Exception('Token missing'); + final requestBody = jsonEncode(body); + // print('🔍 [路径规划请求] ====== 完整请求体 ======'); + // print(requestBody); + //print('🔍 [路径规划请求] ====== 请求体结束 ======'); final response = await http.post( Uri.parse('https://servicepathplan.satabot.com/api/path'), - headers: {'Content-Type': 'application/json', 'Authorization': token}, - body: jsonEncode(body), + headers: {'Content' + '-Type': 'application/json', 'Authorization': token}, + body: requestBody, ); // 🔥 完整打印响应体 - //printFullString(formatJson(jsonEncode(response.body))); + //print('🔄 [路径规划API] 响应状态码: ${response.statusCode}'); + // print('🔄 [路径规划API] 响应体长度: ${response.body.length}'); + //print('🔄 [路径规划API] 响应体前500字符: ${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); try { final decoded = jsonDecode(response.body) as Map; - if (decoded['success'] == true) { - final pathJson = decoded['data']?['path']; - // print('路径数据: $pathJson'); // 调试输出路径数据 - _logger.logWithLevel( '路径数据: $pathJson',level: 'INFO'); - if (pathJson == null) { - throw Exception('API returned null for "path"'); - } + // print('🔄 [路径规划API] 解析后 keys: ${decoded.keys.toList()}'); + //print('🔄 [路径规划API] code=${decoded['code']}, success=${decoded['success']}, msg=${decoded['msg']}, hasData=${decoded.containsKey('data')}'); + // 🔥 对齐 Web 端:优先判断 data.path 是否存在,兼容 code==200 和 success==true 两种格式 + final dataObj = decoded['data']; + final pathJson = dataObj is Map ? dataObj['path'] : null; + if (pathJson != null) { + / print('✅ [路径规划API] 路径数据点数: ${pathJson is List ? pathJson.length : '非列表'}'); if (pathJson is! List) { throw Exception('Expected "path" to be a List, but got ${pathJson.runtimeType}'); } final List jsonData = pathJson; return jsonData.map((item) => DeviceAddPathPointModel.fromJson(item)).toList(); - } else { - final apiCode = decoded['code'] ?? -1; - final msg = decoded['msg'] ?? '未知错误'; - throw Exception('API 错误 [$apiCode]: $msg'); } + // 检查 success 字段(旧格式兼容) + if (decoded['success'] == true) { + if (pathJson == null) { + throw Exception('API returned null for "path"'); + } + } + // 兼容 code==200 格式 + final apiCode = decoded['code']; + if (apiCode != null && apiCode.toString() == '200') { + throw Exception('API code=200 但 data.path 为空'); + } + final msg = decoded['message'] ?? decoded['msg'] ?? '未知错误'; + throw Exception('API 错误 [${decoded['code'] ?? -1}]: $msg'); } catch (e) { throw Exception('Network error: $e'); } } } + diff --git a/lib/features/devices/data/models/device_work_area_param_model.dart b/lib/features/devices/data/models/device_work_area_param_model.dart index be0285d7..b1f74acf 100644 --- a/lib/features/devices/data/models/device_work_area_param_model.dart +++ b/lib/features/devices/data/models/device_work_area_param_model.dart @@ -4,7 +4,7 @@ class ReferencePoint { ReferencePoint({required this.lat, required this.lon}); - Map toJson() => {'lat': lat, 'lng': lon}; + Map toJson() => {'lat': lat, 'lon': lon}; } class OuterBoundary { @@ -86,5 +86,5 @@ class Position { return Position(lat: lat, lon: lon); } - Map toJson() => {'lat': lat, 'lng': lon}; + Map toJson() => {'lat': lat, 'lon': lon}; } diff --git a/lib/features/devices/data/models/work_record_entity.dart b/lib/features/devices/data/models/work_record_entity.dart index b72a1572..9c4a4937 100644 --- a/lib/features/devices/data/models/work_record_entity.dart +++ b/lib/features/devices/data/models/work_record_entity.dart @@ -11,7 +11,7 @@ class WorkRecordEntity { final int userId; final int id; final String workName; - final WorkRecordJsonData? jsonData; + final dynamic jsonData; // 支持 WorkRecordJsonData(JSON接口)或 String(XML接口原始JSON,避免二次序列化丢失数据) final String? imgUrl; WorkRecordEntity({ @@ -43,11 +43,8 @@ class WorkRecordEntity { userId: int.tryParse(xmlData['userId']?.toString() ?? '0') ?? 0, id: int.tryParse(xmlData['id']?.toString() ?? '0') ?? 0, workName: xmlData['workName'] as String? ?? '', - jsonData: xmlData['jsonData'] != null - ? WorkRecordJsonData.fromXml( - xmlData['jsonData'] as Map, - ) - : null, + // 🔥 核心修复:_parseJsonDataNode 现在返回 JSON 字符串,直接存储,避免 Map→jsonEncode 丢失路径数据 + jsonData: xmlData['jsonData'], imgUrl: xmlData['imgUrl'] as String?, ); } diff --git a/lib/features/devices/data/repositories/device_hostriry_work_impl.dart b/lib/features/devices/data/repositories/device_hostriry_work_impl.dart index a0c10fdf..f2d9fee7 100644 --- a/lib/features/devices/data/repositories/device_hostriry_work_impl.dart +++ b/lib/features/devices/data/repositories/device_hostriry_work_impl.dart @@ -35,7 +35,7 @@ class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRe _logger.logWithLevel('用户信息获取成功,Token: $token', level: 'info'); final response = await client.get( - 'https://serviceri.satabot.com/iot/device/getDeviceRunStatistics?deviceId=$deviceId', + 'http://1.95.137.212:8081/iot/device/getDeviceRunStatistics?deviceId=$deviceId', options: Options(headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'}), ); diff --git a/lib/features/devices/data/repositories/generate_path_repository_Impl.dart b/lib/features/devices/data/repositories/generate_path_repository_Impl.dart index b09a4b13..be00eee1 100644 --- a/lib/features/devices/data/repositories/generate_path_repository_Impl.dart +++ b/lib/features/devices/data/repositories/generate_path_repository_Impl.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:http/http.dart' as http; import 'dart:convert'; +import 'package:dio/dio.dart'; import 'package:xml/xml.dart'; import '../../../../core/di/injection.dart'; @@ -16,7 +17,7 @@ class PathRepositoryImpl implements PathRepository { final PathHttpDatasource _datasource; PathRepositoryImpl({required PathHttpDatasource datasource}) : _datasource = datasource; - // 生成路径 + // 鐢熸垚璺�緞 @override Future> generatePath({ required ReferencePoint reference, @@ -31,22 +32,22 @@ class PathRepositoryImpl implements PathRepository { 'outer': outer.toJson(), 'holes': holes != null && holes.isNotEmpty ? holes.values.first - .toJson() // 取第一个值并序列化,去掉外层 Map - : {}, + .toJson() // 鍙栫�涓€涓�€煎苟搴忓垪鍖栵紝鍘绘帀澶栧眰 Map + : [], 'workType': workType, }; return await _datasource.generatePathRaw(body: body); } - // 保存工作记录 + // 淇濆瓨宸ヤ綔璁板綍 @override Future> saveWorkRecord({ required String workName, required String userId, required String jsonData, }) async { - final url = Uri.parse('https://serviceri.satabot.com/iot/workRecord/add'); + final url = Uri.parse('http://1.95.137.212:8081/iot/workRecord/add'); final headers = {'Content-Type': 'application/json'}; final body = jsonEncode({ 'workName': workName, @@ -73,7 +74,7 @@ class PathRepositoryImpl implements PathRepository { }) async { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'https://serviceri.satabot.com/iot/workRecord/selectByUserId', + 'http://1.95.137.212:8081/iot/workRecord/selectByUserId', ).replace(queryParameters: {'userId': userId, '_t': timestamp.toString()}); try { @@ -102,7 +103,7 @@ class PathRepositoryImpl implements PathRepository { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'https://serviceri.satabot.com/iot/workRecord/deleteByWorkName', + 'http://1.95.137.212:8081/iot/workRecord/deleteByWorkName', ).replace( queryParameters: {'workName': workName, '_t': timestamp.toString()}, ); @@ -112,7 +113,7 @@ class PathRepositoryImpl implements PathRepository { final data = jsonDecode(response.body) as Map; if (response.statusCode == 200 && data['code'] == 200) { - return data; // 返回 {"code": 200, "msg": "删除成功"} + return data; // 杈斿洖 {"code": 200, "msg": "鍒犻櫎鎴愬姛"} } else { throw Exception( 'Delete failed: ${data['msg'] ?? response.reasonPhrase}', @@ -123,7 +124,7 @@ class PathRepositoryImpl implements PathRepository { } } - /// 选择工作记录 + /// 选择工作记录(根据workName) @override Future>> selectWorkRecordByName({ required String workName, @@ -131,41 +132,54 @@ class PathRepositoryImpl implements PathRepository { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'https://serviceri.satabot.com/iot/workRecord/selectByWorkName', + 'http://1.95.137.212:8081/iot/workRecord/selectByWorkName', ).replace( queryParameters: {'workName': workName, '_t': timestamp.toString()}, ); try { - final response = await http.get(url); + final response = await http.get(url).timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - - if (data['code'] == 200 && data.containsKey('data')) { - final dynamic rawData = data['data']; - - // 🔧 关键修复:安全转换为 List - - List> records; - if (rawData is List) { - records = rawData.map((e) { - if (e is Map) { - return Map.from(e); - } - throw Exception('List item is not a Map: ${e.runtimeType}'); - }).toList(); - } else if (rawData is Map) { - records = [Map.from(rawData)]; - } else { - throw Exception( - 'Unexpected data type for "data": ${rawData.runtimeType}', - ); + // 判断响应格式:XML 还是 JSON + final trimmed = response.body.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + // JSON 格式(原有逻辑) + Map data; + try { + data = jsonDecode(response.body) as Map; + } catch (formatError) { + print('[selectWorkRecordByName] JSON解析失败: $formatError'); + throw Exception('JSON解析失败: $formatError'); } - return records; + if (data['code'] == 200 && data.containsKey('data')) { + final dynamic rawData = data['data']; + + List> records; + if (rawData is List) { + records = rawData.map((e) { + if (e is Map) { + return Map.from(e); + } + throw Exception('List item is not a Map: ${e.runtimeType}'); + }).toList(); + } else if (rawData is Map) { + records = [Map.from(rawData)]; + } else { + throw Exception( + 'Unexpected data type for "data": ${rawData.runtimeType}', + ); + } + + return records; + } else { + throw Exception('API error: ${data['msg'] ?? 'Unknown'}'); + } } else { - throw Exception('API error: ${data['msg'] ?? 'Unknown'}'); + // XML 格式:使用 XML 解析逻辑 + print('[selectByWorkName] 检测到 XML 格式响应'); + return _parseXmlSelectByNameResponse(response.body); } } else { throw Exception( @@ -177,7 +191,75 @@ class PathRepositoryImpl implements PathRepository { } } - /// 根据场站ID查询工作记录列表(XML格式) + /// 解析 selectByWorkName 的 XML 响应 + /// 返回 List>,将 jsonData 中的 path/outer 提取到顶层 + Future>> _parseXmlSelectByNameResponse(String body) async { + print('[selectByWorkName XML] 原始响应前500字符: ${body.length > 500 ? body.substring(0, 500) : body}'); + final codeMatch = RegExp(r'<\w*:?code[^>]*>(\d+)').firstMatch(body); + final code = codeMatch?.group(1); + print('[selectByWorkName XML] code: $code'); + + if (code != '200') { + final msgMatch = RegExp(r'<\w*:?msg[^>]*>(.*?)').firstMatch(body); + throw Exception('API error: ${msgMatch?.group(1) ?? 'Unknown'}'); + } + + final dataRegex = RegExp(r'<\w*:?data[^>]*>([\s\S]*?)'); + final dataMatches = dataRegex.allMatches(body); + print('[selectByWorkName XML] 找到 data 节点数量: ${dataMatches.length}'); + + final List> records = []; + + for (final match in dataMatches) { + final dataContent = match.group(1)!; + print('[selectByWorkName XML] data内容前300字符: ${dataContent.length > 300 ? dataContent.substring(0, 300) : dataContent}'); + try { + final wrappedXml = '$dataContent'; + final document = XmlDocument.parse(wrappedXml); + final recordElement = document.rootElement; + final recordData = _parseXmlRecord(recordElement); + print('[selectByWorkName XML] recordData keys: ${recordData.keys.toList()}'); + print('[selectByWorkName XML] jsonData 类型: ${recordData['jsonData']?.runtimeType}'); + + // 将 jsonData 中的 path/outer 提取到顶层,供 UI 直接使用 + if (recordData['jsonData'] is String) { + try { + final jsonDataMap = jsonDecode(recordData['jsonData'] as String) as Map; + print('[selectByWorkName XML] jsonData 解析后 keys: ${jsonDataMap.keys.toList()}'); + print('[selectByWorkName XML] path 类型: ${jsonDataMap['path']?.runtimeType}, outer 类型: ${jsonDataMap['outer']?.runtimeType}'); + if (jsonDataMap.containsKey('path')) { + recordData['path'] = jsonDataMap['path']; + } + if (jsonDataMap.containsKey('outer')) { + recordData['outer'] = jsonDataMap['outer']; + } + if (jsonDataMap.containsKey('planModel')) { + recordData['planModel'] = jsonDataMap['planModel']; + } + print('[selectByWorkName XML] 提取后 recordData path类型: ${recordData['path']?.runtimeType}, outer类型: ${recordData['outer']?.runtimeType}'); + } catch (e) { + print('[selectByWorkName XML] jsonData 解析失败: $e'); + } + } else { + print('[selectByWorkName XML] jsonData 不是 String 类型! 值: ${recordData['jsonData']}'); + } + + records.add(recordData); + } catch (e) { + print('[selectByWorkName XML] 解析单个 data 节点失败: $e'); + } + } + + print('[selectByWorkName XML] 最终解析记录数: ${records.length}'); + if (records.isNotEmpty) { + final first = records.first; + print('[selectByWorkName XML] 第一条记录 path长度: ${(first['path'] as List?)?.length ?? "null"}'); + print('[selectByWorkName XML] 第一条记录 outer长度: ${(first['outer'] as List?)?.length ?? "null"}'); + } + return records; + } + + /// 鏍规嵁鍦虹珯ID鏌ヨ�宸ヤ綔璁板綍鍒楄〃锛圶ML鏍煎紡锛? @override Future> getWorkRecordsBySiteId({ required int siteId, @@ -185,7 +267,7 @@ class PathRepositoryImpl implements PathRepository { final timestamp = DateTime.now().millisecondsSinceEpoch; final url = Uri.parse( - 'http://1.95.137.212:59015/iot/workRecord/selectBySiteId', + 'http://1.95.137.212:8081/iot/workRecord/selectBySiteId', ).replace( queryParameters: { 'siteId': siteId.toString(), @@ -198,24 +280,27 @@ class PathRepositoryImpl implements PathRepository { url, headers: {'Accept': 'application/xml, text/xml, */*'}, ); - /// print('[XML接口] 响应状态码: ${response.statusCode}'); - ////print('[XML接口] 响应内容长度: ${response.body.length}'); - ///print('[XML接口] Content-Type: ${response.headers['content-type']}'); + /// print('[XML鎺ュ彛] 鍝嶅簲鐘舵€佺爜: ${response.statusCode}'); + ////print('[XML鎺ュ彛] 鍝嶅簲鍐呭�闀垮害: ${response.body.length}'); + ///print('[XML鎺ュ彛] Content-Type: ${response.headers['content-type']}'); if (response.statusCode == 200) { - // 打印前200字符确认格式 + // 鎵撳嵃鍓?00瀛楃�纭��鏍煎紡 final preview = response.body.length > 200 ? response.body.substring(0, 200) : response.body; - /// print('[XML接口] 响应开头: $preview'); + // 🔥 调试:打印原始 API 响应 + // print('[selectBySiteId 原始响应] 长度=${response.body.length}'); + // print('[selectBySiteId 原始响应] 前1000字符: ${response.body.length > 1000 ? response.body.substring(0, 1000) : response.body}'); + /// print('[XML鎺ュ彛] 鍝嶅簲寮€澶? $preview'); - // 判断是JSON还是XML格式 + // 鍒ゆ柇鏄疛SON杩樻槸XML鏍煎紡 final trimmed = response.body.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - ///print('[XML接口] 检测到JSON格式,使用JSON解析'); + ///print('[XML鎺ュ彛] 妫€娴嬪埌JSON鏍煎紡锛屼娇鐢↗SON瑙f瀽'); return _parseJsonResponse(response.body); } else { - ///print('[XML接口] 检测到XML格式,使用XML解析'); + ///print('[XML鎺ュ彛] 妫€娴嬪埌XML鏍煎紡锛屼娇鐢╔ML瑙f瀽'); return _parseXmlResponse(response.body); } } else { @@ -224,12 +309,12 @@ class PathRepositoryImpl implements PathRepository { ); } } catch (e) { - /// print('[XML接口] 错误: $e'); + /// print('[XML鎺ュ彛] 閿欒�: $e'); throw Exception('Network error in getWorkRecordsBySiteId: $e'); } } - /// 解析JSON格式响应 + /// 瑙f瀽JSON鏍煎紡鍝嶅簲 Future> _parseJsonResponse(String body) async { final data = jsonDecode(body) as Map; @@ -252,18 +337,18 @@ class PathRepositoryImpl implements PathRepository { records.add(WorkRecordEntity.fromJson(recordsData)); } - /// print('[XML接口] 最终解析记录数: ${records.length}'); + /// print('[XML鎺ュ彛] 鏈€缁堣В鏋愯�褰曟暟: ${records.length}'); return records; } - /// 解析XML格式响应 + /// 瑙f瀽XML鏍煎紡鍝嶅簲 Future> _parseXmlResponse(String body) async { - // 用更宽松的正则检查响应码(支持命名空间前缀) + // 鐢ㄦ洿瀹芥澗鐨勬�鍒欐�鏌ュ搷搴旂爜锛堟敮鎸佸懡鍚嶇┖闂村墠缂€锛? final codeMatch = RegExp( r'<\w*:?code[^>]*>(\d+)', ).firstMatch(body); final code = codeMatch?.group(1); - print('[XML接口] code: $code'); + print('[XML鎺ュ彛] code: $code'); if (code != '200') { final msgMatch = RegExp( @@ -272,84 +357,104 @@ class PathRepositoryImpl implements PathRepository { throw Exception('API error: ${msgMatch?.group(1) ?? 'Unknown'}'); } - // 使用正则表达式提取所有...节点 - // 使用非贪婪匹配,确保每个data节点独立提取 - final dataRegex = RegExp(r'([\s\S]*?)'); + // 浣跨敤姝e垯琛ㄨ揪寮忔彁鍙栨墍鏈?data>...鑺傜偣 + // 浣跨敤闈炶椽濠�尮閰嶏紝纭�繚姣忎釜data鑺傜偣鐙�鎻愬彇 + final dataRegex = RegExp(r'<\w*:?data[^>]*>([\s\S]*?)'); final dataMatches = dataRegex.allMatches(body); - ///print('[XML接口] 找到data节点数量: ${dataMatches.length}'); + ///print('[XML鎺ュ彛] 鎵惧埌data鑺傜偣鏁伴噺: ${dataMatches.length}'); - // 解析所有工作记录 + // 瑙f瀽鎵€鏈夊伐浣滆�褰? final List records = []; for (int i = 0; i < dataMatches.length; i++) { final match = dataMatches.elementAt(i); - final dataContent = match.group(1)!; // 获取和之间的内容 + final dataContent = match.group(1)!; // 鑾峰彇鍜?/data>涔嬮棿鐨勫唴瀹? try { - // 将提取的内容包装成完整XML进行解析 + // 灏嗘彁鍙栫殑鍐呭�鍖呰�鎴愬畬鏁碭ML杩涜�瑙f瀽 final wrappedXml = '$dataContent'; final document = XmlDocument.parse(wrappedXml); final recordElement = document.rootElement; final recordData = _parseXmlRecord(recordElement); + final _jdVal = recordData['jsonData']; + // print('[selectBySiteId XML] data[$i] workName=${recordData['workName']}, jsonData类型=${_jdVal?.runtimeType}, jsonData长度=${_jdVal is String ? _jdVal.length : "N/A"}'); + // if (_jdVal is String && _jdVal.isNotEmpty) { + // try { + // final _jdDecoded = jsonDecode(_jdVal); + // if (_jdDecoded is Map) { + // print('[selectBySiteId XML] data[$i] jsonData.path类型=${_jdDecoded['path']?.runtimeType}, outer类型=${_jdDecoded['outer']?.runtimeType}'); + // if (_jdDecoded['path'] is List) print('[selectBySiteId XML] data[$i] path长度=${(_jdDecoded['path'] as List).length}'); + // } + // } catch (_) {} + // } /// print( - /// '[XML接口] data[$i] 解析结果: workName=${recordData['workName']}, id=${recordData['id']}, imgUrl=${recordData['imgUrl']}', + /// '[XML鎺ュ彛] data[$i] 瑙f瀽缁撴灉: workName=${recordData['workName']}, id=${recordData['id']}, imgUrl=${recordData['imgUrl']}', /// ); records.add(WorkRecordEntity.fromXml(recordData)); } catch (e) { - print('[XML接口] 解析单个data节点失败: $e'); + print('[XML鎺ュ彛] 瑙f瀽鍗曚釜data鑺傜偣澶辫触: $e'); } } - ///print('[XML接口] 最终解析记录数: ${records.length}'); + ///print('[XML鎺ュ彛] 鏈€缁堣В鏋愯�褰曟暟: ${records.length}'); return records; } - /// 解析XML工作记录节点 + /// 瑙f瀽XML宸ヤ綔璁板綍鑺傜偣 Map _parseXmlRecord(XmlElement recordElement) { final Map result = {}; - //// print('[XML解析] 开始解析节点,子元素数量: ${recordElement.childElements.length}'); + //// print('[XML瑙f瀽] 寮€濮嬭В鏋愯妭鐐癸紝瀛愬厓绱犳暟閲? ${recordElement.childElements.length}'); for (final child in recordElement.childElements) { final tagName = child.name.local; final innerText = child.innerText.trim(); // print( - // '[XML解析] 标签: $tagName, 值: ${innerText.length > 50 ? innerText.substring(0, 50) + '...' : innerText}', + // '[XML瑙f瀽] 鏍囩�: $tagName, 鍊? ${innerText.length > 50 ? innerText.substring(0, 50) + '...' : innerText}', // ); - // 特殊处理jsonData节点(包含嵌套结构) + // 鐗规畩澶勭悊jsonData鑺傜偣锛堝寘鍚�祵濂楃粨鏋勶級 if (tagName == 'jsonData') { result['jsonData'] = _parseJsonDataNode(child); } else { - // 普通节点直接取值 + // 鏅�€氳妭鐐圭洿鎺ュ彇鍊? result[tagName] = innerText; } } - //print('[XML解析] 解析完成,结果keys: ${result.keys.toList()}'); + //print('[XML瑙f瀽] 瑙f瀽瀹屾垚锛岀粨鏋渒eys: ${result.keys.toList()}'); return result; } /// 解析jsonData节点 - Map _parseJsonDataNode(XmlElement jsonDataElement) { + /// 返回 JSON 字符串,避免 Map → jsonEncode 的二次序列化导致数据丢失 + String _parseJsonDataNode(XmlElement jsonDataElement) { final Map result = {}; + // // 🔥 调试:打印 jsonData 节点的原始 XML 内容 + // final rawXml = jsonDataElement.outerXml; + // print('[jsonData 原始XML] 长度=${rawXml.length}, 前500字符: ${rawXml.length > 500 ? rawXml.substring(0, 500) : rawXml}'); + // print('[jsonData 子元素数量] ${jsonDataElement.childElements.length}'); + // for (final child in jsonDataElement.childElements) { + // print('[jsonData 子元素] tag=${child.name.local}, innerText长度=${child.innerText.trim().length}'); + // } + for (final child in jsonDataElement.childElements) { final tagName = child.name.local; if (tagName == 'path' || tagName == 'outer') { - // path和outer可能包含嵌套的path/outer节点 result[tagName] = _parseCoordinateList(child, tagName); } else { - // 普通字段(name, img, planModel等) result[tagName] = child.innerText.trim(); } } - return result; + final encoded = jsonEncode(result); + // print('[jsonData 解析结果] keys=${result.keys.toList()}, 长度=${encoded.length}'); + return encoded; } /// 解析坐标列表(path或outer) @@ -365,7 +470,7 @@ class PathRepositoryImpl implements PathRepository { .toList(); if (nestedElements.isNotEmpty) { - // 有嵌套结构:outer > outer > {lat, lng} + // 嵌套结构:outer > outer > {lat, lng} for (final nestedElement in nestedElements) { final latElement = nestedElement.getElement('lat'); final lngElement = nestedElement.getElement('lng'); @@ -378,55 +483,106 @@ class PathRepositoryImpl implements PathRepository { } } } else { - // 直接包含lat/lng节点 - final latElement = element.getElement('lat'); - final lngElement = element.getElement('lng'); + // 🔥 核心修复:提取所有同级 lat/lng 对,而不是只取第一个 + // 支持格式:30.1120.130.2120.2 + final allLatElements = element.childElements + .where((e) => e.name.local == 'lat') + .toList(); + final allLngElements = element.childElements + .where((e) => e.name.local == 'lng') + .toList(); - if (latElement != null && lngElement != null) { - coordinates.add({ - 'lat': double.tryParse(latElement.innerText.trim()) ?? 0.0, - 'lng': double.tryParse(lngElement.innerText.trim()) ?? 0.0, - }); + final count = allLatElements.length < allLngElements.length + ? allLatElements.length + : allLngElements.length; + + for (int i = 0; i < count; i++) { + final lat = double.tryParse(allLatElements[i].innerText.trim()) ?? 0.0; + final lng = double.tryParse(allLngElements[i].innerText.trim()) ?? 0.0; + coordinates.add({'lat': lat, 'lng': lng}); } } return coordinates; } - /// 创建设备任务(通过接口执行作业) - /// 接口地址: http://1.95.137.212:59015/iot/deviceTask/createDeviceTask - /// 入参: {"deviceId":"...","routeId":76,"siteId":22} + /// 鍒涘缓璁惧�浠诲姟锛堥€氳繃鎺ュ彛鎵ц�浣滀笟锛? + /// 鎺ュ彛鍦板潃: http://1.95.137.212:8081/iot/deviceTask/createDeviceTask + /// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5} + /// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔�D @override - Future createDeviceTask({ + Future createDeviceTask({ required String deviceId, required int routeId, required int siteId, + required int orgId, }) async { - final url = Uri.parse('http://1.95.137.212:59015/iot/deviceTask/createDeviceTask'); - - final body = jsonEncode({ + final dio = sl(); + + final body = { 'deviceId': deviceId, 'routeId': routeId, 'siteId': siteId, - }); + 'orgId': orgId, + }; - print('📤 [创建设备任务] 请求参数: $body'); + print('[创建设备任务] 请求参数: ${jsonEncode(body)}'); try { - final response = await http.post( - url, - headers: {'Content-Type': 'application/json'}, - body: body, + final response = await dio.post( + 'http://1.95.137.212:8081/iot/deviceTask/createDeviceTask', + data: body, ); - print('📥 [创建设备任务] 响应状态码: ${response.statusCode}'); - print('📥 [创建设备任务] 响应内容: ${response.body}'); + print('[创建设备任务] 响应状态码: ${response.statusCode}'); + print('[创建设备任务] 响应原始数据: ${response.data}'); if (response.statusCode != 200) { throw Exception('创建设备任务失败: HTTP ${response.statusCode}'); } + + // 解析响应,提取 taskId + final data = response.data as Map; + final code = data['code']; + print('[创建设备任务] code字段值: $code, 类型: ${code.runtimeType}'); + + // 兼容 int 和 String 类型的 code + final codeValue = code is int ? code : int.tryParse(code?.toString() ?? ''); + if (codeValue == 200) { + final taskData = data['data']; + print('[创建设备任务] data字段值: $taskData, 类型: ${taskData?.runtimeType}'); + + if (taskData is int) { + print('[创建设备任务] 任务ID(int): $taskData'); + return taskData; + } else if (taskData is String) { + final taskId = int.tryParse(taskData); + if (taskId != null) { + print('[创建设备任务] 任务ID(String->int): $taskId'); + return taskId; + } + } else if (taskData is Map) { + final taskId = taskData['id'] ?? taskData['taskId']; + print('[创建设备任务] data是Map, id=$taskId, 类型=${taskId?.runtimeType}'); + if (taskId is int) { + print('[创建设备任务] 任务ID(Map.id): $taskId'); + return taskId; + } else if (taskId is String) { + final parsedId = int.tryParse(taskId); + if (parsedId != null) { + print('[创建设备任务] 任务ID(Map.id->int): $parsedId'); + return parsedId; + } + } + } + // code==200 但无法解析taskId,可能是新格式,打印完整响应方便调试 + print('[创建设备任务] WARNING: code=200但无法解析taskId, 完整响应: ${response.data}'); + throw Exception('任务已创建但无法解析taskId, 完整响应: ${response.data}'); + } else { + throw Exception('创建设备任务失败: ${data['msg'] ?? response.data}'); + } } catch (e) { - print('❌ [创建设备任务] 错误: $e'); + print('[创建设备任务] 错误: $e'); throw Exception('创建设备任务失败: $e'); } } diff --git a/lib/features/devices/data/repositories/route_planning_repository_impl.dart b/lib/features/devices/data/repositories/route_planning_repository_impl.dart index 2f36db69..4aadf4c9 100644 --- a/lib/features/devices/data/repositories/route_planning_repository_impl.dart +++ b/lib/features/devices/data/repositories/route_planning_repository_impl.dart @@ -84,14 +84,14 @@ class PathPlanner { PathPlanner(this.tcpClient); + // 🔥 已弃用:路径点逐个下发已改为服务端通过 createDeviceTask HTTP 接口处理, + // APP 不再通过 TCP 逐个发送路径点,避免与服务器重复发送导致下位机收到双份数据。 void sendNextLocation() { + _logger.log("[sendNextLocation] 已弃用 - 路径点下发由服务端 HTTP 接口负责"); + return; + + /* ========== 以下为原有 TCP 逐个发点逻辑,已注释保留 ========== _logger.log("[发送sendNextLocation方法指令]"); - // if (isStart && locationQueue.isEmpty) { - // isStart = false; - // print("[track]"); - // tcpClient.disconnect(); - // return; - // } // 关键检查:暂停或停止时不再发送 if (_isPaused) { _logger.log("[暂停状态],停止发送指令"); @@ -101,11 +101,9 @@ class PathPlanner { if (_isStopped || (isStart && locationQueue.isEmpty)) { isStart = false; if (_isStopped) { - //print("⏹️ 已停止作业,清空队列"); _logger.log("[路径点发送完毕] 停止"); locationQueue.clear(); // 清空剩余队列 } else { - //print("[路径点发送完毕]"); _logger.log("[路径点发送完毕]"); } _isStopped = false; // 重置停止标志 @@ -121,10 +119,10 @@ class PathPlanner { speed: 1000, ); - //print("====Lat:${entity.latitude} ====Lng:${entity.longitude}"); _logger.log("[发送指令]"); tcpClient.sendPathPoint(routePlanSendEntity); isStart = true; + ========== 原有 TCP 逐个发点逻辑结束 ========== */ } /// 开始 @@ -186,36 +184,30 @@ class PathPlanner { } } - /// 暂停 + /// 暂停(已切换为 HTTP 接口 pauseTask,TCP 方式已弃用) void pauseRPWork() { - //print("⏸️ 暂停作业,设置_isPaused = true"); - _logger.log("[暂停作业,设置_isPaused = true]"); + _logger.log("[pauseRPWork] 已弃用 - 暂停由 HTTP pauseTask 接口负责"); _isPaused = true; + /* ========== 以下为原有 TCP 发送暂停指令,已注释保留 ========== var entity = new RoutePlanSendEntity(commandType: 0x01, pointCounts: 2, targetLatitude: 0, targetLongitude: 0, speed: 0); - // print("📡 发送暂停指令到设备..."); _logger.log("[发送暂停指令到设备...]"); tcpClient.sendDeviceStateChange(entity); + ========== 原有 TCP 暂停指令结束 ========== */ } - /// 恢复 + /// 恢复(已切换为 HTTP 接口 recoveryTask,TCP 方式已弃用) void resumeRPWork() { - //print("▶️ 恢复作业,设置_isPaused = false"); - _logger.log("[恢复作业,设置_isPaused = false]"); + _logger.log("[resumeRPWork] 已弃用 - 恢复由 HTTP recoveryTask 接口负责"); _isPaused = false; + /* ========== 以下为原有 TCP 发送恢复指令,已注释保留 ========== var entity = new RoutePlanSendEntity(commandType: 0x01, pointCounts: 3, targetLatitude: 0, targetLongitude: 0, speed: 0); - // print("📡 发送恢复指令到设备..."); _logger.log("[发送恢复指令到设备...]"); tcpClient.sendDeviceStateChange(entity); // 恢复后继续发送下一个点 - // if (locationQueue.isNotEmpty) { - // print("📍 恢复发送下一个路径点"); - // sendNextLocation(); - // } if (locationQueue.isNotEmpty) { - //print("📍 恢复发送下一个路径点"); _logger.log("[恢复发送下一个路径点]"); if (PathPlanningConfig.useAckHandshake) { sendNextLocationWithAck(sl()); @@ -223,52 +215,53 @@ class PathPlanner { sendNextLocation(); } } + ========== 原有 TCP 恢复指令结束 ========== */ } - /// 停止 + /// 停止(已切换为 HTTP 接口 cancelTask,TCP 方式已弃用) void stopRoutePlanning() { - // print("⏹️ 停止作业,设置_isStopped = true"); - _logger.log("[停止作业,设置_isStopped = true]"); + _logger.log("[stopRoutePlanning] 已弃用 - 停止由 HTTP cancelTask 接口负责"); _isStopped = true; - _isPaused = false; // 清除暂停状态 - - // 🔥 核心修复:清空队列 - // print("🗑️ 清空待发送队列,剩余 ${locationQueue.length} 个点"); - _logger.log("[清空待发送队列,剩余 ${locationQueue.length} 个点]"); + _isPaused = false; locationQueue.clear(); final devicesCubit = sl(); devicesCubit.setArrivedLocation(0.0, 0.0); + /* ========== 以下为原有 TCP 发送停止指令,已注释保留 ========== var entity = new RoutePlanSendEntity(commandType: 0x01, pointCounts: 0, targetLatitude: 0, targetLongitude: 0, speed: 0); - // print("📡 发送停止指令到设备..."); _logger.log("[发送停止指令到设备...] 按下停止按钮"); tcpClient.sendDeviceStateChange(entity); - // 发送完成工作播报指令 //sendWorkCompleteBroadcast(); + ========== 原有 TCP 停止指令结束 ========== */ } - //发送完成工作播报指令 + // 发送完成工作播报指令(已切换为 HTTP 接口,TCP 方式已弃用) void sendWorkCompleteBroadcast() { + _logger.log("[sendWorkCompleteBroadcast] 已弃用 - 由服务端 HTTP 接口负责"); + + /* ========== 以下为原有 TCP 发送完成播报指令,已注释保留 ========== var entity = new RoutePlanSendEntity(commandType: 0x01, pointCounts: 0xFF, targetLatitude: 0, targetLongitude: 0, speed: 0); - // print("发送完成工作播报指令..."); _logger.log("[发送完成工作播报指令...]"); tcpClient.sendDeviceStateChange(entity); + ========== 原有 TCP 完成播报指令结束 ========== */ } - // 🔥 新增:ACK 握手机制的发送方法 (新模式) + // 🔥 已弃用:路径点逐个下发已改为服务端通过 createDeviceTask HTTP 接口处理, + // APP 不再通过 TCP 逐个发送路径点,避免与服务器重复发送导致下位机收到双份数据。 void sendNextLocationWithAck(NetMessageDispatcher dispatcher) { - // print("[在发送指令 sendNextLocationWithAck 方法中 - ACK 模式]"); + _logger.log("[sendNextLocationWithAck] 已弃用 - 路径点下发由服务端 HTTP 接口负责"); + return; + + /* ========== 以下为原有 TCP 逐个发点逻辑(ACK 模式),已注释保留 ========== _logger.log("[在发送指令 sendNextLocationWithAck 方法中 - ACK 模式]"); - // 🔥 关键检查:如果正在等待 ACK,不要重复发送 + // 关键检查:如果正在等待 ACK,不要重复发送 if (_waitingForAck) { - //print("⏳ 正在等待 ACK 确认,跳过发送"); _logger.log("[正在等待 ACK 确认,跳过发送]"); return; } - // 🔥 关键检查:暂停或停止时不再发送 + // 关键检查:暂停或停止时不再发送 if (_isPaused) { - //print("⏸️ 当前处于暂停状态,停止发送指令"); _logger.log("[暂停状态]"); return; } @@ -276,7 +269,6 @@ class PathPlanner { if (_isStopped || (isStart && locationQueue.isEmpty)) { isStart = false; if (_isStopped) { - // print("️ 已停止作业,清空队列"); _logger.log("[已停止作业,清空队列]"); locationQueue.clear(); // 清空剩余队列 } else { @@ -289,26 +281,25 @@ class PathPlanner { final entity = locationQueue.removeFirst(); // 类型:DeviceAddPathPointModel - // 🔥 核心改进:使用递增的点编号,确保精准匹配 + // 核心改进:使用递增的点编号,确保精准匹配 final pointIndex = _currentPointIndex++; final routePlanSendEntity = RoutePlanSendEntity( commandType: 0x01, - pointCounts: 1, // 🔥 使用点编号,而不是固定的 1 + pointCounts: 1, targetLatitude: entity.latitude, targetLongitude: entity.longitude, speed: 1000, ); - //print("====Lat:${entity.latitude} ====Lng:${entity.longitude}"); _logger.log("[发送路径点] Lat:${entity.latitude} Lng:${entity.longitude}"); tcpClient.sendPathPoint(routePlanSendEntity); isStart = true; - // 🔥 关键:标记为正在等待 ACK + // 标记为正在等待 ACK _waitingForAck = true; - dispatcher.setExpectedPointIndex(pointIndex); // 🔥 通知 Dispatcher - //sprint(" 已锁定发送,等待 ACK 确认 (0x02)..."); + dispatcher.setExpectedPointIndex(pointIndex); // 通知 Dispatcher _logger.log("[已锁定发送,等待 ACK 确认 (0x02)...]"); + ========== 原有 TCP 逐个发点逻辑结束 ========== */ } } diff --git a/lib/features/devices/domain/repositories/path_repository.dart b/lib/features/devices/domain/repositories/path_repository.dart index 23bfc2e9..0b04d290 100644 --- a/lib/features/devices/domain/repositories/path_repository.dart +++ b/lib/features/devices/domain/repositories/path_repository.dart @@ -28,12 +28,11 @@ abstract class PathRepository { Future> getWorkRecordsBySiteId({required int siteId}); /// 创建设备任务(通过接口执行作业) - /// deviceId: 设备ID(targetDevice) - /// routeId: 路线ID(选中的路线任务ID) - /// siteId: 场站ID - Future createDeviceTask({ + /// 返回创建成功的任务ID + Future createDeviceTask({ required String deviceId, required int routeId, required int siteId, + required int orgId, }); } diff --git a/lib/features/devices/domain/usecases/create_device_task_usecase.dart b/lib/features/devices/domain/usecases/create_device_task_usecase.dart index 5358ea24..50ab32ad 100644 --- a/lib/features/devices/domain/usecases/create_device_task_usecase.dart +++ b/lib/features/devices/domain/usecases/create_device_task_usecase.dart @@ -7,18 +7,20 @@ class CreateDeviceTaskUseCase { CreateDeviceTaskUseCase(this.repository); - Future> execute({ + Future> execute({ required String deviceId, required int routeId, required int siteId, + required int orgId, }) async { try { - await repository.createDeviceTask( + final taskId = await repository.createDeviceTask( deviceId: deviceId, routeId: routeId, siteId: siteId, + orgId: orgId, ); - return const Right(null); + return Right(taskId); } catch (e) { return Left(DeviceFailure.networkError(message: e.toString())); } diff --git a/lib/features/devices/presentation/bloc/device_task_cubit.dart b/lib/features/devices/presentation/bloc/device_task_cubit.dart index 1588e69d..a15edd4e 100644 --- a/lib/features/devices/presentation/bloc/device_task_cubit.dart +++ b/lib/features/devices/presentation/bloc/device_task_cubit.dart @@ -138,17 +138,19 @@ class DeviceTaskCubit extends Cubit { return; } - final result = await _cancelTaskUseCase.call( - CancelTaskParams( - deviceId: deviceId, - taskId: taskId, - orgId: user.orgId ?? 0, - siteId: siteId, - ), + final params = CancelTaskParams( + deviceId: deviceId, + taskId: taskId, + orgId: user.orgId ?? 0, + siteId: siteId, ); + _logger.logWithLevel('[取消任务] 请求: POST /iot/deviceTask/cancelTask'); + _logger.logWithLevel('[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId'); + final result = await _cancelTaskUseCase.call(params); result.fold( (failure) { + _logger.logWithLevel('[取消任务] 响应失败: ${failure.message}'); _logger.logWithLevel('❌ 取消任务失败: ${failure.message}'); emit(state.copyWith( isLoading: false, @@ -158,6 +160,7 @@ class DeviceTaskCubit extends Cubit { )); }, (success) { + _logger.logWithLevel('[取消任务] 响应成功: $success'); _logger.logWithLevel('✅ 取消任务成功'); emit(state.copyWith( isLoading: false, @@ -204,17 +207,19 @@ class DeviceTaskCubit extends Cubit { return; } - final result = await _pauseTaskUseCase.call( - PauseTaskParams( - deviceId: deviceId, - taskId: taskId, - orgId: user.orgId ?? 0, - siteId: siteId, - ), + final params2 = PauseTaskParams( + deviceId: deviceId, + taskId: taskId, + orgId: user.orgId ?? 0, + siteId: siteId, ); + _logger.logWithLevel('[暂停任务] 请求: POST /iot/deviceTask/pauseTask'); + _logger.logWithLevel('[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId'); + final result = await _pauseTaskUseCase.call(params2); result.fold( (failure) { + _logger.logWithLevel('[暂停任务] 响应失败: ${failure.message}'); _logger.logWithLevel('❌ 暂停任务失败: ${failure.message}'); emit(state.copyWith( isLoading: false, @@ -224,6 +229,7 @@ class DeviceTaskCubit extends Cubit { )); }, (success) { + _logger.logWithLevel('[暂停任务] 响应成功: $success'); _logger.logWithLevel('✅ 暂停任务成功'); emit(state.copyWith( isLoading: false, @@ -270,17 +276,19 @@ class DeviceTaskCubit extends Cubit { return; } - final result = await _recoveryTaskUseCase.call( - RecoveryTaskParams( - deviceId: deviceId, - taskId: taskId, - orgId: user.orgId ?? 0, - siteId: siteId, - ), + final params3 = RecoveryTaskParams( + deviceId: deviceId, + taskId: taskId, + orgId: user.orgId ?? 0, + siteId: siteId, ); + _logger.logWithLevel('[恢复任务] 请求: POST /iot/deviceTask/recoveryTask'); + _logger.logWithLevel('[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId'); + final result = await _recoveryTaskUseCase.call(params3); result.fold( (failure) { + _logger.logWithLevel('[恢复任务] 响应失败: ${failure.message}'); _logger.logWithLevel('❌ 恢复任务失败: ${failure.message}'); emit(state.copyWith( isLoading: false, @@ -290,6 +298,7 @@ class DeviceTaskCubit extends Cubit { )); }, (data) { + _logger.logWithLevel('[恢复任务] 响应成功: $data'); _logger.logWithLevel('✅ 恢复任务成功: $data'); emit(state.copyWith( isLoading: false, diff --git a/lib/features/devices/presentation/bloc/devices_cubit.dart b/lib/features/devices/presentation/bloc/devices_cubit.dart index f5f0db1f..441dd877 100644 --- a/lib/features/devices/presentation/bloc/devices_cubit.dart +++ b/lib/features/devices/presentation/bloc/devices_cubit.dart @@ -378,19 +378,26 @@ class DevicesCubit extends Cubit { emit(state.copyWith(isLoading: false, errorMessage: failure.message)); }, (records) { - print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}'); + // print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}'); // 将 WorkRecordEntity 转换为 Map 以兼容现有UI final mappedRecords = records.map((record) { + // 🔥 核心修复:根据 jsonData 类型决定存储方式 + // XML接口返回的已是 JSON 字符串,直接使用,避免二次序列化丢失路径数据 + // JSON接口返回的 WorkRecordJsonData 对象,仍需序列化 + String? jsonDataStr; + if (record.jsonData is String) { + jsonDataStr = record.jsonData as String; + } else if (record.jsonData is WorkRecordJsonData) { + jsonDataStr = _workRecordJsonDataToJson(record.jsonData as WorkRecordJsonData); + } return { 'id': record.id.toString(), 'workName': record.workName, 'imgUrl': record.imgUrl ?? '', - 'jsonData': record.jsonData != null - ? _workRecordJsonDataToJson(record.jsonData!) - : null, + 'jsonData': jsonDataStr, }; }).toList(); - print('🔍 [DevicesCubit] 转换后的数据: $mappedRecords'); + // print('🔍 [DevicesCubit] 转换后的数据: $mappedRecords'); emit(state.copyWith(isLoading: false, workRecords: mappedRecords)); }, ); diff --git a/lib/features/home/presentation/pages/running_status_page.dart b/lib/features/home/presentation/pages/running_status_page.dart index f598ab2a..8497e633 100644 --- a/lib/features/home/presentation/pages/running_status_page.dart +++ b/lib/features/home/presentation/pages/running_status_page.dart @@ -63,6 +63,9 @@ class _RunningStatusPageState extends State with WidgetsBindi Timer? _dataTimeoutTimer; bool _isDataTimeout = false; // 标记是否数据超时 + // 🔥 标记是否已从 bloc 缓存初始化过图表数据,防止 BlocBuilder 重复追加 + bool _hasSeededFromCache = false; + @override void initState() { super.initState(); @@ -77,6 +80,22 @@ class _RunningStatusPageState extends State with WidgetsBindi // 🔥 新增:监听应用生命周期 WidgetsBinding.instance.addObserver(this); + + // 🔥 关键修复:页面进入时立即用 bloc 当前缓存数据初始化图表 + // 不用等下一次 TCP 推送才显示数据 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final blocState = context.read().state; + if (blocState is DeviceStatusUpdated) { + debugPrint('🚀 [RunningStatusPage] bloc已有缓存数据,立即初始化图表'); + _hasSeededFromCache = true; + _appendChartData(blocState); + _startDataTimeoutTimer(); // 重置超时计时器 + if (_isDataTimeout) { + setState(() => _isDataTimeout = false); + } + } + }); } @override @@ -1000,7 +1019,13 @@ class _RunningStatusPageState extends State with WidgetsBindi if (!_isDataTimeout && state is DeviceStatusUpdated) { debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据'); - _appendChartData(state); + // 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发 + if (_hasSeededFromCache) { + _hasSeededFromCache = false; // 只跳过第一次 + debugPrint('📈 [UI] 跳过首次 BlocBuilder 追加(已由 initState 缓存初始化)'); + } else { + _appendChartData(state); + } // 🔥 关键:收到数据后立即重置超时计时器 _startDataTimeoutTimer(); // 如果之前是超时状态,现在恢复 diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index 4a5b60e6..3f716e2c 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -67,7 +67,8 @@ const String kSavedGcjOuterPoints = 'saved_gcj_outer_points'; const String kSavedMarkedPoints = 'saved_marked_points'; const String kSavedObstacleHoles = 'saved_obstacle_holes'; const String kSavedIsWorkAreaCompleted = 'saved_is_work_area_completed'; -const String kSavedIsWorkPanelOpen = 'saved_is_work_panel_open'; +const String kSavedRobotModeWgsPoints = "saved_robot_mode_wgs_points"; +const String kSavedIsWorkPanelOpen = "saved_is_work_panel_open"; const String kSavedSelectedPlot = 'saved_selected_plot'; const String kSavedStartWorkList = 'saved_start_work_list'; const String kSavedWorkMode = 'saved_work_mode'; @@ -101,6 +102,13 @@ class PlotDataPath { PlotDataPath({this.jsonData}); } +class _ToastItem { + final String message; + final ToastType type; + + _ToastItem({required this.message, required this.type}); +} + class MapPageEnterprise extends StatefulWidget { const MapPageEnterprise({Key? key}) : super(key: key); @@ -121,6 +129,10 @@ class _MapPageEnterpriseState extends State { final GlobalKey _mapRepaintKey = GlobalKey(); // 用于存储当前页面的 Toast OverlayEntry List _toastEntries = []; + // Toast 队列:存储待显示的消息 + final _toastQueue = <_ToastItem>[]; + // 是否正在显示 Toast + bool _isToastShowing = false; final MapController _mapController = MapController(); // 初始化轨迹管理器(泛型指定为LatLng) @@ -212,7 +224,7 @@ class _MapPageEnterpriseState extends State { // 🔥 核心修复:使用 addPostFrameCallback 延迟获取地图中心(渲染完成后执行) WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - + debugPrint('🗺️ [MapPage] addPostFrameCallback 执行'); if (_mapController.camera != null) { final originalCenter = _mapController.camera!.center; @@ -252,7 +264,7 @@ class _MapPageEnterpriseState extends State { } } }); - + debugPrint('✅ [MapPage] initState 完成'); } @@ -394,6 +406,20 @@ class _MapPageEnterpriseState extends State { .toList(); } + // 加载机器人模式WGS打点 + final robotWgsJson = prefs.getString(kSavedRobotModeWgsPoints); + if (robotWgsJson != null) { + final List robotWgsList = jsonDecode(robotWgsJson); + _robotModeWgsPoints = robotWgsList + .map( + (item) => LatLng( + double.parse(item['lat'].toString()), + double.parse(item['lng'].toString()), + ), + ) + .toList(); + } + // 加载障碍物数据 final obstacleJson = prefs.getString(kSavedObstacleHoles); if (obstacleJson != null) { @@ -436,11 +462,20 @@ class _MapPageEnterpriseState extends State { final selectedPlotJson = prefs.getString(kSavedSelectedPlot); if (selectedPlotJson != null && selectedPlotJson.isNotEmpty) { final Map plotMap = jsonDecode(selectedPlotJson); + // 兼容 jsonData 为 Map 或 String + String? cachedJsonData; + final rawCached = plotMap['jsonData']; + if (rawCached is String) { + cachedJsonData = rawCached; + } else if (rawCached is Map) { + cachedJsonData = jsonEncode(rawCached); + } + _selectedPlot = PlotData( id: plotMap['id'] ?? '', plotName: plotMap['plotName'] ?? '', imageUrl: plotMap['imageUrl'] ?? '', - jsonData: plotMap['jsonData'], + jsonData: cachedJsonData, ); } @@ -451,7 +486,15 @@ class _MapPageEnterpriseState extends State { } // 2. 关键:同步机器人模式坐标(保存时边框不丢失) - if (_currentRobotMode == RobotMode.point) { + if (_currentRobotMode == RobotMode.point && _robotModeWgsPoints.isEmpty) { + _robotModeWgsPoints = _markedPoints.map((p) { + return gcj02ToWgs84(p.latitude, p.longitude); + }).toList(); + } + // 兜底:Robot模式下_robotModeWgsPoints缓存未写入时,从_markedPoints恢复 + if (_currentRobotMode == RobotMode.robot && + _robotModeWgsPoints.isEmpty && + _markedPoints.isNotEmpty) { _robotModeWgsPoints = _markedPoints.map((p) { return gcj02ToWgs84(p.latitude, p.longitude); }).toList(); @@ -495,6 +538,12 @@ class _MapPageEnterpriseState extends State { .toList(); prefs.setString(kSavedMarkedPoints, jsonEncode(markedList)); + // 保存机器人模式WGS打点 + final robotWgsList = _robotModeWgsPoints + .map((point) => {'lat': point.latitude, 'lng': point.longitude}) + .toList(); + prefs.setString(kSavedRobotModeWgsPoints, jsonEncode(robotWgsList)); + // 保存障碍物数据 final obstacleList = _obstacleHoles.map((hole) { return hole @@ -795,46 +844,50 @@ class _MapPageEnterpriseState extends State { } Future _savePlotData(String plotName, String? imgBase64) async { + debugPrint('🔍 [保存地块] ====== _savePlotData 被调用, plotName=$plotName ======'); final loc = AppLocalizations.of(context); // 🔥 V2 适配:使用 SiteCubit 获取站点ID(从 v2 首页场站选择获取,有默认值) final siteId = sl().state.selectedSite?.id; if (siteId == null) { + debugPrint('❌ [保存地块] siteId为空,提前返回!'); _showPageToast(message: '请先在场站列表中选择一个场站!', type: ToastType.error); return; } for (var i = 0; i < typedPathList.length; i++) { debugPrint(typedPathList[i].toString()); } - // 2. 构造 SavePath 数据 (严格对应你的 JS 结构) + // 2. 构造 SavePath 数据 (对齐 Web 端逻辑) + // 🔥 Web 端参考: + // 弓字模式: {path: API返回路径, outer: 打点坐标, planModel: 0} + // 自定义模式: {path: 打点坐标, outer: [], planModel: 2} + + // 辅助方法:将打点转为 WGS84 格式 {lng, lat} + List> _markedPointsToWgs() { + if (_currentRobotMode == RobotMode.robot) { + return _robotModeWgsPoints + .map((p) => {'lng': p.longitude, 'lat': p.latitude}) + .toList(); + } + return _markedPoints.map((point) { + final wgs84 = gcj02ToWgs84(point.latitude, point.longitude); + return {'lng': wgs84.longitude, 'lat': wgs84.latitude}; + }).toList(); + } + final Map savePath = { //'img': imgBase64 ?? '', // 截图的 Base64 字符串 - 'name': plotName, // 地块名称 - 'outer': - // 核心修改:根据机器人模式选择不同的坐标源 - (_currentRobotMode == RobotMode.robot - ? _robotModeWgsPoints - : gcjOuterPoints) - .map((point) { - final wgs84 = _currentRobotMode == RobotMode.robot - ? point // Robot模式直接使用原始WGS84坐标 - : gcj02ToWgs84( - point.latitude, - point.longitude, - ); // Point模式转换 - - return {'lng': wgs84.longitude, 'lat': wgs84.latitude}; - }) - .toList(), + // 'name': plotName, // 旧逻辑:Web 端不包含 name + // 🔥 对齐 Web 端:弓字模式 outer=打点,自定义模式 outer=[] + // 旧逻辑:outer 始终取 gcjOuterPoints + 'outer': _currentWorkMode == WorkMode.custom + ? [] // 自定义模式 outer 为空 + : _markedPointsToWgs(), // 弓字模式 outer 为打点坐标 + // 🔥 对齐 Web 端:弓字模式 path=API返回,自定义模式 path=打点 + // 旧逻辑:自定义模式 path 为空数组 'path': _currentWorkMode == WorkMode.custom - ? [] // 自定义模式 path 为空数组 - : typedPathList - .map( - (point) => { - // 路径点数组 - 'lng': point.longitude, - 'lat': point.latitude, - }, - ) + ? _markedPointsToWgs() // 自定义模式 path 为打点坐标 + : typedPathList // 弓字模式 path 为 API 返回的路径 + .map((point) => {'lng': point.longitude, 'lat': point.latitude}) .toList(), 'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值 @@ -844,15 +897,26 @@ class _MapPageEnterpriseState extends State { final Map workRecord = { 'workName': plotName, 'siteId': siteId, // 修改:使用 siteId 代替 userId - 'jsonData': jsonEncode(savePath), // 将 savePath 转为 JSON 字符串 + // 🔥 jsonData 必须是对象,不能是字符串(后端 PlanPath 类型要求) + 'jsonData': savePath, }; final String workRecordJson = jsonEncode(workRecord); final http.MultipartRequest request = http.MultipartRequest( 'POST', - Uri.parse('https://serviceri.satabot.com/iot/workRecord/add'), + Uri.parse('http://1.95.137.212:8081/iot/workRecord/add'), ); + // 🔥 添加 Authorization 认证头 + final userToken = sl().state.user?.token; + if (userToken != null) { + request.headers['Authorization'] = 'Bearer $userToken'; + debugPrint('📤 [保存地块] Authorization 已设置'); + } else { + debugPrint('⚠️ [保存地块] Token为空,请求可能被拒绝'); + } + // 5. 处理文件:将 Base64 转为 MultipartFile (对应 JS 的 dataURLtoFile) + // 🔥 后端要求 file 字段必须存在,即使没有截图也要传 if (imgBase64 != null && imgBase64.isNotEmpty) { // 移除 Base64 头部 (如果有的话) String base64String = imgBase64; @@ -870,20 +934,47 @@ class _MapPageEnterpriseState extends State { contentType: http.MediaType('image', 'jpeg'), ), ); + } else { + // 🔥 没有截图时,也要传一个空的 image/jpeg 文件,满足后端要求 + final emptyImageBytes = []; // 空字节数组 + request.files.add( + http.MultipartFile.fromBytes( + 'file', + emptyImageBytes, + filename: 'empty.jpg', + contentType: http.MediaType('image', 'jpeg'), + ), + ); } // 6. 添加 workRecord (对应 formData.append("workRecord", Blob)) + // 🔥 必须用 fromBytes 包装成文件,匹配前端 new Blob([JSON.stringify(workRecord)], {type: "application/json"}) + final workRecordBytes = utf8.encode(workRecordJson); request.files.add( - http.MultipartFile.fromString( - 'workRecord', // 键名必须是 workRecord - workRecordJson, - filename: 'workRecord.json', // 后端可能需要这个文件名来识别 - contentType: http.MediaType('application', 'json'), // 关键:指定 JSON 类型 + http.MultipartFile.fromBytes( + 'workRecord', + workRecordBytes, + filename: '', // 关键:和浏览器 Blob 保持一致,文件名填空字符串 + contentType: http.MediaType('application', 'json'), ), ); + debugPrint('🔍 [保存地块] 构造请求完成,即将进入try发送...'); // 7. 发送请求 try { + // debugPrint('📤 [保存地块] ========== 请求参数 BEGIN =========='); + // debugPrint('📤 [保存地块] plotName: $plotName'); + // debugPrint('📤 [保存地块] siteId: $siteId'); + // debugPrint('📤 [保存地块] workMode: $_currentWorkMode'); + // debugPrint('📤 [保存地块] planModel: ${savePath['planModel']}'); + // debugPrint('📤 [保存地块] savePath.name: ${savePath['name']}'); + // debugPrint('📤 [保存地块] path长度: ${(savePath['path'] as List?)?.length ?? 0}'); + // debugPrint('📤 [保存地块] outer长度: ${(savePath['outer'] as List?)?.length ?? 0}'); + // debugPrint('📤 [保存地块] 是否有图片文件: ${imgBase64 != null && imgBase64.isNotEmpty}'); + // debugPrint('📤 [保存地块] 请求URL: http://1.95.137.212:8081/iot/workRecord/add'); + // debugPrint('📤 [保存地块] workRecord(完整JSON): $workRecordJson'); + // debugPrint('📤 [保存地块] savePath(原始对象): $savePath'); + // debugPrint('📤 [保存地块] ========== 请求参数 END =========='); _showPageToast( message: loc.translate('route_planning.saving') + "「$plotName」...", type: ToastType.loading, @@ -894,8 +985,30 @@ class _MapPageEnterpriseState extends State { final http.StreamedResponse response = await request.send(); final String responseBody = await response.stream.bytesToString(); + debugPrint('📥 [保存地块] 响应状态码: ${response.statusCode}'); + debugPrint('📥 [保存地块] 响应体(完整): $responseBody'); if (response.statusCode == 200) { + // 🔥 解析响应体确认后端是否真的成功 + Map? respJson; + try { + respJson = jsonDecode(responseBody) as Map; + } catch (_) { + debugPrint('❌ [保存地块] 响应体非JSON: $responseBody'); + throw Exception('响应体非JSON: $responseBody'); + } + + final apiCode = respJson['code']; + if (apiCode != null && apiCode.toString() != '200') { + final apiMsg = respJson['msg'] ?? respJson['message'] ?? '未知错误'; + debugPrint('❌ [保存地块] 后端返回失败, code=$apiCode, msg=$apiMsg'); + throw Exception('$apiMsg'); + } + + debugPrint('✅ [保存地块] 保存成功, plotName=$plotName, 响应: $respJson'); + // 🔥 刷新地块列表 + debugPrint('🔄 [保存地块] 刷新地块列表, siteId=$siteId'); + context.read().loadWorkRecordsBySiteId(siteId); _showPageToast( message: loc.translate('route_planning.save_success') + "「$plotName」", type: ToastType.success, @@ -942,6 +1055,9 @@ class _MapPageEnterpriseState extends State { // ========== 统一撤回方法(适配完成后状态) ========== void _undoAction() { + debugPrint( + '🔄 [撤回] 当前区域模式: $_currentAreaMode, 作业点数: ${_markedPoints.length}, 障碍物点数: ${_currentObstaclePoints.length}, 已完成空洞: ${_obstacleHoles.length}', + ); setState(() { // 1. 作业区域模式 if (_currentAreaMode == AreaMode.work) { @@ -1016,6 +1132,9 @@ class _MapPageEnterpriseState extends State { // ========== 统一删除方法(适配完成后状态) ========== void _deleteAllAction() { + debugPrint( + '🗑️ [删除全部] 当前区域模式: $_currentAreaMode, 作业点数: ${_markedPoints.length}, 障碍物点数: ${_currentObstaclePoints.length}, 已完成空洞: ${_obstacleHoles.length}', + ); setState(() { // 1. 作业区域模式:清空作业点 + 重置路径 if (_currentAreaMode == AreaMode.work) { @@ -1076,6 +1195,7 @@ class _MapPageEnterpriseState extends State { } void _handleGotoLocation() { + debugPrint('📍 [定位] 跳转到设备当前位置: $_currentLatLng'); _mapController.move(_currentLatLng!, 18); } @@ -1088,6 +1208,7 @@ class _MapPageEnterpriseState extends State { // ========== 新增:页面刷新初始化方法 ========== Future _handleRefresh() async { + debugPrint('🔄 [刷新] 开始重置页面状态...'); if (_workStatus != WorkStatus.idle) { _stopWork(); } @@ -1223,11 +1344,15 @@ class _MapPageEnterpriseState extends State { /// 手动回到当前位置 void _moveToCurrentLocation() { if (_currentLatLng == null) return; + debugPrint('📍 [定位] 移动到当前位置: $_currentLatLng'); _mapController.move(_currentLatLng!, 17); } // ========== 障碍物模式:完成当前障碍物绘制 ========== void _completeObstacle() { + debugPrint( + '✅ [完成障碍物] 当前障碍物点数: ${_currentObstaclePoints.length}, 已有障碍物区域: ${_obstacleHoles.length}', + ); if (_currentObstaclePoints.length < 3) { _showPageToast(message: "障碍物区域至少需要3个打点!", type: ToastType.error); //ToastUtils.showError(context, '障碍物区域至少需要3个打点!'); @@ -1259,6 +1384,9 @@ class _MapPageEnterpriseState extends State { // ========== 核心方法:打点逻辑 ========== void _addMarkedPoint() { + debugPrint( + '📍 [打点] 机器人模式: $_currentRobotMode, 区域模式: $_currentAreaMode, 作业已完成: $_isWorkAreaCompleted, 当前作业点数: ${_markedPoints.length}', + ); _traceManager.reset(); if (_currentAreaMode == AreaMode.work && _isWorkAreaCompleted) { _showPageToast(message: "作业区域已完成,无法继续添加打点!", type: ToastType.error); @@ -1325,7 +1453,7 @@ class _MapPageEnterpriseState extends State { // ========== 新增:返回上一级页面的方法 ========== void _navigateBack() { - // 关闭当前页面,返回上一级 + debugPrint('🔙 [返回] 关闭页面,返回上一级'); Navigator.of(context).pop(); } @@ -1340,6 +1468,7 @@ class _MapPageEnterpriseState extends State { } void _handleVideo() { + debugPrint('🎬 [视频] 打开视频弹窗'); setState(() { _isVideoDialogOpen = true; // 打开视频弹窗 }); @@ -1398,12 +1527,98 @@ class _MapPageEnterpriseState extends State { try { _traceManager.reset(); - await context.read().loadSelectedPath(plot.plotName); - final cubitState = context.read().state; - final _loadedPlot = cubitState.pathData; - // 【优化2】提前快速判断,不进无用逻辑 - if (_loadedPlot is! List || _loadedPlot!.isEmpty) { + // 🔥 核心修复:优先使用列表中已加载的 plot.jsonData,避免重复查询导致所有任务显示同一条路线 + Map? parsedJson; + + if (plot.jsonData != null && plot.jsonData!.isNotEmpty) { + debugPrint('✅ [选中地块] 尝试使用本地 jsonData,长度: ${plot.jsonData!.length}'); + try { + final decoded = jsonDecode(plot.jsonData!); + if (decoded is Map) { + // 🔥 关键检查:jsonData 必须包含 path 或 outer 才算有效数据 + // selectBySiteId 接口返回的 是空标签,解析后是 {},没有路径数据 + final hasPath = + decoded.containsKey('path') && decoded['path'] != null; + final hasOuter = + decoded.containsKey('outer') && decoded['outer'] != null; + if (hasPath || hasOuter) { + parsedJson = decoded; + debugPrint( + '✅ [选中地块] 本地 jsonData 有效,path=$hasPath, outer=$hasOuter', + ); + } else { + debugPrint( + '⚠️ [选中地块] 本地 jsonData 无路径数据 (path=$hasPath, outer=$hasOuter),将回退调接口', + ); + } + } else { + debugPrint( + '❌ [选中地块] plot.jsonData jsonDecode 结果不是 Map: ${decoded.runtimeType}', + ); + } + } catch (e) { + debugPrint('❌ [选中地块] plot.jsonData jsonDecode 失败: $e'); + } + } + + // 回退:本地 jsonData 为空时,才调接口查询 + if (parsedJson == null) { + debugPrint('⚠️ [选中地块] 本地 jsonData 为空,回退调用接口查询'); + await context.read().loadSelectedPath(plot.plotName); + final cubitState = context.read().state; + final _loadedPlot = cubitState.pathData; + debugPrint( + '🔍 [选中地块] pathData 类型: ${_loadedPlot.runtimeType}, 是否为空: ${_loadedPlot?.isEmpty ?? true}', + ); + + if (_loadedPlot is List && _loadedPlot!.isNotEmpty) { + final pathRecords = _loadedPlot + .where((item) => item is Map) + .cast>() + .toList(); + + if (pathRecords.isNotEmpty) { + final record = pathRecords.first; + // 🔥 核心修复:直接从接口记录的顶层字段取 path/outer,而不是从 jsonData 中取 + final rawPath = record['path']; + final rawOuter = record['outer']; + final planModel = record['planModel']; + + debugPrint( + '🔍 [选中地块] 接口顶层 path 类型: ${rawPath.runtimeType}, outer 类型: ${rawOuter.runtimeType}', + ); + + // 构造 parsedJson,保持 path/outer 为顶层字段 + parsedJson = { + 'path': rawPath, + 'outer': rawOuter, + if (planModel != null) 'planModel': planModel, + }; + + // 如果顶层 path/outer 都为空,尝试从 jsonData 兜底 + if (rawPath == null && + rawOuter == null && + record['jsonData'] != null) { + debugPrint('⚠️ [选中地块] 顶层 path/outer 为空,尝试从 jsonData 兜底'); + final nestedJsonRaw = record['jsonData']; + if (nestedJsonRaw is String) { + try { + final decoded = jsonDecode(nestedJsonRaw); + if (decoded is Map) { + parsedJson = decoded; + } + } catch (_) {} + } else if (nestedJsonRaw is Map) { + parsedJson = nestedJsonRaw; + } + } + } + } + } + + if (parsedJson == null) { + debugPrint('❌ [选中地块] 无法获取路径数据,面板显示但 startWorkList 为空'); setState(() { _isWorkPanelOpen = true; _isPanelOpen = false; @@ -1412,52 +1627,25 @@ class _MapPageEnterpriseState extends State { return; } - final pathRecords = _loadedPlot - .where((item) => item is Map) - .cast>() - .toList(); - if (pathRecords.isEmpty) { - setState(() { - _isWorkPanelOpen = true; - _isPanelOpen = false; - _directionBoxOpen = false; - }); - return; - } + debugPrint( + '🔍 [选中地块] parsedJson keys: ${parsedJson.keys}, planModel: ${parsedJson['planModel']}', + ); - final selectedPlotPath = PlotDataPath(jsonData: pathRecords.first); - final nestedJsonRaw = selectedPlotPath.jsonData['jsonData']; + // 🔥 安全解析 planModel:支持 int 和 String 类型 + final int planModelValue = + int.tryParse(parsedJson['planModel']?.toString() ?? '0') ?? 0; + debugPrint('🔍 [选中地块] planModelValue: $planModelValue'); - if (nestedJsonRaw is! String) { - setState(() { - _isWorkPanelOpen = true; - _isPanelOpen = false; - _directionBoxOpen = false; - }); - return; - } - - // 【优化3】只解码一次核心JSON,最耗时操作只跑一遍 - final parsedJson = jsonDecode(nestedJsonRaw); - if (parsedJson is! Map) { - setState(() { - _isWorkPanelOpen = true; - _isPanelOpen = false; - _directionBoxOpen = false; - }); - return; - } - - workMode = parsedJson['planModel'] == WorkMode.bow.value - ? "弓字模式" - : "自定义模式"; - _currentWorkMode = parsedJson['planModel'] == WorkMode.bow.value + workMode = planModelValue == WorkMode.bow.value ? "弓字模式" : "自定义模式"; + _currentWorkMode = planModelValue == WorkMode.bow.value ? WorkMode.bow : WorkMode.custom; + debugPrint('🔍 [选中地块] workMode: $workMode'); // 【优化4】统一 path/outer 解析逻辑,不重复代码 List pathList = []; final rawPath = parsedJson['path']; + debugPrint('🔍 [选中地块] rawPath 类型: ${rawPath.runtimeType}'); if (rawPath is String) { try { pathList = jsonDecode(rawPath) as List; @@ -1465,9 +1653,14 @@ class _MapPageEnterpriseState extends State { } else if (rawPath is List) { pathList = rawPath; } + debugPrint('🔍 [选中地块] pathList 长度: ${pathList.length}'); + if (pathList.isNotEmpty) { + debugPrint('🔍 [选中地块] pathList[0]: ${pathList.first}'); + } List outerList = []; final rawOuter = parsedJson['outer']; + debugPrint('🔍 [选中地块] rawOuter 类型: ${rawOuter.runtimeType}'); if (rawOuter is String) { try { outerList = jsonDecode(rawOuter) as List; @@ -1475,13 +1668,20 @@ class _MapPageEnterpriseState extends State { } else if (rawOuter is List) { outerList = rawOuter; } - - if (workMode == "弓字模式") { - startWorkList = pathList; - } else { - startWorkList = outerList; + debugPrint('🔍 [选中地块] outerList 长度: ${outerList.length}'); + if (outerList.isNotEmpty) { + debugPrint('🔍 [选中地块] outerList[0]: ${outerList.first}'); } + // 弓字模式:path是路线轨迹,outer是边界框 + // 自定义模式:只有path + if (workMode == "弓字模式") { + startWorkList = pathList.isNotEmpty ? pathList : outerList; + } else { + startWorkList = pathList.isNotEmpty ? pathList : outerList; + } + debugPrint('✅ [选中地块] startWorkList 长度: ${startWorkList.length}'); + // 【优化5】一次性生成坐标,不反复操作数组 List newPathPoints = []; List newOuterPoints = []; @@ -1501,15 +1701,20 @@ class _MapPageEnterpriseState extends State { } // 【优化6】整个方法只调用一次 setState,性能暴涨 + // 🔥 弓字模式(0):显示 outer灰色透明框 + path路线轨迹 + // 🔥 自定义模式(2):只显示 path setState(() { - gcjPathPoints = _currentWorkMode == WorkMode.custom - ? [] - : newPathPoints; - gcjOuterPoints = newOuterPoints; + gcjPathPoints = newPathPoints; // 两种模式都显示 path + gcjOuterPoints = _currentWorkMode == WorkMode.bow + ? newOuterPoints // 弓字模式:显示 outer 边框 + : []; // 自定义模式:不显示 outer _isWorkPanelOpen = true; _isPanelOpen = false; _directionBoxOpen = false; }); + debugPrint( + '✅ [选中地块] 最终渲染: gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}, workMode=$_currentWorkMode', + ); // 最后移动地图 List allPoints = []; @@ -1677,16 +1882,16 @@ class _MapPageEnterpriseState extends State { ], ), - // 绘制outer边框(Polygon模式) + // 绘制outer边框(Polygon模式)—— 弓字模式:灰色透明框 + 内部路线轨迹 if (gcjOuterPoints.isNotEmpty && _currentWorkMode == WorkMode.bow) PolygonLayer( polygons: [ Polygon( points: gcjOuterPoints, // 转换后的GCJ02坐标 - color: Colors.green.withOpacity(0.1), // 内部填充色(透明) - borderColor: Colors.green, // 边框颜色 - borderStrokeWidth: 1.0, // 边框宽度 - isFilled: true, // 开启填充(即使透明,也需要开启才能显示边框) + color: Colors.grey.withOpacity(0.15), // 灰色半透明填充 + borderColor: Colors.grey.withOpacity(0.6), // 灰色半透明边框 + borderStrokeWidth: 1.5, // 边框宽度 + isFilled: true, // 开启填充 ), ], ), @@ -2109,7 +2314,15 @@ class _MapPageEnterpriseState extends State { return; } - // 🔥 5. 先过滤出活跃任务,让用户选择 + // 5. 获取组织ID(从 AppUserCubit 的 user) + final userCubit = sl(); + final orgId = userCubit.state.user?.orgId; + if (orgId == null) { + _showPageToast(message: "用户信息异常,请重新登录", type: ToastType.error); + return; + } + + // 🔥 6. 先过滤出活跃任务,让用户选择 debugPrint('🔍 [开始作业] 正在查询活跃任务...'); final taskCubit = sl(); await taskCubit.fetchAndFilterTask(deviceId); @@ -2120,40 +2333,34 @@ class _MapPageEnterpriseState extends State { final activeTasks = taskCubit.state.activeTasks; debugPrint('✅ [开始作业] 找到 ${activeTasks.length} 个活跃任务'); - if (activeTasks.isEmpty) { - _showPageToast( - message: "当前设备没有活跃任务(新建/执行中/暂停中)", - type: ToastType.warn, - ); - return; - } - - // 🔥 6. 如果有多条任务,显示选择弹窗 + // 🔥 6. 任务选择(仅当有活跃任务时才需要选择,否则直接创建新任务) DeviceTaskEntity? selectedTask; if (activeTasks.length > 1) { debugPrint('⚠️ [开始作业] 有多个活跃任务,显示选择弹窗'); selectedTask = await _showTaskSelectionDialog(activeTasks); if (selectedTask == null) { - // 用户取消选择 debugPrint('❌ [开始作业] 用户取消选择任务'); return; } - // 用户选择了任务,更新 cubit taskCubit.selectTask(selectedTask); - } else { - // 只有1条任务,直接使用 + } else if (activeTasks.length == 1) { selectedTask = activeTasks.first; debugPrint('✅ [开始作业] 自动选择唯一任务 #${selectedTask.id}'); + } else { + debugPrint('ℹ️ [开始作业] 无活跃任务,将直接创建新任务'); } - debugPrint('📋 [开始作业] 最终选择的任务ID: ${selectedTask.id}, 状态: ${selectedTask.taskStatus}'); + debugPrint( + '📋 [开始作业] 选中的任务ID: ${selectedTask?.id ?? "(新建)"}, 状态: ${selectedTask?.taskStatus ?? "N/A"}', + ); // 7. 打印请求参数日志 debugPrint('🚀 [开始作业] 请求参数:'); debugPrint(' ├─ deviceId: $deviceId'); debugPrint(' ├─ routeId: $routeId'); - debugPrint(' ├─ taskId: ${selectedTask.id}'); - debugPrint(' └─ siteId: $siteId'); + debugPrint(' ├─ siteId: $siteId'); + debugPrint(' ├─ orgId: $orgId'); + debugPrint(' └─ taskId: ${selectedTask?.id ?? "(无,将新建)"}'); // 8. 更新UI状态 setState(() { @@ -2168,40 +2375,70 @@ class _MapPageEnterpriseState extends State { // 9. 更新应用状态 context.read().updateAppState(AppState.routePlanning); - try { - // 10. 调用接口创建设备任务 - final result = await sl().execute( - deviceId: deviceId, - routeId: routeId, - siteId: siteId, - ); + // 10. 如果池子里已有活跃任务,直接使用;否则创建新任务 + if (selectedTask != null) { + // 🔥 池子里已有任务,直接使用,不需要再创建 + debugPrint('✅ [开始作业] 使用已有任务 #${selectedTask.id},跳过创建'); + // taskId 已由 selectTask 存入 cubit + } else { + // 🔥 池子里没有,创建新任务 + debugPrint('🆕 [开始作业] 池子为空,创建新任务...'); + try { + final result = await sl().execute( + deviceId: deviceId, + routeId: routeId, + siteId: siteId, + orgId: orgId, + ); - result.fold( - (failure) { - // 失败 - debugPrint('❌ [开始作业] 创建设备任务失败: $failure'); - _showPageToast(message: "作业启动失败", type: ToastType.error); - setState(() { - isStartWork = false; - _workStatus = WorkStatus.idle; - }); - }, - (_) { - // 成功 - debugPrint('✅ [开始作业] 创建设备任务成功'); - _showPageToast(message: "作业已开始", type: ToastType.success); - _saveDataToLocal(); - }, - ); - } catch (e) { - debugPrint('❌ [开始作业] 异常: $e'); - _showPageToast(message: "作业启动异常: $e", type: ToastType.error); - setState(() { - isStartWork = false; - _workStatus = WorkStatus.idle; - }); + var needRequery = false; + var failed = false; + result.fold( + (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; + }, + (taskId) { + taskCubit.updateCurrentTaskId(taskId); + }, + ); + + if (failed) return; + if (needRequery) { + await taskCubit.fetchAndFilterTask(deviceId); + await Future.delayed(const Duration(milliseconds: 300)); + final existingTasks = taskCubit.state.activeTasks; + if (existingTasks.isNotEmpty) { + taskCubit.selectTask(existingTasks.first); + } else { + debugPrint('[开始作业] needRequery 后仍无活跃任务'); + _showPageToast(message: '未找到活跃任务', type: ToastType.warn); + } + return; + } + + } catch (e) { + debugPrint('❌ [开始作业] 异常: $e'); + _showPageToast(message: "作业启动异常: $e", type: ToastType.error); + taskCubit.clearCurrentTask(); + // 🔥 不重置 _workStatus,保持按钮可见 + return; + } } + // 🔥 到这里说明任务已就绪(无论是已有还是新建),刷新一次任务池显示最新状态 + taskCubit.fetchAndFilterTask(deviceId); + _showPageToast(message: "作业已开始", type: ToastType.success); + _saveDataToLocal(); + // ============ 以下是原有的 TCP 方式代码,已注释 ============ /* if (startWorkList.isEmpty) { @@ -2284,24 +2521,29 @@ class _MapPageEnterpriseState extends State { } void _stopWork() async { - _logger.log('按下停止按钮'); + _logger.log('[停止] 按下停止按钮'); + debugPrint('══════════ [停止作业] 开始 ══════════'); // 🔥 获取设备ID和taskId final targetDevice = context.read().state.targetDevice; final deviceId = targetDevice?.deviceName; + debugPrint('[停止作业] deviceId: $deviceId, targetDevice: ${targetDevice?.deviceName}'); if (deviceId == null || deviceId.isEmpty) { + debugPrint('[停止作业] ❌ deviceId 为空,退出'); _showPageToast(message: "请先选择一个设备", type: ToastType.info); return; } final taskCubit = sl(); final taskId = taskCubit.state.currentTaskId; + debugPrint('[停止作业] taskId: $taskId'); if (taskId == null) { + debugPrint('[停止作业] ❌ taskId 为 null,退出'); _showPageToast(message: "无可用任务", type: ToastType.warn); return; } - debugPrint('⏹️ [停止作业] deviceId: $deviceId, taskId: $taskId'); + debugPrint('[停止作业] 准备调用 cancelTask API, deviceId: $deviceId, taskId: $taskId'); if (mounted) { setState(() { @@ -2315,21 +2557,34 @@ class _MapPageEnterpriseState extends State { // 1. 立刻切换模式(阻止新的点再进入 NAVIGATION 逻辑) _traceManager.setMode(TPMode.LOCATION); }); + debugPrint('[停止作业] setState 完成,workStatus = idle'); } - // 🔥 调用接口取消任务(注释掉原有的 TCP 方式) + // 🔥 调用接口取消任务 try { + debugPrint('[停止作业] 正在调用 taskCubit.cancelTask...'); await taskCubit.cancelTask(deviceId); + debugPrint('[停止作业] cancelTask 返回成功'); + taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务 + debugPrint('[停止作业] clearCurrentTask 完成'); _showPageToast(message: "作业已停止", type: ToastType.success); + debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令'); + + // 🔥 还需要通过 TCP 向机器发送停止指令 + try { + debugPrint('[停止作业] 调用 TCP stopRoutePlanning...'); + await context.read().stopRoutePlanning(); + debugPrint('[停止作业] TCP stopRoutePlanning 完成'); + } catch (tcpError) { + debugPrint('[停止作业] ⚠️ TCP 停止异常: $tcpError'); + } } catch (e) { - debugPrint('❌ [停止作业] 异常: $e'); + debugPrint('[停止作业] ❌ cancelTask 异常: $e'); _showPageToast(message: "停止失败: $e", type: ToastType.error); } - // 原有的 TCP 方式(已注释) - //await context.read().stopRoutePlanning(); - _saveDataToLocal(); + debugPrint('[停止作业] _saveDataToLocal 完成'); await Future.delayed(const Duration(milliseconds: 300)); if (mounted) { setState(() { @@ -2340,8 +2595,10 @@ class _MapPageEnterpriseState extends State { tracePoint = []; gctracePoint = []; }); + debugPrint('[停止作业] 最终 setState 完成,轨迹已清空'); } + debugPrint('══════════ [停止作业] 结束 ══════════'); _showPageToast(message: "作业已停止", type: ToastType.error); } @@ -2396,6 +2653,94 @@ class _MapPageEnterpriseState extends State { ); } + /// 🔥 从 pathData 中实时解析 startWorkList,避免时序问题: + /// BlocBuilder 在 pathData 更新后立即渲染面板,但 startWorkList 在 onTap async 中 + /// 稍后才赋值,导致面板先显示"路径区域为空"。此方法直接从 state.pathData 计算, + /// 确保面板渲染时数据已就绪。 + List _parseStartWorkListFromPathData( + List>? pathData, + ) { + debugPrint( + '🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}', + ); + if (pathData == null || pathData.isEmpty) { + debugPrint('❌ [_parseSWL] pathData 为空,返回 []'); + return []; + } + + final firstRecord = pathData.first; + debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}'); + final nestedJsonRaw = firstRecord['jsonData']; + debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}'); + + // 🔥 兼容两种格式:String(需 jsonDecode)和 Map(已解析) + Map parsedJson; + if (nestedJsonRaw is String) { + try { + final decoded = jsonDecode(nestedJsonRaw); + if (decoded is! Map) { + debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []'); + return []; + } + parsedJson = decoded; + } catch (_) { + debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []'); + return []; + } + } else if (nestedJsonRaw is Map) { + parsedJson = nestedJsonRaw; + } else { + debugPrint( + '❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []', + ); + return []; + } + + 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}', + ); + final isBow = planModelValue == WorkMode.bow.value; + + List pathList = []; + final rawPath = parsedJson['path']; + if (rawPath is String) { + try { + pathList = jsonDecode(rawPath); + } catch (_) {} + } else if (rawPath is List) { + pathList = rawPath; + } + + List outerList = []; + final rawOuter = parsedJson['outer']; + if (rawOuter is String) { + try { + outerList = jsonDecode(rawOuter); + } catch (_) {} + } else if (rawOuter is List) { + outerList = rawOuter; + } + + if (isBow) { + final result = pathList.isNotEmpty ? pathList : outerList; + 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}', + ); + return result; + } + } + Widget _buildWorkPanel(headingStatus) { return BlocBuilder( builder: (context, state) { @@ -2403,6 +2748,23 @@ class _MapPageEnterpriseState extends State { return const SizedBox.shrink(); } + // 🔥 核心修复:从 state.pathData 实时计算 startWorkList + // 确保 BlocBuilder 触发时数据已就绪,不再依赖 onTap 的异步赋值时序 + 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}', + ); + } else { + debugPrint( + '⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}', + ); + } + return Positioned( left: 0, right: 0, @@ -2476,6 +2838,7 @@ class _MapPageEnterpriseState extends State { ), IconButton( onPressed: () { + debugPrint('❌ [工作面板] 关闭面板,重置状态'); setState(() { _isWorkPanelOpen = false; _selectedPlot = null; @@ -2749,8 +3112,7 @@ class _MapPageEnterpriseState extends State { headingStatus == 0 ? null : () { - //@开始作业通过后端接口 - // _startWork(); + _startWork(); }, style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF00C853), @@ -2923,26 +3285,54 @@ class _MapPageEnterpriseState extends State { List _convertWorkRecordsToPlotData( List> records, ) { + // debugPrint('📊 [_convertWorkRecordsToPlotData] 输入记录数: ${records.length}'); return records.map((record) { + final jd = record['jsonData']; + // debugPrint(' ├─ workName: ${record['workName']}, jsonData类型: ${jd?.runtimeType}, jsonData长度: ${jd is String ? jd.length : "N/A"}'); + // if (jd is String && jd.isNotEmpty) { + // try { + // final decoded = jsonDecode(jd); + // if (decoded is Map) { + // debugPrint(' │ path类型: ${decoded['path']?.runtimeType}, outer类型: ${decoded['outer']?.runtimeType}, planModel: ${decoded['planModel']}'); + // } + // } catch (_) {} + // } + // 兼容 jsonData 为 Map 或 String 的情况:统一转为 JSON 字符串 + String? jsonDataStr; + final rawJsonData = record['jsonData']; + if (rawJsonData is String) { + jsonDataStr = rawJsonData; + } else if (rawJsonData is Map) { + jsonDataStr = jsonEncode(rawJsonData); + } + return PlotData( id: record['id']?.toString() ?? DateTime.now().microsecondsSinceEpoch.toString(), // 唯一ID plotName: record['workName'] ?? '未命名地块', // 地块名称(从接口字段取) imageUrl: record['imgUrl'] ?? '', // 图片URL(从接口字段取,无则为空) - jsonData: record['jsonData'], // 原始数据的JSON字符串(可选,便于调试或后续使用) + jsonData: jsonDataStr, // 原始数据的JSON字符串(可选,便于调试或后续使用) ); }).toList(); } // ========== 抽象:生成路径的核心函数(打点函数) ========== Future _generatePath({bool showTips = true}) async { + debugPrint( + '🛤️ [生成路径] 作业模式: $_currentWorkMode, 机器人模式: $_currentRobotMode, 打点数: ${_markedPoints.length}, 行距: $_workDistance, 航向角: $_angle', + ); if (_currentWorkMode == WorkMode.custom) { + // 🔥 对齐 Web 端:自定义模式 path=打点, outer=[] + debugPrint( + '📌 [生成路径] 自定义模式: path点数=${_markedPoints.length}, outer=[], planModel=2', + ); setState(() { _saveBoxOpen = true; _isWorkAreaCompleted = true; gcjPathPoints = List.from(_markedPoints); - gcjOuterPoints = List.from(_markedPoints); + // gcjOuterPoints = List.from(_markedPoints); // 旧逻辑:outer也存打点 + gcjOuterPoints = []; // 对齐 Web 端:自定义模式 outer 为空 typedPathList = []; }); return; @@ -2967,6 +3357,8 @@ class _MapPageEnterpriseState extends State { return; } + // 弓字模式行距默认为0.0,与Web端保持一致 + try { LatLng firstPointWgs; if (_currentRobotMode == RobotMode.robot && @@ -2979,6 +3371,13 @@ class _MapPageEnterpriseState extends State { firstPointGcj.latitude, firstPointGcj.longitude, ); + } else if (_markedPoints.isNotEmpty) { + // 兜底:_robotModeWgsPoints 为空但_markedPoints有数据(新缓存key未写入等场景) + LatLng firstPointGcj = _markedPoints.first; + firstPointWgs = gcj02ToWgs84( + firstPointGcj.latitude, + firstPointGcj.longitude, + ); } else { if (showTips) { _showPageToast(message: '参考点坐标为空,请先添加作业区域打点!', type: ToastType.error); @@ -3001,8 +3400,8 @@ class _MapPageEnterpriseState extends State { lon: latLng.longitude, ); }).toList(); - } else if (_currentRobotMode == RobotMode.point && - _markedPoints.isNotEmpty) { + } else if (_markedPoints.isNotEmpty) { + // robot模式但_robotModeWgsPoints为空时,兜底用_markedPoints转换 outerPositions = _markedPoints.map((latLng) { final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude); return work_area_model.Position( @@ -3069,6 +3468,18 @@ class _MapPageEnterpriseState extends State { } final workType = _currentWorkMode == WorkMode.bow ? 0 : 2; + debugPrint( + '🔍 [生成路径-发送] referencePoint=(${referencePoint.lat}, ${referencePoint.lon}), heading=$heading, workType=$workType', + ); + debugPrint( + '🔍 [生成路径-发送] outerPositions点数=${outerPositions.length}, sideWidth=$_workDistance', + ); + for (var i = 0; i < outerPositions.length; i++) { + debugPrint( + '🔍 [生成路径-发送] outer[$i]=(${outerPositions[i].lat}, ${outerPositions[i].lon})', + ); + } + await context.read().generatePath( reference: referencePoint, heading: heading, @@ -3080,6 +3491,7 @@ class _MapPageEnterpriseState extends State { final cubitState = context.read().state; if (cubitState.errorMessage != null) { + debugPrint('❌ [生成路径] 失败: ${cubitState.errorMessage}'); if (showTips) { _showPageToast( message: '路径生成失败:${cubitState.errorMessage}', @@ -3090,6 +3502,9 @@ class _MapPageEnterpriseState extends State { if (showTips) { _showPageToast(message: '路径生成成功!', type: ToastType.success); } + debugPrint( + '✅ [生成路径] 成功,路径点数: ${(cubitState.generatedPath as List).length}', + ); if (cubitState.generatedPath is List) { setState(() { _isWorkAreaCompleted = true; @@ -3122,19 +3537,22 @@ class _MapPageEnterpriseState extends State { } } - void _cancelAllToast() { - for (final entry in _toastEntries) { - if (entry.mounted) { - entry.remove(); - } + void _showPageToast({required String message, required ToastType type}) { + _toastQueue.add(_ToastItem(message: message, type: type)); + if (!_isToastShowing) { + _processNextToast(); } - _toastEntries.clear(); } - // 只在当前页面显示安全 Toast,退出自动消失 - void _showPageToast({required String message, required ToastType type}) { - _cancelAllToast(); - // 🔥 延迟执行 Overlay 操作,避开构建阶段 + void _processNextToast() { + if (_toastQueue.isEmpty) { + _isToastShowing = false; + return; + } + + _isToastShowing = true; + final item = _toastQueue.removeAt(0); + WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -3146,11 +3564,11 @@ class _MapPageEnterpriseState extends State { margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: _getToastColor(type), + color: _getToastColor(item.type), borderRadius: BorderRadius.circular(8), ), child: Text( - message, + item.message, style: const TextStyle(color: Colors.white, fontSize: 14), ), ), @@ -3161,14 +3579,25 @@ class _MapPageEnterpriseState extends State { _toastEntries.add(entry); Overlay.of(context)?.insert(entry); - // 2秒后自动关闭 Future.delayed(const Duration(seconds: 2), () { if (entry.mounted) entry.remove(); _toastEntries.remove(entry); + _processNextToast(); }); }); } + void _cancelAllToast() { + for (final entry in _toastEntries) { + if (entry.mounted) { + entry.remove(); + } + } + _toastEntries.clear(); + _toastQueue.clear(); + _isToastShowing = false; + } + Color _getToastColor(ToastType type) { switch (type) { case ToastType.success: diff --git a/lib/features/my/repository/my_repository_impl.dart b/lib/features/my/repository/my_repository_impl.dart index 8cba6b2f..5313721e 100644 --- a/lib/features/my/repository/my_repository_impl.dart +++ b/lib/features/my/repository/my_repository_impl.dart @@ -26,7 +26,7 @@ class MyRepositoryImpl implements MyRepository { // 核心修复:严格匹配抽象类的方法签名 @override Future> updateName(String nickName) async { - final url = Uri.parse('https://serviceri.satabot.com/system/user/profile'); + final url = Uri.parse('http://1.95.137.212:8081/system/user/profile'); // 补充:从 UserStorage 获取 token(接口通常需要认证) final token = await _getToken(); 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 b72a78df..b0d55147 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -79,6 +79,7 @@ class RemoteControlCubit extends Cubit { String? _cacheBattery; String? _cacheCtrlMode; int? _cachePing; + DateTime? _lastStatusPushTime; // 最后一次收到设备状态推送的时间 void _initDeviceStatusListener() { _deviceStatusSub?.cancel(); @@ -97,6 +98,7 @@ class RemoteControlCubit extends Cubit { _cacheBattery = battery.toString(); _cacheCtrlMode = controlMode; _cachePing = c; + _lastStatusPushTime = DateTime.now(); // 记录最后一次推送时间 // 500ms节流,不到时间不刷新UI final now = DateTime.now(); @@ -117,6 +119,7 @@ class RemoteControlCubit extends Cubit { ), battery: int.tryParse(_cacheBattery ?? '') ?? 0, ping: _cachePing, + hasReceivedStatusPush: true, // 标记已收到设备状态推送 // 🔥 标记为设备状态更新 updateType: 'device_status', ), @@ -677,6 +680,22 @@ class RemoteControlCubit extends Cubit { void toggleRightPip() => emit(state.copyWith(showRightPip: !state.showRightPip)); + // 🔥 切换前后视角(双击屏幕触发) + void toggleCameraView() { + final currentOriginY = state.controlEntity.originY; + // 🔥 originY >= 0 表示前视角,< 0 表示后视角 + // 切换逻辑:如果当前是前视角(>=0),切换到后视角(-1);反之亦然 + final newOriginY = currentOriginY >= 0 ? -1 : 1; + + debugPrint('📷 [RemoteControlCubit] 切换前后视角: $currentOriginY -> $newOriginY'); + + final updatedEntity = state.controlEntity.copyWith(y: newOriginY); + emit(state.copyWith(controlEntity: updatedEntity)); + + // 🔥 发送 TCP 指令到设备 + _repository.sendControlMachineCmd(updatedEntity); + } + @override Future close() { _timer?.cancel(); @@ -926,6 +945,14 @@ class RemoteControlCubit extends Cubit { emit(state.copyWith(topRightIsExpanded: !state.topRightIsExpanded)); } + /// 检查设备状态推送是否活跃(5秒内有推送视为活跃) + bool isStatusPushActive() { + if (!state.hasReceivedStatusPush) return false; + if (_lastStatusPushTime == null) return false; + // 超过5秒未收到新推送,视为推送已中断 + return DateTime.now().difference(_lastStatusPushTime!) < const Duration(seconds: 5); + } + Future getNetworkDelay() async { try { // 直接 Ping 你的服务器IP @@ -956,6 +983,10 @@ class RemoteControlCubit extends Cubit { debugPrint('🎯 [RemoteControl] 设备名称: ${device.deviceName}'); debugPrint('🎯 [RemoteControl] 当前TCP状态: ${tcpClient.isConnected ? "已连接" : "未连接"}'); + // 🔥 关键修复:切换设备时清空所有缓存,防止显示上一个设备的数据 + _clearAllCache(); + debugPrint('✅ [RemoteControl] 已清空所有缓存数据'); + debugPrint('📦 [RemoteControl] 更新targetDevice状态'); emit(state.copyWith(targetDevice: device)); debugPrint('✅ [RemoteControl] targetDevice状态已更新'); @@ -997,6 +1028,11 @@ class RemoteControlCubit extends Cubit { void clearTargetDevice() { // debugPrint('🧹 [RemoteControl] 清除待控制设备'); // _logger.logWithLevel('🧹 [RemoteControl] 清除待控制设备'); + + // 🔥 关键修复:退出页面时清空所有缓存,防止数据滞留 + _clearAllCache(); + debugPrint('✅ [RemoteControl] 已清空所有缓存数据'); + emit(state.copyWith(targetDevice: null)); } @@ -1006,5 +1042,32 @@ class RemoteControlCubit extends Cubit { return state.targetDevice; } + /// 🔥 清空所有缓存数据(解决数据滞留和跨设备数据显示问题) + void _clearAllCache() { + _cacheVoltage = null; + _cacheBattery = null; + _cacheCtrlMode = null; + _cachePing = null; + _lastUiUpdateTime = null; + _lastStatusPushTime = null; // 重置推送时间 + + // 🔥 同时重置 UI 状态为初始值 + if (!isClosed) { + emit( + state.copyWith( + runningStatusModel: state.runningStatusModel.copyWith( + voltage: '--', + battery: '--', + controlMode: '--', + ), + battery: 0, + ping: null, + hasReceivedStatusPush: false, // 重置推送接收标志 + ), + ); + } + + debugPrint('🗑️ [RemoteControl] 缓存已清空 - voltage, battery, controlMode, ping'); + } } diff --git a/lib/features/remote_control/presentation/bloc/remote_control_state.dart b/lib/features/remote_control/presentation/bloc/remote_control_state.dart index 620ee70d..6ec5196f 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_state.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_state.dart @@ -30,6 +30,7 @@ class RemoteControlState extends Equatable { final String obstacleFlag; //障碍物标志位 final RunningStatusModel runningStatusModel; final DeviceEntity? targetDevice; + final bool hasReceivedStatusPush; // 是否收到过设备状态推送 // 🔥 状态更新类型标记 - 用于区分是设备状态更新还是弹窗状态更新 final String? updateType; @@ -53,6 +54,7 @@ class RemoteControlState extends Equatable { this.showRightPip = true, this.obstacleFlag = '', required this.runningStatusModel, + this.hasReceivedStatusPush = false, this.topRightIsExpanded = false, this.obstacleRecognitionFlag = true, this.targetDevice, @@ -79,6 +81,7 @@ class RemoteControlState extends Equatable { bool? showRightPip, RunningStatusModel? runningStatusModel, String? obstacleFlag, + bool? hasReceivedStatusPush, bool? topRightIsExpanded, bool? obstacleRecognitionFlag, DeviceEntity? targetDevice, @@ -104,6 +107,7 @@ class RemoteControlState extends Equatable { showRightPip: showRightPip ?? this.showRightPip, runningStatusModel: runningStatusModel ?? this.runningStatusModel, obstacleFlag: obstacleFlag ?? this.obstacleFlag, + hasReceivedStatusPush: hasReceivedStatusPush ?? this.hasReceivedStatusPush, topRightIsExpanded: topRightIsExpanded ?? this.topRightIsExpanded, obstacleRecognitionFlag: obstacleRecognitionFlag ?? this.obstacleRecognitionFlag, @@ -132,6 +136,7 @@ class RemoteControlState extends Equatable { showRightPip, runningStatusModel, obstacleFlag, + hasReceivedStatusPush, topRightIsExpanded, obstacleRecognitionFlag, targetDevice, diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart index 871a9f75..fde41f58 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -146,17 +146,16 @@ class _RemoteControlPageState extends State { @override Widget build(BuildContext context) { - // 监听全局状态(这些通常不随摇杆频繁变化) - final userState = context.watch().state; - final deviceState = context.watch().state; - final currentDevice = deviceState.selectedDevice; - + // 🔥 只监听 RemoteControlCubit,避免其他状态变化导致频繁 rebuild final remoteCubit = context.read(); + final targetDevice = remoteCubit.state.targetDevice; final hasPermission = remoteCubit.state.hasPermission; - ///context.read().requestControlPermissionS(currentDevice!.deviceName, "app"); + // 🔥 调试日志:检查设备状态 + debugPrint('🔍 [RemoteControlPage] build - targetDevice: ${targetDevice?.deviceName}'); - if (currentDevice == null) { + if (targetDevice == null) { + debugPrint('❌ [RemoteControlPage] targetDevice 为空,显示离线页面'); return _buildOfflineScaffold(); } @@ -195,25 +194,23 @@ class _RemoteControlPageState extends State { p.showRightPip != c.showRightPip, builder: (context, state) { - // 🔥 只从 targetDevice 获取 deviceId + // 🔥 从 targetDevice 获取 deviceId final targetDevice = context .watch() .state .targetDevice; final deviceId = targetDevice?.deviceName; - // debugPrint('🔍 [WebRTC检查] targetDevice: $targetDevice, deviceId: $deviceId'); - // debugPrint('🔍 [WebRTC检查] user: ${userState.user != null}, token: ${userState.user?.token != null}'); - - if (deviceId != null && - userState.user != null && - userState.user!.token != null) { + // 🔥 从 RemoteControlCubit 获取 token(如果有的话) + // 注意:如果 token 不在 RemoteControlCubit 中,需要从其他地方获取 + // 这里假设 token 是有效的,直接构建 URL + if (deviceId != null && deviceId.isNotEmpty) { _videoStreamUrl = - "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}"; - // debugPrint('🎬 [WebRTC] URL构建成功: $_videoStreamUrl'); + "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId"; + debugPrint('🎬 [WebRTC] URL构建成功: $_videoStreamUrl'); } else { _videoStreamUrl = ''; - // debugPrint('❌ [WebRTC] URL构建失败 - deviceId: $deviceId, hasUser: ${userState.user != null}, hasToken: ${userState.user?.token != null}'); + debugPrint('❌ [WebRTC] URL构建失败 - deviceId: $deviceId'); } final int originY = context .watch() @@ -222,18 +219,16 @@ class _RemoteControlPageState extends State { .originY; // debugPrint("${originY},originY"); return WebRTCLocalPlayer( - // 这里的 URL 拼接根据你的后端规则 - // streamUrl: "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}", streamUrl: _videoStreamUrl, - showLeftPip: state.showLeftPip, // 从 Cubit 状态中读取 - showRightPip: state.showRightPip, // 从 Cubit 状态中读取 - isFrontMain: - context - .watch() - .state - .controlEntity - .originY >= - 0, + showLeftPip: state.showLeftPip, + showRightPip: state.showRightPip, + isFrontMain: originY >= 0, + onDoubleTap: () { + debugPrint('👆 [RemoteControlPage] 双击屏幕,切换前后视角'); + // 🔥 通过 TCP 发送切换视角指令 + // 这里需要调用 RemoteControlCubit 的方法来切换视角 + context.read().toggleCameraView(); + }, ); }, ), diff --git a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart index 0707e9c8..29c78b10 100644 --- a/lib/features/remote_control/presentation/widgets/left_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/left_joystick_area.dart @@ -24,6 +24,49 @@ class LeftJoystickArea extends StatefulWidget { class _LeftJoystickAreaState extends State { int _lastSentY = 0; bool _isTouching = false; + bool _hasShownNoPushDialog = false; // 防止重复弹窗 + + /// 检查是否可以控制,不能则弹出提示 + bool _checkCanControl(BuildContext context) { + final cubit = context.read(); + if (!cubit.isStatusPushActive()) { + if (!_hasShownNoPushDialog) { + _hasShownNoPushDialog = true; + _showNoStatusPushDialog(context); + } + return false; + } + _hasShownNoPushDialog = false; + return true; + } + + /// 显示暂未收到推送的提示弹窗(小圆角) + void _showNoStatusPushDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: const Color(0xE5333333), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '暂未收到该机器的推送,不能控制', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ), + ), + ); + // 1.5秒后自动关闭 + Future.delayed(const Duration(milliseconds: 1500), () { + Navigator.of(context).pop(); + }); + } @override Widget build(BuildContext context) { @@ -39,6 +82,7 @@ class _LeftJoystickAreaState extends State { radius: widget.width, axisHint: AxisHint.forwardBackward, onValueChanged: (value) { + if (!_checkCanControl(context)) return; // 标记:正在触摸 _isTouching = true; @@ -50,11 +94,13 @@ class _LeftJoystickAreaState extends State { } }, onPress: () { + if (!_checkCanControl(context)) return; _isTouching = true; debugPrint('onPress'); _triggerVibration(); }, onPanEnd: () async { + _hasShownNoPushDialog = false; // 松手重置,下次触摸重新检查 // 🔥 松手 100% 归零 debugPrint('onPanEnd'); await _stopJoystick(); 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 ac440df4..4b44f519 100644 --- a/lib/features/remote_control/presentation/widgets/right_joystick_area.dart +++ b/lib/features/remote_control/presentation/widgets/right_joystick_area.dart @@ -23,6 +23,49 @@ class RightJoystickArea extends StatefulWidget { class _RightJoystickAreaState extends State { int _lastX = 0; + bool _hasShownNoPushDialog = false; // 防止重复弹窗 + + /// 检查是否可以控制,不能则弹出提示 + bool _checkCanControl(BuildContext context) { + final cubit = context.read(); + if (!cubit.isStatusPushActive()) { + if (!_hasShownNoPushDialog) { + _hasShownNoPushDialog = true; + _showNoStatusPushDialog(context); + } + return false; + } + _hasShownNoPushDialog = false; + return true; + } + + /// 显示暂未收到推送的提示弹窗(小圆角) + void _showNoStatusPushDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: const Color(0xE5333333), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '暂未收到该机器的推送,不能控制', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ), + ), + ); + // 1.5秒后自动关闭 + Future.delayed(const Duration(milliseconds: 1500), () { + Navigator.of(context).pop(); + }); + } @override Widget build(BuildContext context) { @@ -38,6 +81,7 @@ class _RightJoystickAreaState extends State { radius: widget.width, axisHint: AxisHint.leftRight, onValueChanged: (value) { + if (!_checkCanControl(context)) return; int currentX = value.x.toInt(); if (currentX == _lastX) return; _lastX = currentX; @@ -46,8 +90,12 @@ class _RightJoystickAreaState extends State { debugPrint('右摇杆的数据- x: $finalX, y: 0'); context.read().updateOriginX(finalX); }, - onPress: _triggerVibration, + onPress: () { + if (!_checkCanControl(context)) return; + _triggerVibration(); + }, onPanEnd: () async { + _hasShownNoPushDialog = false; // 松手重置,下次触摸重新检查 debugPrint('🕹️ [右摇杆] 松手,强制 X=0, Y=0'); _lastX = 0; 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 b33b4643..cc2987e9 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 @@ -8,16 +8,18 @@ class WebRTCLocalPlayer extends StatefulWidget { final String streamUrl; final bool showLeftPip; final bool showRightPip; - - // 🔥 新增:外部传入是否显示前视角 final bool isFrontMain; + final Alignment? mainViewAlignment; // 🔥 主视角对齐方式(指定后覆盖 isFrontMain) + final VoidCallback? onDoubleTap; // 🔥 添加双击回调 const WebRTCLocalPlayer({ super.key, required this.streamUrl, this.showLeftPip = true, this.showRightPip = true, - required this.isFrontMain, // 🔥 必传 + this.isFrontMain = true, + this.mainViewAlignment, // 🔥 可选,指定要显示的象限 + this.onDoubleTap, // 🔥 可选回调 }); @override @@ -25,12 +27,16 @@ class WebRTCLocalPlayer extends StatefulWidget { } class _WebRTCLocalPlayerState extends State { + // 🔥 显示器:只初始化一次,终生使用 final RTCVideoRenderer _renderer = RTCVideoRenderer(); + + // 🔥 信号线:可以更换,但必须先拔后插 RTCPeerConnection? _peerConnection; + + // 🔥 URL 缓存:用于判断是否需要换线 + String? _currentStreamUrl; - bool _isInitialized = false; - - // 使用 ValueNotifier 配合局部刷新,提升拖拽性能 + // 画中画位置管理 final ValueNotifier _leftPosNotifier = ValueNotifier(const Offset(20, 80)); final ValueNotifier _rightPosNotifier = ValueNotifier(const Offset(200, 80)); bool _isPosInitialized = false; @@ -38,47 +44,76 @@ class _WebRTCLocalPlayerState extends State { @override void initState() { super.initState(); - _prepareAndConnect(); + _initRenderer(); // 🔥 显示器只初始化一次 } - Future _prepareAndConnect() async { + /// 🔥 初始化显示器(只调用一次) + Future _initRenderer() async { try { await _renderer.initialize(); - if (!mounted) return; - await _initWebRTCConnection(); - setState(() => _isInitialized = true); + if (mounted) { + _connectSignal(); // 🔥 显示器就绪后,插上第一根信号线 + } } catch (e) { - debugPrint("初始化失败: $e"); + debugPrint("❌ [WebRTC] 显示器初始化失败: $e"); } } - Future _initWebRTCConnection() async { - // 🔥 安全检查: URL 为空时不初始化 + @override + void didUpdateWidget(WebRTCLocalPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + + // 🔥 只有 URL 真正变化时才换线 + if (oldWidget.streamUrl != widget.streamUrl && widget.streamUrl.isNotEmpty) { + debugPrint('🔄 [WebRTC] URL 变化,准备换线'); + _connectSignal(); + } + } + + /// 🔥 换线逻辑:先拔后插 + Future _connectSignal() async { + // 1️⃣ 如果 URL 为空,不插线 if (widget.streamUrl.isEmpty) { - debugPrint('⚠️ [WebRTC] streamUrl 为空,跳过初始化'); - setState(() => _isInitialized = true); // 🔥 标记为已初始化,避免卡loading + debugPrint('⚠️ [WebRTC] URL 为空,跳过连接'); return; } - _peerConnection = await createPeerConnection({ - "sdpSemantics": "unified-plan", - "iceServers": [ - {"urls": "stun:stun.l.google.com:19302"}, - ], - }); - - await _peerConnection!.addTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, - init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), - ); - - _peerConnection!.onTrack = (RTCTrackEvent event) { - if (event.track.kind == 'video' && event.streams.isNotEmpty) { - if (mounted) setState(() => _renderer.srcObject = event.streams[0]); - } - }; - + // 2️⃣ 如果 URL 没变,不换线(防止重复连接) + if (_currentStreamUrl == widget.streamUrl && _peerConnection != null) { + debugPrint('✅ [WebRTC] URL 未变化,保持当前连接'); + return; + } + + // 3️⃣ 拔掉旧线(彻底释放旧连接) + await _disconnectSignal(); + + // 4️⃣ 插上新线(创建新连接) try { + debugPrint('🔌 [WebRTC] 建立新连接...'); + + _peerConnection = await createPeerConnection({ + "sdpSemantics": "unified-plan", + "iceServers": [ + {"urls": "stun:stun.l.google.com:19302"}, + ], + }); + + await _peerConnection!.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), + ); + + // 🔥 收到视频流时,直接连接到显示器 + _peerConnection!.onTrack = (RTCTrackEvent event) { + if (event.track.kind == 'video' && event.streams.isNotEmpty) { + debugPrint('✅ [WebRTC] 收到视频流,连接到显示器'); + if (mounted) { + setState(() => _renderer.srcObject = event.streams[0]); + } + } + }; + + // 🔥 SDP 协商 RTCSessionDescription offer = await _peerConnection!.createOffer({'offerToReceiveVideo': 1}); await _peerConnection!.setLocalDescription(offer); @@ -88,39 +123,53 @@ class _WebRTCLocalPlayerState extends State { final response = await http.post( Uri.parse(apiUrl), headers: {'Content-Type': 'application/json'}, - body: jsonEncode({"api": apiUrl, "streamurl": widget.streamUrl, "clientip": null, "sdp": offer.sdp}), + body: jsonEncode({ + "api": apiUrl, + "streamurl": widget.streamUrl, + "clientip": null, + "sdp": offer.sdp + }), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final sdp = data['sdp']; - // 🔥 安全检查: SDP 不能为 null if (sdp != null && sdp is String && sdp.isNotEmpty) { await _peerConnection!.setRemoteDescription(RTCSessionDescription(sdp, 'answer')); - debugPrint('✅ [WebRTC] setRemoteDescription 成功'); - } else { - debugPrint('❌ [WebRTC] SDP 为空或无效,跳过 setRemoteDescription'); + _currentStreamUrl = widget.streamUrl; // 🔥 记录当前 URL + debugPrint('✅ [WebRTC] 连接成功'); } } } catch (e) { - debugPrint("信令错误: $e"); + debugPrint("❌ [WebRTC] 连接失败: $e"); + } + } + + /// 🔥 拔线逻辑:彻底释放旧连接 + Future _disconnectSignal() async { + if (_peerConnection != null) { + debugPrint('🗑️ [WebRTC] 释放旧连接...'); + await _peerConnection?.close(); + await _peerConnection?.dispose(); + _peerConnection = null; + _renderer.srcObject = null; // 🔥 清空显示器的信号源 + debugPrint('✅ [WebRTC] 旧连接已释放'); } } @override void dispose() { - _peerConnection?.dispose(); + _disconnectSignal(); // 🔥 拔掉信号线 _renderer.srcObject = null; - _renderer.dispose(); + _renderer.dispose(); // 🔥 销毁显示器 _leftPosNotifier.dispose(); _rightPosNotifier.dispose(); super.dispose(); } - // 四分屏裁剪视图 Widget _buildQuadrantView({required Alignment alignment}) { - if (!_isInitialized || _renderer.srcObject == null) return Container(color: Colors.black); + if (_renderer.srcObject == null) return Container(color: Colors.black); return RepaintBoundary( child: ClipRect( @@ -156,30 +205,17 @@ class _WebRTCLocalPlayerState extends State { ).createShader(bounds); }, blendMode: BlendMode.dstIn, - child: _buildQuadrantView(alignment: widget.isFrontMain ? Alignment.topLeft : Alignment.topRight), + child: _buildQuadrantView(alignment: widget.mainViewAlignment ?? (widget.isFrontMain ? Alignment.topLeft : Alignment.topRight)), ), ); } @override Widget build(BuildContext context) { - // 🔥 如果 URL 为空且未初始化,返回黑色占位符 - if (!_isInitialized && widget.streamUrl.isEmpty) { + // 🔥 如果正在初始化,显示加载指示器 + if (_peerConnection == null && _currentStreamUrl == null) { return Container( - color: Colors.black, // 🔥 使用黑色背景 - child: const Center( - child: Text( - '无视频信号', - style: TextStyle(color: Colors.white54, fontSize: 14), - ), - ), - ); - } - - // 🔥 如果正在初始化但有 URL,显示加载指示器(使用黑色背景) - if (!_isInitialized) { - return Container( - color: Colors.black, // 🔥 使用黑色背景 + color: Colors.black, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -197,13 +233,12 @@ class _WebRTCLocalPlayerState extends State { } return Scaffold( - backgroundColor: Colors.black, // 🔥 使用黑色背景 + backgroundColor: Colors.black, body: LayoutBuilder( builder: (context, constraints) { final double pipW = constraints.maxWidth / 5; final double pipH = pipW * 9 / 16; - // 核心:处理 Windows 窗口大小变化时的坐标修正 Future.microtask(() { if (!mounted) return; _correctPosition(_leftPosNotifier, constraints, pipW, pipH); @@ -218,12 +253,15 @@ class _WebRTCLocalPlayerState extends State { return Stack( children: [ - // 🔥 已移除背景虚化层,避免遮挡上方内容 - // 主视频层的羽化效果由 _buildMainViewWithFeathering() 处理 - - // ② 主视频层(🔥 去掉双击,交给外部控制) + // ② 主视频层(🔥 恢复双击切换前后视角) Center( - child: AspectRatio(aspectRatio: 16 / 9, child: _buildMainViewWithFeathering()), + child: GestureDetector( + onDoubleTap: () { + debugPrint('👆 [WebRTC] 双击屏幕,切换前后视角'); + widget.onDoubleTap?.call(); // 🔥 调用父组件回调 + }, + child: AspectRatio(aspectRatio: 16 / 9, child: _buildMainViewWithFeathering()), + ), ), // ③ 悬浮小窗 - 解决阻尼感的 1:1 稳定拖拽 @@ -246,7 +284,6 @@ class _WebRTCLocalPlayerState extends State { } } - // 极致丝滑的局部刷新组件 Widget _buildFastPip(ValueNotifier notifier, Alignment align, BoxConstraints constraints, double w, double h) { return ValueListenableBuilder( valueListenable: notifier, @@ -257,7 +294,6 @@ class _WebRTCLocalPlayerState extends State { child: GestureDetector( behavior: HitTestBehavior.opaque, onPanUpdate: (details) { - // 关键修正:使用 notifier.value.dx 替换 pos.dx,消除位移丢失导致的阻尼感 final double nextX = (notifier.value.dx + details.delta.dx).clamp(0.0, (constraints.maxWidth - w).clamp(0.0, double.infinity)); final double nextY = (notifier.value.dy + details.delta.dy).clamp(0.0, (constraints.maxHeight - h).clamp(0.0, double.infinity)); notifier.value = Offset(nextX, nextY); diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart index 6419f528..286756fe 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart @@ -34,4 +34,14 @@ abstract class DroneStationDataSource { VideoQualityType qualityType = VideoQualityType.adaptive, int videoExpire = 720000000, }); + + /// 暂停飞行任务(通过 flightTaskCommand 接口) + Future> pauseFlightTask({ + required String deviceSn, + }); + + /// 返航(通过 flightTaskCommand 接口) + Future> returnHome({ + required String deviceSn, + }); } diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart index b319a9b8..42010ad2 100644 --- a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart @@ -330,4 +330,88 @@ class DroneStationDataSourceImpl implements DroneStationDataSource { return 'high'; } } + + @override + Future> pauseFlightTask({ + required String deviceSn, + }) async { + final response = await dio.post( + HttpApiConsts.flightTaskCommand, + data: { + 'command': 'flighttask_pause', + 'deviceSn': deviceSn, + }, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + // 🔥 重要:接口返回的是 text/plain,需要手动解析 JSON + dynamic responseData; + if (response.data is String) { + try { + responseData = jsonDecode(response.data as String); + } catch (e) { + throw Exception('响应数据解析失败: $e'); + } + } else { + responseData = response.data; + } + + // 确保 responseData 是 Map + if (responseData is! Map) { + throw Exception('响应数据格式错误'); + } + + // 🔥 直接返回接口返回的 message,不自己拟定 + if (responseData['code'] != 200) { + final message = responseData['message'] ?? '操作失败'; + throw Exception(message); + } + + return responseData; + } + + @override + Future> returnHome({ + required String deviceSn, + }) async { + final response = await dio.post( + HttpApiConsts.flightTaskCommand, + data: { + 'command': 'return_home', + 'deviceSn': deviceSn, + }, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + // 🔥 重要:接口返回的是 text/plain,需要手动解析 JSON + dynamic responseData; + if (response.data is String) { + try { + responseData = jsonDecode(response.data as String); + } catch (e) { + throw Exception('响应数据解析失败: $e'); + } + } else { + responseData = response.data; + } + + // 确保 responseData 是 Map + if (responseData is! Map) { + throw Exception('响应数据格式错误'); + } + + // 🔥 直接返回接口返回的 message,不自己拟定 + if (responseData['code'] != 200) { + final message = responseData['message'] ?? '操作失败'; + throw Exception(message); + } + + return responseData; + } } diff --git a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart index 7a804efb..7b80654a 100644 --- a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart @@ -120,4 +120,28 @@ class DroneStationRepositoryImpl implements DroneStationRepository { return Left(Failure(e.toString())); } } + + @override + Future>> pauseFlightTask({ + required String deviceSn, + }) async { + try { + final result = await dataSource.pauseFlightTask(deviceSn: deviceSn); + return Right(result); + } catch (e) { + return Left(Failure(e.toString())); + } + } + + @override + Future>> returnHome({ + required String deviceSn, + }) async { + try { + final result = await dataSource.returnHome(deviceSn: deviceSn); + return Right(result); + } catch (e) { + return Left(Failure(e.toString())); + } + } } \ No newline at end of file diff --git a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart index 34cdb349..41973620 100644 --- a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart @@ -37,4 +37,14 @@ abstract class DroneStationRepository { VideoQualityType qualityType, int videoExpire, }); + + /// 暂停飞行任务(通过 flightTaskCommand 接口) + Future>> pauseFlightTask({ + required String deviceSn, + }); + + /// 返航(通过 flightTaskCommand 接口) + Future>> returnHome({ + required String deviceSn, + }); } \ No newline at end of file diff --git a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart index 9da70c0a..b710ab42 100644 --- a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart +++ b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart @@ -55,7 +55,8 @@ class FloatBarWidgetState extends State { DroneTaskInfo? _droneTaskInfo; DroneStationBloc? _droneStationBloc; DroneOsdDataSource? _droneOsdDataSource; - StreamSubscription? _osdSubscription; + StreamSubscription? _osdSubscription; // 无人机 OSD + StreamSubscription? _stationOsdSubscription; // 🔥 机场 OSD Timer? _simulationTimer; List _trajectoryPoints = []; LatLng? _currentPosition; @@ -97,6 +98,7 @@ class FloatBarWidgetState extends State { _bloc.close(); _droneStationBloc?.close(); _osdSubscription?.cancel(); + _stationOsdSubscription?.cancel(); // 🔥 取消机场 OSD 订阅 _droneOsdDataSource?.dispose(); _mapController?.dispose(); _destroyRtcEngine(); @@ -160,6 +162,8 @@ class FloatBarWidgetState extends State { _simulationTimer = null; _osdSubscription?.cancel(); _osdSubscription = null; + _stationOsdSubscription?.cancel(); // 🔥 取消机场 OSD 订阅 + _stationOsdSubscription = null; _droneOsdDataSource?.dispose(); _droneOsdDataSource = null; _droneStationBloc?.close(); @@ -201,22 +205,12 @@ class FloatBarWidgetState extends State { // 加载视频流 _loadVideoStream(); - - // 🔥 启动模拟轨迹用于测试(可以选择不同模式) - // 模式选项: circle(圆形)、polygon(六边形)、bow(弓字形)、rectangle(矩形) - // 等天气好了,注释掉下面这行即可使用真实 MQTT 数据 - debugPrint('🚀 [FloatBarWidget] 准备启动模拟轨迹...'); - _startSimulationTrajectory(mode: SimulationMode.circle, pointCount: 30); } else { debugPrint('⚠️ [FloatBarWidget] 无人机离线,不启动 OSD 监听和视频流'); - debugPrint('🚀 [FloatBarWidget] 但仍然启动模拟轨迹用于测试...'); setState(() { _videoError = '无人机离线,无视频信号'; }); - - // 离线时也启动模拟轨迹,方便测试 - _startSimulationTrajectory(mode: SimulationMode.circle, pointCount: 30); } } @@ -231,25 +225,31 @@ class FloatBarWidgetState extends State { gatewaySn: _droneTaskInfo!.gatewaySn, ); + // 🔥 监听无人机 OSD(轨迹、位置等) _osdSubscription = _droneOsdDataSource!.droneOsdStream.listen( (osd) { if (!mounted) return; - _handleOsdUpdate(osd); + _handleDroneOsdUpdate(osd); }, onError: (error) { - debugPrint('❌ [FloatBarWidget] OSD 监听错误: $error'); + debugPrint('❌ [FloatBarWidget] 无人机 OSD 监听错误: $error'); + }, + ); + + // 🔥 新增:监听机场 OSD(环境温度、风速等) + _stationOsdSubscription = _droneOsdDataSource!.stationOsdStream.listen( + (osd) { + if (!mounted) return; + _handleStationOsdUpdate(osd); + }, + onError: (error) { + debugPrint('❌ [FloatBarWidget] 机场 OSD 监听错误: $error'); }, ); } - /// 处理 OSD 数据更新 - void _handleOsdUpdate(DroneOsdEntity osd) { - // ⚠️ 调试期间:忽略 MQTT 数据,只看模拟轨迹 - if (_simulationTimer != null && _simulationTimer!.isActive) { - debugPrint('⚠️ [FloatBarWidget] 模拟轨迹运行中,忽略 MQTT 数据'); - return; - } - + /// 处理无人机 OSD 数据更新(轨迹、位置等) + void _handleDroneOsdUpdate(DroneOsdEntity osd) { final rawData = osd.rawData; debugPrint('📍 [FloatBarWidget] 收到 OSD 数据: ${rawData.keys.join(', ')}'); @@ -307,6 +307,20 @@ class FloatBarWidgetState extends State { } } + /// 🔥 处理机场 OSD 数据更新(环境温度、风速等) + void _handleStationOsdUpdate(DroneOsdEntity osd) { + final rawData = osd.rawData; + + debugPrint('\n========== 📥 [FloatBarWidget] 机场OSD 完整原始数据 =========='); + debugPrint('$rawData'); + debugPrint('===========================================\n'); + + // TODO: 在这里解析机场的环境温度和风速数据 + // 例如: + // final environmentTemp = rawData['environment_temperature']; + // final windSpeed = rawData['wind_speed']; + } + /// 计算两点之间的距离(米)- Haversine 公式 double _calculateDistance(double lat1, double lon1, double lat2, double lon2) { const double earthRadius = 6371000; // 地球半径(米) 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 cdc14b78..b54952ff 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 @@ -204,22 +204,53 @@ class _CreateTaskPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - OutlinedButton( - onPressed: () => Navigator.pop(context), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFE5E6EB)), - minimumSize: const Size(80, 36), + // 取消按钮 + SizedBox( + width: 100, + height: 44, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFE5E6EB)), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '取消', + style: TextStyle( + fontSize: 14, + color: Color(0xFF4E5969), + fontWeight: FontWeight.w500, + ), + ), ), - child: const Text('取消'), ), const SizedBox(width: 12), - ElevatedButton( - onPressed: _onCreateTask, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF165DFF), - minimumSize: const Size(100, 36), + // 确认创建按钮 + SizedBox( + width: 140, + height: 44, + child: ElevatedButton( + onPressed: _onCreateTask, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + ), + child: const Text( + '确认创建', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), ), - child: const Text('确认创建'), ), ], ), 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 c1ada510..15324398 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 @@ -9,6 +9,8 @@ import '../../domain/entities/flight_task_entity.dart'; import '../../domain/entities/flight_task_detail_entity.dart'; import '../../domain/entities/drone_station_entity.dart'; import '../../domain/usecases/update_flight_task_status_usecase.dart'; +import '../../domain/usecases/pause_flight_task_usecase.dart'; +import '../../domain/usecases/return_home_usecase.dart'; import '../bloc/drone_station_bloc.dart'; import '../float_bar/view/float_bar_widget.dart'; @@ -28,6 +30,10 @@ class _DroneMissionControlPageState extends State { FlightTaskDetailEntity? _detailTask; // 详情数据 bool _isLoading = false; final Dio _dio = Dio(); + + // 🔥 任务状态管理 + bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮) + bool _isReturning = false; // 是否正在返航中 @override void initState() { @@ -201,6 +207,147 @@ class _DroneMissionControlPageState extends State { } } + // 🔥 暂停任务 + Future _pauseTask() async { + if (_detailTask == null || _detailTask!.sn.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('设备序列号不存在'))); + return; + } + + try { + print('🔍 [DroneMissionControl] 开始暂停任务, deviceSn: ${_detailTask!.sn}'); + + final useCase = GetIt.I(); + final result = await useCase.execute( + deviceSn: _detailTask!.sn, + ); + + result.fold( + (failure) { + print('❌ [DroneMissionControl] 暂停任务失败: ${failure.message}'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('暂停任务失败: ${failure.message}'))); + }, + (data) { + print('✅ [DroneMissionControl] 任务暂停成功: $data'); + setState(() { + _isPaused = true; // 更新状态为已暂停 + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('任务已暂停'))); + }, + ); + } catch (e) { + print('❌ [DroneMissionControl] 暂停任务异常: $e'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('暂停任务失败: $e'))); + } + } + + // 🔥 恢复任务 + Future _resumeTask() async { + if (_detailTask == null || _detailTask!.uuid.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('任务ID不存在'))); + return; + } + + try { + print('🔍 [DroneMissionControl] 开始恢复任务: ${_detailTask!.uuid}'); + + final useCase = GetIt.I(); + final result = await useCase.execute( + taskId: _detailTask!.uuid, + status: 'restored', + ); + + result.fold( + (failure) { + print('❌ [DroneMissionControl] 恢复任务失败: ${failure.message}'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('恢复任务失败: ${failure.message}'))); + }, + (data) { + print('✅ [DroneMissionControl] 任务恢复成功: $data'); + setState(() { + _isPaused = false; // 更新状态为执行中 + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('任务已恢复执行'))); + }, + ); + } catch (e) { + print('❌ [DroneMissionControl] 恢复任务异常: $e'); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('恢复任务失败: $e'))); + } + } + + // 🔥 返航降落 + Future _returnHome() async { + if (_detailTask == null || _detailTask!.sn.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('设备序列号不存在'))); + return; + } + + // 防止重复点击 + if (_isReturning) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已在返航中...'))); + return; + } + + try { + print('🔍 [DroneMissionControl] 开始返航, deviceSn: ${_detailTask!.sn}'); + + setState(() { + _isReturning = true; // 标记正在返航 + }); + + final useCase = GetIt.I(); + final result = await useCase.execute(deviceSn: _detailTask!.sn); + + result.fold( + (failure) { + print('❌ [DroneMissionControl] 返航失败: ${failure.message}'); + setState(() { + _isReturning = false; // 重置状态 + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('返航失败: ${failure.message}'))); + }, + (data) { + print('✅ [DroneMissionControl] 返航指令发送成功: $data'); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已在返航'))); + // 注意:不重置 _isReturning,因为返航是一个持续过程 + }, + ); + } catch (e) { + print('❌ [DroneMissionControl] 返航异常: $e'); + setState(() { + _isReturning = false; // 重置状态 + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('返航失败: $e'))); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -588,9 +735,9 @@ class _DroneMissionControlPageState extends State { const SizedBox(width: 12), Expanded( child: ElevatedButton( - onPressed: () {}, + onPressed: _isPaused ? _resumeTask : _pauseTask, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFFF7D00), + backgroundColor: _isPaused ? const Color(0xFF165DFF) : const Color(0xFFFF7D00), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( @@ -598,27 +745,27 @@ class _DroneMissionControlPageState extends State { ), elevation: 0, ), - child: const Text( - '暂停任务', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + child: Text( + _isPaused ? '恢复任务' : '暂停任务', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), ), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton( - onPressed: () {}, + onPressed: _isReturning ? null : _returnHome, style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF4E5969), - side: const BorderSide(color: 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), ), ), - child: const Text( - '返航降落', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + child: Text( + _isReturning ? '已在返航' : '返航降落', + 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 9b7c94df..fd701e16 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 @@ -32,6 +32,9 @@ class _DroneStationDetailPageState extends State { // 无人机状态轮询计时器 Timer? _droneStatusPollingTimer; + + // 🔥 标记是否已经初始化过(用于判断是否从其他页面返回) + bool _hasInitialized = false; @override void initState() { @@ -43,12 +46,45 @@ class _DroneStationDetailPageState extends State { deviceSn: widget.station.deviceSn, ), ); + + // 🔥 标记已初始化 + _hasInitialized = true; // 启动无人机状态轮询(每5秒刷新一次) // 🔥 已禁用自动轮询,改为手动下拉刷新 // _startDroneStatusPolling(); } + /// 🔥 页面重新激活时调用(从其他页面返回时) + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新) + if (_hasInitialized && _bloc.state is UAVDetailLoaded) { + debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据'); + // 延迟一下再刷新,避免与 Bloc 状态更新冲突 + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) { + _refreshData(); + } + }); + } + } + + /// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新) + void _refreshData() { + if (!mounted) return; + + debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据'); + _bloc.add( + UAVDetailLoad( + gatewaySn: widget.station.gatewaySn, + deviceSn: widget.station.deviceSn, + ), + ); + } + @override void dispose() { _bloc.close(); @@ -216,6 +252,20 @@ 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) { + if (!completer.isCompleted) { + completer.complete(); + } + } + }); + // 重新加载无人机详情 _bloc.add( UAVDetailLoad( @@ -223,9 +273,19 @@ class _DroneStationDetailPageState extends State { deviceSn: widget.station.deviceSn, ), ); - // 重置轮询计时器,使用新的状态 - // 🔥 已禁用自动轮询,无需重置 - // _scheduleDroneStatusPoll(); + + // 🔥 等待数据加载完成(最多等待5秒) + await completer.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时'); + }, + ); + + // 取消订阅 + subscription.cancel(); + + debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成'); } Widget _buildMonitorCard() { 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 0fadfe49..c80b45b0 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,7 +1,13 @@ +import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/di/injection.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'; import '../../domain/entities/drone_station_entity.dart'; import '../bloc/drone_station_bloc.dart'; @@ -53,11 +59,23 @@ class _DroneVideoControlPageState extends State { final volc.IRTCRoomEventHandler _roomEventHandler = volc.IRTCRoomEventHandler(); + // 🔥 实时轨迹相关状态 + MapController? _mapController; + List _trajectoryPoints = []; + LatLng? _currentPosition; + double? _currentHeading; + StreamSubscription? _osdSubscription; + DroneOsdDataSource? _droneOsdDataSource; + @override void initState() { super.initState(); _bloc = sl(); + // 🔥 初始化 MQTT OSD 数据源 + _droneOsdDataSource = sl(); + _startOsdListening(); + // 初始化事件处理器 _initVolcEventHandlers(); @@ -110,6 +128,9 @@ class _DroneVideoControlPageState extends State { @override void dispose() { + _osdSubscription?.cancel(); + _droneOsdDataSource?.dispose(); + _mapController?.dispose(); _destroyRtcEngine(); _bloc.close(); super.dispose(); @@ -219,6 +240,147 @@ class _DroneVideoControlPageState extends State { } } + /// 🔥 开始监听 MQTT OSD 数据 + void _startOsdListening() async { + if (_droneOsdDataSource == null) return; + + debugPrint('🛸 [DroneVideoControlPage] 开始监听 OSD 数据'); + debugPrint(' droneSn: ${widget.droneSn}'); + debugPrint(' gatewaySn: ${widget.gatewaySn}'); + + await _droneOsdDataSource!.startListening( + deviceSn: widget.droneSn, + gatewaySn: widget.gatewaySn, + ); + + _osdSubscription = _droneOsdDataSource!.droneOsdStream.listen((osdData) { + if (!mounted) return; + debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件'); + _handleOsdUpdate(osdData); + }, onError: (error) { + debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error'); + }); + + debugPrint('✅ [DroneVideoControlPage] OSD 监听已启动'); + } + + /// 🔥 处理 OSD 数据更新,绘制轨迹 + 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) { + 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'); + } + + // 如果 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'); + } + } + + // 获取航向角 + 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(); + } + + 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); + + 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'); + } + }); + } else { + debugPrint('⚠️ [DroneVideoControlPage] 位置数据无效: lat=$lat, lng=$lng'); + } + } + + /// 🔥 计算两点之间的距离(米) + double _calculateDistance(double lat1, double lon1, double lat2, double lon2) { + const p = 0.017453292519943295; // Math.PI / 180 + 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) + } + // 初始化火山引擎事件处理器 void _initVolcEventHandlers() { _engineEventHandler.onWarning = (volc.WarningCode code) { @@ -522,7 +684,7 @@ class _DroneVideoControlPageState extends State { } } else if (state is UavVideoStreamError) { setState(() { - _errorMessage = state.message; + _errorMessage = '暂无视频'; _isLoading = false; }); } @@ -537,11 +699,15 @@ class _DroneVideoControlPageState extends State { children: [ _buildVideoPlayer(), const SizedBox(height: 12), + // 🔥 实时轨迹地图(放在视频和飞行数据之间) + _buildTrajectoryMap(), + const SizedBox(height: 12), _buildFlightData(), const SizedBox(height: 12), _buildAIResults(), const SizedBox(height: 12), - _buildMapAndJoystick(), + // 🔥 摇杆控制(单独一行) + _buildJoystickControl(), const SizedBox(height: 16), _buildBottomToolbar(), ], @@ -901,29 +1067,351 @@ class _DroneVideoControlPageState extends State { ); } + /// 🔥 实时轨迹地图(放在视频和飞行数据之间) + Widget _buildTrajectoryMap() { + debugPrint('🗺️ [DroneVideoControlPage] 构建地图轨迹组件'); + debugPrint(' currentPosition: $_currentPosition'); + debugPrint(' currentHeading: $_currentHeading'); + debugPrint(' trajectoryPoints.length: ${_trajectoryPoints.length}'); + + return Container( + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF7F8FA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + // 🔥 地图 + FlutterMap( + mapController: _mapController ??= MapController(), // ✅ 懒加载初始化 + options: MapOptions( + initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074), + initialZoom: 18, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all & ~InteractiveFlag.rotate, + ), + ), + children: [ + // 高德地图瓦片(最底层) + TileLayer( + 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', + ), + + // 🔥 轨迹线(中间层) + if (_trajectoryPoints.length >= 1) + PolylineLayer( + polylines: [ + Polyline( + points: _trajectoryPoints, + color: const Color(0xFF00B42A).withOpacity(0.9), + strokeWidth: 4, + borderColor: Colors.white, + borderStrokeWidth: 2, + ), + ], + ), + + // 🔥 无人机当前位置标记(最上层) + if (_currentPosition != null) + MarkerLayer( + markers: [ + Marker( + key: const ValueKey('drone_marker'), + point: _currentPosition!, + width: 40, + height: 40, + child: Transform.rotate( + angle: (_currentHeading ?? 0) * pi / 180, + alignment: Alignment.center, + child: const Icon( + Icons.flight, + color: Color(0xFF165DFF), + size: 32, + ), + ), + ), + ], + ), + ], + ), + + // 🔥 GPS定位标签 + Positioned( + top: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + const Text( + 'GPS定位', + style: TextStyle(fontSize: 10, color: Colors.white), + ), + ], + ), + ), + ), + + // 🔥 轨迹点数 + if (_trajectoryPoints.isNotEmpty) + Positioned( + top: 8, + right: 8, + child: Container( + 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), + ), + ), + ), + + // 🔥 等待定位提示 + if (_currentPosition == null) + const Positioned( + bottom: 8, + right: 8, + child: Text( + '等待定位...', + style: TextStyle(fontSize: 10, color: Colors.black54), + ), + ), + ], + ), + ), + ); + } + + /// 🔥 摇杆控制(单独一行) + Widget _buildJoystickControl() { + return Container( + height: 200, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFC9CDD4), + shape: BoxShape.circle, + ), + ), + 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: () {}, + ), + ), + ], + ), + ); + } + Widget _buildMapAndJoystick() { return Row( children: [ Expanded( child: Container( - height: 160, + height: 200, decoration: BoxDecoration( - color: Colors.white, + color: const Color(0xFFF7F8FA), borderRadius: BorderRadius.circular(12), - boxShadow: const [ - BoxShadow( - color: Color(0x0D000000), - blurRadius: 8, - offset: Offset(0, 2), - ), - ], + border: Border.all(color: Colors.grey.withOpacity(0.2)), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.asset( - 'assets/images/xunjian.png', - fit: BoxFit.cover, - width: double.infinity, + child: Stack( + children: [ + // 🔥 地图 + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074), + initialZoom: 18, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all & ~InteractiveFlag.rotate, + ), + ), + children: [ + // 高德地图瓦片(最底层) + TileLayer( + 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', + ), + + // 🔥 轨迹线(中间层) + if (_trajectoryPoints.length >= 1) + PolylineLayer( + polylines: [ + Polyline( + points: _trajectoryPoints, + color: const Color(0xFF00B42A).withOpacity(0.9), + strokeWidth: 4, + borderColor: Colors.white, + borderStrokeWidth: 2, + ), + ], + ), + + // 🔥 无人机当前位置标记(最上层) + if (_currentPosition != null) + MarkerLayer( + markers: [ + Marker( + key: const ValueKey('drone_marker'), + point: _currentPosition!, + width: 40, + height: 40, + child: Transform.rotate( + angle: (_currentHeading ?? 0) * pi / 180, + alignment: Alignment.center, + child: const Icon( + Icons.flight, + color: Color(0xFF165DFF), + size: 32, + ), + ), + ), + ], + ), + ], + ), + + // 🔥 GPS定位标签 + Positioned( + top: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + const Text( + 'GPS定位', + style: TextStyle(fontSize: 10, color: Colors.white), + ), + ], + ), + ), + ), + + // 🔥 轨迹点数 + if (_trajectoryPoints.isNotEmpty) + Positioned( + top: 8, + right: 8, + child: Container( + 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), + ), + ), + ), + + // 🔥 等待定位提示 + if (_currentPosition == null) + const Positioned( + bottom: 8, + right: 8, + child: Text( + '等待定位...', + style: TextStyle(fontSize: 10, color: Colors.black54), + ), + ), + ], ), ), ), @@ -931,7 +1419,7 @@ class _DroneVideoControlPageState extends State { const SizedBox(width: 12), Expanded( child: Container( - height: 160, + height: 200, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), diff --git a/lib/features/v2/device_list/presentation/pages/robot_control_page.dart b/lib/features/v2/device_list/presentation/pages/robot_control_page.dart index c511e570..c623b97c 100644 --- a/lib/features/v2/device_list/presentation/pages/robot_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/robot_control_page.dart @@ -25,13 +25,32 @@ class RobotControlPage extends StatelessWidget { icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)), onPressed: () => Navigator.pop(context), ), - title: Text( - '${robot['type']}控制', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF1D2129), - ), + title: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + robot['alias'] != null && (robot['alias'] as String).isNotEmpty + ? robot['alias'] + : '暂无别名', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + robot['name'] ?? '', // 🔥 使用 name 字段(长序列号 deviceName) + style: const TextStyle( + fontSize: 11, + color: Color(0xFF86909C), + ), + textAlign: TextAlign.center, + maxLines: 2, + softWrap: true, + ), + ], ), centerTitle: true, actions: [ diff --git a/lib/features/v2/device_list/presentation/pages/robot_list_page.dart b/lib/features/v2/device_list/presentation/pages/robot_list_page.dart index 68c5693f..c6890f14 100644 --- a/lib/features/v2/device_list/presentation/pages/robot_list_page.dart +++ b/lib/features/v2/device_list/presentation/pages/robot_list_page.dart @@ -112,7 +112,8 @@ class _RobotListViewState extends State { .where((r) => r.status == '在线') .length; final onlineCleaning = cleaningRobots.where((r) => r.status == '在线').length; - final onlineWeeding = weedingRobots.where((r) => r.status == '在线').length; + // 暂且所有设备都视为除草机器人,在线数使用总数 + final onlineWeeding = allRobots.where((r) => r.status == '在线').length; return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), padding: const EdgeInsets.symmetric(vertical: 16), @@ -351,7 +352,7 @@ class _RobotListViewState extends State { ), const SizedBox(width: 8), Text( - '${weedingRobots.length}', + '${allRobots.length}', style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, 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 198797c0..b448229e 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 @@ -70,7 +70,10 @@ class _DroneOsdCardState extends State { _subscription = _dataSource.droneOsdStream.listen((osd) { if (!mounted) return; - debugPrint('📥 [DroneOsdCard] 收到无人机 OSD 数据'); + // 🔥 打印完整的 MQTT 原始数据(不做任何解析) + debugPrint('\n========== 📥 [无人机OSD] 完整原始数据 =========='); + debugPrint('${osd.rawData}'); + debugPrint('===========================================\n'); setState(() { _currentOsd = osd; @@ -115,7 +118,9 @@ class _DroneOsdCardState extends State { return; } - debugPrint('📊 [DroneOsdCard] 数据键: ${droneData.keys.toList()}'); + // 🔥 打印完整原始数据(用于调试) + debugPrint('📊 [DroneOsdCard] drone/host 数据键: ${droneData.keys.toList()}'); + debugPrint('📊 [DroneOsdCard] 完整原始数据: $droneData'); // ========== 1. 基础飞行信息 ========== // 无人机高度(height / altitude) @@ -321,28 +326,28 @@ class _DroneOsdCardState extends State { ? 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.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': '飞行模式', @@ -350,22 +355,22 @@ class _DroneOsdCardState extends State { '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), - }, + // { + // '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), + // }, ]; // 应用缓存逻辑:有新值则更新缓存,否则使用旧值 diff --git a/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart index e8cb3839..480403e2 100644 --- a/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/drone_station_item_card.dart @@ -135,10 +135,9 @@ class DroneStationItemCard extends StatelessWidget { // ], ], ), - // - // const SizedBox(height: 12), - // - // // 电量卡片 + const SizedBox(height: 12), + + // 🔥 电量状态 - 已隐藏 // _buildInfoCard( // title: '电量状态', // icon: Icons.battery_full_rounded, @@ -146,10 +145,10 @@ class DroneStationItemCard extends StatelessWidget { // value: station.capacityPercent != null ? '${station.capacityPercent}%' : '未知', // progressValue: station.capacityPercent, // ), - // + // const SizedBox(height: 12), - // - // // 环境信息卡片 + + // 🔥 环境监测 - 已隐藏 // _buildSectionCard( // title: '环境监测', // icon: Icons.thermostat_rounded, 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 14eee1f1..17eaa89e 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 @@ -66,7 +66,10 @@ class _DroneStationOsdCardState extends State { _subscription = _dataSource.stationOsdStream.listen((osd) { if (!mounted) return; - //debugPrint('📥 [DroneStationOsdCard] 收到机场 OSD 原始数据'); + // 🔥 打印完整的 MQTT 原始数据(不做任何解析) + debugPrint('\n========== 📥 [机场OSD] 完整原始数据 =========='); + debugPrint('${osd.rawData}'); + debugPrint('===========================================\n'); setState(() { _currentOsd = osd; @@ -95,8 +98,11 @@ class _DroneStationOsdCardState extends State { return; } - // debugPrint('📊 [DroneStationOsdCard] 开始解析 OSD 数据...'); - //debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}'); + // 🔥 打印完整的 host 数据键,查看所有可用字段 + debugPrint('📊 [DroneStationOsdCard] host 数据键: ${hostData.keys.toList()}'); + + // 🔥 打印完整原始数据(用于调试) + debugPrint('📊 [DroneStationOsdCard] 完整原始数据: $hostData'); // ========== 1. 电池相关 ========== // 提取无人机电量(从 drone_battery_maintenance_info.batteries[0].capacity_percent) 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 f3ded756..d6bf856b 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 @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; 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'; @@ -15,7 +16,6 @@ class RobotHeaderCard extends StatefulWidget { } class _RobotHeaderCardState extends State { - String _videoStreamUrl = ''; int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上 // 视角配置 @@ -30,30 +30,7 @@ class _RobotHeaderCardState extends State { @override void initState() { super.initState(); - _initVideoUrl(); - } - - /// 初始化视频流 URL - void _initVideoUrl() { - final userState = context.read().state; - final deviceId = widget.robot['id'] as String?; - - debugPrint(' [RobotHeaderCard] 开始初始化视频URL'); - debugPrint('🎬 [RobotHeaderCard] deviceId: $deviceId'); - debugPrint('🎬 [RobotHeaderCard] user: ${userState.user}'); - debugPrint('🎬 [RobotHeaderCard] token: ${userState.user?.token}'); - - if (deviceId != null && deviceId.isNotEmpty && userState.user != null && userState.user!.token != null) { - setState(() { - _videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}"; - }); - debugPrint('✅ [RobotHeaderCard] 视频URL初始化成功: $_videoStreamUrl'); - } else { - debugPrint('❌ [RobotHeaderCard] 视频URL初始化失败'); - debugPrint(' - deviceId.isEmpty: ${deviceId == null || deviceId.isEmpty}'); - debugPrint(' - user == null: ${userState.user == null}'); - debugPrint(' - token == null: ${userState.user?.token == null}'); - } + debugPrint('🎬 [RobotHeaderCard] 初始化 - robot: ${widget.robot}'); } @override @@ -147,26 +124,59 @@ class _RobotHeaderCardState extends State { aspectRatio: 16 / 9, child: Container( color: Colors.black, // 🔥 强制整个视频区域为黑色背景 - child: _videoStreamUrl.isNotEmpty - ? WebRTCLocalPlayer( - streamUrl: _videoStreamUrl, - showLeftPip: false, // 不显示悬浮小窗 - showRightPip: false, - isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面 - ) - : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.videocam_off, size: 48, color: Colors.white54), - const SizedBox(height: 12), - Text( - '无视频信号', - style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500), + child: BlocBuilder( + builder: (context, userState) { + final deviceId = widget.robot['name'] as String?; // 🔥 使用 name 字段(长序列号) + String videoStreamUrl = ''; + + debugPrint('🎬 [RobotHeaderCard] BlocBuilder 重建'); + debugPrint(' - deviceId (name): $deviceId'); + debugPrint(' - user != null: ${userState.user != null}'); + debugPrint(' - token != null: ${userState.user?.token != null}'); + debugPrint(' - TCP_IP: ${TCPConsts.TCP_IP}'); + + if (deviceId != null && + deviceId.isNotEmpty && + userState.user != null && + userState.user!.token != null) { + videoStreamUrl = "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=${userState.user!.token}"; + debugPrint('✅ [RobotHeaderCard] 视频URL构建成功: $videoStreamUrl'); + } else { + debugPrint('❌ [RobotHeaderCard] 视频URL构建失败'); + if (deviceId == null || deviceId.isEmpty) { + debugPrint(' - 原因: deviceId 为空'); + } + if (userState.user == null) { + debugPrint(' - 原因: user 为 null'); + } + if (userState.user?.token == null) { + debugPrint(' - 原因: token 为 null'); + } + } + + return videoStreamUrl.isNotEmpty + ? WebRTCLocalPlayer( + key: ValueKey(videoStreamUrl), + streamUrl: videoStreamUrl, + showLeftPip: false, + showRightPip: false, + mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment, + ) + : Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.videocam_off, size: 48, color: Colors.white54), + const SizedBox(height: 12), + Text( + '暂无视频', + style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500), + ), + ], ), - ], - ), - ), + ); + }, + ), ), ), @@ -224,3 +234,5 @@ class _RobotHeaderCardState extends State { ); } } + + diff --git a/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart b/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart index 245109f5..b3a9bc55 100644 --- a/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/robot_item_card.dart @@ -73,7 +73,7 @@ class RobotItemCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - name, + alias != null && alias!.isNotEmpty ? alias! : '暂无别名', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -84,13 +84,13 @@ class RobotItemCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - alias != null && alias!.isNotEmpty - ? '别名: $alias' - : 'ID: $id', + name, // 🔥 显示完整的长序列号 (deviceName) style: const TextStyle( - fontSize: 12, + fontSize: 11, color: Color(0xFF86909C), ), + maxLines: 2, + softWrap: true, ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 090ff792..43f440fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -199,9 +199,10 @@ class _UpdateCheckerState extends State<_UpdateChecker> { @override void initState() { super.initState(); - Future.delayed(const Duration(seconds: 2), () { - if (mounted) context.read().checkUpdate(); - }); + // TODO: 暂时注释掉应用启动时的自动更新检查 + // Future.delayed(const Duration(seconds: 2), () { + // if (mounted) context.read().checkUpdate(); + // }); } @override