优化和路径规划新适配
This commit is contained in:
@@ -58,4 +58,7 @@ class HttpApiConsts {
|
||||
|
||||
// 获取无人机详情
|
||||
static const String getUAVDetail = "$baseUrl/iot/UAV/getUAVDetail";
|
||||
|
||||
// 飞行任务命令控制(暂停、返航等)
|
||||
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void> init() async {
|
||||
/// 1.3 Log工具Sentry
|
||||
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
|
||||
|
||||
/// 1.4 --- MQTT Data Sources ---
|
||||
/// 1.4 --- Route Observer (路由监听器) ---
|
||||
sl.registerLazySingleton<RouteObserver>(() => RouteObserver<ModalRoute<void>>());
|
||||
|
||||
/// 1.5 --- MQTT Data Sources ---
|
||||
sl.registerFactory<DroneOsdDataSource>(
|
||||
() =>
|
||||
DroneOsdDataSourceImpl(sl<MqttClient>(instanceName: 'droneOsdClient')),
|
||||
@@ -358,6 +364,12 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<UpdateFlightTaskStatusUseCase>(
|
||||
() => UpdateFlightTaskStatusUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<PauseFlightTaskUseCase>(
|
||||
() => PauseFlightTaskUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<ReturnHomeUseCase>(
|
||||
() => ReturnHomeUseCase(sl()),
|
||||
);
|
||||
sl.registerFactory<DroneStationBloc>(
|
||||
() => DroneStationBloc(sl(), sl(), sl(), sl()),
|
||||
);
|
||||
|
||||
13
lib/core/env/env_config.dart
vendored
13
lib/core/env/env_config.dart
vendored
@@ -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 端口
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -21,66 +21,83 @@ class PathHttpDatasourceImpl implements PathHttpDatasource {
|
||||
@override
|
||||
Future<List<DeviceAddPathPointModel>> generatePathRaw({required Map<String, dynamic> 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<String, dynamic>;
|
||||
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<dynamic> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ class ReferencePoint {
|
||||
|
||||
ReferencePoint({required this.lat, required this.lon});
|
||||
|
||||
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lon};
|
||||
Map<String, dynamic> toJson() => {'lat': lat, 'lon': lon};
|
||||
}
|
||||
|
||||
class OuterBoundary {
|
||||
@@ -86,5 +86,5 @@ class Position {
|
||||
return Position(lat: lat, lon: lon);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lon};
|
||||
Map<String, dynamic> toJson() => {'lat': lat, 'lon': lon};
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
// 🔥 核心修复:_parseJsonDataNode 现在返回 JSON 字符串,直接存储,避免 Map→jsonEncode 丢失路径数据
|
||||
jsonData: xmlData['jsonData'],
|
||||
imgUrl: xmlData['imgUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
// 生成路径
|
||||
// 鐢熸垚璺<EFBFBD>緞
|
||||
@override
|
||||
Future<List<DeviceAddPathPointModel>> 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() // 鍙栫<EFBFBD>涓€涓<EFBFBD>€煎苟搴忓垪鍖栵紝鍘绘帀澶栧眰 Map
|
||||
: [],
|
||||
'workType': workType,
|
||||
};
|
||||
|
||||
return await _datasource.generatePathRaw(body: body);
|
||||
}
|
||||
|
||||
// 保存工作记录
|
||||
// 淇濆瓨宸ヤ綔璁板綍
|
||||
@override
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
|
||||
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<List<Map<String, dynamic>>> selectWorkRecordByName({
|
||||
required String workName,
|
||||
@@ -131,22 +132,30 @@ 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<String, dynamic>;
|
||||
// 判断响应格式:XML 还是 JSON
|
||||
final trimmed = response.body.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
// JSON 格式(原有逻辑)
|
||||
Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
} catch (formatError) {
|
||||
print('[selectWorkRecordByName] JSON解析失败: $formatError');
|
||||
throw Exception('JSON解析失败: $formatError');
|
||||
}
|
||||
|
||||
if (data['code'] == 200 && data.containsKey('data')) {
|
||||
final dynamic rawData = data['data'];
|
||||
|
||||
// 🔧 关键修复:安全转换为 List
|
||||
|
||||
List<Map<String, dynamic>> records;
|
||||
if (rawData is List) {
|
||||
records = rawData.map((e) {
|
||||
@@ -167,6 +176,11 @@ class PathRepositoryImpl implements PathRepository {
|
||||
} else {
|
||||
throw Exception('API error: ${data['msg'] ?? 'Unknown'}');
|
||||
}
|
||||
} else {
|
||||
// XML 格式:使用 XML 解析逻辑
|
||||
print('[selectByWorkName] 检测到 XML 格式响应');
|
||||
return _parseXmlSelectByNameResponse(response.body);
|
||||
}
|
||||
} else {
|
||||
throw Exception(
|
||||
'HTTP ${response.statusCode}: ${response.reasonPhrase}',
|
||||
@@ -177,7 +191,75 @@ class PathRepositoryImpl implements PathRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据场站ID查询工作记录列表(XML格式)
|
||||
/// 解析 selectByWorkName 的 XML 响应
|
||||
/// 返回 List<Map<String, dynamic>>,将 jsonData 中的 path/outer 提取到顶层
|
||||
Future<List<Map<String, dynamic>>> _parseXmlSelectByNameResponse(String body) async {
|
||||
print('[selectByWorkName XML] 原始响应前500字符: ${body.length > 500 ? body.substring(0, 500) : body}');
|
||||
final codeMatch = RegExp(r'<\w*:?code[^>]*>(\d+)</\w*:?code>').firstMatch(body);
|
||||
final code = codeMatch?.group(1);
|
||||
print('[selectByWorkName XML] code: $code');
|
||||
|
||||
if (code != '200') {
|
||||
final msgMatch = RegExp(r'<\w*:?msg[^>]*>(.*?)</\w*:?msg>').firstMatch(body);
|
||||
throw Exception('API error: ${msgMatch?.group(1) ?? 'Unknown'}');
|
||||
}
|
||||
|
||||
final dataRegex = RegExp(r'<\w*:?data[^>]*>([\s\S]*?)</\w*:?data>');
|
||||
final dataMatches = dataRegex.allMatches(body);
|
||||
print('[selectByWorkName XML] 找到 data 节点数量: ${dataMatches.length}');
|
||||
|
||||
final List<Map<String, dynamic>> 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 = '<root>$dataContent</root>';
|
||||
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<String, dynamic>;
|
||||
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鏌ヨ<E98F8C>宸ヤ綔璁板綍鍒楄〃锛圶ML鏍煎紡锛?
|
||||
@override
|
||||
Future<List<WorkRecordEntity>> 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鎺ュ彛] 鍝嶅簲鍐呭<E98D90>闀垮害: ${response.body.length}');
|
||||
///print('[XML鎺ュ彛] Content-Type: ${response.headers['content-type']}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// 打印前200字符确认格式
|
||||
// 鎵撳嵃鍓?00瀛楃<E7809B>纭<EFBFBD><E7BAAD>鏍煎紡
|
||||
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鎺ュ彛] 閿欒<E996BF>: $e');
|
||||
throw Exception('Network error in getWorkRecordsBySiteId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析JSON格式响应
|
||||
/// 瑙f瀽JSON鏍煎紡鍝嶅簲
|
||||
Future<List<WorkRecordEntity>> _parseJsonResponse(String body) async {
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
@@ -252,18 +337,18 @@ class PathRepositoryImpl implements PathRepository {
|
||||
records.add(WorkRecordEntity.fromJson(recordsData));
|
||||
}
|
||||
|
||||
/// print('[XML接口] 最终解析记录数: ${records.length}');
|
||||
/// print('[XML鎺ュ彛] 鏈€缁堣В鏋愯<E98F8B>褰曟暟: ${records.length}');
|
||||
return records;
|
||||
}
|
||||
|
||||
/// 解析XML格式响应
|
||||
/// 瑙f瀽XML鏍煎紡鍝嶅簲
|
||||
Future<List<WorkRecordEntity>> _parseXmlResponse(String body) async {
|
||||
// 用更宽松的正则检查响应码(支持命名空间前缀)
|
||||
// 鐢ㄦ洿瀹芥澗鐨勬<EFBFBD>鍒欐<EFBFBD>鏌ュ搷搴旂爜锛堟敮鎸佸懡鍚嶇┖闂村墠缂€锛?
|
||||
final codeMatch = RegExp(
|
||||
r'<\w*:?code[^>]*>(\d+)</\w*:?code>',
|
||||
).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>...</data>节点
|
||||
// 使用非贪婪匹配,确保每个data节点独立提取
|
||||
final dataRegex = RegExp(r'<data>([\s\S]*?)</data>');
|
||||
// 浣跨敤姝e垯琛ㄨ揪寮忔彁鍙栨墍鏈?data>...</data>鑺傜偣
|
||||
// 浣跨敤闈炶椽濠<EFBFBD>尮閰嶏紝纭<EFBFBD>繚姣忎釜data鑺傜偣鐙<EFBFBD>鎻愬彇
|
||||
final dataRegex = RegExp(r'<\w*:?data[^>]*>([\s\S]*?)</\w*:?data>');
|
||||
final dataMatches = dataRegex.allMatches(body);
|
||||
///print('[XML接口] 找到data节点数量: ${dataMatches.length}');
|
||||
///print('[XML鎺ュ彛] 鎵惧埌data鑺傜偣鏁伴噺: ${dataMatches.length}');
|
||||
|
||||
// 解析所有工作记录
|
||||
// 瑙f瀽鎵€鏈夊伐浣滆<EFBFBD>褰?
|
||||
final List<WorkRecordEntity> records = [];
|
||||
|
||||
for (int i = 0; i < dataMatches.length; i++) {
|
||||
final match = dataMatches.elementAt(i);
|
||||
final dataContent = match.group(1)!; // 获取<data>和</data>之间的内容
|
||||
final dataContent = match.group(1)!; // 鑾峰彇<data>鍜?/data>涔嬮棿鐨勫唴瀹?
|
||||
|
||||
try {
|
||||
// 将提取的内容包装成完整XML进行解析
|
||||
// 灏嗘彁鍙栫殑鍐呭<EFBFBD>鍖呰<EFBFBD>鎴愬畬鏁碭ML杩涜<EFBFBD>瑙f瀽
|
||||
final wrappedXml = '<root>$dataContent</root>';
|
||||
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鎺ュ彛] 鏈€缁堣В鏋愯<E98F8B>褰曟暟: ${records.length}');
|
||||
return records;
|
||||
}
|
||||
|
||||
/// 解析XML工作记录节点
|
||||
/// 瑙f瀽XML宸ヤ綔璁板綍鑺傜偣
|
||||
Map<String, dynamic> _parseXmlRecord(XmlElement recordElement) {
|
||||
final Map<String, dynamic> 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瀽] 鏍囩<E98F8D>: $tagName, 鍊? ${innerText.length > 50 ? innerText.substring(0, 50) + '...' : innerText}',
|
||||
// );
|
||||
|
||||
// 特殊处理jsonData节点(包含嵌套结构)
|
||||
// 鐗规畩澶勭悊jsonData鑺傜偣锛堝寘鍚<EFBFBD>祵濂楃粨鏋勶級
|
||||
if (tagName == 'jsonData') {
|
||||
result['jsonData'] = _parseJsonDataNode(child);
|
||||
} else {
|
||||
// 普通节点直接取值
|
||||
// 鏅<EFBFBD>€氳妭鐐圭洿鎺ュ彇鍊?
|
||||
result[tagName] = innerText;
|
||||
}
|
||||
}
|
||||
|
||||
//print('[XML解析] 解析完成,结果keys: ${result.keys.toList()}');
|
||||
//print('[XML瑙f瀽] 瑙f瀽瀹屾垚锛岀粨鏋渒eys: ${result.keys.toList()}');
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 解析jsonData节点
|
||||
Map<String, dynamic> _parseJsonDataNode(XmlElement jsonDataElement) {
|
||||
/// 返回 JSON 字符串,避免 Map → jsonEncode 的二次序列化导致数据丢失
|
||||
String _parseJsonDataNode(XmlElement jsonDataElement) {
|
||||
final Map<String, dynamic> 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 对,而不是只取第一个
|
||||
// 支持格式:<path><lat>30.1</lat><lng>120.1</lng><lat>30.2</lat><lng>120.2</lng></path>
|
||||
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}
|
||||
/// 鍒涘缓璁惧<EFBFBD>浠诲姟锛堥€氳繃鎺ュ彛鎵ц<EFBFBD>浣滀笟锛?
|
||||
/// 鎺ュ彛鍦板潃: http://1.95.137.212:8081/iot/deviceTask/createDeviceTask
|
||||
/// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5}
|
||||
/// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔<E68DA2>D
|
||||
@override
|
||||
Future<void> createDeviceTask({
|
||||
Future<int> 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 dio = sl<Dio>();
|
||||
|
||||
final body = jsonEncode({
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NetMessageDispatcher>());
|
||||
@@ -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>();
|
||||
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 逐个发点逻辑结束 ========== */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,11 @@ abstract class PathRepository {
|
||||
Future<List<WorkRecordEntity>> getWorkRecordsBySiteId({required int siteId});
|
||||
|
||||
/// 创建设备任务(通过接口执行作业)
|
||||
/// deviceId: 设备ID(targetDevice)
|
||||
/// routeId: 路线ID(选中的路线任务ID)
|
||||
/// siteId: 场站ID
|
||||
Future<void> createDeviceTask({
|
||||
/// 返回创建成功的任务ID
|
||||
Future<int> createDeviceTask({
|
||||
required String deviceId,
|
||||
required int routeId,
|
||||
required int siteId,
|
||||
required int orgId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,18 +7,20 @@ class CreateDeviceTaskUseCase {
|
||||
|
||||
CreateDeviceTaskUseCase(this.repository);
|
||||
|
||||
Future<Either<DeviceFailure, void>> execute({
|
||||
Future<Either<DeviceFailure, int>> 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()));
|
||||
}
|
||||
|
||||
@@ -138,17 +138,19 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _cancelTaskUseCase.call(
|
||||
CancelTaskParams(
|
||||
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<DeviceTaskState> {
|
||||
));
|
||||
},
|
||||
(success) {
|
||||
_logger.logWithLevel('[取消任务] 响应成功: $success');
|
||||
_logger.logWithLevel('✅ 取消任务成功');
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
@@ -204,17 +207,19 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _pauseTaskUseCase.call(
|
||||
PauseTaskParams(
|
||||
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<DeviceTaskState> {
|
||||
));
|
||||
},
|
||||
(success) {
|
||||
_logger.logWithLevel('[暂停任务] 响应成功: $success');
|
||||
_logger.logWithLevel('✅ 暂停任务成功');
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
@@ -270,17 +276,19 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _recoveryTaskUseCase.call(
|
||||
RecoveryTaskParams(
|
||||
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<DeviceTaskState> {
|
||||
));
|
||||
},
|
||||
(data) {
|
||||
_logger.logWithLevel('[恢复任务] 响应成功: $data');
|
||||
_logger.logWithLevel('✅ 恢复任务成功: $data');
|
||||
emit(state.copyWith(
|
||||
isLoading: false,
|
||||
|
||||
@@ -378,19 +378,26 @@ class DevicesCubit extends Cubit<DevicesState> {
|
||||
emit(state.copyWith(isLoading: false, errorMessage: failure.message));
|
||||
},
|
||||
(records) {
|
||||
print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}');
|
||||
// print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}');
|
||||
// 将 WorkRecordEntity 转换为 Map<String, dynamic> 以兼容现有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 <String, dynamic>{
|
||||
'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));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -63,6 +63,9 @@ class _RunningStatusPageState extends State<RunningStatusPage> 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<RunningStatusPage> with WidgetsBindi
|
||||
|
||||
// 🔥 新增:监听应用生命周期
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// 🔥 关键修复:页面进入时立即用 bloc 当前缓存数据初始化图表
|
||||
// 不用等下一次 TCP 推送才显示数据
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final blocState = context.read<DeviceStatusBloc>().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<RunningStatusPage> with WidgetsBindi
|
||||
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据');
|
||||
// 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发
|
||||
if (_hasSeededFromCache) {
|
||||
_hasSeededFromCache = false; // 只跳过第一次
|
||||
debugPrint('📈 [UI] 跳过首次 BlocBuilder 追加(已由 initState 缓存初始化)');
|
||||
} else {
|
||||
_appendChartData(state);
|
||||
}
|
||||
// 🔥 关键:收到数据后立即重置超时计时器
|
||||
_startDataTimeoutTimer();
|
||||
// 如果之前是超时状态,现在恢复
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ class MyRepositoryImpl implements MyRepository {
|
||||
// 核心修复:严格匹配抽象类的方法签名
|
||||
@override
|
||||
Future<Either<DeviceFailure, int>> 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();
|
||||
|
||||
@@ -79,6 +79,7 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
String? _cacheBattery;
|
||||
String? _cacheCtrlMode;
|
||||
int? _cachePing;
|
||||
DateTime? _lastStatusPushTime; // 最后一次收到设备状态推送的时间
|
||||
|
||||
void _initDeviceStatusListener() {
|
||||
_deviceStatusSub?.cancel();
|
||||
@@ -97,6 +98,7 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
_cacheBattery = battery.toString();
|
||||
_cacheCtrlMode = controlMode;
|
||||
_cachePing = c;
|
||||
_lastStatusPushTime = DateTime.now(); // 记录最后一次推送时间
|
||||
|
||||
// 500ms节流,不到时间不刷新UI
|
||||
final now = DateTime.now();
|
||||
@@ -117,6 +119,7 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
),
|
||||
battery: int.tryParse(_cacheBattery ?? '') ?? 0,
|
||||
ping: _cachePing,
|
||||
hasReceivedStatusPush: true, // 标记已收到设备状态推送
|
||||
// 🔥 标记为设备状态更新
|
||||
updateType: 'device_status',
|
||||
),
|
||||
@@ -677,6 +680,22 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
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<void> close() {
|
||||
_timer?.cancel();
|
||||
@@ -926,6 +945,14 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
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<int> getNetworkDelay() async {
|
||||
try {
|
||||
// 直接 Ping 你的服务器IP
|
||||
@@ -956,6 +983,10 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
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<RemoteControlState> {
|
||||
void clearTargetDevice() {
|
||||
// debugPrint('🧹 [RemoteControl] 清除待控制设备');
|
||||
// _logger.logWithLevel('🧹 [RemoteControl] 清除待控制设备');
|
||||
|
||||
// 🔥 关键修复:退出页面时清空所有缓存,防止数据滞留
|
||||
_clearAllCache();
|
||||
debugPrint('✅ [RemoteControl] 已清空所有缓存数据');
|
||||
|
||||
emit(state.copyWith(targetDevice: null));
|
||||
}
|
||||
|
||||
@@ -1006,5 +1042,32 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -146,17 +146,16 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 监听全局状态(这些通常不随摇杆频繁变化)
|
||||
final userState = context.watch<AppUserCubit>().state;
|
||||
final deviceState = context.watch<DevicesCubit>().state;
|
||||
final currentDevice = deviceState.selectedDevice;
|
||||
|
||||
// 🔥 只监听 RemoteControlCubit,避免其他状态变化导致频繁 rebuild
|
||||
final remoteCubit = context.read<RemoteControlCubit>();
|
||||
final targetDevice = remoteCubit.state.targetDevice;
|
||||
final hasPermission = remoteCubit.state.hasPermission;
|
||||
|
||||
///context.read<RemoteControlCubit>().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<RemoteControlPage> {
|
||||
p.showRightPip != c.showRightPip,
|
||||
|
||||
builder: (context, state) {
|
||||
// 🔥 只从 targetDevice 获取 deviceId
|
||||
// 🔥 从 targetDevice 获取 deviceId
|
||||
final targetDevice = context
|
||||
.watch<RemoteControlCubit>()
|
||||
.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<RemoteControlCubit>()
|
||||
@@ -222,18 +219,16 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
.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<RemoteControlCubit>()
|
||||
.state
|
||||
.controlEntity
|
||||
.originY >=
|
||||
0,
|
||||
showLeftPip: state.showLeftPip,
|
||||
showRightPip: state.showRightPip,
|
||||
isFrontMain: originY >= 0,
|
||||
onDoubleTap: () {
|
||||
debugPrint('👆 [RemoteControlPage] 双击屏幕,切换前后视角');
|
||||
// 🔥 通过 TCP 发送切换视角指令
|
||||
// 这里需要调用 RemoteControlCubit 的方法来切换视角
|
||||
context.read<RemoteControlCubit>().toggleCameraView();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -24,6 +24,49 @@ class LeftJoystickArea extends StatefulWidget {
|
||||
class _LeftJoystickAreaState extends State<LeftJoystickArea> {
|
||||
int _lastSentY = 0;
|
||||
bool _isTouching = false;
|
||||
bool _hasShownNoPushDialog = false; // 防止重复弹窗
|
||||
|
||||
/// 检查是否可以控制,不能则弹出提示
|
||||
bool _checkCanControl(BuildContext context) {
|
||||
final cubit = context.read<RemoteControlCubit>();
|
||||
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<LeftJoystickArea> {
|
||||
radius: widget.width,
|
||||
axisHint: AxisHint.forwardBackward,
|
||||
onValueChanged: (value) {
|
||||
if (!_checkCanControl(context)) return;
|
||||
// 标记:正在触摸
|
||||
_isTouching = true;
|
||||
|
||||
@@ -50,11 +94,13 @@ class _LeftJoystickAreaState extends State<LeftJoystickArea> {
|
||||
}
|
||||
},
|
||||
onPress: () {
|
||||
if (!_checkCanControl(context)) return;
|
||||
_isTouching = true;
|
||||
debugPrint('onPress');
|
||||
_triggerVibration();
|
||||
},
|
||||
onPanEnd: () async {
|
||||
_hasShownNoPushDialog = false; // 松手重置,下次触摸重新检查
|
||||
// 🔥 松手 100% 归零
|
||||
debugPrint('onPanEnd');
|
||||
await _stopJoystick();
|
||||
|
||||
@@ -23,6 +23,49 @@ class RightJoystickArea extends StatefulWidget {
|
||||
|
||||
class _RightJoystickAreaState extends State<RightJoystickArea> {
|
||||
int _lastX = 0;
|
||||
bool _hasShownNoPushDialog = false; // 防止重复弹窗
|
||||
|
||||
/// 检查是否可以控制,不能则弹出提示
|
||||
bool _checkCanControl(BuildContext context) {
|
||||
final cubit = context.read<RemoteControlCubit>();
|
||||
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<RightJoystickArea> {
|
||||
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<RightJoystickArea> {
|
||||
debugPrint('右摇杆的数据- x: $finalX, y: 0');
|
||||
context.read<RemoteControlCubit>().updateOriginX(finalX);
|
||||
},
|
||||
onPress: _triggerVibration,
|
||||
onPress: () {
|
||||
if (!_checkCanControl(context)) return;
|
||||
_triggerVibration();
|
||||
},
|
||||
onPanEnd: () async {
|
||||
_hasShownNoPushDialog = false; // 松手重置,下次触摸重新检查
|
||||
debugPrint('🕹️ [右摇杆] 松手,强制 X=0, Y=0');
|
||||
_lastX = 0;
|
||||
|
||||
|
||||
@@ -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<WebRTCLocalPlayer> {
|
||||
// 🔥 显示器:只初始化一次,终生使用
|
||||
final RTCVideoRenderer _renderer = RTCVideoRenderer();
|
||||
|
||||
// 🔥 信号线:可以更换,但必须先拔后插
|
||||
RTCPeerConnection? _peerConnection;
|
||||
|
||||
bool _isInitialized = false;
|
||||
// 🔥 URL 缓存:用于判断是否需要换线
|
||||
String? _currentStreamUrl;
|
||||
|
||||
// 使用 ValueNotifier 配合局部刷新,提升拖拽性能
|
||||
// 画中画位置管理
|
||||
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
|
||||
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
|
||||
bool _isPosInitialized = false;
|
||||
@@ -38,28 +44,53 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prepareAndConnect();
|
||||
_initRenderer(); // 🔥 显示器只初始化一次
|
||||
}
|
||||
|
||||
Future<void> _prepareAndConnect() async {
|
||||
/// 🔥 初始化显示器(只调用一次)
|
||||
Future<void> _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<void> _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<void> _connectSignal() async {
|
||||
// 1️⃣ 如果 URL 为空,不插线
|
||||
if (widget.streamUrl.isEmpty) {
|
||||
debugPrint('⚠️ [WebRTC] streamUrl 为空,跳过初始化');
|
||||
setState(() => _isInitialized = true); // 🔥 标记为已初始化,避免卡loading
|
||||
debugPrint('⚠️ [WebRTC] URL 为空,跳过连接');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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": [
|
||||
@@ -72,13 +103,17 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
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]);
|
||||
debugPrint('✅ [WebRTC] 收到视频流,连接到显示器');
|
||||
if (mounted) {
|
||||
setState(() => _renderer.srcObject = event.streams[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 🔥 SDP 协商
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer({'offerToReceiveVideo': 1});
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
@@ -88,39 +123,53 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
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<void> _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<WebRTCLocalPlayer> {
|
||||
).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<WebRTCLocalPlayer> {
|
||||
}
|
||||
|
||||
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,13 +253,16 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 🔥 已移除背景虚化层,避免遮挡上方内容
|
||||
// 主视频层的羽化效果由 _buildMainViewWithFeathering() 处理
|
||||
|
||||
// ② 主视频层(🔥 去掉双击,交给外部控制)
|
||||
// ② 主视频层(🔥 恢复双击切换前后视角)
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onDoubleTap: () {
|
||||
debugPrint('👆 [WebRTC] 双击屏幕,切换前后视角');
|
||||
widget.onDoubleTap?.call(); // 🔥 调用父组件回调
|
||||
},
|
||||
child: AspectRatio(aspectRatio: 16 / 9, child: _buildMainViewWithFeathering()),
|
||||
),
|
||||
),
|
||||
|
||||
// ③ 悬浮小窗 - 解决阻尼感的 1:1 稳定拖拽
|
||||
if (widget.showLeftPip) _buildFastPip(_leftPosNotifier, Alignment.bottomLeft, constraints, pipW, pipH),
|
||||
@@ -246,7 +284,6 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
// 极致丝滑的局部刷新组件
|
||||
Widget _buildFastPip(ValueNotifier<Offset> notifier, Alignment align, BoxConstraints constraints, double w, double h) {
|
||||
return ValueListenableBuilder<Offset>(
|
||||
valueListenable: notifier,
|
||||
@@ -257,7 +294,6 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
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);
|
||||
|
||||
@@ -34,4 +34,14 @@ abstract class DroneStationDataSource {
|
||||
VideoQualityType qualityType = VideoQualityType.adaptive,
|
||||
int videoExpire = 720000000,
|
||||
});
|
||||
|
||||
/// 暂停飞行任务(通过 flightTaskCommand 接口)
|
||||
Future<Map<String, dynamic>> pauseFlightTask({
|
||||
required String deviceSn,
|
||||
});
|
||||
|
||||
/// 返航(通过 flightTaskCommand 接口)
|
||||
Future<Map<String, dynamic>> returnHome({
|
||||
required String deviceSn,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,4 +330,88 @@ class DroneStationDataSourceImpl implements DroneStationDataSource {
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>) {
|
||||
throw Exception('响应数据格式错误');
|
||||
}
|
||||
|
||||
// 🔥 直接返回接口返回的 message,不自己拟定
|
||||
if (responseData['code'] != 200) {
|
||||
final message = responseData['message'] ?? '操作失败';
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>) {
|
||||
throw Exception('响应数据格式错误');
|
||||
}
|
||||
|
||||
// 🔥 直接返回接口返回的 message,不自己拟定
|
||||
if (responseData['code'] != 200) {
|
||||
final message = responseData['message'] ?? '操作失败';
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,28 @@ class DroneStationRepositoryImpl implements DroneStationRepository {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, Map<String, dynamic>>> 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<Either<Failure, Map<String, dynamic>>> returnHome({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
try {
|
||||
final result = await dataSource.returnHome(deviceSn: deviceSn);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,14 @@ abstract class DroneStationRepository {
|
||||
VideoQualityType qualityType,
|
||||
int videoExpire,
|
||||
});
|
||||
|
||||
/// 暂停飞行任务(通过 flightTaskCommand 接口)
|
||||
Future<Either<Failure, Map<String, dynamic>>> pauseFlightTask({
|
||||
required String deviceSn,
|
||||
});
|
||||
|
||||
/// 返航(通过 flightTaskCommand 接口)
|
||||
Future<Either<Failure, Map<String, dynamic>>> returnHome({
|
||||
required String deviceSn,
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,8 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
DroneTaskInfo? _droneTaskInfo;
|
||||
DroneStationBloc? _droneStationBloc;
|
||||
DroneOsdDataSource? _droneOsdDataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription; // 无人机 OSD
|
||||
StreamSubscription<DroneOsdEntity>? _stationOsdSubscription; // 🔥 机场 OSD
|
||||
Timer? _simulationTimer;
|
||||
List<LatLng> _trajectoryPoints = [];
|
||||
LatLng? _currentPosition;
|
||||
@@ -97,6 +98,7 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
_bloc.close();
|
||||
_droneStationBloc?.close();
|
||||
_osdSubscription?.cancel();
|
||||
_stationOsdSubscription?.cancel(); // 🔥 取消机场 OSD 订阅
|
||||
_droneOsdDataSource?.dispose();
|
||||
_mapController?.dispose();
|
||||
_destroyRtcEngine();
|
||||
@@ -160,6 +162,8 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
|
||||
_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<FloatBarWidget> {
|
||||
|
||||
// 加载视频流
|
||||
_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<FloatBarWidget> {
|
||||
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<FloatBarWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 处理机场 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; // 地球半径(米)
|
||||
|
||||
@@ -204,22 +204,53 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
// 取消按钮
|
||||
SizedBox(
|
||||
width: 100,
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Color(0xFFE5E6EB)),
|
||||
minimumSize: const Size(80, 36),
|
||||
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(
|
||||
// 确认创建按钮
|
||||
SizedBox(
|
||||
width: 140,
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: _onCreateTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
minimumSize: const Size(100, 36),
|
||||
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('确认创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -29,6 +31,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
bool _isLoading = false;
|
||||
final Dio _dio = Dio();
|
||||
|
||||
// 🔥 任务状态管理
|
||||
bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮)
|
||||
bool _isReturning = false; // 是否正在返航中
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -201,6 +207,147 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 暂停任务
|
||||
Future<void> _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<PauseFlightTaskUseCase>();
|
||||
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<void> _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<UpdateFlightTaskStatusUseCase>();
|
||||
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<void> _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<ReturnHomeUseCase>();
|
||||
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<DroneMissionControlPage> {
|
||||
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<DroneMissionControlPage> {
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -33,6 +33,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
// 无人机状态轮询计时器
|
||||
Timer? _droneStatusPollingTimer;
|
||||
|
||||
// 🔥 标记是否已经初始化过(用于判断是否从其他页面返回)
|
||||
bool _hasInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -44,11 +47,44 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
),
|
||||
);
|
||||
|
||||
// 🔥 标记已初始化
|
||||
_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<DroneStationDetailPage> {
|
||||
|
||||
/// 处理下拉刷新
|
||||
Future<void> _handleRefresh() async {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 开始下拉刷新');
|
||||
|
||||
// 🔥 创建一个 Completer 来等待 Bloc 状态更新
|
||||
final completer = Completer<void>();
|
||||
|
||||
// 监听 Bloc 状态变化
|
||||
final subscription = _bloc.stream.listen((state) {
|
||||
if (state is UAVDetailLoaded || state is UAVDetailError) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 重新加载无人机详情
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
@@ -223,9 +273,19 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
// 重置轮询计时器,使用新的状态
|
||||
// 🔥 已禁用自动轮询,无需重置
|
||||
// _scheduleDroneStatusPoll();
|
||||
|
||||
// 🔥 等待数据加载完成(最多等待5秒)
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时');
|
||||
},
|
||||
);
|
||||
|
||||
// 取消订阅
|
||||
subscription.cancel();
|
||||
|
||||
debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成');
|
||||
}
|
||||
|
||||
Widget _buildMonitorCard() {
|
||||
|
||||
@@ -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<DroneVideoControlPage> {
|
||||
final volc.IRTCRoomEventHandler _roomEventHandler =
|
||||
volc.IRTCRoomEventHandler();
|
||||
|
||||
// 🔥 实时轨迹相关状态
|
||||
MapController? _mapController;
|
||||
List<LatLng> _trajectoryPoints = [];
|
||||
LatLng? _currentPosition;
|
||||
double? _currentHeading;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription;
|
||||
DroneOsdDataSource? _droneOsdDataSource;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
|
||||
// 🔥 初始化 MQTT OSD 数据源
|
||||
_droneOsdDataSource = sl<DroneOsdDataSource>();
|
||||
_startOsdListening();
|
||||
|
||||
// 初始化事件处理器
|
||||
_initVolcEventHandlers();
|
||||
|
||||
@@ -110,6 +128,9 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_osdSubscription?.cancel();
|
||||
_droneOsdDataSource?.dispose();
|
||||
_mapController?.dispose();
|
||||
_destroyRtcEngine();
|
||||
_bloc.close();
|
||||
super.dispose();
|
||||
@@ -219,6 +240,147 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 开始监听 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<DroneVideoControlPage> {
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = state.message;
|
||||
_errorMessage = '暂无视频';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -537,11 +699,15 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
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,12 +1067,149 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapAndJoystick() {
|
||||
return Row(
|
||||
/// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
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: [
|
||||
Expanded(
|
||||
// 🔥 地图
|
||||
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(
|
||||
height: 160,
|
||||
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),
|
||||
@@ -918,12 +1221,197 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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: 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: 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<DroneVideoControlPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 160,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
@@ -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']}控制',
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
robot['alias'] != null && (robot['alias'] as String).isNotEmpty
|
||||
? robot['alias']
|
||||
: '暂无别名',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
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: [
|
||||
|
||||
@@ -112,7 +112,8 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
.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<RobotListView> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${weedingRobots.length}',
|
||||
'${allRobots.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
||||
@@ -70,7 +70,10 @@ class _DroneOsdCardState extends State<DroneOsdCard> {
|
||||
_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<DroneOsdCard> {
|
||||
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<DroneOsdCard> {
|
||||
? 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<DroneOsdCard> {
|
||||
'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),
|
||||
// },
|
||||
];
|
||||
|
||||
// 应用缓存逻辑:有新值则更新缓存,否则使用旧值
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -66,7 +66,10 @@ class _DroneStationOsdCardState extends State<DroneStationOsdCard> {
|
||||
_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<DroneStationOsdCard> {
|
||||
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)
|
||||
|
||||
@@ -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<RobotHeaderCard> {
|
||||
String _videoStreamUrl = '';
|
||||
int _currentViewIndex = 0; // 0=前, 1=后, 2=左, 3=右, 4=上
|
||||
|
||||
// 视角配置
|
||||
@@ -30,30 +30,7 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initVideoUrl();
|
||||
}
|
||||
|
||||
/// 初始化视频流 URL
|
||||
void _initVideoUrl() {
|
||||
final userState = context.read<AppUserCubit>().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,12 +124,43 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Colors.black, // 🔥 强制整个视频区域为黑色背景
|
||||
child: _videoStreamUrl.isNotEmpty
|
||||
child: BlocBuilder<AppUserCubit, AppUserState>(
|
||||
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(
|
||||
streamUrl: _videoStreamUrl,
|
||||
showLeftPip: false, // 不显示悬浮小窗
|
||||
key: ValueKey(videoStreamUrl),
|
||||
streamUrl: videoStreamUrl,
|
||||
showLeftPip: false,
|
||||
showRightPip: false,
|
||||
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
|
||||
mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment,
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
@@ -161,11 +169,13 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
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<RobotHeaderCard> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -199,9 +199,10 @@ class _UpdateCheckerState extends State<_UpdateChecker> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) context.read<UpdateCubit>().checkUpdate();
|
||||
});
|
||||
// TODO: 暂时注释掉应用启动时的自动更新检查
|
||||
// Future.delayed(const Duration(seconds: 2), () {
|
||||
// if (mounted) context.read<UpdateCubit>().checkUpdate();
|
||||
// });
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user