优化和路径规划新适配

This commit is contained in:
2026-07-11 16:46:53 +08:00
parent 2124f3bc02
commit 1a4d0aedfd
43 changed files with 2454 additions and 685 deletions

View File

@@ -58,4 +58,7 @@ class HttpApiConsts {
// 获取无人机详情
static const String getUAVDetail = "$baseUrl/iot/UAV/getUAVDetail";
// 飞行任务命令控制(暂停、返航等)
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
}

View File

@@ -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;
}

View File

@@ -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()),
);

View File

@@ -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 端口
}
}

View File

@@ -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');

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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');
}
}
}

View File

@@ -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};
}

View File

@@ -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?,
);
}

View File

@@ -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'}),
);

View File

@@ -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,41 +132,54 @@ class PathRepositoryImpl implements PathRepository {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final url =
Uri.parse(
'https://serviceri.satabot.com/iot/workRecord/selectByWorkName',
'http://1.95.137.212:8081/iot/workRecord/selectByWorkName',
).replace(
queryParameters: {'workName': workName, '_t': timestamp.toString()},
);
try {
final response = await http.get(url);
final response = await http.get(url).timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
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) {
if (e is Map) {
return Map<String, dynamic>.from(e);
}
throw Exception('List item is not a Map: ${e.runtimeType}');
}).toList();
} else if (rawData is Map) {
records = [Map<String, dynamic>.from(rawData)];
} else {
throw Exception(
'Unexpected data type for "data": ${rawData.runtimeType}',
);
// 判断响应格式:XML 还是 JSON
final trimmed = response.body.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// JSON 格式(原有逻辑)
Map<String, dynamic> data;
try {
data = jsonDecode(response.body) as Map<String, dynamic>;
} catch (formatError) {
print('[selectWorkRecordByName] JSON解析失败: $formatError');
throw Exception('JSON解析失败: $formatError');
}
return records;
if (data['code'] == 200 && data.containsKey('data')) {
final dynamic rawData = data['data'];
List<Map<String, dynamic>> records;
if (rawData is List) {
records = rawData.map((e) {
if (e is Map) {
return Map<String, dynamic>.from(e);
}
throw Exception('List item is not a Map: ${e.runtimeType}');
}).toList();
} else if (rawData is Map) {
records = [Map<String, dynamic>.from(rawData)];
} else {
throw Exception(
'Unexpected data type for "data": ${rawData.runtimeType}',
);
}
return records;
} else {
throw Exception('API error: ${data['msg'] ?? 'Unknown'}');
}
} else {
throw Exception('API error: ${data['msg'] ?? 'Unknown'}');
// XML 格式:使用 XML 解析逻辑
print('[selectByWorkName] 检测到 XML 格式响应');
return _parseXmlSelectByNameResponse(response.body);
}
} else {
throw Exception(
@@ -177,7 +191,75 @@ class PathRepositoryImpl implements PathRepository {
}
}
/// 根据场站ID查询工作记录列表(XML格式)
/// 解析 selectByWorkName 的 XML 响应
/// 返回 List<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 body = jsonEncode({
final dio = sl<Dio>();
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');
}
}

View File

@@ -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 逐个发点逻辑结束 ========== */
}
}

View File

@@ -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,
});
}

View File

@@ -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()));
}

View File

