diff --git a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart index d995b8da..1e747df2 100644 --- a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart +++ b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart @@ -46,19 +46,19 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource { }) async { if (_isDisposed) return; - // 同一设备(SN 相同):只增加引用计数,共享订阅 + // 🔥 同一设备(SN 相同):只增加引用计数,不重新订阅 if (_listenerCount > 0 && _deviceSn == deviceSn && - _gatewaySn == gatewaySn && - _subscription != null) { + _gatewaySn == gatewaySn) { _listenerCount++; - debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount'); + debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount (设备相同,不重新订阅)'); return; } - // 切换到不同设备:强制清理旧订阅 - // 不管引用计数多少,SN 变了就必须先取消旧订阅再订阅新的 - if (_deviceSn != null || _gatewaySn != null) { + // 🔥 切换到不同设备或首次订阅时才清理旧订阅 + // 重要:只有在设备不同的情况下才调用 stopListening,避免误取消当前订阅 + final needCleanup = _deviceSn != null || _gatewaySn != null; + if (needCleanup) { // 重置引用计数为 1,让 stopListening 能真正取消 MQTT 订阅 _listenerCount = 1; await stopListening(); diff --git a/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart b/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart new file mode 100644 index 00000000..44ba937a --- /dev/null +++ b/lib/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart @@ -0,0 +1,133 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +/// GeoServer 基地址(公司端) +const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver'; + +/// 首期苏州区域图层标识 +const String kSuzhouLayerName = 'workSpace:suzhou'; + +/// 苏州图层实际覆盖范围(取自 GetCapabilities,约 250m × 190m 的小地块) +final LatLngBounds kSuzhouBounds = LatLngBounds( + const LatLng(31.082450793459877, 120.83906484264836), + const LatLng(31.084141983135456, 120.84164596446476), +); + +/// 场站中心点(真实地块中心) +const LatLng kSuzhouCenter = LatLng(31.083296, 120.840355); + +/// 叠加层取瓦片方式开关: +/// - false(当前):WMS GetMap 兜底,不依赖 GWC 缓存 +/// - true:WMTS GetTile(公司补配 GWC EPSG:3857 gridset 并 Seed 后切换) +const bool kUseWmtsTile = false; + +/// WMS 取图提供器: +/// 实测 GeoServer 对 workSpace:suzhou 只有原生 EPSG:4326 能出图, +/// 请求 EPSG:3857(WMS 1.1.1 / 1.3.0 均验证过)返回纯白图。 +/// 因此这里按瓦片 XYZ 反算经纬度 bbox,以 EPSG:4326 请求 WMS。 +class GeoWmsProvider extends TileProvider { + GeoWmsProvider({this.maxLevel = 21}); + + final int maxLevel; + + /// Web 墨卡托地球周长(米) + static const double _full = 40075016.685578488; + + /// Web 墨卡托 Y(米)→ 纬度(度) + static double _mercatorYToLat(double y) { + final t = y / 6378137.0; + final sinh = (math.exp(t) - math.exp(-t)) / 2; // dart:math 无 sinh,手动实现 + return math.atan(sinh) * 180 / math.pi; + } + + @override + ImageProvider getImage( + TileCoordinates coordinates, + TileLayer options, + ) { + if (coordinates.z > maxLevel) { + return MemoryImage(TileProvider.transparentImage); + } + + final span = _full / math.pow(2, coordinates.z); + final westM = -_full / 2 + coordinates.x * span; + final eastM = westM + span; + final northM = _full / 2 - coordinates.y * span; + final southM = northM - span; + + final westLng = westM / _full * 360; + final eastLng = eastM / _full * 360; + final southLat = _mercatorYToLat(southM); + final northLat = _mercatorYToLat(northM); + + // 与图层实际范围不相交的瓦片直接返回透明图,不发无谓请求 + final intersects = westLng < kSuzhouBounds.east && + eastLng > kSuzhouBounds.west && + southLat < kSuzhouBounds.north && + northLat > kSuzhouBounds.south; + if (!intersects) { + return MemoryImage(TileProvider.transparentImage); + } + + final url = Uri.parse('$kGeoserverBase/wms').replace( + queryParameters: { + 'SERVICE': 'WMS', + 'REQUEST': 'GetMap', + 'VERSION': '1.1.1', + 'LAYERS': kSuzhouLayerName, + 'BBOX': '${westLng.toStringAsFixed(6)},${southLat.toStringAsFixed(6)},' + '${eastLng.toStringAsFixed(6)},${northLat.toStringAsFixed(6)}', + 'WIDTH': '256', + 'HEIGHT': '256', + 'FORMAT': 'image/png', + 'TRANSPARENT': 'true', + 'SRS': 'EPSG:4326', + 'STYLES': '', + }, + ); + return NetworkImage(url.toString()); + } +} + +/// GeoServer 苏州无人机高清影像叠加层(WGS84 基准,覆盖在 ESRI 底图上) +/// +/// 地块很小(约 250m),zoom 16 以上才有意义,默认 minZoom = 16 +class GeoSuzhouOverlay extends StatelessWidget { + const GeoSuzhouOverlay({super.key, this.minZoom = 16, this.maxZoom = 21}); + + /// 小于该级别不请求瓦片(地块太小,低层级看不见) + final double minZoom; + + /// 大于该级别不请求瓦片 + final double maxZoom; + + @override + Widget build(BuildContext context) { + if (kUseWmtsTile) { + // === WMTS 版:公司把 EPSG:3857 gridset 加入 GWC 并 Seed 后生效 === + return TileLayer( + urlTemplate: '$kGeoserverBase/gwc/service/wmts' + '?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0' + '&LAYER=$kSuzhouLayerName&TILEMATRIXSET=EPSG:3857' + '&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png', + userAgentPackageName: 'com.example.app', + minZoom: minZoom, + maxZoom: maxZoom, + maxNativeZoom: maxZoom.toInt(), + tileBounds: kSuzhouBounds, + ); + } + // === WMS 版(当前兜底):按原生 EPSG:4326 请求,GeoServer 实时渲染 === + return TileLayer( + tileProvider: GeoWmsProvider(maxLevel: maxZoom.toInt()), + userAgentPackageName: 'com.example.app', + minZoom: minZoom, + maxZoom: maxZoom, + maxNativeZoom: maxZoom.toInt(), + tileBounds: kSuzhouBounds, + ); + } +} diff --git a/lib/features/home/presentation/widgets/map/test_amap_page.dart b/lib/features/home/presentation/widgets/map/test_amap_page.dart index 55103dd1..1780436b 100644 --- a/lib/features/home/presentation/widgets/map/test_amap_page.dart +++ b/lib/features/home/presentation/widgets/map/test_amap_page.dart @@ -156,11 +156,13 @@ class _AmapFlutterMapPageState extends State { interactiveFlags: InteractiveFlag.all, ), children: [ + // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) + // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 TileLayer( - urlTemplate: - "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}&key=$amapKey", - subdomains: const ['1', '2', '3', '4'], - tileProvider: NetworkTileProvider(), + urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + userAgentPackageName: 'com.example.app', + minZoom: 0, + maxZoom: 19, ), MarkerLayer( markers: [ diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index 429fad14..fe24ef93 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:math'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -48,6 +49,7 @@ import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/trace import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/CenterLocation.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/HeadingPointer.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/dji_tile_layer.dart'; +import 'package:maibu_satabot_v2/features/home/presentation/widgets/map/geoserver_suzhou_layer.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/obsToast.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/startpoint_area.dart'; import 'package:image/image.dart' as _image; // 注意命名空间冲突,使用 as img @@ -490,18 +492,15 @@ class _MapPageEnterpriseState extends State { } // 2. 关键:同步机器人模式坐标(保存时边框不丢失) + // ⚠️ 地图已切换为 WGS84,不再需要坐标转换 if (_currentRobotMode == RobotMode.point && _robotModeWgsPoints.isEmpty) { - _robotModeWgsPoints = _markedPoints.map((p) { - return gcj02ToWgs84(p.latitude, p.longitude); - }).toList(); + _robotModeWgsPoints = List.from(_markedPoints); // 直接使用 WGS84 坐标 } // 兜底:Robot模式下_robotModeWgsPoints缓存未写入时,从_markedPoints恢复 if (_currentRobotMode == RobotMode.robot && _robotModeWgsPoints.isEmpty && _markedPoints.isNotEmpty) { - _robotModeWgsPoints = _markedPoints.map((p) { - return gcj02ToWgs84(p.latitude, p.longitude); - }).toList(); + _robotModeWgsPoints = List.from(_markedPoints); // 直接使用 WGS84 坐标 } if (mounted) { @@ -931,7 +930,8 @@ class _MapPageEnterpriseState extends State { .toList(); } return _markedPoints.map((point) { - final wgs84 = gcj02ToWgs84(point.latitude, point.longitude); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + final wgs84 = LatLng(point.latitude, point.longitude); return {'lng': wgs84.longitude, 'lat': wgs84.latitude}; }).toList(); } @@ -944,14 +944,8 @@ class _MapPageEnterpriseState extends State { 'outer': _currentWorkMode == WorkMode.custom ? [] // 自定义模式 outer 为空 : _markedPointsToWgs(), // 弓字模式 outer 为打点坐标 - // 🔥 对齐 Web 端:弓字模式 path=API返回,自定义模式 path=打点 - // 旧逻辑:自定义模式 path 为空数组 - 'path': _currentWorkMode == WorkMode.custom - ? _markedPointsToWgs() // 自定义模式 path 为打点坐标 - : typedPathList // 弓字模式 path 为 API 返回的路径 - .map((point) => {'lng': point.longitude, 'lat': point.latitude}) - .toList(), - + // 🔥 统一使用用户手动打点的边界点,不再上传算法清扫轨迹点 + 'path': _markedPointsToWgs(), 'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值 }; @@ -1425,10 +1419,8 @@ class _MapPageEnterpriseState extends State { // 保存当前障碍物组(Robot模式下直接用WGS84坐标) if (_currentRobotMode == RobotMode.robot && _robotModeObsWgsPoints.isNotEmpty) { - final gcjPoints = _robotModeObsWgsPoints - .map((wgs) => wgs84ToGcj02(wgs.latitude, wgs.longitude)) - .toList(); - _obstacleHoles.add(gcjPoints); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + _obstacleHoles.add(List.from(_robotModeObsWgsPoints)); _obstacleWgsHoles.add(List.from(_robotModeObsWgsPoints)); } else { _obstacleHoles.add(List.from(_currentObstaclePoints)); @@ -1751,14 +1743,16 @@ class _MapPageEnterpriseState extends State { double? lat = safeToDouble(item['lat']); double? lon = safeToDouble(item['lng'] ?? item['lon']); if (lat != null && lon != null) { - newPathPoints.add(convertWGS84ToGCJ02(lat, lon)); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + newPathPoints.add(LatLng(lat, lon)); } } for (var item in outerList) { double? lng = safeToDouble(item['lng'] ?? item['lon']); double? lat = safeToDouble(item['lat']); if (lng != null && lat != null) { - newOuterPoints.add(convertWGS84ToGCJ02(lat, lng)); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + newOuterPoints.add(LatLng(lat, lng)); } } @@ -1871,26 +1865,28 @@ class _MapPageEnterpriseState extends State { ) { _onDeviceData(lng, lat, obfFlag, headingStatus, controlMode); - final gcjPoint = wgs84ToGcj02(lat, lng); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + final currentPoint = LatLng(lat, lng); //final initCenter = bd09ToGcj02( // '120.81992468426961', // 经度 // '32.04532826114155', //); - // 🔥 修复1:初始化坐标改为直接使用有效GCJ02坐标,避免BD09转换风险 + // 🔥 修复1:初始化坐标改为直接使用有效WGS84坐标 final initCenter = _getDefaultValidCenter(); _currentWgsLatLng = LatLng(lat, lng); _traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE); tracePoint = _traceManager.getTracePoint(); - gctracePoint = batchWgs84ToGcj02(tracePoint!); + // ⚠️ 地图已切换为 WGS84,直接使用原始轨迹点 + gctracePoint = List.from(tracePoint!); // _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}"); // _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 () - //_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新 - if (_isValidLatLng(gcjPoint.latitude, gcjPoint.longitude)) { - _currentLatLng = gcjPoint; + //_currentLatLng = currentPoint; // ✅ 状态变量在setState内更新 + if (_isValidLatLng(currentPoint.latitude, currentPoint.longitude)) { + _currentLatLng = currentPoint; } else { _currentLatLng = null; } @@ -1909,24 +1905,20 @@ class _MapPageEnterpriseState extends State { onTap: (_, __) {}, // 空实现,禁用地图点击响应 ), children: [ - /// 高德瓦片(GCJ-02) + /// ESRI World Imagery(WGS84 坐标系,全球高清卫星图) + /// 替代高德地图(GCJ02),统一坐标系,无需坐标转换 TileLayer( - //urlTemplate: - // "https://webst02.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=2&style=7&key=bbb1f0f20eed6bf679eddf2625630aba", - urlTemplate: - 'https://webrd02.is.autonavi.com/appmaptile' - '?style=8&x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1' - '&key=bbb1f0f20eed6bf679eddf2625630aba', - // 🔥 修复:适配高DPI(替代pixelRatio) - // tileSize: 512, // 高清瓦片尺寸(默认256,改为512适配scale=2) - tileProvider: NetworkTileProvider( - // 自定义瓦片请求头(可选,提升兼容性) - headers: { - 'Referer': 'https://www.amap.com', // 高德要求的Referer - }, - ), - maxZoom: 18, + urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + userAgentPackageName: 'com.example.app', + minZoom: 0, + maxZoom: 19, ), + + /// GeoServer 苏州无人机高清影像叠加层(WGS84 基准,无偏移) + /// 实际地块很小(约 250m,中心 31.0833/120.8404),需 zoom >= 16 才能看到 + /// 当前 WMS 兜底(原生 EPSG:4326);公司补配 GWC EPSG:3857 后切 WMTS + /// 切换位置:geoserver_suzhou_layer.dart 的 kUseWmtsTile 开关 + GeoSuzhouOverlay(), //if (isDjMapShow) DJITileLayer(stationName: _currentStation.name, mapController: _mapController), // 绘制path折线(Line模式) @@ -2512,8 +2504,87 @@ class _MapPageEnterpriseState extends State { return; } - // 🔥 步骤3: 任务确认成功,更新UI状态 - debugPrint('🎯 [开始作业] 步骤3: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}'); + // 🔥 新增:步骤2.5 - 调用 getTaskPath 接口获取完整路径数据 + debugPrint('📍 [开始作业] 步骤2.5: 调用 getTaskPath 接口获取路径数据'); + final currentTaskId = taskCubit.state.currentTaskId; + bool pathFetchSuccess = false; + List? fetchedPathPoints; + + try { + final dio = sl(); + final response = await dio.get( + 'http://1.95.137.212:8081/iot/taskPath/getTaskPath', + queryParameters: { + 'taskId': currentTaskId, + }, + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + final code = data['code']; + final codeValue = code is int ? code : int.tryParse(code?.toString() ?? ''); + + if (codeValue == 200) { + final pathData = data['data']; + debugPrint('✅ [开始作业] getTaskPath 接口调用成功, data类型: ${pathData.runtimeType}'); + + if (pathData != null && pathData is Map) { + final pathList = pathData['path']; + if (pathList != null && pathList is List && pathList.isNotEmpty) { + debugPrint('✅ [开始作业] 获取到路径点数: ${pathList.length}'); + + // 将路径点渲染到地图上 + fetchedPathPoints = pathList.map((point) { + final lat = (point['lat'] as num?)?.toDouble() ?? 0.0; + final lng = (point['lng'] ?? point['lon'] as num?)?.toDouble() ?? 0.0; + return LatLng(lat, lng); + }).toList(); + + // 渲染路径到地图 + setState(() { + gcjPathPoints = fetchedPathPoints!; + typedPathList = fetchedPathPoints!.map((latLng) { + return work_area_model.DeviceAddPathPointModel( + latitude: latLng.latitude, + longitude: latLng.longitude, + ); + }).toList(); + }); + + pathFetchSuccess = true; + debugPrint('✅ [开始作业] 路径已渲染到地图'); + } else { + debugPrint('⚠️ [开始作业] 路径数据为空'); + } + } else { + debugPrint('⚠️ [开始作业] 路径数据结构格式错误'); + } + } else { + debugPrint('❌ [开始作业] getTaskPath 接口返回业务失败: $code'); + } + } else { + debugPrint('❌ [开始作业] getTaskPath 接口HTTP失败: ${response.statusCode}'); + } + } catch (e) { + debugPrint('❌ [开始作业] getTaskPath 接口调用异常: $e'); + } + + // 🔥 判断路径获取结果 + if (!pathFetchSuccess) { + debugPrint('❌ [开始作业] 路径数据获取失败或为空,关闭loading,弹出提示,终止作业启动'); + setState(() { + isStopWork = false; + isStartWork = false; + _workStatus = WorkStatus.idle; + }); + _showPageToast( + message: '获取路径数据失败,无法启动作业', + type: ToastType.error, + ); + return; + } + + debugPrint('🎯 [开始作业] 步骤4: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}'); setState(() { isStopWork = false; isStartWork = true; @@ -2521,8 +2592,8 @@ class _MapPageEnterpriseState extends State { }); context.read().updateAppState(AppState.routePlanning); - // 🔥 步骤4: 刷新任务池(保护 taskId 不被覆盖) - debugPrint('🔄 [开始作业] 步骤4: 刷新任务池'); + // 🔥 步骤5: 刷新任务池(保护 taskId 不被覆盖) + debugPrint('🔄 [开始作业] 步骤5: 刷新任务池'); final _confirmedTaskId = taskCubit.state.currentTaskId; await taskCubit.fetchAndFilterTask(deviceId); if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) { @@ -2530,8 +2601,8 @@ class _MapPageEnterpriseState extends State { debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId'); } - // 🔥 步骤5: 启动MQTT到达点监听 - debugPrint('📡 [开始作业] 步骤5: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId'); + // 🔥 步骤6: 启动MQTT到达点监听 + debugPrint('📡 [开始作业] 步骤6: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId'); try { await context.read().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!); debugPrint('✅ [开始作业] MQTT到达点监听已启动'); @@ -2539,8 +2610,8 @@ class _MapPageEnterpriseState extends State { debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)'); } - // 🔥 步骤6: 持久化状态 - debugPrint('💾 [开始作业] 步骤6: 持久化状态, taskId=${taskCubit.state.currentTaskId}'); + // 🔥 步骤7: 持久化状态 + debugPrint('💾 [开始作业] 步骤7: 持久化状态, taskId=${taskCubit.state.currentTaskId}'); _showPageToast(message: "作业已开始", type: ToastType.success); _saveDataToLocal(); @@ -3511,18 +3582,12 @@ class _MapPageEnterpriseState extends State { firstPointWgs = _robotModeWgsPoints.first; } else if (_currentRobotMode == RobotMode.point && _markedPoints.isNotEmpty) { - LatLng firstPointGcj = _markedPoints.first; - firstPointWgs = gcj02ToWgs84( - firstPointGcj.latitude, - firstPointGcj.longitude, - ); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + firstPointWgs = _markedPoints.first; } else if (_markedPoints.isNotEmpty) { // 兜底:_robotModeWgsPoints 为空但_markedPoints有数据(新缓存key未写入等场景) - LatLng firstPointGcj = _markedPoints.first; - firstPointWgs = gcj02ToWgs84( - firstPointGcj.latitude, - firstPointGcj.longitude, - ); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + firstPointWgs = _markedPoints.first; } else { if (showTips) { _showPageToast(message: '参考点坐标为空,请先添加作业区域打点!', type: ToastType.error); @@ -3546,20 +3611,19 @@ class _MapPageEnterpriseState extends State { ); }).toList(); } else if (_markedPoints.isNotEmpty) { - // robot模式但_robotModeWgsPoints为空时,兜底用_markedPoints转换 + // robot模式但_robotModeWgsPoints为空时,用_markedPoints(已是WGS84) outerPositions = _markedPoints.map((latLng) { - final wgs84Point = gcj02ToWgs84(latLng.latitude, latLng.longitude); return work_area_model.Position( - lat: wgs84Point.latitude, - lon: wgs84Point.longitude, + lat: latLng.latitude, + lon: latLng.longitude, ); }).toList(); } gcjOuterPoints = outerPositions.map((point) { double wgs84Lat = point.lat; double wgs84Lon = point.lon; - final gcjPoint = wgs84ToGcj02(wgs84Lat, wgs84Lon); - return LatLng(gcjPoint.latitude, gcjPoint.longitude); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 + return LatLng(wgs84Lat, wgs84Lon); }).toList(); final outerBoundary = work_area_model.OuterBoundary( @@ -3588,13 +3652,10 @@ class _MapPageEnterpriseState extends State { for (var holePoints in _obstacleHoles) { List innerPositionList = []; innerPositionList = holePoints.map((latLng) { - final wgs84Point = gcj02ToWgs84( - latLng.latitude, - latLng.longitude, - ); + // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 return work_area_model.Position( - lat: wgs84Point.latitude, - lon: wgs84Point.longitude, + lat: latLng.latitude, + lon: latLng.longitude, ); }).toList(); positionList.add(innerPositionList); @@ -3625,6 +3686,8 @@ class _MapPageEnterpriseState extends State { ); } + // 🔥 已废弃:不再调用旧的路径生成API,path只传用户手动打点的边界点 + /* await context.read().generatePath( reference: referencePoint, heading: heading, @@ -3671,6 +3734,7 @@ class _MapPageEnterpriseState extends State { moveMapToPointsCenter(gcjPathPoints); } } + */ } catch (e) { debugPrint('路径生成异常:$e'); if (showTips) { @@ -3950,6 +4014,39 @@ class _MapPageEnterpriseState extends State { ), ), + /// 悬浮"回到苏州"按钮:一键跳转到 GeoServer 苏州地块(zoom 18 才能看清 250m 地块) + Positioned( + top: 60, + left: 10, + child: GestureDetector( + onTap: () => _mapController.move(kSuzhouCenter, 18), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.8), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: const Text( + '苏', + style: TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + /// 核心:地图正中心固定定位标 if (_currentRobotMode == RobotMode.point) Positioned( @@ -4428,12 +4525,14 @@ class _MapPageEnterpriseState extends State { } } -// 转换单个WGS84坐标到GCJ02 -LatLng convertWGS84ToGCJ02(double lat, double lon) { - return wgs84ToGcj02(lat, lon); -} +// ⚠️ 以下函数已注释 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换 +// 后续确认无误后可删除 +// ⚠️ convertWGS84ToGCJ02 函数已删除 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换 +// 以下坐标转换函数保留但注释,后续确认无误后可删除 -/// ======================================================= +// ⚠️ 坐标转换函数已注释 - 地图已切换为 ESRI WGS84,不再需要 GCJ02 转换 +// 以下函数保留但注释,后续确认无误后可删除 +/* /// 坐标转换:WGS84 -> GCJ02(国内高德/腾讯通用) /// ======================================================= const double _pi = 3.1415926535897932384626; @@ -4560,8 +4659,9 @@ double _transformLon(double x, double y) { (150.0 * sin(x / 12.0 * _pi) + 300.0 * sin(x / 30.0 * _pi)) * 2.0 / 3.0; return ret; } +*/ -/// 计算两个经纬度点之间的地面距离(单位:米,GCJ02/WGS84通用) +/// 计算两个经纬度点之间的地面距离(单位:米,GCJ02/WGS84 通用) String _calculateLatLngDistance(LatLng point1, LatLng point2) { const double earthRadius = 6371000; // 地球半径(米) diff --git a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart index b710ab42..13cc6d48 100644 --- a/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart +++ b/lib/features/v2/device_list/presentation/float_bar/view/float_bar_widget.dart @@ -1501,12 +1501,13 @@ class FloatBarWidgetState extends State { }, ), children: [ - // 高德地图瓦片(最底层) + // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) + // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 TileLayer( - urlTemplate: - 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}', - subdomains: ['1', '2', '3', '4'], + urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', userAgentPackageName: 'com.example.app', + minZoom: 0, + maxZoom: 19, ), // 轨迹线(中间层) diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index 09c922e4..daed03e0 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -36,6 +36,7 @@ class _DroneStationDetailPageState extends State { late DroneOsdDataSource _osdDataSource; StreamSubscription? _stationOsdSubscription; + StreamSubscription? _blocStateSubscription; /// 静态缓存:按 gatewaySn 分区,跨页面实例保留每个机场上次的 host 数据 /// 避免进入新机场时显示上一个机场的缓存数据 @@ -62,23 +63,34 @@ class _DroneStationDetailPageState extends State { ); _osdDataSource = sl(); - _startStationOsdListening(); - - _hasInitialized = true; - } - - void _startStationOsdListening() { - // 使用 station 自带的 deviceSn + gatewaySn,与列表页预订阅参数一致 - // 避免因参数不同导致 DataSource 重新订阅、打断数据流 - _osdDataSource.startListening( - deviceSn: widget.station.deviceSn, - gatewaySn: widget.station.gatewaySn, - ); + // 🔥 立即建立 stationOsdStream 的监听(关键!) + // 否则 _parseAndUpdateHostData 永远不会被调用,机场状态卡片一直显示"未知" _stationOsdSubscription = _osdDataSource.stationOsdStream.listen((osd) { if (!mounted) return; _parseAndUpdateHostData(osd); }); + + // 🔥 监听 Bloc 状态变化,等数据加载完成后再确保 MQTT 订阅建立 + _blocStateSubscription = _bloc.stream.listen((state) { + if (state is UAVDetailLoaded && mounted) { + debugPrint('✅ [DroneStationDetailPage] Bloc 数据加载完成,确保 MQTT 订阅'); + debugPrint(' deviceSn: ${state.detail.deviceSn}'); + debugPrint(' gatewaySn: ${widget.station.gatewaySn}'); + + // 确保 MQTT 订阅建立 + _osdDataSource.startListening( + deviceSn: state.detail.deviceSn, + gatewaySn: widget.station.gatewaySn, + ); + _droneSn = state.detail.deviceSn; + + // 取消监听(只执行一次) + _blocStateSubscription?.cancel(); + } + }); + + _hasInitialized = true; } void _parseAndUpdateHostData(DroneOsdEntity osd) { @@ -95,6 +107,18 @@ class _DroneStationDetailPageState extends State { final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null; if (hostData == null || hostData is! Map) return; + // 🔥 关键检查:确认是否包含机场状态的关键字段 + // 避免将其他类型的OSD消息(如设备状态、电池信息等)覆盖机场状态数据 + final hasHostFields = hostData.containsKey('cover_state') || + hostData.containsKey('drone_in_dock') || + hostData.containsKey('temperature') || + hostData.containsKey('humidity'); + if (!hasHostFields) { + debugPrint('⚠️ [DroneStationDetailPage] ⚠️ 此消息不包含机场状态字段,跳过更新!'); + debugPrint(' hostData keys: ${hostData.keys.toList()}'); + return; // 不是机场状态数据,直接返回 + } + // 🔥 基于已有数据合并,只更新非 null 字段,避免 Type 2 消息覆盖已有值 final Map merged = Map.from( _stationHostData.value, @@ -111,10 +135,13 @@ class _DroneStationDetailPageState extends State { _put('humidity', (hostData['humidity'] as num?)?.toDouble()); _put('wind_speed', (hostData['wind_speed'] as num?)?.toDouble()); _put('rainfall', hostData['rainfall']?.toString()); - _put('cover_state', hostData['cover_state']?.toString()); - _put('drone_in_dock', hostData['drone_in_dock']?.toString()); + + // 🔥 不再做严格校验,直接存储原始值,让UI层自己去解析 + _put('cover_state', hostData['cover_state']); + _put('drone_in_dock', hostData['drone_in_dock']); _put('temperature', (hostData['temperature'] as num?)?.toDouble()); - _put('putter_state', hostData['putter_state']?.toString()); + _put('putter_state', hostData['putter_state']); + _put( 'supplement_light_state', hostData['supplement_light_state']?.toString(), @@ -144,25 +171,17 @@ class _DroneStationDetailPageState extends State { final acData = hostData['air_conditioner']; if (acData is Map) { _put('air_conditioner', acData); - - // 解析空调开关状态 - final acStateVal = int.tryParse(acData['air_conditioner_state']?.toString() ?? '') ?? -1; - if (acStateVal == 0 || acStateVal == 1) { - _put('air_conditioner_state', acStateVal.toString()); - } - - // 解析空调运行时间 - final switchTime = acData['switch_time']; - if (switchTime != null) { - _put('air_conditioner_switch_time', '$switchTime'); - } + // 不单独提取air_conditioner_state,让UI层自己解析 } + // 🔥 存储到ValueNotifier触发UI更新 _stationHostData.value = merged; - // 同步写入按 gatewaySn 分区的静态缓存 + // 同步写入按gatewaySn分区的静态缓存 _cachedHostDataBySn[widget.station.gatewaySn] = Map.from( merged, ); + + debugPrint('[DroneStationDetailPage] 收到机场状态数据'); } /// 🔥 页面重新激活时调用(从其他页面返回时) @@ -198,6 +217,7 @@ class _DroneStationDetailPageState extends State { @override void dispose() { _stationOsdSubscription?.cancel(); + _blocStateSubscription?.cancel(); // 只调用 stopListening 减少引用计数,不调用 dispose() // 因为 DroneOsdDataSource 是 LazySingleton,被列表页和详情页共享 // 详情页退出后列表页可能仍需要该订阅 @@ -331,17 +351,6 @@ class _DroneStationDetailPageState extends State { Widget _buildContent(UAVDetailEntity detail) { _detail = detail; - final newDroneSn = detail.deviceSn; - - // 只在 droneSn 真正变化时才重新订阅,避免 Bloc 刷新时重复 startListening - // 导致引用计数虚增、dispose 时无法真正取消订阅 - if (newDroneSn != null && newDroneSn.isNotEmpty && newDroneSn != _droneSn) { - _droneSn = newDroneSn; - _osdDataSource.startListening( - deviceSn: newDroneSn, - gatewaySn: widget.station.gatewaySn, - ); - } return RefreshIndicator( onRefresh: _handleRefresh, @@ -484,8 +493,11 @@ class _DroneStationDetailPageState extends State { final rainfall = hostData['rainfall']; final coverState = hostData['cover_state']; final droneInDock = hostData['drone_in_dock']; - final acState = hostData['air_conditioner_state']; final alarmState = hostData['alarm_state']; + + // 🔥 从 air_conditioner Map 中解析空调状态 + final acDataMap = hostData['air_conditioner']; + final acState = acDataMap is Map ? acDataMap['air_conditioner_state'] : null; String envTempStr = '未知'; if (envTemp != null) { @@ -516,7 +528,8 @@ class _DroneStationDetailPageState extends State { String coverStr = '未知'; Color coverColor = const Color(0xFF86909C); if (coverState != null) { - final state = int.tryParse(coverState) ?? -1; + // 🔥 像 DroneStationOsdCard 一样解析,支持多种数据格式 + final state = coverState is int ? coverState : int.tryParse(coverState.toString()) ?? -1; if (state == 1) { coverStr = '开启'; coverColor = const Color(0xFFFF7D00); @@ -529,7 +542,8 @@ class _DroneStationDetailPageState extends State { String droneDockStr = '未知'; Color droneDockColor = const Color(0xFF86909C); if (droneInDock != null) { - final state = int.tryParse(droneInDock) ?? -1; + // 🔥 像 DroneStationOsdCard 一样解析 + final state = droneInDock is int ? droneInDock : int.tryParse(droneInDock.toString()) ?? -1; if (state == 1) { droneDockStr = '在库内'; droneDockColor = const Color(0xFF00B42A); @@ -542,7 +556,7 @@ class _DroneStationDetailPageState extends State { String acStr = '未知'; Color acColor = const Color(0xFF86909C); if (acState != null) { - // 🔥 增强兼容性:尝试多种数据格式(int、string等) + // 🔥 增强兼容性:支持多种数据格式和更多状态值 final acValue = acState is int ? acState : int.tryParse(acState.toString()) ?? -1; if (acValue == 1) { acStr = '开启'; @@ -551,7 +565,6 @@ class _DroneStationDetailPageState extends State { acStr = '关闭'; acColor = const Color(0xFF86909C); } else if (acValue == 2) { - // 部分设备使用 2 表示待机或其他状态 acStr = '待机'; acColor = const Color(0xFFFF7D00); } diff --git a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart index 1bf0fcdc..b3fef7d2 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart @@ -1290,12 +1290,13 @@ class _DroneVideoControlPageState extends State { ), ), children: [ - // 高德地图瓦片(最底层) + // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) + // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 TileLayer( - urlTemplate: - 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}', - subdomains: ['1', '2', '3', '4'], + urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', userAgentPackageName: 'com.example.app', + minZoom: 0, + maxZoom: 19, ), // 🔥 轨迹线(中间层) @@ -1563,12 +1564,13 @@ class _DroneVideoControlPageState extends State { ), ), children: [ - // 高德地图瓦片(最底层) + // ESRI World Imagery(WGS84 坐标系,全球高清卫星图) + // 替代高德地图(GCJ02),统一坐标系,无需坐标转换 TileLayer( - urlTemplate: - 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}', - subdomains: ['1', '2', '3', '4'], + urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', userAgentPackageName: 'com.example.app', + minZoom: 0, + maxZoom: 19, ), // 🔥 轨迹线(中间层)