@@ -138,17 +138,19 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
return;
}
final result = await _cancelTaskUseCase.call(
CancelTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
siteId: siteId,
),
final params = CancelTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
siteId: siteId,
);
_logger.logWithLevel('[取消任务] 请求: POST /iot/deviceTask/cancelTask');
_logger.logWithLevel('[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
final result = await _cancelTaskUseCase.call(params);
result.fold(
(failure) {
_logger.logWithLevel('[取消任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 取消任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
@@ -158,6 +160,7 @@ class DeviceTaskCubit extends Cubit<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(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
siteId: siteId,
),
final params2 = PauseTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
siteId: siteId,
);
_logger.logWithLevel('[暂停任务] 请求: POST /iot/deviceTask/pauseTask');
_logger.logWithLevel('[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
final result = await _pauseTaskUseCase.call(params2);
result.fold(
(failure) {
_logger.logWithLevel('[暂停任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 暂停任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
@@ -224,6 +229,7 @@ class DeviceTaskCubit extends Cubit<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(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
siteId: siteId,
),
final params3 = RecoveryTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
siteId: siteId,
);
_logger.logWithLevel('[恢复任务] 请求: POST /iot/deviceTask/recoveryTask');
_logger.logWithLevel('[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
final result = await _recoveryTaskUseCase.call(params3);
result.fold(
(failure) {
_logger.logWithLevel('[恢复任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 恢复任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
@@ -290,6 +298,7 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
));
},
(data) {
_logger.logWithLevel('[恢复任务] 响应成功: $data');
_logger.logWithLevel('✅ 恢复任务成功: $data');
emit(state.copyWith(
isLoading: false,

View File

@@ -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));
},
);

View File

@@ -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 状态,准备追加图表数据');
_appendChartData(state);
// 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发
if (_hasSeededFromCache) {
_hasSeededFromCache = false; // 只跳过第一次
debugPrint('📈 [UI] 跳过首次 BlocBuilder 追加(已由 initState 缓存初始化)');
} else {
_appendChartData(state);
}
// 🔥 关键:收到数据后立即重置超时计时器
_startDataTimeoutTimer();
// 如果之前是超时状态,现在恢复

File diff suppressed because it is too large Load Diff

View File

@@ -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();

View File

@@ -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');
}
}

View File

@@ -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,

View File

@@ -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();
},
);
},
),

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;
// 🔥 URL 缓存:用于判断是否需要换线
String? _currentStreamUrl;
bool _isInitialized = false;
// 使用 ValueNotifier 配合局部刷新,提升拖拽性能
// 画中画位置管理
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
bool _isPosInitialized = false;
@@ -38,47 +44,76 @@ 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;
}
_peerConnection = await createPeerConnection({
"sdpSemantics": "unified-plan",
"iceServers": [
{"urls": "stun:stun.l.google.com:19302"},
],
});
await _peerConnection!.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
);
_peerConnection!.onTrack = (RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
if (mounted) setState(() => _renderer.srcObject = event.streams[0]);
}
};
// 2️⃣ 如果 URL 没变,不换线(防止重复连接)
if (_currentStreamUrl == widget.streamUrl && _peerConnection != null) {
debugPrint('✅ [WebRTC] URL 未变化,保持当前连接');
return;
}
// 3️⃣ 拔掉旧线(彻底释放旧连接)
await _disconnectSignal();
// 4️⃣ 插上新线(创建新连接)
try {
debugPrint('🔌 [WebRTC] 建立新连接...');
_peerConnection = await createPeerConnection({
"sdpSemantics": "unified-plan",
"iceServers": [
{"urls": "stun:stun.l.google.com:19302"},
],
});
await _peerConnection!.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
);
// 🔥 收到视频流时,直接连接到显示器
_peerConnection!.onTrack = (RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
debugPrint('✅ [WebRTC] 收到视频流,连接到显示器');
if (mounted) {
setState(() => _renderer.srcObject = event.streams[0]);
}
}
};
// 🔥 SDP 协商
RTCSessionDescription offer = await _peerConnection!.createOffer({'offerToReceiveVideo': 1});
await _peerConnection!.setLocalDescription(offer);
@@ -88,39 +123,53 @@ class _WebRTCLocalPlayerState extends State<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,12 +253,15 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
return Stack(
children: [
// 🔥 已移除背景虚化层,避免遮挡上方内容
// 主视频层的羽化效果由 _buildMainViewWithFeathering() 处理
// ② 主视频层(🔥 去掉双击,交给外部控制)
// ② 主视频层(🔥 恢复双击切换前后视角)
Center(
child: AspectRatio(aspectRatio: 16 / 9, child: _buildMainViewWithFeathering()),
child: GestureDetector(
onDoubleTap: () {
debugPrint('👆 [WebRTC] 双击屏幕,切换前后视角');
widget.onDoubleTap?.call(); // 🔥 调用父组件回调
},
child: AspectRatio(aspectRatio: 16 / 9, child: _buildMainViewWithFeathering()),
),
),
// ③ 悬浮小窗 - 解决阻尼感的 1:1 稳定拖拽
@@ -246,7 +284,6 @@ class _WebRTCLocalPlayerState extends State<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);

View File

@@ -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,
});
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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,
});
}

View File

@@ -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; // 地球半径(米)

View File

@@ -204,22 +204,53 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFE5E6EB)),
minimumSize: const Size(80, 36),
// 取消按钮
SizedBox(
width: 100,
height: 44,
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFE5E6EB)),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'取消',
style: TextStyle(
fontSize: 14,
color: Color(0xFF4E5969),
fontWeight: FontWeight.w500,
),
),
),
child: const Text('取消'),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _onCreateTask,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
minimumSize: const Size(100, 36),
// 确认创建按钮
SizedBox(
width: 140,
height: 44,
child: ElevatedButton(
onPressed: _onCreateTask,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: const Text(
'确认创建',
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
child: const Text('确认创建'),
),
],
),

View File

@@ -9,6 +9,8 @@ import '../../domain/entities/flight_task_entity.dart';
import '../../domain/entities/flight_task_detail_entity.dart';
import '../../domain/entities/drone_station_entity.dart';
import '../../domain/usecases/update_flight_task_status_usecase.dart';
import '../../domain/usecases/pause_flight_task_usecase.dart';
import '../../domain/usecases/return_home_usecase.dart';
import '../bloc/drone_station_bloc.dart';
import '../float_bar/view/float_bar_widget.dart';
@@ -28,6 +30,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
FlightTaskDetailEntity? _detailTask; // 详情数据
bool _isLoading = false;
final Dio _dio = Dio();
// 🔥 任务状态管理
bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮)
bool _isReturning = false; // 是否正在返航中
@override
void initState() {
@@ -201,6 +207,147 @@ class _DroneMissionControlPageState extends State<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),
),
),
),

View File

@@ -32,6 +32,9 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
// 无人机状态轮询计时器
Timer? _droneStatusPollingTimer;
// 🔥 标记是否已经初始化过(用于判断是否从其他页面返回)
bool _hasInitialized = false;
@override
void initState() {
@@ -43,12 +46,45 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
deviceSn: widget.station.deviceSn,
),
);
// 🔥 标记已初始化
_hasInitialized = true;
// 启动无人机状态轮询(每5秒刷新一次)
// 🔥 已禁用自动轮询,改为手动下拉刷新
// _startDroneStatusPolling();
}
/// 🔥 页面重新激活时调用(从其他页面返回时)
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新)
if (_hasInitialized && _bloc.state is UAVDetailLoaded) {
debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据');
// 延迟一下再刷新,避免与 Bloc 状态更新冲突
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
_refreshData();
}
});
}
}
/// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新)
void _refreshData() {
if (!mounted) return;
debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据');
_bloc.add(
UAVDetailLoad(
gatewaySn: widget.station.gatewaySn,
deviceSn: widget.station.deviceSn,
),
);
}
@override
void dispose() {
_bloc.close();
@@ -216,6 +252,20 @@ class _DroneStationDetailPageState extends State<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() {

View File

@@ -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,29 +1067,351 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
);
}
/// 🔥 实时轨迹地图(放在视频和飞行数据之间)
Widget _buildTrajectoryMap() {
debugPrint('🗺️ [DroneVideoControlPage] 构建地图轨迹组件');
debugPrint(' currentPosition: $_currentPosition');
debugPrint(' currentHeading: $_currentHeading');
debugPrint(' trajectoryPoints.length: ${_trajectoryPoints.length}');
return Container(
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
// 🔥 地图
FlutterMap(
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
options: MapOptions(
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
initialZoom: 18,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
),
children: [
// 高德地图瓦片(最底层)
TileLayer(
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
subdomains: ['1', '2', '3', '4'],
userAgentPackageName: 'com.example.app',
),
// 🔥 轨迹线(中间层)
if (_trajectoryPoints.length >= 1)
PolylineLayer(
polylines: [
Polyline(
points: _trajectoryPoints,
color: const Color(0xFF00B42A).withOpacity(0.9),
strokeWidth: 4,
borderColor: Colors.white,
borderStrokeWidth: 2,
),
],
),
// 🔥 无人机当前位置标记(最上层)
if (_currentPosition != null)
MarkerLayer(
markers: [
Marker(
key: const ValueKey('drone_marker'),
point: _currentPosition!,
width: 40,
height: 40,
child: Transform.rotate(
angle: (_currentHeading ?? 0) * pi / 180,
alignment: Alignment.center,
child: const Icon(
Icons.flight,
color: Color(0xFF165DFF),
size: 32,
),
),
),
],
),
],
),
// 🔥 GPS定位标签
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
const Text(
'GPS定位',
style: TextStyle(fontSize: 10, color: Colors.white),
),
],
),
),
),
// 🔥 轨迹点数
if (_trajectoryPoints.isNotEmpty)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'轨迹点: ${_trajectoryPoints.length}',
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
// 🔥 等待定位提示
if (_currentPosition == null)
const Positioned(
bottom: 8,
right: 8,
child: Text(
'等待定位...',
style: TextStyle(fontSize: 10, color: Colors.black54),
),
),
],
),
),
);
}
/// 🔥 摇杆控制(单独一行)
Widget _buildJoystickControl() {
return Container(
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFC9CDD4),
shape: BoxShape.circle,
),
),
Positioned(
top: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_drop_up,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
Positioned(
bottom: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_drop_down,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
Positioned(
left: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_left,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
Positioned(
right: 16,
child: IconButton(
icon: const Icon(
Icons.arrow_right,
size: 32,
color: Color(0xFF4E5969),
),
onPressed: () {},
),
),
],
),
);
}
Widget _buildMapAndJoystick() {
return Row(
children: [
Expanded(
child: Container(
height: 160,
height: 200,
decoration: BoxDecoration(
color: Colors.white,
color: const Color(0xFFF7F8FA),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/images/xunjian.png',
fit: BoxFit.cover,
width: double.infinity,
child: Stack(
children: [
// 🔥 地图
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
initialZoom: 18,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
),
children: [
// 高德地图瓦片(最底层)
TileLayer(
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
subdomains: ['1', '2', '3', '4'],
userAgentPackageName: 'com.example.app',
),
// 🔥 轨迹线(中间层)
if (_trajectoryPoints.length >= 1)
PolylineLayer(
polylines: [
Polyline(
points: _trajectoryPoints,
color: const Color(0xFF00B42A).withOpacity(0.9),
strokeWidth: 4,
borderColor: Colors.white,
borderStrokeWidth: 2,
),
],
),
// 🔥 无人机当前位置标记(最上层)
if (_currentPosition != null)
MarkerLayer(
markers: [
Marker(
key: const ValueKey('drone_marker'),
point: _currentPosition!,
width: 40,
height: 40,
child: Transform.rotate(
angle: (_currentHeading ?? 0) * pi / 180,
alignment: Alignment.center,
child: const Icon(
Icons.flight,
color: Color(0xFF165DFF),
size: 32,
),
),
),
],
),
],
),
// 🔥 GPS定位标签
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
const Text(
'GPS定位',
style: TextStyle(fontSize: 10, color: Colors.white),
),
],
),
),
),
// 🔥 轨迹点数
if (_trajectoryPoints.isNotEmpty)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'轨迹点: ${_trajectoryPoints.length}',
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
// 🔥 等待定位提示
if (_currentPosition == null)
const Positioned(
bottom: 8,
right: 8,
child: Text(
'等待定位...',
style: TextStyle(fontSize: 10, color: Colors.black54),
),
),
],
),
),
),
@@ -931,7 +1419,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
const SizedBox(width: 12),
Expanded(
child: Container(
height: 160,
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),

View File

@@ -25,13 +25,32 @@ class RobotControlPage extends StatelessWidget {
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
onPressed: () => Navigator.pop(context),
),
title: Text(
'${robot['type']}控制',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
title: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
robot['alias'] != null && (robot['alias'] as String).isNotEmpty
? robot['alias']
: '暂无别名',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2129),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
Text(
robot['name'] ?? '', // 🔥 使用 name 字段(长序列号 deviceName)
style: const TextStyle(
fontSize: 11,
color: Color(0xFF86909C),
),
textAlign: TextAlign.center,
maxLines: 2,
softWrap: true,
),
],
),
centerTitle: true,
actions: [

View File

@@ -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,

View File

@@ -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),
// },
];
// 应用缓存逻辑:有新值则更新缓存,否则使用旧值

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,26 +124,59 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
aspectRatio: 16 / 9,
child: Container(
color: Colors.black, // 🔥 强制整个视频区域为黑色背景
child: _videoStreamUrl.isNotEmpty
? WebRTCLocalPlayer(
streamUrl: _videoStreamUrl,
showLeftPip: false, // 不显示悬浮小窗
showRightPip: false,
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.videocam_off, size: 48, color: Colors.white54),
const SizedBox(height: 12),
Text(
'无视频信号',
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
child: BlocBuilder<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(
key: ValueKey(videoStreamUrl),
streamUrl: videoStreamUrl,
showLeftPip: false,
showRightPip: false,
mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment,
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.videocam_off, size: 48, color: Colors.white54),
const SizedBox(height: 12),
Text(
'暂无视频',
style: TextStyle(color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
),
),
);
},
),
),
),
@@ -224,3 +234,5 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
);
}
}

View File

@@ -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,
),
],
),

View File

@@ -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