From 61bce2c1cf01e8940367a104620700cb634de950 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Tue, 1 Sep 2026 08:59:20 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=91=E5=AE=9A=E8=AE=BE=E5=A4=87+=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=A8=A1=E5=BC=8F+=E6=8E=A7=E5=88=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BAbug+=E5=9C=A8=E7=BA=BF=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/languages/en-US.json | 2 +- assets/languages/zh-CN.json | 2 +- dist/manifest.json | 4 +- fix_json.ps1 | 12 + flutter_01.log | 106 +++ flutter_02.log | 109 +++ lib/components/device_status_modal.dart | 3 + lib/core/consts/http_api_consts.dart | 94 +-- lib/core/di/injection.dart | 29 +- lib/core/env/env_config.dart | 20 +- lib/core/logging/log_time.dart | 14 + .../mower_realtime_datasource.dart | 178 ++++ .../data/infrastructure/mqtt_client_impl.dart | 28 +- .../mower_realtime_repository_impl.dart | 42 + .../entities/mower_location_entity.dart | 54 ++ .../domain/entities/route_status_entity.dart | 50 ++ .../mqtt/domain/models/mqtt_config.dart | 15 + .../mower_realtime_repository.dart | 19 + lib/core/network/mqtt/mqtt_manager.dart | 6 +- lib/core/update/version_check_service.dart | 121 +-- .../datasources/device_task_datasource.dart | 3 + .../impl/device_http_datasource_impl.dart | 12 +- .../device_hostriry_work_impl.dart | 3 +- .../device_task_repository_impl.dart | 2 + .../repositories/device_task_repository.dart | 1 + .../domain/usecases/cancel_task_usecase.dart | 3 + .../presentation/bloc/device_status_bloc.dart | 106 ++- .../presentation/bloc/device_task_cubit.dart | 321 +++++--- .../presentation/bloc/devices_cubit.dart | 58 +- .../widgets/map/resilient_tile_provider.dart | 104 +++ .../widgets/map/testmap_pages.dart | 762 ++++++++++++------ .../presentation/widgets/path_list_pages.dart | 9 +- .../my/repository/my_repository_impl.dart | 3 +- .../bloc/remote_control_cubit.dart | 4 + .../pages/remote_control_page.dart | 36 +- .../datasources/device_remote_datasource.dart | 72 +- .../data/models/device_data_model.dart | 3 + .../repositories/device_repository_impl.dart | 6 + .../domain/entities/device_entity.dart | 2 + .../repositories/device_repository.dart | 1 + .../usecases/get_all_devices_usecase.dart | 13 + .../cubit/float_bar_setting_cubit.dart | 10 +- .../float_bar/view/float_bar_widget.dart | 6 +- .../pages/ble_device_detail_page.dart | 374 +++++++-- .../pages/device_status_page.dart | 83 +- .../pages/drone_video_control_page.dart | 2 +- .../widgets/bluetooth_scan_modal.dart | 111 ++- .../impl/my_remote_datasource_impl.dart | 2 +- .../pages/system_settings_page.dart | 124 +++ 49 files changed, 2424 insertions(+), 720 deletions(-) create mode 100644 fix_json.ps1 create mode 100644 flutter_01.log create mode 100644 flutter_02.log create mode 100644 lib/core/logging/log_time.dart create mode 100644 lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart create mode 100644 lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart create mode 100644 lib/core/network/mqtt/domain/entities/mower_location_entity.dart create mode 100644 lib/core/network/mqtt/domain/entities/route_status_entity.dart create mode 100644 lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart create mode 100644 lib/features/home/presentation/widgets/map/resilient_tile_provider.dart create mode 100644 lib/features/v2/device_list/domain/usecases/get_all_devices_usecase.dart diff --git a/assets/languages/en-US.json b/assets/languages/en-US.json index c8a3c772..0ed19775 100644 --- a/assets/languages/en-US.json +++ b/assets/languages/en-US.json @@ -288,7 +288,7 @@ "location": "Location", "refresh": "Refresh", "select_work_mode": "Select Work Mode", - "bow_mode": "Bow Mode", + "bow_mode": "Coverage Mode", "bow_mode_subtitle": "Standard full coverage path planning", "custom_mode": "Custom Mode", "custom_mode_subtitle": "Manually set work area", diff --git a/assets/languages/zh-CN.json b/assets/languages/zh-CN.json index 4b99f238..e847d07c 100644 --- a/assets/languages/zh-CN.json +++ b/assets/languages/zh-CN.json @@ -302,7 +302,7 @@ "location": "定位", "refresh": "刷新", "select_work_mode": "选择作业模式", - "bow_mode": "弓字模式", + "bow_mode": "覆盖模式", "bow_mode_subtitle": "标准全覆盖路径规划", "custom_mode": "自定义模式", "custom_mode_subtitle": "手动设定作业区域", diff --git a/dist/manifest.json b/dist/manifest.json index 98024515..cfbb6d3f 100644 --- a/dist/manifest.json +++ b/dist/manifest.json @@ -1,6 +1,6 @@ { - "version": "1.0.13", - "md5": "181176ea51ec26ac97f34c2a550661f0", + "version": "1.0.7", + "md5": "3e9ad72a89880b96b024eeacec9518c6", "targetVersionCode": 1, "abi": "arm64-v8a" } diff --git a/fix_json.ps1 b/fix_json.ps1 new file mode 100644 index 00000000..0ac56c02 --- /dev/null +++ b/fix_json.ps1 @@ -0,0 +1,12 @@ +$zhPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\zh-CN.json' +$enPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\en-US.json' + +$zhContent = [System.IO.File]::ReadAllText($zhPath) +$zhContent = $zhContent.Replace('"bow_mode": "弓字模式"', '"bow_mode": "覆盖模式"') +[System.IO.File]::WriteAllText($zhPath, $zhContent) + +$enContent = [System.IO.File]::ReadAllText($enPath) +$enContent = $enContent.Replace('"bow_mode": "Bow Mode"', '"bow_mode": "Coverage Mode"') +[System.IO.File]::WriteAllText($enPath, $enContent) + +Write-Host "Done!" diff --git a/flutter_01.log b/flutter_01.log new file mode 100644 index 00000000..ad5ec9b5 --- /dev/null +++ b/flutter_01.log @@ -0,0 +1,106 @@ +Flutter crash report. +Please report a bug at https://github.com/flutter/flutter/issues. + +## command + +flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.48:37689 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart + +## exception + +FormatException: FormatException: Unexpected character (at character 1) +Failed to init NativeSymbolResolver (SymInitialize 3221225476) +^ + + +``` +#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5) +#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9) +#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22) +#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13) +#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5) +#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18) +#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24) +#7 _rootRunUnary (dart:async/zone.dart:1538:47) +#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19) +#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7) +#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11) +#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7) +#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11) +#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11) +#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11) +#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7) +#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20) +#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5) +#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18) +#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24) +#20 _rootRunUnary (dart:async/zone.dart:1538:47) +#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19) +#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7) +#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11) +#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7) +#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19) +#26 _StreamController._add (dart:async/stream_controller.dart:663:7) +#27 _StreamController.add (dart:async/stream_controller.dart:618:5) +#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41) +#29 _rootRunUnary (dart:async/zone.dart:1546:13) +#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19) +#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7) +#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11) +#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7) +#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19) +#35 _StreamController._add (dart:async/stream_controller.dart:663:7) +#36 _StreamController.add (dart:async/stream_controller.dart:618:5) +#37 new _RawSocket. (dart:io-patch/socket_patch.dart:2315:31) +#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14) +#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35) +#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5) +#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13) +#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5) +``` + +## flutter doctor + +``` +[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [1,623ms] + • Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter + • Upstream repository https://github.com/flutter/flutter.git + • Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800 + • Engine revision 78fc3012e4 + • Dart version 3.10.7 + • DevTools version 2.51.1 + • Pub download mirror https://pub.flutter-io.cn + • Flutter download mirror https://storage.flutter-io.cn + • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging + +[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [9.3s] + +[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [14.1s] + • Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk + • Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A) + • Platform android-36, build-tools 36.1.0 + • ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk + • Java binary at: D:\devtools\anrdstod\jbr\bin\java + This is the JDK bundled with the latest Android Studio installation on this machine. + To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. + • Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72) + • All Android licenses accepted. + +[✓] Chrome - develop for the web [20ms] + • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe + +[✗] Visual Studio - develop Windows apps [17ms] + ✗ Visual Studio not installed; this is necessary to develop Windows apps. + Download at https://visualstudio.microsoft.com/downloads/. + Please install the "Desktop development with C++" workload, including all of its default components + +[✓] Connected device (4 available) [8.2s] + • PKB110 (mobile) • 192.168.2.48:37689 • android-arm64 • Android 16 (API 36) + • Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168] + • Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138 + • Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93 + +[✓] Network resources [1,268ms] + • All expected network resources are available. + +! Doctor found issues in 1 category. +``` diff --git a/flutter_02.log b/flutter_02.log new file mode 100644 index 00000000..dd790a08 --- /dev/null +++ b/flutter_02.log @@ -0,0 +1,109 @@ +Flutter crash report. +Please report a bug at https://github.com/flutter/flutter/issues. + +## command + +flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.39:42831 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart + +## exception + +FormatException: FormatException: Unexpected character (at character 1) +Failed to init NativeSymbolResolver (SymInitialize 3221225476) +^ + + +``` +#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5) +#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9) +#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22) +#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13) +#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5) +#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18) +#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24) +#7 _rootRunUnary (dart:async/zone.dart:1538:47) +#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19) +#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7) +#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11) +#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7) +#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11) +#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11) +#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11) +#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7) +#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20) +#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5) +#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18) +#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24) +#20 _rootRunUnary (dart:async/zone.dart:1538:47) +#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19) +#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7) +#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11) +#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7) +#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19) +#26 _StreamController._add (dart:async/stream_controller.dart:663:7) +#27 _StreamController.add (dart:async/stream_controller.dart:618:5) +#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41) +#29 _rootRunUnary (dart:async/zone.dart:1546:13) +#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19) +#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7) +#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11) +#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7) +#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19) +#35 _StreamController._add (dart:async/stream_controller.dart:663:7) +#36 _StreamController.add (dart:async/stream_controller.dart:618:5) +#37 new _RawSocket. (dart:io-patch/socket_patch.dart:2315:31) +#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14) +#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35) +#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5) +#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13) +#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5) +``` + +## flutter doctor + +``` +[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [841ms] + • Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter + • Upstream repository https://github.com/flutter/flutter.git + • Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800 + • Engine revision 78fc3012e4 + • Dart version 3.10.7 + • DevTools version 2.51.1 + • Pub download mirror https://pub.flutter-io.cn + • Flutter download mirror https://storage.flutter-io.cn + • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging + +[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.4s] + +[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.0s] + • Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk + • Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A) + • Platform android-36, build-tools 36.1.0 + • ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk + • Java binary at: D:\devtools\anrdstod\jbr\bin\java + This is the JDK bundled with the latest Android Studio installation on this machine. + To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. + • Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72) + • All Android licenses accepted. + +[✓] Chrome - develop for the web [11ms] + • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe + +[✗] Visual Studio - develop Windows apps [8ms] + ✗ Visual Studio not installed; this is necessary to develop Windows apps. + Download at https://visualstudio.microsoft.com/downloads/. + Please install the "Desktop development with C++" workload, including all of its default components + +[✓] Connected device (7 available) [11.6s] + • 21091116AC (mobile) • 192.168.2.16:35367 • android-arm64 • Android 13 (API 33) + • PKB110 (mobile) • 192.168.2.39:42831 • android-arm64 • Android 16 (API 36) + • PKB110 (wireless) (mobile) • adb-4TRKWOEE4HR8AMEA-ti69Ed._adb-tls-connect._tcp • android-arm64 • Android 16 (API 36) + • 21091116AC (wireless) (mobile) • adb-QK4LCEO78TWOVKOB-v9ALzk._adb-tls-connect._tcp • android-arm64 • Android 13 (API 33) + • Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168] + • Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138 + • Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93 + +[✓] Network resources [939ms] + • All expected network resources are available. + +! Doctor found issues in 1 category. +``` diff --git a/lib/components/device_status_modal.dart b/lib/components/device_status_modal.dart index 411be35a..45193303 100644 --- a/lib/components/device_status_modal.dart +++ b/lib/components/device_status_modal.dart @@ -14,6 +14,7 @@ import '../features/devices/presentation/bloc/device_status_event.dart'; import '../features/devices/presentation/bloc/devices_cubit.dart'; import '../features/remote_control/presentation/bloc/remote_control_cubit.dart'; import '../features/remote_control/presentation/bloc/remote_control_state.dart'; +import '../core/logging/log_time.dart'; const int DATA_TIMEOUT_SECONDS = 15; @@ -49,6 +50,7 @@ class _DeviceStatusModalState extends State { @override void initState() { super.initState(); + debugPrint('${LogTime.now()} 🖥️ [设备数据监控] 页面打开,开始展示当前订阅设备的数据'); _startDataTimeoutTimer(); // 🔥 页面初始化时自动调用刷新 WidgetsBinding.instance.addPostFrameCallback((_) { @@ -64,6 +66,7 @@ class _DeviceStatusModalState extends State { @override void dispose() { + debugPrint('${LogTime.now()} 🚪 [设备数据监控] 页面关闭(MQTT订阅保持,后台继续收数据)'); _dataTimeoutTimer?.cancel(); super.dispose(); } diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart index cdd61f6d..360d568b 100644 --- a/lib/core/consts/http_api_consts.dart +++ b/lib/core/consts/http_api_consts.dart @@ -1,123 +1,125 @@ +import '../env/env_config.dart'; + class HttpApiConsts { - // static const String baseUrl = "http://8.159.134.0:8012"; // 旧地址 - // static const String baseUrl = "http://1.95.137.212:59015"; // 测试地址 - // static const String baseUrl = "http://1.95.137.212:8081"; // 生产地址(暂时切到测试服 59003,回退时恢复此行) - static const String baseUrl = "http://1.95.137.212:59003"; // 测试服地址 + static String get baseUrl => EnvConfig.baseUrl; /// 账号相关 // 登录 - static const String loginUrl = "$baseUrl/login"; + static String get loginUrl => "$baseUrl/login"; ///设备相关 // 获取设备列表 - static const String getUserDevicesList = "$baseUrl/iot/device/list"; + static String get getUserDevicesList => "$baseUrl/iot/device/list"; // 绑定设备 - static const String bindDevice = "$baseUrl/iot/device/bind"; + static String get bindDevice => "$baseUrl/iot/device/bind"; // 解绑设备 - static const String unbindDevice = "$baseUrl/forward/device/unbind"; + static String get unbindDevice => "$baseUrl/forward/device/unbind"; // 切换设备 - static const String switchDevice = "$baseUrl/forward/device/switchDevice"; + static String get switchDevice => "$baseUrl/forward/device/switchDevice"; // 获取光伏电站列表 - static const String getSiteList = "$baseUrl/system/site/selectByUserId"; + static String get getSiteList => "$baseUrl/system/site/selectByUserId"; // 获取场站下的设备列表 - static const String getSiteDeviceList = "$baseUrl/iot/device/getSiteList"; + static String get getSiteDeviceList => "$baseUrl/iot/device/getSiteList"; // 获取场站下的无人机机场列表 - static const String getSiteUAVList = "$baseUrl/iot/UAV/getSiteUAVList"; + static String get getSiteUAVList => "$baseUrl/iot/UAV/getSiteUAVList"; // 获取UAV状态详情 - static const String getUAVState = "$baseUrl/iot/UAV/getUAVState"; + static String get getUAVState => "$baseUrl/iot/UAV/getUAVState"; // 切换摄像头获取视频流 - static const String changeCamera = "$baseUrl/iot/UAV/changeCamera"; + static String get changeCamera => "$baseUrl/iot/UAV/changeCamera"; // 获取设备位置 - static const String getDeviceLocation = "$baseUrl/iot/device/userDevice"; + static String get getDeviceLocation => "$baseUrl/iot/device/userDevice"; + + // 获取全部设备列表(用于蓝牙绑定匹配) + static String get getDeviceList => "$baseUrl/iot/device/getDeviceList"; // 获取机器人列表 - static const String getRobotList = "$baseUrl/iot/device/getSiteList"; + static String get getRobotList => "$baseUrl/iot/device/getSiteList"; // 获取飞行任务列表 - static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask"; + static String get getFlightTask => "$baseUrl/iot/UAV/getFlightTask"; // 获取飞行任务详情 - static const String getFlightTaskDetail = + static String get getFlightTaskDetail => "$baseUrl/iot/UAV/getFlightTaskDetail"; // 获取航线列表 - static const String getWayline = "$baseUrl/iot/UAV/getWayline"; + static String get getWayline => "$baseUrl/iot/UAV/getWayline"; // 创建飞行任务 - static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask"; + static String get createFlightTask => "$baseUrl/iot/UAV/createFlightTask"; // 更新飞行任务状态 - static const String updateFlightTaskStatus = + static String get updateFlightTaskStatus => "$baseUrl/iot/UAV/updateFlightTaskStatus"; // 切换无人机镜头获取视频流 - static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens"; + static String get changeUAVLens => "$baseUrl/iot/UAV/changeLens"; // 获取无人机详情 - static const String getUAVDetail = "$baseUrl/iot/UAV/getUAVDetail"; + static String get getUAVDetail => "$baseUrl/iot/UAV/getUAVDetail"; // 飞行任务命令控制(暂停、返航等) - static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand"; + static String get flightTaskCommand => "$baseUrl/iot/UAV/flightTaskCommand"; /// 告警相关 - static const String alarmBaseUrl = baseUrl; + static String get alarmBaseUrl => baseUrl; // 获取告警工单配置列表 - static const String alarmOrderConfigList = + static String get alarmOrderConfigList => "$alarmBaseUrl/iot/alarmOrderConfig/list"; // 获取告警列表 - static const String alarmList = "$baseUrl/iot/alarm/list"; + static String get alarmList => "$baseUrl/iot/alarm/list"; // 获取告警详情 - static const String alarmDetail = "$baseUrl/iot/alarm"; + static String get alarmDetail => "$baseUrl/iot/alarm"; // 处理告警(确认/关闭等) - static const String alarmHandle = "$baseUrl/iot/alarm/handle"; + static String get alarmHandle => "$baseUrl/iot/alarm/handle"; /// 工单相关 // 获取工单模型配置列表 - static const String orderModelList = "$baseUrl/iot/orderModel/list"; + static String get orderModelList => "$baseUrl/iot/orderModel/list"; // 添加工单(上报) - static const String workOrderAdd = "$baseUrl/iot/ioTworkOrder/add"; + static String get workOrderAdd => "$baseUrl/iot/ioTworkOrder/add"; // 获取工单列表 - static const String workOrderList = "$baseUrl/iot/ioTworkOrder/list"; + static String get workOrderList => "$baseUrl/iot/ioTworkOrder/list"; // 获取工单详情 - static const String workOrderDetail = "$baseUrl/iot/ioTworkOrder"; + static String get workOrderDetail => "$baseUrl/iot/ioTworkOrder"; // 派发工单 - static const String workOrderDispat = "$baseUrl/iot/ioTworkOrder/dispatch"; + static String get workOrderDispat => "$baseUrl/iot/ioTworkOrder/dispatch"; // 挂起工单 - static const String workOrderSuspend = "$baseUrl/iot/ioTworkOrder/suspend"; + static String get workOrderSuspend => "$baseUrl/iot/ioTworkOrder/suspend"; // 完成工单 - static const String workOrderComplete = "$baseUrl/iot/ioTworkOrder/complete"; + static String get workOrderComplete => "$baseUrl/iot/ioTworkOrder/complete"; // 开始执行工单 - static const String workOrderStart = "$baseUrl/iot/ioTworkOrder/start"; + static String get workOrderStart => "$baseUrl/iot/ioTworkOrder/start"; // 获取工单统计 - static const String workOrderCount = "$baseUrl/iot/ioTworkOrder/count"; + static String get workOrderCount => "$baseUrl/iot/ioTworkOrder/count"; // 设备运行参数查询 - static const String deviceRunParamSelect = + static String get deviceRunParamSelect => "$baseUrl/iot/deviceRunParam/selectByDeviceId"; // 设备运行参数保存 - static const String deviceRunParamSave = "$baseUrl/iot/deviceRunParam/save"; + static String get deviceRunParamSave => "$baseUrl/iot/deviceRunParam/save"; // 设备操作权限校验 - static const String hasPermission = "$baseUrl/iot/device/hasPermission"; + static String get hasPermission => "$baseUrl/iot/device/hasPermission"; // 释放远程控制权限(请求体:platform/type/deviceId/token) - static const String releaseControlUrl = + static String get releaseControlUrl => "$baseUrl/forward/device/releaseControl"; /// 用户相关 // 获取用户列表 - static const String systemUserList = "$baseUrl/system/user/list"; + static String get systemUserList => "$baseUrl/system/user/list"; /// 组织与场站相关 // 获取组织列表 - static const String orgList = "$baseUrl/system/org/list"; + static String get orgList => "$baseUrl/system/org/list"; // 根据组织ID获取场站列表 - static const String siteListByOrgId = "$baseUrl/system/site/selectByOrgId"; + static String get siteListByOrgId => "$baseUrl/system/site/selectByOrgId"; // 根据场站ID获取用户列表 - static const String userListBySiteId = "$baseUrl/system/user/list"; + static String get userListBySiteId => "$baseUrl/system/user/list"; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index d68acc25..3b225e11 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -78,6 +78,7 @@ import '../../features/v2/device_list/domain/repositories/device_repository.dart as device_v2_domain; import '../../features/v2/device_list/domain/usecases/get_device_status_data_usecase.dart' as device_v2_usecase; +import '../../features/v2/device_list/domain/usecases/get_all_devices_usecase.dart'; import '../../features/v2/device_list/presentation/bloc/device_status_bloc.dart' as device_v2_bloc; import '../../features/v2/device_list/data/datasources/drone_station_datasource.dart'; @@ -151,10 +152,13 @@ import '../network/mqtt/domain/interfaces/mqtt_client.dart'; import '../network/mqtt/data/infrastructure/mqtt_client_impl.dart'; import '../network/mqtt/data/datasources/drone_osd_datasource.dart'; import '../network/mqtt/data/datasources/task_message_datasource.dart'; +import '../network/mqtt/data/datasources/mower_realtime_datasource.dart'; import '../network/mqtt/domain/repositories/drone_osd_repository.dart'; import '../network/mqtt/domain/repositories/task_message_repository.dart'; +import '../network/mqtt/domain/repositories/mower_realtime_repository.dart'; import '../network/mqtt/data/repositories/drone_osd_repository_impl.dart'; import '../network/mqtt/data/repositories/task_message_repository_impl.dart'; +import '../network/mqtt/data/repositories/mower_realtime_repository_impl.dart'; import '../router/app_router.dart'; import '../storage/impl/user_storage_impl.dart'; import '../storage/user_storage.dart'; @@ -177,15 +181,20 @@ Future init() async { ); sl.registerLazySingleton(() => PathPlanningService()); - /// 1.1.3 MQTT Clients - 两个独立的 MQTT 客户端实例 + /// 1.1.3 MQTT Clients - 三个独立的 MQTT 客户端实例 sl.registerLazySingleton( - () => MqttClientImpl(), + () => MqttClientImpl(clientName: 'droneOsdClient'), instanceName: 'droneOsdClient', ); sl.registerLazySingleton( - () => MqttClientImpl(), + () => MqttClientImpl(clientName: 'taskMessageClient'), instanceName: 'taskMessageClient', ); + // 🔥 割草机机器状态专用 MQTT 客户端(1.95.137.212:59007) + sl.registerLazySingleton( + () => MqttClientImpl(clientName: 'mowerRealtimeClient'), + instanceName: 'mowerRealtimeClient', + ); /// 1.1.4 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它 sl.registerLazySingleton( @@ -228,6 +237,14 @@ Future init() async { ), ); + /// 割草机机器状态(MQTT):车辆实时+定位,LazySingleton 跨页面共享同一订阅 + /// 🔥 使用独立 MQTT 连接 mowerRealtimeClient(1.95.137.212:59007) + sl.registerLazySingleton( + () => MowerRealtimeDataSourceImpl( + sl(instanceName: 'mowerRealtimeClient'), + ), + ); + /// 1.5 --- MQTT Repositories --- sl.registerLazySingleton( () => DroneOsdRepositoryImpl(sl()), @@ -235,6 +252,9 @@ Future init() async { sl.registerLazySingleton( () => TaskMessageRepositoryImpl(sl()), ); + sl.registerLazySingleton( + () => MowerRealtimeRepositoryImpl(sl()), + ); /// 2. 数据源 (DataSource) sl.registerLazySingleton( @@ -357,6 +377,9 @@ Future init() async { sl.registerLazySingleton( () => device_v2_usecase.GetDeviceStatusDataUseCase(repository: sl()), ); + sl.registerLazySingleton( + () => GetAllDevicesUseCase(repository: sl()), + ); sl.registerFactory( () => device_v2_bloc.DeviceStatusBloc(sl()), ); diff --git a/lib/core/env/env_config.dart b/lib/core/env/env_config.dart index 66a7dff8..c9bdfd1d 100644 --- a/lib/core/env/env_config.dart +++ b/lib/core/env/env_config.dart @@ -14,14 +14,14 @@ class EnvConfig { static bool get isProduction => environment == 'prod'; - /// TCP 服务器配置 - static String get tcpIp => '1.95.137.212'; - - static int get tcpPort { - // 测试服: 59016, 生产服: 9001(目前暂时都走测试服 59004,回退时改回 9001) - if (environment == 'prod') { - return 59004; - } - return 59004; // TCP 端口 - } + // ─── 服务器 IP(测试/生产共用同一台机器) ─── + static const String serverHost = '1.95.137.212'; + + // ─── TCP 控制长连接 ─── + static String get tcpIp => serverHost; + static int get tcpPort => isProduction ? 9001 : 59004; + + // ─── HTTP 接口 ─── + static int get httpPort => isProduction ? 8081 : 59003; + static String get baseUrl => 'http://$serverHost:$httpPort'; } diff --git a/lib/core/logging/log_time.dart b/lib/core/logging/log_time.dart new file mode 100644 index 00000000..57617199 --- /dev/null +++ b/lib/core/logging/log_time.dart @@ -0,0 +1,14 @@ +/// 链路日志统一时间戳工具 +/// +/// 用于打印"什么时间做了什么"的全链路日志,格式:[HH:mm:ss.SSS] +class LogTime { + LogTime._(); + + static String now() { + final now = DateTime.now(); + return '[${now.hour.toString().padLeft(2, '0')}:' + '${now.minute.toString().padLeft(2, '0')}:' + '${now.second.toString().padLeft(2, '0')}.' + '${now.millisecond.toString().padLeft(3, '0')}]'; + } +} diff --git a/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart b/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart new file mode 100644 index 00000000..ffe8fb5c --- /dev/null +++ b/lib/core/network/mqtt/data/datasources/mower_realtime_datasource.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + +import '../../../../logging/log_time.dart'; +import '../../domain/interfaces/mqtt_client.dart'; +import '../../domain/models/mqtt_message.dart'; +import '../../domain/entities/mower_location_entity.dart'; +import '../../domain/entities/real_time_message_entity.dart'; +import '../../domain/entities/route_status_entity.dart'; + +/// 割草机实时状态 MQTT 数据源(替代原 TCP 0x02 机器状态推送) +/// +/// 订阅主题: +/// - 车辆实时状态 mower/{sn}/property/realtime/post(1Hz,不含定位) +/// - 高频定位 mower/{sn}/property/location/post(5-10Hz) +/// - 路径规划 device/{deviceId}/route/post(任务执行后推送覆盖清扫路线) +abstract class MowerRealtimeDataSource { + Stream get vehicleStream; + Stream get locationStream; + Stream get routeStatusStream; + + /// 按设备 SN 启动订阅:自动退订旧设备主题、同 SN 去重 + Future startListening({required String deviceSn}); + + Future stopListening(); +} + +class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource { + final MqttClient mqttClient; + + static const String _realtimeSuffix = 'property/realtime/post'; + static const String _locationSuffix = 'property/location/post'; + static const String _routeSuffix = 'route/post'; + + final _vehicleController = + StreamController.broadcast(); + final _locationController = StreamController.broadcast(); + final _routeController = StreamController.broadcast(); + + StreamSubscription? _subscription; + + /// 当前监听的设备 SN + String? _deviceSn; + + /// 收帧计数(用于标记首帧) + int _vehicleMsgCount = 0; + int _locationMsgCount = 0; + int _routeMsgCount = 0; + + MowerRealtimeDataSourceImpl(this.mqttClient); + + @override + Stream get vehicleStream => _vehicleController.stream; + + @override + Stream get locationStream => _locationController.stream; + + @override + Stream get routeStatusStream => _routeController.stream; + + String _realtimeTopic(String sn) => 'mower/$sn/$_realtimeSuffix'; + String _locationTopic(String sn) => 'mower/$sn/$_locationSuffix'; + String _routeTopic(String sn) => 'device/$sn/$_routeSuffix'; + + @override + Future startListening({required String deviceSn}) async { + if (deviceSn.isEmpty) { + debugPrint('${LogTime.now()} ⚠️ [MowerRealtimeDataSource] 订阅跳过:设备SN为空'); + return; + } + + // 🔥 同一设备重复调用:去重,不重复订阅(参照 DroneOsdDataSource 已验证的模式) + if (_deviceSn == deviceSn && _subscription != null) { + debugPrint('${LogTime.now()} [MowerRealtimeDataSource] 已在监听该设备,跳过: $deviceSn'); + return; + } + + // 🔥 切换设备:先退订旧设备的主题,防止数据串台 + if (_deviceSn != null && _deviceSn!.isNotEmpty) { + await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!)); + await mqttClient.unsubscribe(_locationTopic(_deviceSn!)); + await mqttClient.unsubscribe(_routeTopic(_deviceSn!)); + debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 已退订旧设备: $_deviceSn'); + } + + _deviceSn = deviceSn; + _vehicleMsgCount = 0; + _locationMsgCount = 0; + _routeMsgCount = 0; + + await mqttClient.subscribe(_realtimeTopic(deviceSn)); + await mqttClient.subscribe(_locationTopic(deviceSn)); + await mqttClient.subscribe(_routeTopic(deviceSn)); + + // 消息流监听只建立一次,按 _deviceSn 过滤 + _subscription ??= mqttClient.messageStream?.listen(_handleMessage); + + debugPrint('${LogTime.now()} ✅ [MowerRealtimeDataSource] 开始监听设备: $deviceSn'); + debugPrint(' 车辆实时: ${_realtimeTopic(deviceSn)}'); + debugPrint(' 定位: ${_locationTopic(deviceSn)}'); + debugPrint(' 路径规划: ${_routeTopic(deviceSn)}'); + } + + @override + Future stopListening() async { + if (_deviceSn == null || _deviceSn!.isEmpty) return; + + await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!)); + await mqttClient.unsubscribe(_locationTopic(_deviceSn!)); + await mqttClient.unsubscribe(_routeTopic(_deviceSn!)); + debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 停止监听设备: $_deviceSn'); + _deviceSn = null; + } + + void _handleMessage(MqttMessage message) { + final sn = _deviceSn; + if (sn == null || sn.isEmpty) return; + + // 🔥 防串台:只处理当前监听设备的主题(mower/ 和 device/ 前缀都要匹配) + if (!message.topic.contains('mower/$sn/') && + !message.topic.contains('device/$sn/')) return; + + // 🔥 第一阶段:打印原始报文,确认格式后再完善解析 + debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] ──────── 收到消息 ────────'); + debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] topic: ${message.topic}'); + debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] payload: ${message.payload}'); + + try { + final jsonData = jsonDecode(message.payload) as Map; + + if (message.topic.endsWith(_realtimeSuffix)) { + // {name, value, unit} 数据点格式与 RealTimeMessageEntity 吻合,防御性解析 + final vehicle = RealTimeMessageEntity.fromJson(jsonData); + if (vehicle.data.isNotEmpty) { + _vehicleMsgCount++; + if (_vehicleMsgCount == 1) { + debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 车辆实时首帧到达: $sn'); + } + debugPrint('${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_vehicleMsgCount帧 解析后: type=${vehicle.type}, 数据点=${vehicle.data.length}个'); + for (final dp in vehicle.data) { + debugPrint('${LogTime.now()} ├ ${dp.name} = ${dp.value}${dp.unit}'); + } + _vehicleController.add(vehicle); + } + } else if (message.topic.endsWith(_locationSuffix)) { + _locationMsgCount++; + if (_locationMsgCount == 1) { + debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 定位首帧到达: $sn'); + } + // 🔥 关键修复:坐标在 data 子对象中,不是顶层 + final dataObj = jsonData['data'] as Map? ?? jsonData; + final location = MowerLocationEntity.fromJson(dataObj); + debugPrint('${LogTime.now()} 📍 [MQTT-机器状态] 定位第$_locationMsgCount帧 解析后: lat=${location.latitude}, lng=${location.longitude}, alt=${location.altitude}, heading=${location.heading}, speed=${location.speed}'); + _locationController.add(location); + } else if (message.topic.endsWith(_routeSuffix)) { + _routeMsgCount++; + if (_routeMsgCount == 1) { + debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 路径规划首帧到达: $sn'); + } + final routeMsg = RouteStatusEntity.fromJson(jsonData); + debugPrint('${LogTime.now()} 🛤️ [MQTT-机器状态] 路径规划第$_routeMsgCount帧 - 设备: ${routeMsg.deviceId}, 任务: ${routeMsg.taskId}, 点数: ${routeMsg.data.length}'); + _routeController.add(routeMsg); + } + } catch (e) { + debugPrint('${LogTime.now()} ❌ [MowerRealtimeDataSource] 解析消息失败: $e'); + } + } + + void dispose() { + stopListening(); + _subscription?.cancel(); + _subscription = null; + _vehicleController.close(); + _locationController.close(); + _routeController.close(); + } +} diff --git a/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart b/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart index 69336a35..042f6441 100644 --- a/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart +++ b/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:mqtt_client/mqtt_client.dart' as mqtt; import 'package:mqtt_client/mqtt_server_client.dart' as mqtt_server; +import '../../../../logging/log_time.dart'; import '../../domain/interfaces/mqtt_client.dart'; import '../../domain/models/mqtt_config.dart'; import '../../domain/models/mqtt_message.dart'; @@ -13,6 +14,9 @@ class MqttClientImpl implements MqttClient { final _messageController = StreamController.broadcast(); final Map _subscriptionRefs = {}; + /// 客户端名称,用于日志区分(如 droneOsdClient / taskMessageClient / mowerRealtimeClient) + final String clientName; + MqttConfig? _currentConfig; StreamSubscription? _updatesSubscription; Timer? _reconnectTimer; @@ -20,6 +24,8 @@ class MqttClientImpl implements MqttClient { bool _isConnecting = false; bool _manualDisconnect = false; + MqttClientImpl({this.clientName = 'MqttClient'}); + @override Stream? get messageStream => _messageController.stream; @@ -50,13 +56,13 @@ class MqttClientImpl implements MqttClient { _client = _createClient(config); _configureClient(_client!, config); - debugPrint('[MqttClient] connecting to ${config.connectionAddress}'); + debugPrint('${LogTime.now()} 🔌 [$clientName] 正在连接 ${config.connectionAddress}:${config.port} (clientId=${config.clientId})'); await _client!.connect(config.username, config.password); if (_client!.connectionStatus?.state == mqtt.MqttConnectionState.connected) { _isConnected = true; - debugPrint('[MqttClient] connected'); + debugPrint('${LogTime.now()} ✅ [$clientName] 连接成功 ${config.connectionAddress}:${config.port}'); _listenToMessages(); _resubscribeAll(); } else { @@ -65,7 +71,7 @@ class MqttClientImpl implements MqttClient { ); } } catch (e) { - debugPrint('[MqttClient] connect error: $e'); + debugPrint('${LogTime.now()} ❌ [$clientName] 连接失败: $e'); _isConnected = false; _isConnecting = false; _scheduleReconnect(); @@ -153,7 +159,7 @@ class MqttClientImpl implements MqttClient { return; } - debugPrint('[MqttClient] subscribe: $topic'); + debugPrint('${LogTime.now()} 📮 [$clientName] 发起订阅: $topic'); _client!.subscribe(topic, mqtt.MqttQos.atLeastOnce); } @@ -173,7 +179,7 @@ class MqttClientImpl implements MqttClient { return; } - debugPrint('[MqttClient] unsubscribe: $topic'); + debugPrint('${LogTime.now()} 🔕 [$clientName] 退订: $topic'); _client!.unsubscribe(topic); } @@ -198,7 +204,7 @@ class MqttClientImpl implements MqttClient { (msg.payload as mqtt.MqttPublishMessage).payload.message, ); - debugPrint('[MqttClient] received [$topic]: $payload'); + debugPrint('${LogTime.now()} 📥 [$clientName] 收到消息 [$topic]: $payload'); _messageController.add(MqttMessage(topic: topic, payload: payload)); } }, @@ -215,7 +221,7 @@ class MqttClientImpl implements MqttClient { if (_subscriptionRefs.isEmpty || _client == null) return; for (final topic in _subscriptionRefs.keys) { - debugPrint('[MqttClient] resubscribe: $topic'); + debugPrint('${LogTime.now()} 📮 [$clientName] 重连后重新订阅: $topic'); _client!.subscribe(topic, mqtt.MqttQos.atLeastOnce); } } @@ -226,7 +232,7 @@ class MqttClientImpl implements MqttClient { } void _onDisconnected() { - debugPrint('[MqttClient] disconnected callback'); + debugPrint('${LogTime.now()} ⚠️ [$clientName] 连接断开'); _isConnected = false; if (!_manualDisconnect) { _scheduleReconnect(); @@ -234,7 +240,7 @@ class MqttClientImpl implements MqttClient { } void _onSubscribed(String topic) { - debugPrint('[MqttClient] subscribed: $topic'); + debugPrint('${LogTime.now()} ✅ [$clientName] 订阅确认成功: $topic'); } void _scheduleReconnect() { @@ -246,12 +252,12 @@ class MqttClientImpl implements MqttClient { if (_reconnectTimer?.isActive ?? false) return; final delay = Duration(milliseconds: config.reconnectDelayMs); - debugPrint('[MqttClient] reconnect scheduled in ${delay.inMilliseconds}ms'); + debugPrint('${LogTime.now()} ⏳ [$clientName] ${delay.inMilliseconds}ms 后尝试重连'); _reconnectTimer = Timer(delay, () async { if (_manualDisconnect || _isConnected || _isConnecting) return; try { - debugPrint('[MqttClient] reconnecting...'); + debugPrint('${LogTime.now()} 🔄 [$clientName] 开始重连...'); await connect(config); } catch (e) { debugPrint('[MqttClient] reconnect failed: $e'); diff --git a/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart b/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart new file mode 100644 index 00000000..992237ac --- /dev/null +++ b/lib/core/network/mqtt/data/repositories/mower_realtime_repository_impl.dart @@ -0,0 +1,42 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../datasources/mower_realtime_datasource.dart'; +import '../../domain/repositories/mower_realtime_repository.dart'; +import '../../domain/entities/mower_location_entity.dart'; +import '../../domain/entities/real_time_message_entity.dart'; +import '../../domain/entities/route_status_entity.dart'; + +class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository { + final MowerRealtimeDataSource dataSource; + + MowerRealtimeRepositoryImpl(this.dataSource); + + @override + Stream get vehicleStream => dataSource.vehicleStream; + + @override + Stream get locationStream => dataSource.locationStream; + + @override + Stream get routeStatusStream => + dataSource.routeStatusStream; + + @override + Future> startListening({ + required String deviceSn, + }) async { + try { + await dataSource.startListening(deviceSn: deviceSn); + return right(null); + } catch (e) { + return left(Failure(e.toString())); + } + } + + @override + Future stopListening() async { + await dataSource.stopListening(); + } +} diff --git a/lib/core/network/mqtt/domain/entities/mower_location_entity.dart b/lib/core/network/mqtt/domain/entities/mower_location_entity.dart new file mode 100644 index 00000000..f7d7bd73 --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/mower_location_entity.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; + +/// 割草机定位消息实体(MQTT 主题 mower/{sn}/property/location/post,5-10Hz) +/// +/// 报文格式尚未最终确认(第一阶段仅打印),fromLen 对常见字段名做宽容解析, +/// 确认后如与实际不符在此调整即可,不影响上层。 +class MowerLocationEntity extends Equatable { + final double? latitude; + final double? longitude; + final double? altitude; + + /// 航向角 + final double? heading; + + /// 速度 + final double? speed; + final String? timestamp; + + /// 原始报文,便于解析格式确认后补充字段 + final Map rawData; + + const MowerLocationEntity({ + this.latitude, + this.longitude, + this.altitude, + this.heading, + this.speed, + this.timestamp, + this.rawData = const {}, + }); + + bool get isValid => latitude != null && longitude != null; + + factory MowerLocationEntity.fromJson(Map json) { + return MowerLocationEntity( + latitude: _toDouble(json['latitude'] ?? json['lat']), + longitude: _toDouble(json['longitude'] ?? json['lng'] ?? json['lon']), + altitude: _toDouble(json['altitude'] ?? json['height']), + heading: _toDouble(json['heading'] ?? json['yaw']), + speed: _toDouble(json['speed']), + timestamp: json['timestamp']?.toString(), + rawData: json, + ); + } + + static double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + + @override + List get props => [latitude, longitude, altitude, heading, speed, timestamp]; +} diff --git a/lib/core/network/mqtt/domain/entities/route_status_entity.dart b/lib/core/network/mqtt/domain/entities/route_status_entity.dart new file mode 100644 index 00000000..4f72c0ff --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/route_status_entity.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; + +/// MQTT 路径规划消息实体 +/// 对应后端 Java DTO: WebRouteStatusMessageDTO +/// Topic: device/{deviceId}/route/post +class RouteStatusEntity extends Equatable { + final List data; + final String deviceId; + final int taskId; + final String type; + + const RouteStatusEntity({ + required this.data, + required this.deviceId, + required this.taskId, + required this.type, + }); + + factory RouteStatusEntity.fromJson(Map json) { + final dataList = json['data'] as List? ?? []; + return RouteStatusEntity( + data: dataList.map((item) => LatAndLngEntity.fromJson(item)).toList(), + deviceId: json['deviceId'] as String? ?? '', + taskId: (json['taskId'] as num?)?.toInt() ?? 0, + type: json['type'] as String? ?? '', + ); + } + + @override + List get props => [data, deviceId, taskId, type]; +} + +/// 经纬度坐标实体 +/// 对应后端 Java DTO: LatAndLngEntity +class LatAndLngEntity extends Equatable { + final double lat; + final double lng; + + const LatAndLngEntity({required this.lat, required this.lng}); + + factory LatAndLngEntity.fromJson(Map json) { + return LatAndLngEntity( + lat: (json['lat'] as num?)?.toDouble() ?? 0.0, + lng: (json['lng'] as num?)?.toDouble() ?? 0.0, + ); + } + + @override + List get props => [lat, lng]; +} diff --git a/lib/core/network/mqtt/domain/models/mqtt_config.dart b/lib/core/network/mqtt/domain/models/mqtt_config.dart index 49bf5051..8447c12f 100644 --- a/lib/core/network/mqtt/domain/models/mqtt_config.dart +++ b/lib/core/network/mqtt/domain/models/mqtt_config.dart @@ -81,6 +81,21 @@ class MqttConfig extends Equatable { ); } + /// 🔥 割草机机器状态(TCP MQTT)——独立连接,地址 1.95.137.212:59007 + /// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post + factory MqttConfig.mowerRealtime() { + return const MqttConfig( + host: '1.95.137.212', + port: 59007, + username: 'maibu', + password: 'jsmbzn520', + protocol: MqttProtocol.tcp, + clientId: 'mower_realtime_client', + cleanSession: true, + reconnectDelayMs: 3000, + ); + } + MqttConfig copyWith({ String? host, int? port, diff --git a/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart b/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart new file mode 100644 index 00000000..b194432c --- /dev/null +++ b/lib/core/network/mqtt/domain/repositories/mower_realtime_repository.dart @@ -0,0 +1,19 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../entities/mower_location_entity.dart'; +import '../entities/real_time_message_entity.dart'; +import '../entities/route_status_entity.dart'; + +abstract class MowerRealtimeRepository { + Stream get vehicleStream; + Stream get locationStream; + Stream get routeStatusStream; + + Future> startListening({ + required String deviceSn, + }); + + Future stopListening(); +} diff --git a/lib/core/network/mqtt/mqtt_manager.dart b/lib/core/network/mqtt/mqtt_manager.dart index 8d4ef7f4..9bfaf123 100644 --- a/lib/core/network/mqtt/mqtt_manager.dart +++ b/lib/core/network/mqtt/mqtt_manager.dart @@ -24,10 +24,12 @@ class MqttManager { final droneOsdClient = sl(instanceName: 'droneOsdClient'); final taskMessageClient = sl(instanceName: 'taskMessageClient'); + final mowerRealtimeClient = sl(instanceName: 'mowerRealtimeClient'); - // 独立连接两个 MQTT 客户端,互不影响 + // 独立连接三个 MQTT 客户端,互不影响 await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient'); await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient'); + await _connectClient(mowerRealtimeClient, MqttConfig.mowerRealtime(), 'mowerRealtimeClient'); _isInitialized = true; debugPrint('✅ [MqttManager] MQTT 初始化完成'); @@ -53,9 +55,11 @@ class MqttManager { final droneOsdClient = sl(instanceName: 'droneOsdClient'); final taskMessageClient = sl(instanceName: 'taskMessageClient'); + final mowerRealtimeClient = sl(instanceName: 'mowerRealtimeClient'); await droneOsdClient.disconnect(); await taskMessageClient.disconnect(); + await mowerRealtimeClient.disconnect(); _isInitialized = false; debugPrint('✅ [MqttManager] 所有 MQTT 连接已断开'); diff --git a/lib/core/update/version_check_service.dart b/lib/core/update/version_check_service.dart index e0b46332..1a324cc9 100644 --- a/lib/core/update/version_check_service.dart +++ b/lib/core/update/version_check_service.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_patcher/flutter_patcher.dart'; import 'package:logger/logger.dart'; import 'package:path_provider/path_provider.dart'; @@ -56,6 +57,7 @@ class VersionCheckService { final Dio _dio = Dio(BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), + validateStatus: (status) => true, )); static const String apiUrl = 'http://8.159.134.0:8012/api/update/check'; @@ -67,29 +69,26 @@ class VersionCheckService { required int currentVersionCode, }) async { try { - _logger.i('🔍 检查版本更新'); - _logger.i('📱 APK 自带版本: $currentVersion ($currentVersionCode)'); + debugPrint(''); + debugPrint('========================================'); + debugPrint(' UPDATE CHECK START'); + debugPrint('========================================'); + debugPrint('[1] APK version: $currentVersion ($currentVersionCode)'); // 从文件读取已应用的补丁版本 final patchInfo = await _loadPatchVersionInfo(); final appliedPatchVersion = patchInfo['version'] as String?; final appliedPatchVersionCode = patchInfo['versionCode'] as int?; - _logger.i('📂 读取到的补丁版本: $appliedPatchVersion ($appliedPatchVersionCode)'); + debugPrint('[2] Patch file version: $appliedPatchVersion ($appliedPatchVersionCode)'); - // 如果打过补丁,使用补丁的版本;否则使用 APK 自带版本 - final requestVersion = appliedPatchVersion ?? currentVersion; - final requestVersionCode = appliedPatchVersionCode ?? currentVersionCode; + // 始终用 APK 基础版本请求服务端,让服务端返回最新版本 + // 客户端自己判断本地是否已经是最新 + final requestVersion = currentVersion; + final requestVersionCode = currentVersionCode; - _logger.i('🚀 请求后端版本: $requestVersion ($requestVersionCode)'); - - // 🔥 打印完整的请求参数 - _logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - _logger.i('📤 App 发送的请求参数:'); - _logger.i(' URL: $apiUrl'); - _logger.i(' version: $requestVersion'); - _logger.i(' versionCode: $requestVersionCode'); - _logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + debugPrint('[3] Request to server: version=$requestVersion, versionCode=$requestVersionCode'); + debugPrint('[4] URL: $apiUrl?version=$requestVersion&versionCode=$requestVersionCode'); final response = await _dio.get( apiUrl, @@ -99,47 +98,67 @@ class VersionCheckService { }, ); - // 🔥 打印完整的后端返回数据 - _logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - _logger.i('📥 后端返回的完整数据:'); - _logger.i(' statusCode: ${response.statusCode}'); - _logger.i(' 原始响应: ${response.data}'); - _logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + debugPrint('[5] HTTP status: ${response.statusCode}'); + debugPrint('[6] Response body: ${response.data}'); - if (response.statusCode == 200) { - final data = response.data['data']; + final respCode = response.data['code']; + final data = response.data['data']; + final message = response.data['message'] ?? ''; - if (data['hasUpdate'] == false) { - _logger.i('✅ 已是最新版本'); - return null; - } - - final versionInfo = AppVersionInfo.fromJson(data); - - // 🔥 关键修复:无论更新类型是什么,只要本地已应用此版本,就跳过 - if (appliedPatchVersion != null && appliedPatchVersion == versionInfo.version) { - _logger.i('✅ 本地已应用版本 ${versionInfo.version},跳过更新(updateType: ${versionInfo.updateType})'); - return null; - } - - // 🔥 额外检查:如果本地补丁版本code >= 服务端返回的versionCode,也跳过 - if (appliedPatchVersionCode != null && appliedPatchVersionCode >= versionInfo.versionCode) { - _logger.i('✅ 本地补丁版本code ($appliedPatchVersionCode) >= 服务端versionCode (${versionInfo.versionCode}),跳过更新'); - return null; - } - - _logger.i('✅ 发现新版本: ${versionInfo.version}'); - _logger.i('🔄 更新类型: ${versionInfo.updateType}'); - _logger.i('⚠️ 强制更新: ${versionInfo.forceUpdate}'); - - return versionInfo; + debugPrint('[7] respCode=$respCode, data=$data, message=$message'); + + if (respCode != 200 || data == null) { + debugPrint('[RESULT] No update - server returned code=$respCode, data=${data == null ? "NULL" : "NOT_NULL"}, message=$message'); + debugPrint('========================================'); + debugPrint(''); + return null; } + + debugPrint('[7.1] data type: ${data.runtimeType}'); + debugPrint('[7.2] data keys: ${data is Map ? data.keys.toList() : "NOT A MAP"}'); + debugPrint('[7.3] hasUpdate = ${data['hasUpdate']} (type: ${data['hasUpdate']?.runtimeType})'); + debugPrint('[7.4] version = ${data['version']}'); + debugPrint('[7.5] versionCode = ${data['versionCode']}'); + debugPrint('[7.6] updateType = ${data['updateType']}'); + debugPrint('[7.7] patch = ${data['patch']}'); + debugPrint('[7.8] fullApk = ${data['fullApk']}'); + + if (data['hasUpdate'] == false) { + debugPrint('[RESULT] No update - hasUpdate=false'); + debugPrint('========================================'); + debugPrint(''); + return null; + } + + final versionInfo = AppVersionInfo.fromJson(data); + debugPrint('[8] Server version: ${versionInfo.version} (${versionInfo.versionCode})'); + debugPrint('[9] Update type: ${versionInfo.updateType}, force: ${versionInfo.forceUpdate}'); + debugPrint('[10] patchUrl: ${versionInfo.patchUrl}'); + debugPrint('[11] patchMd5: ${versionInfo.patchMd5}'); + debugPrint('[12] apkUrl: ${versionInfo.apkUrl}'); + // 只用版本字符串判断是否已更新:服务端 hasUpdate=true 说了算 + if (appliedPatchVersion != null && appliedPatchVersion == versionInfo.version) { + debugPrint('[RESULT] No update - already at version ${versionInfo.version}'); + debugPrint('========================================'); + debugPrint(''); + return null; + } + + debugPrint('[8.1] Local patch version: ${appliedPatchVersion ?? "none"}'); + debugPrint('[8.2] Server version: ${versionInfo.version}'); + debugPrint('[8.3] Version different: ${appliedPatchVersion != versionInfo.version}'); + + debugPrint('[RESULT] NEW VERSION FOUND: ${versionInfo.version} (${versionInfo.versionCode})'); + debugPrint('========================================'); + debugPrint(''); + + return versionInfo; + } catch (e) { + debugPrint('[RESULT] ERROR: $e'); + debugPrint('========================================'); + debugPrint(''); return null; - } catch (e, stack) { - _logger.e('❌ 版本检查失败: $e'); - _logger.e('❌ 堆栈信息: $stack'); - throw Exception('网络请求失败: $e'); } } diff --git a/lib/features/devices/data/datasources/device_task_datasource.dart b/lib/features/devices/data/datasources/device_task_datasource.dart index 38c4d4c2..773472ec 100644 --- a/lib/features/devices/data/datasources/device_task_datasource.dart +++ b/lib/features/devices/data/datasources/device_task_datasource.dart @@ -19,6 +19,7 @@ abstract class DeviceTaskDatasource { required int taskId, required int orgId, required int siteId, + bool forceCancel = true, }); Future pauseTask({ @@ -90,6 +91,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { required int taskId, required int orgId, required int siteId, + bool forceCancel = true, }) async { final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask'; final body = { @@ -97,6 +99,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource { 'taskId': taskId, 'orgId': orgId, 'siteId': siteId, + 'forceCancel': forceCancel, }; _logger.logWithLevel('[cancelTask] 请求: POST $url'); _logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}'); diff --git a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart index 0f4a39f5..7f8f0178 100644 --- a/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart +++ b/lib/features/devices/data/datasources/impl/device_http_datasource_impl.dart @@ -34,7 +34,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { logger.logWithLevel( '用户点击bindDevice方法API 请求开始', level: 'INFO', - data: {'url': 'http://1.95.137.212:59003/iot/device/bindDevice'}, + data: {'url': '${HttpApiConsts.baseUrl}/iot/device/bindDevice'}, ); var response = await dio.post( HttpApiConsts.bindDevice, @@ -44,7 +44,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { '请求详细参数', level: 'DEBUG', data: { - 'url': 'http://1.95.137.212:59003/iot/device/bindDevice', + 'url': '${HttpApiConsts.baseUrl}/iot/device/bindDevice', 'deviceId': deviceId, 'deviceAlias': deviceAlias, }, @@ -122,7 +122,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { logger.logWithLevel( '用户点击switchDevice方法API 请求开始', level: 'INFO', - data: {'url': 'http://1.95.137.212:59003/iot/device/switchDevice'}, + data: {'url': '${HttpApiConsts.baseUrl}/iot/device/switchDevice'}, ); var response = await dio.post( HttpApiConsts.switchDevice, @@ -138,7 +138,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { '请求详细参数', level: 'DEBUG', data: { - 'url': 'http://1.95.137.212:59003/iot/device/switchDevice', + 'url': '${HttpApiConsts.baseUrl}/iot/device/switchDevice', 'platform': platform, 'deviceId': deviceId, }, @@ -219,12 +219,12 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource { '请求详细参数', level: 'DEBUG', data: { - 'url': 'http://1.95.137.212:59003/iot/device/userDevice', + 'url': '${HttpApiConsts.baseUrl}/iot/device/userDevice', 'tenantName': deviceame, }, ); final response = await dio.get( - 'http://1.95.137.212:59003/iot/device/userDevice', + '${HttpApiConsts.baseUrl}/iot/device/userDevice', queryParameters: {'tenantName': deviceame}, // options: Options( // headers: { diff --git a/lib/features/devices/data/repositories/device_hostriry_work_impl.dart b/lib/features/devices/data/repositories/device_hostriry_work_impl.dart index 11a528f7..bd4d4bd1 100644 --- a/lib/features/devices/data/repositories/device_hostriry_work_impl.dart +++ b/lib/features/devices/data/repositories/device_hostriry_work_impl.dart @@ -11,6 +11,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.d import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_hostrity_work_repository.dart'; import '../../../../core/logging/i_logger_service.dart'; +import '../../../../core/consts/http_api_consts.dart'; class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRepository { final Dio client; @@ -35,7 +36,7 @@ class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRe _logger.logWithLevel('用户信息获取成功,Token: $token', level: 'info'); final response = await client.get( - 'http://1.95.137.212:59003/iot/device/getDeviceRunStatistics?deviceId=$deviceId', + '${HttpApiConsts.baseUrl}/iot/device/getDeviceRunStatistics?deviceId=$deviceId', options: Options(headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'}), ); diff --git a/lib/features/devices/data/repositories/device_task_repository_impl.dart b/lib/features/devices/data/repositories/device_task_repository_impl.dart index e8ae8b62..10c4f076 100644 --- a/lib/features/devices/data/repositories/device_task_repository_impl.dart +++ b/lib/features/devices/data/repositories/device_task_repository_impl.dart @@ -37,6 +37,7 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository { required int taskId, required int orgId, required int siteId, + bool forceCancel = true, }) async { try { final result = await datasource.cancelTask( @@ -44,6 +45,7 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository { taskId: taskId, orgId: orgId, siteId: siteId, + forceCancel: forceCancel, ); return Right(result); } catch (e) { diff --git a/lib/features/devices/domain/repositories/device_task_repository.dart b/lib/features/devices/domain/repositories/device_task_repository.dart index 8056895f..802846c2 100644 --- a/lib/features/devices/domain/repositories/device_task_repository.dart +++ b/lib/features/devices/domain/repositories/device_task_repository.dart @@ -18,6 +18,7 @@ abstract class DeviceTaskRepository { required int taskId, required int orgId, required int siteId, + bool forceCancel = true, }); /// 暂停任务 diff --git a/lib/features/devices/domain/usecases/cancel_task_usecase.dart b/lib/features/devices/domain/usecases/cancel_task_usecase.dart index 91b87a22..83de3a48 100644 --- a/lib/features/devices/domain/usecases/cancel_task_usecase.dart +++ b/lib/features/devices/domain/usecases/cancel_task_usecase.dart @@ -15,6 +15,7 @@ class CancelTaskUseCase implements BaseUseCase { taskId: params.taskId, orgId: params.orgId, siteId: params.siteId, + forceCancel: params.forceCancel, ); } } @@ -24,11 +25,13 @@ class CancelTaskParams { final int taskId; final int orgId; final int siteId; + final bool forceCancel; CancelTaskParams({ required this.deviceId, required this.taskId, required this.orgId, required this.siteId, + this.forceCancel = true, }); } diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart index 2ca3eeb8..5725be3a 100644 --- a/lib/features/devices/presentation/bloc/device_status_bloc.dart +++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart @@ -7,10 +7,12 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dart'; import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart'; import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/task_message_repository.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart'; import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_arrive_entity.dart'; import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_status_entity.dart'; import '../../../../core/logging/i_logger_service.dart'; +import '../../../../core/logging/log_time.dart'; import '../../../../core/network/net_message_dispatcher.dart'; import '../../../../core/network/tcp/tcp_client.dart'; import 'device_status_event.dart'; @@ -20,6 +22,10 @@ import 'devices_cubit.dart'; // 定义全局的TaskMessageRepository获取方式 TaskMessageRepository get _taskMessageRepo => GetIt.I(); +// 🔥 MQTT机器状态数据源(替代TCP 0x02) +MowerRealtimeRepository get _mowerRealtimeRepo => + GetIt.I(); + class DeviceStatusBloc extends Bloc { final NetMessageDispatcher _dispatcher; final TcpClient tcpClient; // 🔥 新增:直接访问 TcpClient @@ -30,6 +36,10 @@ class DeviceStatusBloc extends Bloc { StreamSubscription? _mqttArriveSubscription; StreamSubscription? _mqttStatusSubscription; + // 🔥 MQTT机器状态订阅(车辆实时+定位,替代TCP 0x02) + StreamSubscription? _mqttVehicleSubscription; + StreamSubscription? _mqttLocationSubscription; + // 🔥 节流相关:500ms节流控制0x02数据推送频率 Timer? _throttleTimer; static const _throttleDuration = Duration(milliseconds: 500); @@ -42,11 +52,17 @@ class DeviceStatusBloc extends Bloc { // 🔥 当前监听的设备ID String? _currentDeviceId; + // 🔥 MQTT机器状态收帧计数(用于链路日志) + int _mqttVehicleFrameCount = 0; + int _mqttLocationFrameCount = 0; + DeviceStatusBloc(this._dispatcher, {TcpClient? client}) : tcpClient = client ?? GetIt.I(), super(DeviceStatusInitial()) { // 🔥 核心改动:直接在构造函数中建立TCP监听,类似RemoteControlCubit - _initDirectTcpListener(); + // 🔥 已停用:机器状态数据源已改为MQTT(见 startMqttRealtimeListening, + // 由用户切换设备时触发)。TCP监听逻辑保留,回滚时取消下行注释即可。 + // _initDirectTcpListener(); // 🔥 初始化MQTT到达点监听 _initMqttArriveListener(); @@ -191,6 +207,86 @@ class DeviceStatusBloc extends Bloc { _logger.log('✅ [DeviceStatusBloc] 直接TCP监听器已建立完成'); } + /// 🔥 MQTT机器状态监听(替代TCP 0x02数据源) + /// + /// [deviceSn] 与TCP时代 switchDevice / connectBySwitch 的设备号获取方式一致 + /// (即 DeviceEntity.deviceName),由用户点击设备列表切换设备时调用。 + /// + /// 第一阶段:订阅并打印原始报文;确认格式后在第二阶段解析为 + /// RunningStatusEntity + GPSEntity,复用现有500ms节流逻辑。 + void startMqttRealtimeListening(String deviceSn) { + if (deviceSn.isEmpty) { + debugPrint('${LogTime.now()} ⚠️ [DeviceStatusBloc] MQTT订阅跳过:设备SN为空'); + return; + } + + debugPrint('${LogTime.now()} 🚀 [DeviceStatusBloc] 启动MQTT机器状态监听: $deviceSn'); + + // 按SN订阅(数据源内部自动退旧订新、同SN去重) + _mowerRealtimeRepo.startListening(deviceSn: deviceSn).then((result) { + result.fold( + (failure) => debugPrint( + '${LogTime.now()} ❌ [DeviceStatusBloc] MQTT机器状态订阅失败: ${failure.message}', + ), + (_) => debugPrint( + '${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态订阅完成: $deviceSn', + ), + ); + }); + + // 切换设备时重置收帧计数,便于观察新设备的首帧与频率 + _mqttVehicleFrameCount = 0; + _mqttLocationFrameCount = 0; + + // 流监听只建立一次,第一阶段仅打印解析结果验证链路 + _mqttVehicleSubscription ??= _mowerRealtimeRepo.vehicleStream.listen(( + message, + ) { + _mqttVehicleFrameCount++; + debugPrint( + '${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_mqttVehicleFrameCount帧 type=${message.type} 数据点=${message.data.length}', + ); + // TODO 第二阶段:将数据点映射到 RunningStatusEntity 字段,复用500ms节流emit + }); + + _mqttLocationSubscription ??= _mowerRealtimeRepo.locationStream.listen(( + location, + ) { + _mqttLocationFrameCount++; + debugPrint( + '${LogTime.now()} 📍 [MQTT-location] 定位第$_mqttLocationFrameCount帧 lat=${location.latitude}, lng=${location.longitude}, heading=${location.heading}', + ); + + // 🔥 核心:用 MQTT location 数据更新缓存并节流 emit + if (location.isValid) { + // 更新 _cachedGps + _cachedGps = GPSEntity(location.latitude!, location.longitude!); + + // 更新 _cachedStatus 中的航向角和坐标(复用已有缓存或新建空壳) + final base = _cachedStatus ?? RunningStatusEntity(); + _cachedStatus = base.copyWith( + latitude: location.latitude!, + longitude: location.longitude!, + yaw: location.heading ?? base.yaw, + ); + + // 500ms节流:与 TCP 0x02 时代逻辑一致 + if (_throttleTimer == null || !_throttleTimer!.isActive) { + _throttleTimer = Timer(_throttleDuration, () { + _emitCachedStatus(); + }); + } + } else { + debugPrint( + '${LogTime.now()} ⚠️ [MQTT-location] 坐标无效,跳过 - lat=${location.latitude}, lng=${location.longitude}', + ); + } + }); + + debugPrint('${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn'); + _logger.log('✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn'); + } + // 🔥 初始化MQTT到达点监听 void _initMqttArriveListener() { debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT到达点监听器'); @@ -304,7 +400,7 @@ class DeviceStatusBloc extends Bloc { // 🔥 节流发射:500ms到期后发射缓存的最新数据 void _emitCachedStatus() { if (_cachedStatus != null && _cachedGps != null && !isClosed) { - debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}'); + debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) yaw=${_cachedStatus!.yaw} | ${DateTime.now().toString().substring(11, 19)}'); emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!)); } } @@ -423,6 +519,12 @@ class DeviceStatusBloc extends Bloc { _mqttStatusSubscription?.cancel(); _mqttStatusSubscription = null; + // 🔥 清理MQTT机器状态订阅 + _mqttVehicleSubscription?.cancel(); + _mqttVehicleSubscription = null; + _mqttLocationSubscription?.cancel(); + _mqttLocationSubscription = null; + // 🔥 清理节流timer和缓存 _throttleTimer?.cancel(); _throttleTimer = null; diff --git a/lib/features/devices/presentation/bloc/device_task_cubit.dart b/lib/features/devices/presentation/bloc/device_task_cubit.dart index c602cfd6..eb314c24 100644 --- a/lib/features/devices/presentation/bloc/device_task_cubit.dart +++ b/lib/features/devices/presentation/bloc/device_task_cubit.dart @@ -34,21 +34,17 @@ class DeviceTaskCubit extends Cubit { final userCubit = GetIt.I(); final user = userCubit.state.user; if (user == null) { - emit(state.copyWith( - isLoading: false, - errorMessage: '用户未登录', - )); + emit(state.copyWith(isLoading: false, errorMessage: '用户未登录')); return; } - // 获取场站ID + // 获取场站信息(siteId 和 orgId 都从当前场站取) final siteCubit = GetIt.I(); - final siteId = siteCubit.state.selectedSite?.id; + final selectedSite = siteCubit.state.selectedSite; + final siteId = selectedSite?.id; + final orgId = selectedSite?.orgId ?? 0; if (siteId == null) { - emit(state.copyWith( - isLoading: false, - errorMessage: '未选择场站', - )); + emit(state.copyWith(isLoading: false, errorMessage: '未选择场站')); return; } @@ -57,125 +53,153 @@ class DeviceTaskCubit extends Cubit { GetDeviceTaskPoolParams( userId: user.userId ?? '', siteId: siteId, - orgId: user.orgId ?? 0, + orgId: orgId, ), ); result.fold( (failure) { _logger.logWithLevel('❌ 获取任务池失败: ${failure.message}'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(failure.message), - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(failure.message), + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); }, (taskList) { - // 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE) + // 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE, CANCELING, FAILED) final activeTasks = taskList.where((task) { // 1. 设备号匹配 if (task.deviceId != deviceId) return false; - - // 2. 状态过滤:只保留新建、执行中、暂停中的任务 + + // 2. 状态过滤:保留新建、执行中、暂停中、取消中、执行失败的任务 final status = task.taskStatus; - return status == 'NEW' || // 新建 - status == 'EXECUTING' || // 执行中 - status == 'PAUSE'; // 暂停中 + return status == 'NEW' || // 新建 + status == 'EXECUTING' || // 执行中 + status == 'PAUSE' || // 暂停中 + status == 'CANCELING' || // 取消中(用户可处理) + status == 'FAILED'; // 执行失败(用户可恢复或取消) }).toList(); _logger.logWithLevel( - '✅ 找到 ${activeTasks.length} 个活跃任务', + '✅ [fetchAndFilterTask] deviceId=$deviceId, 任务池总数=${taskList.length}, 过滤后活跃任务数=${activeTasks.length}', ); + for (final t in taskList) { + _logger.logWithLevel( + ' - taskId=${t.id}, deviceId=${t.deviceId}, status=${t.taskStatus}', + ); + } // 🔥 如果有多个任务,保存所有选项供用户选择 // 如果只有1个,直接选中 final currentTask = activeTasks.isNotEmpty ? activeTasks.first : null; - emit(state.copyWith( - isLoading: false, - taskPool: taskList, - currentTask: currentTask, - currentTaskId: currentTask?.id, - activeTasks: activeTasks, - )); + emit( + state.copyWith( + isLoading: false, + taskPool: taskList, + currentTask: currentTask, + currentTaskId: currentTask?.id, + activeTasks: activeTasks, + ), + ); }, ); } catch (e) { _logger.logWithLevel('❌ 获取任务池异常: $e'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(e), - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(e), + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); } } /// 取消任务 - Future cancelTask(String deviceId) async { + Future cancelTask(String deviceId, {bool forceCancel = true}) async { final taskId = state.currentTaskId; if (taskId == null) { emit(state.copyWith(errorMessage: '无可用任务')); return; } - emit(state.copyWith( - isLoading: true, - operationType: DeviceTaskOperationType.cancel, - )); + emit( + state.copyWith( + isLoading: true, + operationType: DeviceTaskOperationType.cancel, + ), + ); try { final userCubit = GetIt.I(); final user = userCubit.state.user; final siteCubit = GetIt.I(); - final siteId = siteCubit.state.selectedSite?.id; + final selectedSite = siteCubit.state.selectedSite; + final siteId = selectedSite?.id; + final orgId = selectedSite?.orgId ?? 0; if (user == null || siteId == null) { - emit(state.copyWith( - isLoading: false, - errorMessage: '参数不完整', - operationType: DeviceTaskOperationType.none, - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: '参数不完整', + operationType: DeviceTaskOperationType.none, + ), + ); return; } final params = CancelTaskParams( deviceId: deviceId, taskId: taskId, - orgId: user.orgId ?? 0, + orgId: orgId, siteId: siteId, + forceCancel: forceCancel, ); _logger.logWithLevel('[取消任务] 请求: POST /iot/deviceTask/cancelTask'); - _logger.logWithLevel('[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId'); + _logger.logWithLevel( + '[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId, forceCancel=$forceCancel', + ); final result = await _cancelTaskUseCase.call(params); result.fold( (failure) { _logger.logWithLevel('[取消任务] 响应失败: ${failure.message}'); _logger.logWithLevel('❌ 取消任务失败: ${failure.message}'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(failure.message), - operationType: DeviceTaskOperationType.none, - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(failure.message), + operationType: DeviceTaskOperationType.none, + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); }, (success) { _logger.logWithLevel('[取消任务] 响应成功: $success'); _logger.logWithLevel('✅ 取消任务成功'); - emit(state.copyWith( - isLoading: false, - operationType: DeviceTaskOperationType.none, - )); + emit( + state.copyWith( + isLoading: false, + operationType: DeviceTaskOperationType.none, + ), + ); }, ); } catch (e) { _logger.logWithLevel('❌ 取消任务异常: $e'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(e), - operationType: DeviceTaskOperationType.none, - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(e), + operationType: DeviceTaskOperationType.none, + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); } } @@ -187,64 +211,78 @@ class DeviceTaskCubit extends Cubit { return; } - emit(state.copyWith( - isLoading: true, - operationType: DeviceTaskOperationType.pause, - )); + emit( + state.copyWith( + isLoading: true, + operationType: DeviceTaskOperationType.pause, + ), + ); try { final userCubit = GetIt.I(); final user = userCubit.state.user; final siteCubit = GetIt.I(); - final siteId = siteCubit.state.selectedSite?.id; + final selectedSite = siteCubit.state.selectedSite; + final siteId = selectedSite?.id; + final orgId = selectedSite?.orgId ?? 0; if (user == null || siteId == null) { - emit(state.copyWith( - isLoading: false, - errorMessage: '参数不完整', - operationType: DeviceTaskOperationType.none, - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: '参数不完整', + operationType: DeviceTaskOperationType.none, + ), + ); return; } final params2 = PauseTaskParams( deviceId: deviceId, taskId: taskId, - orgId: user.orgId ?? 0, + orgId: orgId, siteId: siteId, ); _logger.logWithLevel('[暂停任务] 请求: POST /iot/deviceTask/pauseTask'); - _logger.logWithLevel('[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId'); + _logger.logWithLevel( + '[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId', + ); final result = await _pauseTaskUseCase.call(params2); result.fold( (failure) { _logger.logWithLevel('[暂停任务] 响应失败: ${failure.message}'); _logger.logWithLevel('❌ 暂停任务失败: ${failure.message}'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(failure.message), - operationType: DeviceTaskOperationType.none, - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(failure.message), + operationType: DeviceTaskOperationType.none, + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); }, (success) { _logger.logWithLevel('[暂停任务] 响应成功: $success'); _logger.logWithLevel('✅ 暂停任务成功'); - emit(state.copyWith( - isLoading: false, - operationType: DeviceTaskOperationType.none, - )); + emit( + state.copyWith( + isLoading: false, + operationType: DeviceTaskOperationType.none, + ), + ); }, ); } catch (e) { _logger.logWithLevel('❌ 暂停任务异常: $e'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(e), - operationType: DeviceTaskOperationType.none, - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(e), + operationType: DeviceTaskOperationType.none, + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); } } @@ -256,88 +294,111 @@ class DeviceTaskCubit extends Cubit { return; } - emit(state.copyWith( - isLoading: true, - operationType: DeviceTaskOperationType.recovery, - )); + emit( + state.copyWith( + isLoading: true, + operationType: DeviceTaskOperationType.recovery, + ), + ); try { final userCubit = GetIt.I(); final user = userCubit.state.user; final siteCubit = GetIt.I(); - final siteId = siteCubit.state.selectedSite?.id; + final selectedSite = siteCubit.state.selectedSite; + final siteId = selectedSite?.id; + final orgId = selectedSite?.orgId ?? 0; if (user == null || siteId == null) { - emit(state.copyWith( - isLoading: false, - errorMessage: '参数不完整', - operationType: DeviceTaskOperationType.none, - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: '参数不完整', + operationType: DeviceTaskOperationType.none, + ), + ); return; } final params3 = RecoveryTaskParams( deviceId: deviceId, taskId: taskId, - orgId: user.orgId ?? 0, + orgId: orgId, siteId: siteId, ); _logger.logWithLevel('[恢复任务] 请求: POST /iot/deviceTask/recoveryTask'); - _logger.logWithLevel('[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId'); + _logger.logWithLevel( + '[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId', + ); final result = await _recoveryTaskUseCase.call(params3); result.fold( (failure) { _logger.logWithLevel('[恢复任务] 响应失败: ${failure.message}'); _logger.logWithLevel('❌ 恢复任务失败: ${failure.message}'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(failure.message), - operationType: DeviceTaskOperationType.none, - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(failure.message), + operationType: DeviceTaskOperationType.none, + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); }, (data) { _logger.logWithLevel('[恢复任务] 响应成功: $data'); _logger.logWithLevel('✅ 恢复任务成功: $data'); - emit(state.copyWith( - isLoading: false, - operationType: DeviceTaskOperationType.none, - )); + emit( + state.copyWith( + isLoading: false, + operationType: DeviceTaskOperationType.none, + ), + ); }, ); } catch (e) { _logger.logWithLevel('❌ 恢复任务异常: $e'); - emit(state.copyWith( - isLoading: false, - errorMessage: ErrorHandler.getErrorMessage(e), - operationType: DeviceTaskOperationType.none, - shouldShowError: true, // 🔥 标记需要显示错误弹窗 - )); + emit( + state.copyWith( + isLoading: false, + errorMessage: ErrorHandler.getErrorMessage(e), + operationType: DeviceTaskOperationType.none, + shouldShowError: true, // 🔥 标记需要显示错误弹窗 + ), + ); } } /// 更新当前任务ID(当选择新航线时调用) void updateCurrentTaskId(int taskId) { - emit(state.copyWith(currentTaskId: taskId)); - _logger.logWithLevel('🔄 更新当前任务ID: $taskId'); + // 🔥 只有 activeTasks 非空时才设置 currentTask,避免信息栏一直显示 + DeviceTaskEntity? taskEntity; + if (state.activeTasks.isNotEmpty) { + taskEntity = state.activeTasks.firstWhere( + (t) => t.id == taskId, + orElse: () => DeviceTaskEntity( + id: taskId, + deviceId: '', + taskStatus: 'EXECUTING', + taskStatusTranslate: '执行中', + ), + ); + } + emit(state.copyWith(currentTaskId: taskId, currentTask: taskEntity)); + _logger.logWithLevel( + '🔄 更新当前任务ID: $taskId, activeTasks: ${state.activeTasks.length}, currentTask: ${taskEntity?.taskStatus ?? "null"}', + ); } /// 🔥 手动选择任务(用户从弹窗中选择) void selectTask(DeviceTaskEntity task) { _logger.logWithLevel('✅ 用户选择任务ID: ${task.id}, 状态: ${task.taskStatus}'); - emit(state.copyWith( - currentTask: task, - currentTaskId: task.id, - )); + emit(state.copyWith(currentTask: task, currentTaskId: task.id)); } /// 清除当前任务 void clearCurrentTask() { - emit(state.copyWith( - currentTask: null, - currentTaskId: null, - )); + emit(state.copyWith(currentTask: null, currentTaskId: null)); _logger.logWithLevel('🧹 清除当前任务'); } diff --git a/lib/features/devices/presentation/bloc/devices_cubit.dart b/lib/features/devices/presentation/bloc/devices_cubit.dart index 166ba3c7..78870d84 100644 --- a/lib/features/devices/presentation/bloc/devices_cubit.dart +++ b/lib/features/devices/presentation/bloc/devices_cubit.dart @@ -16,6 +16,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/usecases/update_devicen import '../../../../core/consts/tcp_consts.dart'; import '../../../../core/logging/i_logger_service.dart'; +import '../../../../core/logging/log_time.dart'; import '../../../../core/network/tcp/tcp_client.dart'; import '../../../../core/network/mqtt/domain/repositories/task_message_repository.dart'; import '../../data/models/device_add_path_point_model.dart'; @@ -268,6 +269,7 @@ class DevicesCubit extends Cubit { /// 切换设备 Future switchDevice(DeviceEntity device) async { + debugPrint('${LogTime.now()} 👆 [DevicesCubit] 开始切换设备: ${device.deviceName}'); // 保持现有列表,只改 loading emit(state.copyWith(isLoading: true)); @@ -276,6 +278,7 @@ class DevicesCubit extends Cubit { result.fold( // 失败处理 (l) { + debugPrint('${LogTime.now()} ❌ [DevicesCubit] 切换设备失败: ${device.deviceName}, 原因: ${l.message}'); emit(state.copyWith(isLoading: false, errorMessage: l.message)); selectDevice(device); }, @@ -288,7 +291,12 @@ class DevicesCubit extends Cubit { // 重置设备状态 Bloc,清除旧设备图表数据 _deviceStatusBloc.add(DeviceStatusReset()); - debugPrint('✅ 新设备切换完成(HTTP-only,无新TCP连接): ${device.deviceName}'); + // 🔥 机器状态数据源已切换为MQTT:用与TCP相同的方式获取设备号(deviceName), + // 订阅 mower/{sn}/property/* 主题(内部自动退订旧设备、同SN去重) + debugPrint('${LogTime.now()} 📡 [DevicesCubit] HTTP切换成功,开始MQTT订阅: ${device.deviceName}'); + _deviceStatusBloc.startMqttRealtimeListening(device.deviceName); + + debugPrint('${LogTime.now()} ✅ 新设备切换完成(HTTP-only,无新TCP连接): ${device.deviceName}'); emit(state.copyWith(isLoading: false, selectedDevice: device)); }, @@ -378,7 +386,7 @@ class DevicesCubit extends Cubit { emit(state.copyWith(isLoading: false, errorMessage: failure.message)); }, (records) { - // print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}'); + print('🔍 [DevicesCubit] selectBySiteId 加载成功,记录数: ${records.length}'); // 将 WorkRecordEntity 转换为 Map 以兼容现有UI final mappedRecords = records.map((record) { // 🔥 核心修复:根据 jsonData 类型决定存储方式 @@ -390,14 +398,27 @@ class DevicesCubit extends Cubit { } else if (record.jsonData is WorkRecordJsonData) { jsonDataStr = _workRecordJsonDataToJson(record.jsonData as WorkRecordJsonData); } - return { + final mapped = { 'id': record.id.toString(), 'workName': record.workName, 'imgUrl': record.imgUrl ?? '', 'jsonData': jsonDataStr, }; + print(' ├─ [${record.workName}] id=${record.id}, jsonData长度=${jsonDataStr?.length ?? "null"}'); + // 打印 jsonData 内部的 path/outer 信息 + if (jsonDataStr != null && jsonDataStr.isNotEmpty) { + try { + final decoded = jsonDecode(jsonDataStr); + if (decoded is Map) { + print(' │ path类型=${decoded['path']?.runtimeType}, outer类型=${decoded['outer']?.runtimeType}, planModel=${decoded['planModel']}'); + if (decoded['path'] is List) print(' │ path长度=${(decoded['path'] as List).length}'); + if (decoded['outer'] is List) print(' │ outer长度=${(decoded['outer'] as List).length}'); + } + } catch (_) {} + } + return mapped; }).toList(); - // print('🔍 [DevicesCubit] 转换后的数据: $mappedRecords'); + print('🔍 [DevicesCubit] 转换完成,emit workRecords 数量: ${mappedRecords.length}'); emit(state.copyWith(isLoading: false, workRecords: mappedRecords)); }, ); @@ -439,15 +460,36 @@ class DevicesCubit extends Cubit { /// 加载指定作业名的路径数据 Future loadSelectedPath(String workName) async { emit(state.copyWith(isLoading: true)); + debugPrint('📡 [loadSelectedPath] 开始查询, workName=$workName'); try { final result = await _selectWorkRecordUseCase.call(workName); result.fold( - (failure) => emit( - state.copyWith(isLoading: false, errorMessage: failure.message), - ), - (data) => emit(state.copyWith(isLoading: false, pathData: data)), + (failure) { + debugPrint('❌ [loadSelectedPath] 接口失败: ${failure.message}'); + emit( + state.copyWith(isLoading: false, errorMessage: failure.message), + ); + }, + (data) { + debugPrint('✅ [loadSelectedPath] 接口成功, 记录数: ${data.length}'); + if (data.isNotEmpty) { + final first = data.first; + debugPrint(' ├─ 第一条记录 keys: ${first.keys.toList()}'); + debugPrint(' ├─ path 类型: ${first['path']?.runtimeType}, 长度: ${(first['path'] as List?)?.length ?? "null"}'); + debugPrint(' ├─ outer 类型: ${first['outer']?.runtimeType}, 长度: ${(first['outer'] as List?)?.length ?? "null"}'); + debugPrint(' └─ planModel: ${first['planModel']}'); + if (first['path'] is List && (first['path'] as List).isNotEmpty) { + debugPrint(' └─ path[0]: ${(first['path'] as List).first}'); + } + if (first['outer'] is List && (first['outer'] as List).isNotEmpty) { + debugPrint(' └─ outer[0]: ${(first['outer'] as List).first}'); + } + } + emit(state.copyWith(isLoading: false, pathData: data)); + }, ); } catch (e) { + debugPrint('❌ [loadSelectedPath] 异常: $e'); emit(state.copyWith(isLoading: false, errorMessage: e.toString())); } } diff --git a/lib/features/home/presentation/widgets/map/resilient_tile_provider.dart b/lib/features/home/presentation/widgets/map/resilient_tile_provider.dart new file mode 100644 index 00000000..a6ae37bf --- /dev/null +++ b/lib/features/home/presentation/widgets/map/resilient_tile_provider.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:http/http.dart' as http; + +/// ESRI World Imagery 底图地址(国内网络可能不可达) +const String kEsriWorldImageryUrl = + 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; + +/// 网络超时时长(瓦片加载) +const Duration _kTileTimeout = Duration(seconds: 8); + +/// ───────────────────────────────────────────────────────── +/// 容错网络瓦片图 +/// ───────────────────────────────────────────────────────── +/// +/// 加载失败(超时 / 断网 / 404 等)时返回 1×1 透明 PNG, +/// 避免 SocketException 刷屏并保证地图其余部分正常渲染。 +class ResilientNetworkImage extends ImageProvider { + const ResilientNetworkImage(this.url); + + final String url; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + ResilientNetworkImage key, + ImageDecoderCallback decode, + ) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(decode), + scale: 1.0, + debugLabel: 'ResilientNetworkImage(${url.split('/').last})', + ); + } + + Future _loadAsync(ImageDecoderCallback decode) async { + try { + final client = http.Client(); + final response = await client + .get(Uri.parse(url)) + .timeout(_kTileTimeout); + + if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) { + final buffer = await ui.ImmutableBuffer.fromUint8List( + response.bodyBytes, + ); + return decode(buffer); + } + } catch (_) { + // 超时 / 网络异常 / 解码失败 → 走下面的透明兜底 + } + // 1×1 透明 PNG,避免 Flutter 框架抛出 MissingImageException + return decode( + await ui.ImmutableBuffer.fromUint8List(_transparentPng), + ); + } + + @override + bool operator ==(Object other) => + other is ResilientNetworkImage && other.url == url; + + @override + int get hashCode => url.hashCode; +} + +/// ───────────────────────────────────────────────────────── +/// 容错 TileProvider —— 直接在 TileLayer.tileProvider 中使用 +/// ───────────────────────────────────────────────────────── +/// +/// ```dart +/// TileLayer( +/// urlTemplate: kEsriWorldImageryUrl, +/// tileProvider: ResilientTileProvider(), +/// minZoom: 0, +/// maxZoom: 19, +/// ) +/// ``` +class ResilientTileProvider extends TileProvider { + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + final url = getTileUrl(coordinates, options); + return ResilientNetworkImage(url); + } +} + +/// 1×1 透明 PNG 字节(兜底用) +final Uint8List _transparentPng = Uint8List.fromList([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1×1 + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // RGBA 8bit + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, // compressed data + 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, // IEND chunk + 0x60, 0x82, +]); diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index dee3581e..008ec20f 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -19,10 +19,12 @@ import 'package:maibu_satabot_v2/components/toast.dart'; import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart'; import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; +import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart'; import 'package:maibu_satabot_v2/core/di/injection.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart'; import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/mower_realtime_repository.dart'; import 'package:maibu_satabot_v2/core/router/route_paths.dart'; import 'package:maibu_satabot_v2/features/devices/data/models/device_add_path_point_model.dart' as work_area_model; @@ -82,7 +84,8 @@ const String kSavedTPMode = "saved_tp_mode"; const String kSavedCurrentRobotMode = "kSavedCurrentRobotMode"; const String kSavedIsPanelOpen = "kSavedIsPanelOpen"; const String kSavedCurrentWorkMode = "kSavedCurrentWorkMode"; -const String kSavedDeviceTaskIds = "saved_device_task_ids"; // {deviceId: taskId} Map +const String kSavedDeviceTaskIds = + "saved_device_task_ids"; // {deviceId: taskId} Map // 保持 PlotData 类不变 class PlotData { @@ -140,7 +143,7 @@ class _MapPageEnterpriseState extends State { final MapController _mapController = MapController(); // 初始化轨迹管理器(泛型指定为LatLng) late final TracePoint _traceManager; - late String workMode = "弓字模式"; // 作业模式:默认值为"弓字模式" + late String workMode = "覆盖模式"; // 作业模式:默认值为"覆盖模式" WorkStatus _workStatus = WorkStatus.idle; bool isStartWork = false; //是否开始作业 @@ -206,6 +209,7 @@ class _MapPageEnterpriseState extends State { // 其他 StreamSubscription? _pathPlanningSub; + StreamSubscription? _routeStatusSub; @override void initState() { @@ -450,7 +454,7 @@ class _MapPageEnterpriseState extends State { _isWorkPanelOpen = prefs.getBool(kSavedIsWorkPanelOpen) ?? false; // 2. 恢复作业模式 - workMode = prefs.getString(kSavedWorkMode) ?? "弓字模式"; + workMode = prefs.getString(kSavedWorkMode) ?? "覆盖模式"; // 读取 _currentWorkMode = prefs.getString(kSavedCurrentWorkMode) != null ? WorkMode.values.firstWhere( @@ -603,7 +607,11 @@ class _MapPageEnterpriseState extends State { try { final taskCubit = sl(); final taskId = taskCubit.state.currentTaskId; - final deviceId = context.read().state.targetDevice?.deviceName; + final deviceId = context + .read() + .state + .targetDevice + ?.deviceName; if (taskId != null && deviceId != null) { final existingJson = prefs.getString(kSavedDeviceTaskIds); Map taskIdMap = {}; @@ -670,18 +678,29 @@ class _MapPageEnterpriseState extends State { } void moveMapToPointsCenter(List points) { - if (points.isEmpty || !mounted) return; + debugPrint('🗺️ [移动地图] 开始: 点数=${points.length}, mounted=$mounted'); + if (points.isEmpty || !mounted) { + debugPrint('🗺️ [移动地图] 跳过: points为空=${points.isEmpty}, mounted=$mounted'); + return; + } // 计算边界 final bounds = calculateBounds(points); - if (bounds == null) return; + if (bounds == null) { + debugPrint('🗺️ [移动地图] 跳过: bounds 计算为 null'); + return; + } final currentZoom = _mapController.camera?.zoom ?? 18.0; + debugPrint('🗺️ [移动地图] 目标中心: ${bounds.center}, 当前缩放: $currentZoom'); + debugPrint( + '🗺️ [移动地图] bounds: southWest=${bounds.southWest}, northEast=${bounds.northEast}', + ); _mapController.move( bounds.center, // 边界中心点 currentZoom, // 保留当前缩放比例 ); - print('地图已移动到绘制内容中心,边界范围:$bounds'); + debugPrint('🗺️ [移动地图] 移动完成, 地图当前中心: ${_mapController.camera?.center}'); } // ========== 新增:安全转换任意类型为double ========== @@ -736,41 +755,42 @@ class _MapPageEnterpriseState extends State { // ============================================== // 2. 航向角提示(不受坐标影响,必须执行) + // 🔥 航向角拦截暂时注释,后续恢复 // ============================================== if (isStartWork && !isStopWork) { - if (headingStatus == 0) { - if (!_showHeadingWarn) { - if (mounted) { - setState(() { - _showHeadingWarn = true; - }); - } - } - } else { - if (_showHeadingWarn && mounted) { - setState(() { - _showHeadingWarn = false; - }); - } - } + // if (headingStatus == 0) { + // if (!_showHeadingWarn) { + // if (mounted) { + // setState(() { + // _showHeadingWarn = true; + // }); + // } + // } + // } else { + // if (_showHeadingWarn && mounted) { + // setState(() { + // _showHeadingWarn = false; + // }); + // } + // } // ============================================== - // 3. 远程模式提示(不受坐标影响,必须执行) + // 3. 远程模式提示(已关闭,用户要求去掉) // ============================================== - String mode = controlMode?.toString() ?? ""; - if (mode != "3") { - if (!_showControlModeWarn && mounted) { - setState(() { - _showControlModeWarn = true; - }); - } - } else { - if (_showControlModeWarn && mounted) { - setState(() { - _showControlModeWarn = false; - }); - } - } + // String mode = controlMode?.toString() ?? ""; + // if (mode != "3") { + // if (!_showControlModeWarn && mounted) { + // setState(() { + // _showControlModeWarn = true; + // }); + // } + // } else { + // if (_showControlModeWarn && mounted) { + // setState(() { + // _showControlModeWarn = false; + // }); + // } + // } } else { // 未作业 → 自动隐藏所有警告 if ((_showHeadingWarn || _showControlModeWarn) && mounted) { @@ -919,7 +939,7 @@ class _MapPageEnterpriseState extends State { } // 2. 构造 SavePath 数据 (对齐 Web 端逻辑) // 🔥 Web 端参考: - // 弓字模式: {path: API返回路径, outer: 打点坐标, planModel: 0} + // 覆盖模式: {path: API返回路径, outer: 打点坐标, planModel: 0} // 自定义模式: {path: 打点坐标, outer: [], planModel: 2} // 辅助方法:将打点转为 WGS84 格式 {lng, lat} @@ -939,11 +959,11 @@ class _MapPageEnterpriseState extends State { final Map savePath = { //'img': imgBase64 ?? '', // 截图的 Base64 字符串 // 'name': plotName, // 旧逻辑:Web 端不包含 name - // 🔥 对齐 Web 端:弓字模式 outer=打点,自定义模式 outer=[] + // 🔥 对齐 Web 端:覆盖模式 outer=打点,自定义模式 outer=[] // 旧逻辑:outer 始终取 gcjOuterPoints 'outer': _currentWorkMode == WorkMode.custom ? [] // 自定义模式 outer 为空 - : _markedPointsToWgs(), // 弓字模式 outer 为打点坐标 + : _markedPointsToWgs(), // 覆盖模式 outer 为打点坐标 // 🔥 统一使用用户手动打点的边界点,不再上传算法清扫轨迹点 'path': _markedPointsToWgs(), 'planModel': _currentWorkMode == WorkMode.bow ? 0 : 2, // 作业模式值 @@ -959,7 +979,7 @@ class _MapPageEnterpriseState extends State { final String workRecordJson = jsonEncode(workRecord); final http.MultipartRequest request = http.MultipartRequest( 'POST', - Uri.parse('http://1.95.137.212:59003/iot/workRecord/add'), + Uri.parse('${HttpApiConsts.baseUrl}/iot/workRecord/add'), ); // 🔥 添加 Authorization 认证头 @@ -1027,7 +1047,7 @@ class _MapPageEnterpriseState extends State { // debugPrint('📤 [保存地块] path长度: ${(savePath['path'] as List?)?.length ?? 0}'); // debugPrint('📤 [保存地块] outer长度: ${(savePath['outer'] as List?)?.length ?? 0}'); // debugPrint('📤 [保存地块] 是否有图片文件: ${imgBase64 != null && imgBase64.isNotEmpty}'); - // debugPrint('📤 [保存地块] 请求URL: http://1.95.137.212:59003/iot/workRecord/add'); + // debugPrint('📤 [保存地块] 请求URL: ${HttpApiConsts.baseUrl}/iot/workRecord/add'); // debugPrint('📤 [保存地块] workRecord(完整JSON): $workRecordJson'); // debugPrint('📤 [保存地块] savePath(原始对象): $savePath'); // debugPrint('📤 [保存地块] ========== 请求参数 END =========='); @@ -1516,6 +1536,7 @@ class _MapPageEnterpriseState extends State { _traceManager.reset(); _mapController.dispose(); _pathPlanningSub?.cancel(); // 🔥 取消订阅 + _routeStatusSub?.cancel(); _saveDataToLocal(); // 🔥 页面销毁时保存最终状态 _cancelAllToast(); // 退出页面 → 关闭所有本页面的Toast super.dispose(); @@ -1564,10 +1585,11 @@ class _MapPageEnterpriseState extends State { return GestureDetector( // 核心:点击列表项触发选中逻辑 onTap: () async { - // 🔥 添加日志:打印选中的路线任务详情 + debugPrint('📋 [路径规划] ====== 开始选中路线任务 ======'); debugPrint('📋 [路径规划] 选中路线任务:'); debugPrint(' ├─ 地块名称: ${plot.plotName}'); - debugPrint(' └─ 数据ID: ${plot.id}'); + debugPrint(' ├─ 数据ID: ${plot.id}'); + debugPrint(' └─ jsonData长度: ${plot.jsonData?.length ?? "null"}'); // 【优化1】第一步就UI响应,不卡手 setState(() { @@ -1587,6 +1609,9 @@ class _MapPageEnterpriseState extends State { if (plot.jsonData != null && plot.jsonData!.isNotEmpty) { debugPrint('✅ [选中地块] 尝试使用本地 jsonData,长度: ${plot.jsonData!.length}'); + debugPrint( + '🔍 [选中地块] plot.jsonData 原始内容(前500字符): ${plot.jsonData!.length > 500 ? plot.jsonData!.substring(0, 500) : plot.jsonData!}', + ); try { final decoded = jsonDecode(plot.jsonData!); if (decoded is Map) { @@ -1690,7 +1715,7 @@ class _MapPageEnterpriseState extends State { int.tryParse(parsedJson['planModel']?.toString() ?? '0') ?? 0; debugPrint('🔍 [选中地块] planModelValue: $planModelValue'); - workMode = planModelValue == WorkMode.bow.value ? "弓字模式" : "自定义模式"; + workMode = planModelValue == WorkMode.bow.value ? "覆盖模式" : "自定义模式"; _currentWorkMode = planModelValue == WorkMode.bow.value ? WorkMode.bow : WorkMode.custom; @@ -1727,9 +1752,9 @@ class _MapPageEnterpriseState extends State { debugPrint('🔍 [选中地块] outerList[0]: ${outerList.first}'); } - // 弓字模式:path是路线轨迹,outer是边界框 + // 覆盖模式:path是路线轨迹,outer是边界框 // 自定义模式:只有path - if (workMode == "弓字模式") { + if (workMode == "覆盖模式") { startWorkList = pathList.isNotEmpty ? pathList : outerList; } else { startWorkList = pathList.isNotEmpty ? pathList : outerList; @@ -1739,30 +1764,73 @@ class _MapPageEnterpriseState extends State { // 【优化5】一次性生成坐标,不反复操作数组 List newPathPoints = []; List newOuterPoints = []; + int pathSkipCount = 0; for (var item in pathList) { double? lat = safeToDouble(item['lat']); double? lon = safeToDouble(item['lng'] ?? item['lon']); if (lat != null && lon != null) { // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 newPathPoints.add(LatLng(lat, lon)); + } else { + pathSkipCount++; + if (pathSkipCount <= 3) { + debugPrint( + '⚠️ [坐标解析] path 跳过无效坐标: raw=${item}, lat=$lat, lon=$lon', + ); + } } } + int outerSkipCount = 0; for (var item in outerList) { double? lng = safeToDouble(item['lng'] ?? item['lon']); double? lat = safeToDouble(item['lat']); if (lng != null && lat != null) { // ⚠️ 地图已切换为 WGS84,直接使用原始坐标 newOuterPoints.add(LatLng(lat, lng)); + } else { + outerSkipCount++; + if (outerSkipCount <= 3) { + debugPrint( + '⚠️ [坐标解析] outer 跳过无效坐标: raw=${item}, lat=$lat, lng=$lng', + ); + } } } + debugPrint( + '✅ [坐标解析] path: 总${pathList.length}, 有效${newPathPoints.length}, 跳过$pathSkipCount', + ); + debugPrint( + '✅ [坐标解析] outer: 总${outerList.length}, 有效${newOuterPoints.length}, 跳过$outerSkipCount', + ); + + // 🔥 容错:后端可能返回 outer 的 lng 全为 0.0(无效经度) + // 保存时 outer 和 path 都是相同的打点坐标,所以 outer 无效时用 path 代替 + final outerAllLngZero = + newOuterPoints.isNotEmpty && + newOuterPoints.every((p) => p.longitude == 0.0); + if (outerAllLngZero && newPathPoints.isNotEmpty) { + debugPrint('⚠️ [坐标解析] outer 所有经度为0(后端数据异常),使用 path 坐标代替 outer'); + newOuterPoints = List.from(newPathPoints); + } + + if (newPathPoints.isNotEmpty) { + debugPrint( + '✅ [坐标解析] path 首=${newPathPoints.first}, 尾=${newPathPoints.last}', + ); + } + if (newOuterPoints.isNotEmpty) { + debugPrint( + '✅ [坐标解析] outer 首=${newOuterPoints.first}, 尾=${newOuterPoints.last}', + ); + } // 【优化6】整个方法只调用一次 setState,性能暴涨 - // 🔥 弓字模式(0):显示 outer灰色透明框 + path路线轨迹 + // 🔥 覆盖模式(0):显示 outer灰色透明框 + path路线轨迹 // 🔥 自定义模式(2):只显示 path setState(() { gcjPathPoints = newPathPoints; // 两种模式都显示 path gcjOuterPoints = _currentWorkMode == WorkMode.bow - ? newOuterPoints // 弓字模式:显示 outer 边框 + ? newOuterPoints // 覆盖模式:显示 outer 边框 : []; // 自定义模式:不显示 outer _isWorkPanelOpen = true; _isPanelOpen = false; @@ -1773,14 +1841,21 @@ class _MapPageEnterpriseState extends State { ); // 最后移动地图 + debugPrint('🗺️ [选中地块] 准备移动地图到作业区域中心...'); List allPoints = []; allPoints.addAll(gcjPathPoints); allPoints.addAll(gcjOuterPoints); + debugPrint( + '🗺️ [选中地块] 准备移动地图: allPoints=${allPoints.length}, gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}', + ); if (allPoints.isNotEmpty) { moveMapToPointsCenter(allPoints); + } else { + debugPrint('❌ [选中地块] allPoints 为空,无法移动地图!'); } - } catch (e) { - debugPrint('解析失败:$e'); + } catch (e, stackTrace) { + debugPrint('❌ 解析失败:$e'); + debugPrint('❌ 解析失败堆栈:$stackTrace'); setState(() { gcjPathPoints = []; gcjOuterPoints = []; @@ -1908,12 +1983,13 @@ class _MapPageEnterpriseState extends State { /// ESRI World Imagery(WGS84 坐标系,全球高清卫星图) /// 替代高德地图(GCJ02),统一坐标系,无需坐标转换 TileLayer( - urlTemplate: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + 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 @@ -1936,7 +2012,7 @@ class _MapPageEnterpriseState extends State { ], ), - // 绘制outer边框(Polygon模式)—— 弓字模式:灰色透明框 + 内部路线轨迹 + // 绘制outer边框(Polygon模式)—— 覆盖模式:灰色透明框 + 内部路线轨迹 if (gcjOuterPoints.isNotEmpty && _currentWorkMode == WorkMode.bow) PolygonLayer( polygons: [ @@ -2172,8 +2248,8 @@ class _MapPageEnterpriseState extends State { polylines: [ Polyline( points: gctracePoint!, - color: const Color.fromARGB(255, 6, 187, 232), - strokeWidth: 3, + color: const Color(0xFF00B42A), // 绿色实时轨迹线 + strokeWidth: 4, ), ], ), @@ -2373,11 +2449,10 @@ class _MapPageEnterpriseState extends State { return; } - // 5. 获取组织ID(从 AppUserCubit 的 user) - final userCubit = sl(); - final orgId = userCubit.state.user?.orgId; + // 5. 获取组织ID(从当前选中场站的 orgId) + final orgId = sl().state.selectedSite?.orgId; if (orgId == null) { - _showPageToast(message: "用户信息异常,请重新登录", type: ToastType.error); + _showPageToast(message: "场站信息异常,请重新选择场站", type: ToastType.error); return; } @@ -2426,10 +2501,10 @@ class _MapPageEnterpriseState extends State { debugPrint(' ├─ orgId: $orgId'); debugPrint(' └─ taskId: ${selectedTask?.id ?? "(无,将新建)"}'); - // 8. 重置轨迹 + 切换到定位模式(关键:防止飞过去的路径被画出来) - debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到定位模式'); + // 8. 重置轨迹 + 切换到轨迹模式(TRACK 模式积累轨迹点,绘制实时轨迹线) + debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到TRACK模式'); _traceManager.reset(); - _traceManager.setMode(TPMode.LOCATION); + _traceManager.setMode(TPMode.TRACK); tracePoint?.clear(); gctracePoint?.clear(); @@ -2450,7 +2525,9 @@ class _MapPageEnterpriseState extends State { // 🔥 池子里没有,创建新任务 debugPrint('🆕 [开始作业] 步骤2a: 池子为空,创建新任务...'); try { - debugPrint('📤 [开始作业] 调用 createDeviceTask API, deviceId=$deviceId, routeId=$routeId'); + debugPrint( + '📤 [开始作业] 调用 createDeviceTask API, deviceId=$deviceId, routeId=$routeId', + ); final result = await sl().execute( deviceId: deviceId, routeId: routeId, @@ -2514,7 +2591,7 @@ class _MapPageEnterpriseState extends State { try { final dio = sl(); final response = await dio.get( - 'http://1.95.137.212:59003/iot/taskPath/getTaskPath', + '${HttpApiConsts.baseUrl}/iot/taskPath/getTaskPath', queryParameters: { 'taskId': currentTaskId, }, @@ -2586,7 +2663,9 @@ class _MapPageEnterpriseState extends State { } */ - debugPrint('🎯 [开始作业] 步骤4: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}'); + debugPrint( + '🎯 [开始作业] 步骤4: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}', + ); setState(() { isStopWork = false; isStartWork = true; @@ -2604,14 +2683,35 @@ class _MapPageEnterpriseState extends State { } // 🔥 步骤6: 启动MQTT到达点监听 - debugPrint('📡 [开始作业] 步骤6: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId'); + debugPrint( + '📡 [开始作业] 步骤6: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId', + ); try { - await context.read().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!); + await context.read().startListeningMqttArrive( + deviceId: deviceId, + taskId: _confirmedTaskId!, + ); debugPrint('✅ [开始作业] MQTT到达点监听已启动'); } catch (e) { debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)'); } + // 🔥 步骤6.5: 监听路径规划 MQTT 消息(mowerRealtimeClient:59007),绘制覆盖清扫路线 + _routeStatusSub?.cancel(); + _routeStatusSub = sl().routeStatusStream.listen(( + routeMsg, + ) { + debugPrint( + '🛤️ [MapPage] 收到路径规划消息, 设备: ${routeMsg.deviceId}, 任务: ${routeMsg.taskId}, 点数: ${routeMsg.data.length}', + ); + setState(() { + gcjPathPoints = routeMsg.data.map((point) { + return LatLng(point.lat, point.lng); + }).toList(); + }); + debugPrint('✅ [MapPage] 覆盖清扫路线已渲染到地图'); + }); + // 🔥 步骤7: 持久化状态 debugPrint('💾 [开始作业] 步骤7: 持久化状态, taskId=${taskCubit.state.currentTaskId}'); _showPageToast(message: "作业已开始", type: ToastType.success); @@ -2710,14 +2810,134 @@ class _MapPageEnterpriseState extends State { //await context.read().pauseRoutePlanning(); } - void _stopWork() async { - _logger.log('[停止] 按下停止按钮'); + /// 🔥 关闭底部工作面板(等同于点击 X 号) + void _closeWorkPanel() { + debugPrint('❌ [工作面板] 关闭面板,重置状态'); + setState(() { + _isWorkPanelOpen = false; + _selectedPlot = null; + gcjPathPoints = []; + gcjOuterPoints = []; + _workStatus = WorkStatus.idle; + _showHeadingWarn = false; + _showControlModeWarn = false; + isStartWork = false; + }); + _saveDataToLocal(); + _stopWork(); + } + + /// 🔥 停止作业前弹窗选择取消方式:普通取消(false) / 强制取消(true) + void _confirmStopWork() { + showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.redAccent, + size: 24, + ), + SizedBox(width: 8), + Text( + '选择取消方式', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '请选择取消任务的方式', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 20), + Row( + children: [ + // 普通取消 + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.pop(dialogContext); + _stopWork(forceCancel: false); + }, + child: const Text( + '普通取消', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: 12), + // 强制取消 + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.pop(dialogContext); + _stopWork(forceCancel: true); + }, + child: const Text( + '强制取消', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('关闭'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _stopWork({bool forceCancel = true}) async { + _logger.log('[停止] 按下停止按钮,forceCancel=$forceCancel'); debugPrint('══════════ [停止作业] 开始 ══════════'); // 🔥 获取设备ID和taskId final targetDevice = context.read().state.targetDevice; final deviceId = targetDevice?.deviceName; - debugPrint('[停止作业] deviceId: $deviceId, targetDevice: ${targetDevice?.deviceName}'); + debugPrint( + '[停止作业] deviceId: $deviceId, targetDevice: ${targetDevice?.deviceName}', + ); if (deviceId == null || deviceId.isEmpty) { debugPrint('[停止作业] ❌ deviceId 为空,退出'); _showPageToast(message: "请先选择一个设备", type: ToastType.info); @@ -2744,7 +2964,9 @@ class _MapPageEnterpriseState extends State { return; } - debugPrint('[停止作业] 准备调用 cancelTask API, deviceId: $deviceId, taskId: $taskId'); + debugPrint( + '[停止作业] 准备调用 cancelTask API, deviceId: $deviceId, taskId: $taskId', + ); if (mounted) { setState(() { @@ -2763,8 +2985,10 @@ class _MapPageEnterpriseState extends State { // 🔥 调用接口取消任务 try { - debugPrint('[停止作业] 正在调用 taskCubit.cancelTask...'); - await taskCubit.cancelTask(deviceId); + debugPrint( + '[停止作业] 正在调用 taskCubit.cancelTask, forceCancel=$forceCancel...', + ); + await taskCubit.cancelTask(deviceId, forceCancel: forceCancel); debugPrint('[停止作业] cancelTask 返回成功'); taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务 debugPrint('[停止作业] clearCurrentTask 完成'); @@ -2777,7 +3001,7 @@ class _MapPageEnterpriseState extends State { _showPageToast(message: "作业已停止", type: ToastType.success); debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令'); - + // 🔥 还需要通过 TCP 向机器发送停止指令 try { debugPrint('[停止作业] 调用 TCP stopRoutePlanning...'); @@ -2949,7 +3173,7 @@ class _MapPageEnterpriseState extends State { if (isBow) { final result = pathList.isNotEmpty ? pathList : outerList; // debugPrint( - // '✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}', + // '✅ [_parseSWL] 覆盖模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}', // ); return result; } else { @@ -2983,7 +3207,6 @@ class _MapPageEnterpriseState extends State { // debugPrint( // '⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}', // ); - } return Positioned( @@ -3058,23 +3281,7 @@ class _MapPageEnterpriseState extends State { ), ), IconButton( - onPressed: () { - debugPrint('❌ [工作面板] 关闭面板,重置状态'); - setState(() { - _isWorkPanelOpen = false; - _selectedPlot = null; - gcjPathPoints = []; - gcjOuterPoints = []; - _workStatus = WorkStatus.idle; - _showHeadingWarn = false; - _showControlModeWarn = false; - isStartWork = false; - }); - _saveDataToLocal(); - _stopWork(); - - //if (isStopWork || !isStartWork) return; - }, + onPressed: _closeWorkPanel, icon: const Icon( Icons.close, color: Colors.grey, @@ -3122,24 +3329,45 @@ class _MapPageEnterpriseState extends State { return GestureDetector( onTap: () async { - // 🔥 点击后显示所有活跃任务供用户选择 - final activeTasks = taskState.activeTasks; - if (activeTasks.isEmpty) { + // 🔥 点击刷新任务列表(调用查询任务接口) + final deviceId = currentTask.deviceId; + if (deviceId.isEmpty) { _showPageToast( - message: "没有可用的任务", + message: "设备信息异常,无法刷新", type: ToastType.warn, ); return; } - final selectedTask = await _showTaskSelectionDialog( - activeTasks, + _showPageToast( + message: "正在刷新任务列表...", + type: ToastType.info, ); - if (selectedTask != null) { - // 用户选择了新任务,更新 cubit - sl().selectTask(selectedTask); + + final taskCubit = sl(); + final previousTaskId = currentTask.id; + debugPrint( + '🔄 [刷新任务] previousTaskId=$previousTaskId, 开始查询...', + ); + await taskCubit.fetchAndFilterTask(deviceId); + + // 🔥 刷新后如果当前任务已不在查询结果中,关闭底部工作面板 + final newActiveTasks = taskCubit.state.activeTasks; + final stillExists = newActiveTasks.any( + (t) => t.id == previousTaskId, + ); + debugPrint( + '🔄 [刷新任务] 查询完成, 活跃任务数=${newActiveTasks.length}, stillExists=$stillExists', + ); + if (!stillExists) { _showPageToast( - message: "已切换到任务 #${selectedTask.id}", + message: "当前任务已不存在,已关闭面板", + type: ToastType.warn, + ); + _closeWorkPanel(); + } else { + _showPageToast( + message: "任务列表已刷新", type: ToastType.success, ); } @@ -3207,13 +3435,13 @@ class _MapPageEnterpriseState extends State { ), ), Icon( - Icons.swap_horiz, + Icons.refresh, color: const Color(0xFF165DFF), size: 18, ), const SizedBox(width: 4), const Text( - '切换', + '刷新', style: TextStyle( fontSize: 12, color: Color(0xFF165DFF), @@ -3229,7 +3457,8 @@ class _MapPageEnterpriseState extends State { const SizedBox(height: 16), // 4. 错误提示(如果加载失败) - if (startWorkList.isEmpty || headingStatus == 0) + // 🔥 航向角拦截暂时注释,后续恢复 + if (startWorkList.isEmpty) Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -3275,45 +3504,46 @@ class _MapPageEnterpriseState extends State { ), // 航向异常提示 50% - if (headingStatus == 0) - Expanded( - flex: 1, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - margin: const EdgeInsets.only(left: 6, bottom: 12), - decoration: BoxDecoration( - color: Colors.red[50], - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.red[200]!), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.error_outline, - color: Colors.redAccent, - size: 16, - ), - SizedBox(width: 8), - Expanded( - child: Text( - "航向信息异常", - style: TextStyle( - fontSize: 14, - color: Colors.redAccent, - ), - softWrap: true, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), + // 🔥 航向角拦截暂时注释,后续恢复 + // if (headingStatus == 0) + // Expanded( + // flex: 1, + // child: Container( + // padding: const EdgeInsets.symmetric( + // horizontal: 12, + // vertical: 8, + // ), + // margin: const EdgeInsets.only(left: 6, bottom: 12), + // decoration: BoxDecoration( + // color: Colors.red[50], + // borderRadius: BorderRadius.circular(6), + // border: Border.all(color: Colors.red[200]!), + // ), + // child: const Row( + // crossAxisAlignment: CrossAxisAlignment.start, + // children: [ + // Icon( + // Icons.error_outline, + // color: Colors.redAccent, + // size: 16, + // ), + // SizedBox(width: 8), + // Expanded( + // child: Text( + // "航向信息异常", + // style: TextStyle( + // fontSize: 14, + // color: Colors.redAccent, + // ), + // softWrap: true, + // maxLines: 2, + // overflow: TextOverflow.ellipsis, + // ), + // ), + // ], + // ), + // ), + // ), ], ), @@ -3324,10 +3554,9 @@ class _MapPageEnterpriseState extends State { width: double.infinity, height: 50, child: ElevatedButton( - onPressed: - state.isLoading || - startWorkList.isEmpty || - headingStatus == 0 + onPressed: state.isLoading || startWorkList.isEmpty + // 🔥 航向角拦截暂时注释,后续恢复 + // || headingStatus == 0 ? null : () { _startWork(); @@ -3405,7 +3634,7 @@ class _MapPageEnterpriseState extends State { width: 50, height: 50, child: ElevatedButton( - onPressed: () => _stopWork(), + onPressed: _confirmStopWork, style: ElevatedButton.styleFrom( backgroundColor: Colors.redAccent, foregroundColor: Colors.white, @@ -3503,18 +3732,26 @@ class _MapPageEnterpriseState extends State { List _convertWorkRecordsToPlotData( List> records, ) { - // debugPrint('📊 [_convertWorkRecordsToPlotData] 输入记录数: ${records.length}'); - return records.map((record) { + debugPrint('📊 [_convertWorkRecordsToPlotData] 输入记录数: ${records.length}'); + final result = records.map((record) { final jd = record['jsonData']; - // debugPrint(' ├─ workName: ${record['workName']}, jsonData类型: ${jd?.runtimeType}, jsonData长度: ${jd is String ? jd.length : "N/A"}'); - // if (jd is String && jd.isNotEmpty) { - // try { - // final decoded = jsonDecode(jd); - // if (decoded is Map) { - // debugPrint(' │ path类型: ${decoded['path']?.runtimeType}, outer类型: ${decoded['outer']?.runtimeType}, planModel: ${decoded['planModel']}'); - // } - // } catch (_) {} - // } + debugPrint( + ' ├─ workName: ${record['workName']}, id: ${record['id']}, jsonData类型: ${jd?.runtimeType}, jsonData长度: ${jd is String ? jd.length : "N/A"}', + ); + if (jd is String && jd.isNotEmpty) { + try { + final decoded = jsonDecode(jd); + if (decoded is Map) { + debugPrint( + ' │ path类型: ${decoded['path']?.runtimeType}, outer类型: ${decoded['outer']?.runtimeType}, planModel: ${decoded['planModel']}', + ); + if (decoded['path'] is List) + debugPrint(' │ path长度: ${(decoded['path'] as List).length}'); + if (decoded['outer'] is List) + debugPrint(' │ outer长度: ${(decoded['outer'] as List).length}'); + } + } catch (_) {} + } // 兼容 jsonData 为 Map 或 String 的情况:统一转为 JSON 字符串 String? jsonDataStr; final rawJsonData = record['jsonData']; @@ -3533,6 +3770,10 @@ class _MapPageEnterpriseState extends State { jsonData: jsonDataStr, // 原始数据的JSON字符串(可选,便于调试或后续使用) ); }).toList(); + debugPrint( + '📊 [_convertWorkRecordsToPlotData] 转换完成,输出地块数: ${result.length}', + ); + return result; } // ========== 抽象:生成路径的核心函数(打点函数) ========== @@ -3575,7 +3816,7 @@ class _MapPageEnterpriseState extends State { return; } - // 弓字模式行距默认为0.0,与Web端保持一致 + // 覆盖模式行距默认为0.0,与Web端保持一致 try { LatLng firstPointWgs; @@ -3738,9 +3979,9 @@ class _MapPageEnterpriseState extends State { } */ - // 🔥 修复:旧路径生成API已废弃,弓字模式直接用打点坐标,打开保存框 + // 🔥 修复:旧路径生成API已废弃,覆盖模式直接用打点坐标,打开保存框 debugPrint( - '📌 [生成路径] 弓字模式: path点数=${_markedPoints.length}, outer=打点坐标, planModel=0', + '📌 [生成路径] 覆盖模式: path点数=${_markedPoints.length}, outer=打点坐标, planModel=0', ); setState(() { _saveBoxOpen = true; @@ -3899,7 +4140,11 @@ class _MapPageEnterpriseState extends State { //有停止信号 if (isFinishWork) { // 🔥 清除 taskId 持久化数据(任务已完成) - final finishDeviceId = context.read().state.targetDevice?.deviceName; + final finishDeviceId = context + .read() + .state + .targetDevice + ?.deviceName; if (finishDeviceId != null) { sl().clearCurrentTask(); _clearTaskIdFromLocal(finishDeviceId); @@ -4147,6 +4392,9 @@ class _MapPageEnterpriseState extends State { context.read().loadWorkRecordsBySiteId( selectedSite.id, ); + debugPrint( + '📋 [路径规划] 作业列表加载请求已发送, siteId=${selectedSite.id}', + ); setState(() { _isListBoxOpen = isOpen; @@ -4295,6 +4543,9 @@ class _MapPageEnterpriseState extends State { final plotList = _convertWorkRecordsToPlotData( state.workRecords ?? [], ); + debugPrint( + '📋 [路径规划] 列表面板渲染: 作业记录=${state.workRecords?.length ?? 0}, 转换后地块数=${plotList.length}', + ); return Positioned.fill( child: Column( @@ -4440,97 +4691,98 @@ class _MapPageEnterpriseState extends State { ); }, ), - if (_showHeadingWarn) - Positioned( - top: 100, // 调整位置,避免遮挡核心内容 - left: 20, - right: 20, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.9), - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - const Icon( - Icons.warning_amber_rounded, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - AppLocalizations.of( - context, - ).translate('route_planning.heading_not_init'), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), - // 2. 远程模式提示 - if (_showControlModeWarn) - Positioned( - top: 160, // 与上一个提示错开位置 - left: 20, - right: 20, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.9), - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black12, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - const Icon( - Icons.error_rounded, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - AppLocalizations.of( - context, - ).translate('route_planning.switch_remote_mode'), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), + // 🔥 航向角拦截暂时注释,后续恢复 + // if (_showHeadingWarn) + // Positioned( + // top: 100, // 调整位置,避免遮挡核心内容 + // left: 20, + // right: 20, + // child: Container( + // padding: const EdgeInsets.symmetric( + // horizontal: 16, + // vertical: 12, + // ), + // decoration: BoxDecoration( + // color: Colors.orange.withOpacity(0.9), + // borderRadius: BorderRadius.circular(8), + // boxShadow: [ + // BoxShadow( + // color: Colors.black12, + // blurRadius: 4, + // offset: const Offset(0, 2), + // ), + // ], + // ), + // child: Row( + // children: [ + // const Icon( + // Icons.warning_amber_rounded, + // color: Colors.white, + // size: 20, + // ), + // const SizedBox(width: 8), + // Expanded( + // child: Text( + // AppLocalizations.of( + // context, + // ).translate('route_planning.heading_not_init'), + // style: const TextStyle( + // color: Colors.white, + // fontSize: 14, + // ), + // ), + // ), + // ], + // ), + // ), + // ), + // 2. 远程模式提示(已关闭,用户要求去掉) + // if (_showControlModeWarn) + // Positioned( + // top: 160, + // left: 20, + // right: 20, + // child: Container( + // padding: const EdgeInsets.symmetric( + // horizontal: 16, + // vertical: 12, + // ), + // decoration: BoxDecoration( + // color: Colors.red.withOpacity(0.9), + // borderRadius: BorderRadius.circular(8), + // boxShadow: [ + // BoxShadow( + // color: Colors.black12, + // blurRadius: 4, + // offset: const Offset(0, 2), + // ), + // ], + // ), + // child: Row( + // children: [ + // const Icon( + // Icons.error_rounded, + // color: Colors.white, + // size: 20, + // ), + // const SizedBox(width: 8), + // Expanded( + // child: Text( + // AppLocalizations.of( + // context, + // ).translate('route_planning.switch_remote_mode'), + // style: const TextStyle( + // color: Colors.white, + // fontSize: 14, + // ), + // ), + // ), + // ], + // ), + // ), + // ), if (_isWorkPanelOpen) _buildWorkPanel(headingStatus), if (_isVideoDialogOpen) _buildVideoPopup(), ], diff --git a/lib/features/home/presentation/widgets/path_list_pages.dart b/lib/features/home/presentation/widgets/path_list_pages.dart index 1ed25d2a..14371682 100644 --- a/lib/features/home/presentation/widgets/path_list_pages.dart +++ b/lib/features/home/presentation/widgets/path_list_pages.dart @@ -187,8 +187,9 @@ class _VerticalFloatMenuState extends State { Text(loc.translate('route_planning.select_work_mode'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 20), _modeItem( - title: loc.translate('route_planning.bow_mode'), - subtitle: loc.translate('route_planning.bow_mode_subtitle'), + // 🔥 硬编码:差包更新不更新 assets,必须写死文案 + title: '覆盖模式', + subtitle: '标准全覆盖路径规划', isSelected: selectedMode == WorkMode.bow, onTap: () { setState(() { @@ -198,8 +199,8 @@ class _VerticalFloatMenuState extends State { ), const SizedBox(height: 12), _modeItem( - title: loc.translate('route_planning.custom_mode'), - subtitle: loc.translate('route_planning.custom_mode_subtitle'), + title: '自定义模式', + subtitle: '手动设定作业区域', isSelected: selectedMode == WorkMode.custom, onTap: () { setState(() { diff --git a/lib/features/my/repository/my_repository_impl.dart b/lib/features/my/repository/my_repository_impl.dart index 20e9ec32..65f49da6 100644 --- a/lib/features/my/repository/my_repository_impl.dart +++ b/lib/features/my/repository/my_repository_impl.dart @@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/my/repository/my_repository.dart'; import 'dart:convert'; import '../../../../core/di/injection.dart'; +import '../../../../core/consts/http_api_consts.dart'; import '../../../../core/storage/user_storage.dart'; // 必须导入 DeviceFailure 类 import '../../../../features/devices/domain/errors/device_failure.dart'; @@ -26,7 +27,7 @@ class MyRepositoryImpl implements MyRepository { // 核心修复:严格匹配抽象类的方法签名 @override Future> updateName(String nickName) async { - final url = Uri.parse('http://1.95.137.212:59003/system/user/profile'); + final url = Uri.parse('${HttpApiConsts.baseUrl}/system/user/profile'); // 补充:从 UserStorage 获取 token(接口通常需要认证) final token = await _getToken(); diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart index d661be9b..355ffabc 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -1028,6 +1028,10 @@ class RemoteControlCubit extends Cubit { debugPrint('📦 [RemoteControl] 更新targetDevice状态'); emit(state.copyWith(targetDevice: device)); debugPrint('✅ [RemoteControl] targetDevice状态已更新'); + + // 🔥 MQTT机器状态订阅:切换设备时订阅新设备的topic(与DevicesCubit.switchDevice相同逻辑) + debugPrint('📡 [RemoteControl] 开始MQTT机器状态订阅: ${device.deviceName}'); + deviceStatusBloc.startMqttRealtimeListening(device.deviceName); // 🔥 关键修复:TCP 已在登录时建立,设备切换只用 HTTP // 不再创建新 TCP 连接,避免重复发送 0x03 触发服务端推送 have_logged_in diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart index 2e59b46b..20df0847 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -12,7 +12,6 @@ import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/router/route_paths.dart'; import '../../../auth/presentation/bloc/auth_cubit.dart'; // 🔥 导入 AuthCubit -import '../../../devices/domain/entities/device_entity.dart'; import '../../../devices/presentation/bloc/devices_cubit.dart'; import '../bloc/remote_control_cubit.dart'; import '../bloc/remote_control_state.dart'; @@ -116,9 +115,6 @@ class _RemoteControlPageState extends State { return; } - // 🔥 弹窗显示当前控制的设备信息 - _showTargetDeviceDialog(context, remoteCubit.state.targetDevice!); - // 🔥 只在控制循环未启动时才启动 remoteCubit.startControlLoop(); // debugPrint('✅ [RemoteControl] 控制循环已启动'); @@ -539,36 +535,6 @@ class _RemoteControlPageState extends State { ); } - /// 🔥 显示当前控制设备的弹窗 - void _showTargetDeviceDialog(BuildContext context, DeviceEntity device) { - showDialog( - context: context, - barrierDismissible: true, - builder: (ctx) => AlertDialog( - title: const Text('🎮 远程控制'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '设备名称: ${device.deviceName}', - style: const TextStyle(fontSize: 16), - ), - const SizedBox(height: 8), - Text( - '设备ID: ${device.deviceName}', - style: const TextStyle(fontSize: 14, color: Colors.grey), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('确定'), - ), - ], - ), - ); - } + /// 🔥 显示当前控制设备的弹窗 - 已移除:进入远程遥控页不再弹出设备确认弹窗 } diff --git a/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart b/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart index 6a3b4d6b..a613764b 100644 --- a/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import '../../../../../core/consts/http_api_consts.dart'; import '../../domain/entities/device_entity.dart'; import '../../domain/entities/device_status_entity.dart'; @@ -12,6 +13,7 @@ abstract class DeviceRemoteDataSource { int? siteId, String? typeFilter, }); + Future> getAllDevices(); } /// 设备远程数据源实现类 - 从真实 API 获取数据 @@ -64,6 +66,37 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { } } + @override + Future> getAllDevices() async { + try { + final response = await dio.get( + HttpApiConsts.getDeviceList, + queryParameters: {'pageNum': 1, 'pageSize': 9999}, + ); + + if (response.statusCode != 200) { + throw Exception('网络请求失败: ${response.statusCode}'); + } + + final responseData = response.data; + if (responseData['code'] != 200) { + throw Exception(responseData['msg'] ?? '业务异常'); + } + + final List rows = responseData['rows'] ?? []; + debugPrint( + '>>> [DeviceRemoteDataSource] getAllDevices 返回 ${rows.length} 条设备', + ); + for (int i = 0; i < rows.length && i < 10; i++) { + debugPrint('>>> [DeviceRemoteDataSource] rows[$i]: ${rows[i]}'); + } + return rows.map((item) => _parseDeviceFromJson(item)).toList(); + } catch (e) { + debugPrint('>>> [DeviceRemoteDataSource] getAllDevices error: $e'); + rethrow; + } + } + Future> _fetchDevicesFromAPI(int siteId) async { final response = await dio.get( HttpApiConsts.getSiteDeviceList, @@ -107,10 +140,19 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { DeviceDataModel _parseDeviceFromJson(Map json) { // 在线状态:使用 onlineStatus 字段(1=在线, 0=离线) final onlineStatus = json['onlineStatus'] ?? 0; + final deviceId = json['deviceId']?.toString() ?? ''; + final name = json['deviceName'] ?? json['name'] ?? ''; + final type = json['deviceTypeName'] ?? json['type'] ?? '未知设备'; + final isRobot = _isRobotDevice(type, name, deviceId); + + debugPrint( + '🔍 [DeviceListDataSource] deviceId=$deviceId name=$name type=$type isRobot=$isRobot', + ); + return DeviceDataModel( - deviceId: json['deviceId']?.toString() ?? '', - name: json['deviceName'] ?? json['name'] ?? '', - type: json['deviceTypeName'] ?? json['type'] ?? '未知设备', + deviceId: deviceId, + name: name, + type: type, status: onlineStatus == 1 ? '在线' : '离线', power: (json['power'] as num?)?.toDouble() ?? 0, todayEnergy: (json['todayEnergy'] as num?)?.toDouble() ?? 0, @@ -118,6 +160,7 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { current: (json['current'] as num?)?.toDouble(), voltage: (json['voltage'] as num?)?.toDouble(), radiation: (json['radiation'] as num?)?.toDouble(), + isRobot: isRobot, ); } @@ -131,9 +174,32 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { power: (json['capacity_percent'] as num?)?.toDouble() ?? 0, todayEnergy: 0, temperature: (json['environment_temperature'] as num?)?.toDouble(), + isRobot: false, ); } + /// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别) + bool _isRobotDevice(String type, String name, String deviceId) { + final haystacks = [ + type.toLowerCase(), + name.toLowerCase(), + deviceId.toLowerCase(), + ]; + // 型号家族前缀(MC700/MC700STD/MC700PLUS/MC700AIR/MC700PRO 等;RCHWD/RCHETD 等) + const prefixes = ['mc', 'rch', 'rcetd', 'rchetd']; + // 中文类型关键字 + const keywords = ['除草', '巡检', '清洗', '割草', '机器人']; + for (final s in haystacks) { + for (final p in prefixes) { + if (s.startsWith(p)) return true; + } + } + for (final k in keywords) { + if (type.contains(k) || name.contains(k)) return true; + } + return false; + } + String _getStatusText(int status) { switch (status) { case 1: diff --git a/lib/features/v2/device_list/data/models/device_data_model.dart b/lib/features/v2/device_list/data/models/device_data_model.dart index f4cfaa8a..80972bc7 100644 --- a/lib/features/v2/device_list/data/models/device_data_model.dart +++ b/lib/features/v2/device_list/data/models/device_data_model.dart @@ -12,6 +12,7 @@ class DeviceDataModel { final double? current; final double? voltage; final double? radiation; + final bool isRobot; const DeviceDataModel({ required this.deviceId, @@ -24,6 +25,7 @@ class DeviceDataModel { this.current, this.voltage, this.radiation, + this.isRobot = false, }); /// 从 JSON 创建数据模型 @@ -71,6 +73,7 @@ class DeviceDataModel { current: current, voltage: voltage, radiation: radiation, + isRobot: isRobot, ); } } diff --git a/lib/features/v2/device_list/data/repositories/device_repository_impl.dart b/lib/features/v2/device_list/data/repositories/device_repository_impl.dart index 8c54cb8b..4426bc1d 100644 --- a/lib/features/v2/device_list/data/repositories/device_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/device_repository_impl.dart @@ -20,4 +20,10 @@ class DeviceRepositoryImpl implements DeviceRepository { final dataModels = await remoteDataSource.getDeviceList(siteId: siteId, typeFilter: typeFilter); return dataModels.map((model) => model.toEntity()).toList(); } + + @override + Future> getAllDevices() async { + final dataModels = await remoteDataSource.getAllDevices(); + return dataModels.map((model) => model.toEntity()).toList(); + } } diff --git a/lib/features/v2/device_list/domain/entities/device_entity.dart b/lib/features/v2/device_list/domain/entities/device_entity.dart index 874077f9..b1542206 100644 --- a/lib/features/v2/device_list/domain/entities/device_entity.dart +++ b/lib/features/v2/device_list/domain/entities/device_entity.dart @@ -10,6 +10,7 @@ class DeviceEntity { final double? current; final double? voltage; final double? radiation; + final bool isRobot; const DeviceEntity({ required this.deviceId, @@ -22,5 +23,6 @@ class DeviceEntity { this.current, this.voltage, this.radiation, + this.isRobot = false, }); } diff --git a/lib/features/v2/device_list/domain/repositories/device_repository.dart b/lib/features/v2/device_list/domain/repositories/device_repository.dart index 35235b79..0e7687da 100644 --- a/lib/features/v2/device_list/domain/repositories/device_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/device_repository.dart @@ -5,4 +5,5 @@ import '../entities/device_status_entity.dart'; abstract class DeviceRepository { Future getDeviceStatus(); Future> getDeviceList({int? siteId, String? typeFilter}); + Future> getAllDevices(); } diff --git a/lib/features/v2/device_list/domain/usecases/get_all_devices_usecase.dart b/lib/features/v2/device_list/domain/usecases/get_all_devices_usecase.dart new file mode 100644 index 00000000..b87f720c --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/get_all_devices_usecase.dart @@ -0,0 +1,13 @@ +import '../entities/device_entity.dart'; +import '../repositories/device_repository.dart'; + +/// 获取全部设备列表用例(用于蓝牙绑定匹配) +class GetAllDevicesUseCase { + final DeviceRepository repository; + + const GetAllDevicesUseCase({required this.repository}); + + Future> execute() async { + return await repository.getAllDevices(); + } +} diff --git a/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart b/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart index b102675e..8b454fec 100644 --- a/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart +++ b/lib/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart @@ -10,15 +10,15 @@ class FloatBarSettingService { /// 🔥 静态实例引用 static FloatBarSettingService? _instance; - /// 🔥 静态状态变量 - 所有组件共享 - static bool _isEnabled = true; + /// 🔥 静态状态变量 - 所有组件共享(默认关闭) + static bool _isEnabled = false; - /// 🔥 静态 ValueNotifier - 用于通知UI变化 - static final ValueNotifier _settingNotifier = ValueNotifier(true); + /// 🔥 静态 ValueNotifier - 用于通知UI变化(默认关闭) + static final ValueNotifier _settingNotifier = ValueNotifier(false); FloatBarSettingService(this._prefs) { _instance = this; - _isEnabled = _prefs.getBool(_key) ?? true; + _isEnabled = _prefs.getBool(_key) ?? false; _settingNotifier.value = _isEnabled; debugPrint('✅ [FloatBarSettingService] 初始化完成,初始状态: $_isEnabled'); } 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 13cc6d48..367a78b1 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 @@ -23,7 +23,7 @@ import '../../../../../../core/network/mqtt/data/datasources/drone_osd_datasourc enum SimulationMode { circle, // 圆形路径 polygon, // 多边形路径(六边形) - bow, // 弓字形路径(Zigzag) + bow, // 覆盖模式路径(Coverage) rectangle, // 矩形路径 } @@ -524,7 +524,7 @@ class FloatBarWidgetState extends State { return path; } - /// 生成弓字形路径(Zigzag) + /// 生成覆盖模式路径(Coverage) List _generateBowPath(double centerLat, double centerLng, int points) { final List path = []; const double width = 0.004; // 宽度约400米 @@ -544,7 +544,7 @@ class FloatBarWidgetState extends State { } } - debugPrint('〰️ 生成弓字形路径: ${path.length}个点, $rows行'); + debugPrint('〰️ 生成覆盖模式路径: ${path.length}个点, $rows行'); return path; } diff --git a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart index 0cd775f9..7376afa0 100644 --- a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:maibu_satabot_v2/core/theme/AppTheme.dart'; import 'dart:developer' as developer; import 'dart:typed_data'; @@ -11,6 +12,9 @@ import '../../../../../core/bluetooth/protocol_parser.dart'; import '../../../../../core/protocol/machine_protocol_constants.dart'; import 'package:get_it/get_it.dart'; import '../../../../../core/services/device_permission_service.dart'; +import '../../domain/entities/device_entity.dart'; +import '../../domain/usecases/get_all_devices_usecase.dart'; +import 'bind_device_page.dart'; class BleDeviceDetailPage extends StatefulWidget { final BluetoothDevice device; @@ -34,6 +38,7 @@ class _BleDeviceDetailPageState extends State { StreamSubscription? _connectingSubscription; StreamSubscription? _packetSubscription; final List<_ReceivedPacket> _receivedPackets = []; + /// 按命令类型分组的最新推送:command → 最新数据 final Map _latestPushByCommand = {}; bool _pushExpanded = false; // 数据推送默认折叠 @@ -44,6 +49,7 @@ class _BleDeviceDetailPageState extends State { /// 等待设备返回 0x06 写配置应答 Completer? _writeRespCompleter; + /// 等待回读 0x05 应答,携带期望字段值用于对比 Completer? _readbackCompleter; Map? _expectedFieldValues; @@ -247,6 +253,107 @@ class _BleDeviceDetailPageState extends State { await BleManager.instance.disconnect(); } + /// 获取当前蓝牙设备的显示名称 + String get _bleDeviceName { + final advData = widget.scanResult?.advertisementData; + return _resolvedName ?? + (advData?.advName.isNotEmpty == true + ? advData!.advName + : (widget.device.platformName.isNotEmpty + ? widget.device.platformName + : (advData?.localName?.isNotEmpty == true + ? advData!.localName! + : (widget.device.advName.isNotEmpty + ? widget.device.advName + : '')))); + } + + /// 绑定设备:调接口获取设备列表,用蓝牙名(时间戳)匹配后跳转绑定页 + Future _bindDevice() async { + final bleName = _bleDeviceName; + if (bleName.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('无法获取蓝牙设备名称'))); + return; + } + + // 显示 loading + BuildContext? loadingCtx; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + loadingCtx = ctx; + return const Center(child: CircularProgressIndicator()); + }, + ); + + try { + final getAllDevicesUseCase = GetIt.instance(); + final devices = await getAllDevicesUseCase.execute(); + + if (loadingCtx != null && Navigator.canPop(loadingCtx!)) { + Navigator.pop(loadingCtx!); + } + + debugPrint( + '>>> [BindDevice] bleName=$bleName, devices=${devices.length}', + ); + for (int i = 0; i < devices.length && i < 10; i++) { + debugPrint( + '>>> [BindDevice] device[$i]: id=${devices[i].deviceId}, name=${devices[i].name}', + ); + } + + // 用蓝牙名(时间戳)匹配设备名中包含该时间戳的设备 + DeviceEntity? matchedDevice; + for (final device in devices) { + if (device.name.contains(bleName)) { + matchedDevice = device; + break; + } + } + + if (matchedDevice == null) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('未找到名称包含「$bleName」的设备'))); + } + return; + } + + if (matchedDevice.deviceId.isEmpty) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('匹配到的设备缺少设备ID'))); + } + return; + } + + if (!mounted) return; + // 复用扫一扫的绑定页面流程 + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => BindDevicePage(scanResult: matchedDevice!.deviceId), + ), + ); + } catch (e) { + if (loadingCtx != null && Navigator.canPop(loadingCtx!)) { + Navigator.pop(loadingCtx!); + } + debugPrint('>>> [BindDevice] error: $e'); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('绑定设备失败: $e'))); + } + } + } + @override void dispose() { _connSubscription?.cancel(); @@ -348,8 +455,8 @@ class _BleDeviceDetailPageState extends State { final hex = payload.isEmpty ? '' : payload - .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) - .join(' '); + .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0')) + .join(' '); setState(() { _receivedPackets.insert( 0, @@ -372,7 +479,6 @@ class _BleDeviceDetailPageState extends State { await _sendAndLog(MachineProtocolConstants.cmdReadConfig, []); } - String _bytesToHex(List bytes) { if (bytes.isEmpty) return ''; return bytes @@ -382,16 +488,26 @@ class _BleDeviceDetailPageState extends State { String _commandName(int command) { switch (command) { - case MachineProtocolConstants.cmdRemoteControl: return '远程遥控'; - case MachineProtocolConstants.cmdPathPlanning: return '路径规划'; - case MachineProtocolConstants.cmdStatusInfo: return '状态信息'; - case MachineProtocolConstants.cmdGetId: return '查询ID'; - case MachineProtocolConstants.cmdGetAuth: return '获取授权'; - case MachineProtocolConstants.cmdReadConfig: return '读配置'; - case MachineProtocolConstants.cmdWriteConfig: return '写配置'; - case MachineProtocolConstants.cmdObstacleAvoid: return '避障'; - case MachineProtocolConstants.cmdHeartbeat: return '心跳包'; - default: return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})'; + case MachineProtocolConstants.cmdRemoteControl: + return '远程遥控'; + case MachineProtocolConstants.cmdPathPlanning: + return '路径规划'; + case MachineProtocolConstants.cmdStatusInfo: + return '状态信息'; + case MachineProtocolConstants.cmdGetId: + return '查询ID'; + case MachineProtocolConstants.cmdGetAuth: + return '获取授权'; + case MachineProtocolConstants.cmdReadConfig: + return '读配置'; + case MachineProtocolConstants.cmdWriteConfig: + return '写配置'; + case MachineProtocolConstants.cmdObstacleAvoid: + return '避障'; + case MachineProtocolConstants.cmdHeartbeat: + return '心跳包'; + default: + return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})'; } } @@ -586,13 +702,19 @@ class _BleDeviceDetailPageState extends State { width: 80, child: Text( label, - style: TextStyle(fontSize: 13, color: context.appColors.textTertiary), + style: TextStyle( + fontSize: 13, + color: context.appColors.textTertiary, + ), ), ), Expanded( child: Text( value, - style: TextStyle(fontSize: 13, color: context.appColors.textPrimary), + style: TextStyle( + fontSize: 13, + color: context.appColors.textPrimary, + ), ), ), ], @@ -626,33 +748,66 @@ class _BleDeviceDetailPageState extends State { ), ), const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isConnecting - ? null - : (_isConnected ? _disconnect : _connect), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: _isConnected - ? context.appColors.danger - : context.appColors.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + Row( + children: [ + // 连接/断开 按钮 + Expanded( + child: ElevatedButton( + onPressed: _isConnecting + ? null + : (_isConnected ? _disconnect : _connect), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _isConnected + ? context.appColors.danger + : context.appColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isConnecting + ? Text( + '连接中 ${_connectingCountdown}s', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ) + : Text(_isConnected ? '断开连接' : '连接设备'), ), ), - child: _isConnecting - ? Text( - '连接中 ${_connectingCountdown}s', - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ) - : Text(_isConnected ? '断开连接' : '连接设备'), - ), + const SizedBox(width: 12), + // 绑定设备 按钮(未连接时灰色禁用) + Expanded( + child: ElevatedButton( + onPressed: _isConnected ? _bindDevice : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: _isConnected + ? context.appColors.primary + : context.appColors.fillBackground, + foregroundColor: _isConnected + ? Colors.white + : context.appColors.textTertiary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + '绑定设备', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: _isConnected + ? Colors.white + : context.appColors.textTertiary, + ), + ), + ), + ), + ], ), ], ), @@ -785,7 +940,10 @@ class _BleDeviceDetailPageState extends State { ), child: Text( p, - style: TextStyle(fontSize: 10, color: context.appColors.primary), + style: TextStyle( + fontSize: 10, + color: context.appColors.primary, + ), ), ), ) @@ -871,14 +1029,21 @@ class _BleDeviceDetailPageState extends State { if (pushes.isNotEmpty) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: context.appColors.primary, borderRadius: BorderRadius.circular(10), ), child: Text( '${pushes.length}', - style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600), + style: const TextStyle( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -894,7 +1059,10 @@ class _BleDeviceDetailPageState extends State { }, child: Text( '清空', - style: TextStyle(fontSize: 13, color: context.appColors.primary), + style: TextStyle( + fontSize: 13, + color: context.appColors.primary, + ), ), ), ], @@ -906,16 +1074,23 @@ class _BleDeviceDetailPageState extends State { child: Center( child: Text( '等待接收数据...', - style: TextStyle(fontSize: 13, color: context.appColors.textTertiary), + style: TextStyle( + fontSize: 13, + color: context.appColors.textTertiary, + ), ), ), ) else Column( - children: pushes.map((p) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _buildPushCard(p), - )).toList(), + children: pushes + .map( + (p) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildPushCard(p), + ), + ) + .toList(), ), ], ), @@ -970,35 +1145,46 @@ class _BleDeviceDetailPageState extends State { '${p.time.hour.toString().padLeft(2, '0')}:' '${p.time.minute.toString().padLeft(2, '0')}:' '${p.time.second.toString().padLeft(2, '0')}', - style: TextStyle(fontSize: 11, color: context.appColors.textTertiary), + style: TextStyle( + fontSize: 11, + color: context.appColors.textTertiary, + ), ), ], ), if (hasParsed) ...[ const SizedBox(height: 8), - ...p.parsedFields!.map((f) => Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 68, - child: Text( - f.label, - style: TextStyle(fontSize: 11, color: context.appColors.textTertiary), + ...p.parsedFields!.map( + (f) => Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 68, + child: Text( + f.label, + style: TextStyle( + fontSize: 11, + color: context.appColors.textTertiary, + ), + ), ), - ), - Expanded( - child: Text( - f.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: context.appColors.textPrimary), + Expanded( + child: Text( + f.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: context.appColors.textPrimary, + ), + ), ), - ), - ], + ], + ), ), - )), + ), ] else if (p.hex.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 6), @@ -1019,12 +1205,7 @@ class _BleDeviceDetailPageState extends State { } /// 可编辑的字段白名单:仅这四个增益属性可修改 - static const _editableFields = { - '左轮前进增益', - '左轮后退增益', - '右轮前进增益', - '右轮后退增益', - }; + static const _editableFields = {'左轮前进增益', '左轮后退增益', '右轮前进增益', '右轮后退增益'}; bool _isEditableField(String label) => _editableFields.contains(label); @@ -1156,7 +1337,9 @@ class _BleDeviceDetailPageState extends State { ), TextSpan( text: ' | ', - style: TextStyle(color: context.appColors.divider), + style: TextStyle( + color: context.appColors.divider, + ), ), TextSpan( text: mod.newValue, @@ -1219,7 +1402,10 @@ class _BleDeviceDetailPageState extends State { hintText: '输入新值', border: const OutlineInputBorder(), suffixText: '当前: $currentValue', - suffixStyle: TextStyle(fontSize: 11, color: context.appColors.textTertiary), + suffixStyle: TextStyle( + fontSize: 11, + color: context.appColors.textTertiary, + ), errorText: errorText, ), ), @@ -1236,7 +1422,9 @@ class _BleDeviceDetailPageState extends State { Navigator.pop(ctx); return; } - final pkt = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig]; + final pkt = + _latestPushByCommand[MachineProtocolConstants + .cmdReadConfig]; final entity = pkt?.configEntity; if (entity == null) { Navigator.pop(ctx); @@ -1269,7 +1457,8 @@ class _BleDeviceDetailPageState extends State { try { // 🔐 前置权限校验:使用读配置(0x05)中的芯片序列号(chipUid)作为设备ID - final readConfigPacket = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig]; + final readConfigPacket = + _latestPushByCommand[MachineProtocolConstants.cmdReadConfig]; final chipUid = readConfigPacket?.configEntity?.chipUid ?? ''; if (chipUid.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -1305,7 +1494,11 @@ class _BleDeviceDetailPageState extends State { builder: (dialogCtx) => AlertDialog( title: Row( children: [ - Icon(Icons.check_circle, color: context.appColors.success, size: 28), + Icon( + Icons.check_circle, + color: context.appColors.success, + size: 28, + ), const SizedBox(width: 8), const Text('权限通过'), ], @@ -1343,7 +1536,10 @@ class _BleDeviceDetailPageState extends State { // 2. 发送 0x06 并等待设备应答 _writeRespCompleter = Completer(); - developer.log('[WriteConfig] ⏳ 发送 0x06, 等待应答...', name: 'BleDeviceDetail'); + developer.log( + '[WriteConfig] ⏳ 发送 0x06, 等待应答...', + name: 'BleDeviceDetail', + ); await BleManager.instance.sendCommand( MachineProtocolConstants.cmdWriteConfig, configBytes.toList(), @@ -1363,7 +1559,9 @@ class _BleDeviceDetailPageState extends State { if (!mounted) return; // 3. 记录期望值,自动回读对比(无论是否收到显式确认都回读) - _expectedFieldValues = Map.from(p.configEntity!.toFieldMap()); + _expectedFieldValues = Map.from( + p.configEntity!.toFieldMap(), + ); // 4. 自动回读配置 _readbackCompleter = Completer(); @@ -1430,14 +1628,13 @@ class _BleDeviceDetailPageState extends State { rawPayload: readbackPayload, configEntity: newEntity, ); - _latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = updatedEntry; + _latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = + updatedEntry; }); } ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - '校验不一致: ${mismatched.join('、')},已恢复为设备实际值', - ), + content: Text('校验不一致: ${mismatched.join('、')},已恢复为设备实际值'), backgroundColor: context.appColors.warning, duration: const Duration(seconds: 3), ), @@ -1462,7 +1659,8 @@ class _BleDeviceDetailPageState extends State { rawPayload: readbackPayload, configEntity: newEntity, ); - _latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = updatedEntry; + _latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = + updatedEntry; _configModifications = {}; }); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index e150bd4c..a890810b 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -119,15 +119,29 @@ class _DeviceStatusViewState extends State { } } - /// 🔥 机器人设备名称前缀,用于从通用设备列表中过滤掉机器人 - static const _robotPrefixes = [ - 'RCHETD-CN', - 'MC700PLUS-CN', - 'MC700-CN', - 'MC700PRO-CN', - 'RCETD-CN', - 'MC700AIR-CN', - ]; + /// 🔥 机器人型号家族前缀(小写,startsWith 匹配,覆盖 MC700/MC700STD/MC700PLUS/MCAIR/RCHWD/RCHETD 等全系列) + static const _robotPrefixes = ['mc', 'rch', 'rcetd', 'rchetd']; + + /// 🔥 机器人类型中文名关键字(接口 deviceTypeName 可能是纯中文) + static const _robotTypeKeywords = ['除草', '巡检', '清洗', '割草', '机器人']; + + /// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别) + static bool _isRobotDevice(String type, String name, String deviceId) { + final haystacks = [ + type.toLowerCase(), + name.toLowerCase(), + deviceId.toLowerCase(), + ]; + for (final s in haystacks) { + for (final p in _robotPrefixes) { + if (s.startsWith(p)) return true; + } + } + for (final k in _robotTypeKeywords) { + if (type.contains(k) || name.contains(k)) return true; + } + return false; + } @override Widget build(BuildContext context) { @@ -186,7 +200,11 @@ class _DeviceStatusViewState extends State { children: [ const Flexible(child: SiteSelectorWidget(compact: true)), const SizedBox(width: 8), - Container(width: 1, height: 20, color: context.appColors.divider), + Container( + width: 1, + height: 20, + color: context.appColors.divider, + ), const SizedBox(width: 8), Text( AppLocalizations.of( @@ -310,7 +328,10 @@ class _DeviceStatusViewState extends State { vertical: 12, ), ), - style: TextStyle(fontSize: 14, color: context.appColors.textPrimary), + style: TextStyle( + fontSize: 14, + color: context.appColors.textPrimary, + ), onSubmitted: (value) { context.read().add( DeviceListEvent.DeviceStatusSearch(value), @@ -490,13 +511,16 @@ class _DeviceStatusViewState extends State { } else { // 🔥 "全部"模式下,排除已由专用组件渲染的类型,避免重复展示 // drone_station 已通过 _buildDroneStationList 渲染 - // 机器人(通过名称前缀识别)已通过 _buildEmbeddedRobotList 渲染 + // 机器人通过下面的 _isRobotDevice 通用匹配识别,已由 _buildEmbeddedRobotList 渲染 filteredByType = state.devices.where((device) { if (device.type == 'drone_station') return false; - final isRobot = _robotPrefixes.any( - (prefix) => - device.name.startsWith(prefix) || - device.deviceId.startsWith(prefix), + final isRobot = _isRobotDevice( + device.type, + device.name, + device.deviceId, + ); + debugPrint( + '🔍 [全部-过滤] deviceId=${device.deviceId} type=${device.type} name=${device.name} → isRobot=$isRobot ${isRobot ? '✅排除' : ''}', ); return !isRobot; }).toList(); @@ -722,7 +746,9 @@ class _DeviceStatusViewState extends State { builder: (context, state) { if (state is DroneStationLoading) { return Center( - child: CircularProgressIndicator(color: context.appColors.primary), + child: CircularProgressIndicator( + color: context.appColors.primary, + ), ); } @@ -1042,9 +1068,8 @@ class _DeviceStatusViewState extends State { debugPrint('🔍 [DeviceStatusPage] 设备ID: $deviceId'); debugPrint('🔍 [DeviceStatusPage] 设备类型: $deviceType'); - // 🔥 判断是否为机器人(根据设备类型前缀) - final robotPrefixes = _robotPrefixes; - bool isRobot = robotPrefixes.any((prefix) => deviceType.startsWith(prefix)); + // 🔥 判断是否为机器人:用通用匹配(型号前缀 + 中文关键字) + bool isRobot = _isRobotDevice(deviceType, deviceName, deviceId); // 🔥 判断是否为无人机机场(根据设备类型包含关键字) final droneStationKeywords = [ @@ -1065,22 +1090,8 @@ class _DeviceStatusViewState extends State { (deviceType.isEmpty || deviceType == '未知设备' || deviceType == '未知类型')) { debugPrint('⚠️ [DeviceStatusPage] type 字段无效,使用设备名称进行二次判断'); - // 检查每个机器人前缀是否匹配 - for (final prefix in robotPrefixes) { - final nameMatch = deviceName.startsWith(prefix); - final idMatch = deviceId.startsWith(prefix); - if (nameMatch || idMatch) { - debugPrint( - '🔍 [DeviceStatusPage] 机器人前缀匹配: "$prefix" -> 名称匹配=$nameMatch, ID匹配=$idMatch', - ); - } - } - - // 根据设备名称前缀判断机器人 - isRobot = robotPrefixes.any( - (prefix) => - deviceName.startsWith(prefix) || deviceId.startsWith(prefix), - ); + // 根据通用匹配重新判断(已统一用 lowercase) + isRobot = _isRobotDevice(deviceType, deviceName, deviceId); // 检查每个无人机关键字是否匹配 for (final keyword in droneStationKeywords) { 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 646c2b5d..99c9c1e8 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 @@ -1460,7 +1460,7 @@ class _DroneVideoControlPageState extends State { try { final dio = DioClient.create(); final response = await dio.post( - 'http://1.95.137.212:59003/iot/UAV/flightTaskCommand', + '${HttpApiConsts.baseUrl}/iot/UAV/flightTaskCommand', data: {'command': command, 'deviceSn': widget.droneSn}, ); diff --git a/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart b/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart index 551d1c8c..a4858dfe 100644 --- a/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart +++ b/lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart @@ -21,6 +21,7 @@ class _BluetoothScanModalState extends State { int _connectingCountdown = 15; Timer? _connectingTimer; bool _isStartingScan = false; + String _searchQuery = ''; StreamSubscription? _stateSubscription; StreamSubscription>? _scanSubscription; @@ -123,7 +124,7 @@ class _BluetoothScanModalState extends State { await BleManager.instance.openBluetooth(); } - /// 排序:已连接设备排第一,有名称设备优先 + /// 排序:已连接设备排第一,纯数字名称(时间戳样式)置顶,有名称设备优先 List _sortResults(List results) { final mgrConnected = BleManager.instance.connectedDevice; final sorted = List.from(results); @@ -133,7 +134,12 @@ class _BluetoothScanModalState extends State { final bConnected = b.device.remoteId == mgrConnected?.remoteId; if (aConnected && !bConnected) return -1; if (!aConnected && bConnected) return 1; - // 第二级:有名称的排前面 + // 第二级:纯数字名称(时间戳样式,如 17xxxx)排前面 + final aIsNumeric = _isNumericName(a); + final bIsNumeric = _isNumericName(b); + if (aIsNumeric && !bIsNumeric) return -1; + if (!aIsNumeric && bIsNumeric) return 1; + // 第三级:有名称的排前面 final aHasName = _hasDeviceName(a); final bHasName = _hasDeviceName(b); if (aHasName && !bHasName) return -1; @@ -152,6 +158,27 @@ class _BluetoothScanModalState extends State { r.device.advName.isNotEmpty; } + /// 获取设备显示名称 + String _getDeviceName(ScanResult r) { + final advData = r.advertisementData; + return advData.advName.isNotEmpty + ? advData.advName + : (r.device.platformName.isNotEmpty + ? r.device.platformName + : (advData.localName?.isNotEmpty == true + ? advData.localName! + : (r.device.advName.isNotEmpty + ? r.device.advName + : '${r.device.remoteId}'))); + } + + /// 判断设备名称是否为纯数字(时间戳样式,如 1777272534998) + bool _isNumericName(ScanResult r) { + final name = _getDeviceName(r); + if (name.isEmpty) return false; + return RegExp(r'^\d+$').hasMatch(name); + } + /// 从详情页返回时同步连接状态(包括连接中和已连接) void _syncConnectedDevice() { final mgrConnected = BleManager.instance.connectedDevice; @@ -448,12 +475,22 @@ class _BluetoothScanModalState extends State { Widget _buildDeviceList() { // 过滤掉已连接设备(它已在状态卡片下方单独显示) - final otherResults = _connectedDevice != null + var otherResults = _connectedDevice != null ? _scanResults - .where((r) => r.device.remoteId != _connectedDevice!.remoteId) - .toList() + .where((r) => r.device.remoteId != _connectedDevice!.remoteId) + .toList() : _scanResults; + // 按搜索关键词过滤 + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + otherResults = otherResults.where((r) { + final name = _getDeviceName(r).toLowerCase(); + final mac = r.device.remoteId.toString().toLowerCase(); + return name.contains(query) || mac.contains(query); + }).toList(); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -466,6 +503,53 @@ class _BluetoothScanModalState extends State { ), ), const SizedBox(height: 12), + // 搜索框 + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFFF2F3F5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.search, size: 18, color: Color(0xFF86909C)), + const SizedBox(width: 8), + Expanded( + child: TextField( + onChanged: (value) { + setState(() => _searchQuery = value); + }, + decoration: const InputDecoration( + hintText: '搜索设备名称或MAC', + hintStyle: TextStyle( + fontSize: 13, + color: Color(0xFF86909C), + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 10), + ), + style: const TextStyle( + fontSize: 13, + color: Color(0xFF1D2129), + ), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + setState(() => _searchQuery = ''); + }, + child: const Icon( + Icons.close, + size: 16, + color: Color(0xFF86909C), + ), + ), + ], + ), + ), if (!_isBluetoothOn) const Padding( padding: EdgeInsets.symmetric(vertical: 24), @@ -487,20 +571,20 @@ class _BluetoothScanModalState extends State { ), ) else if (otherResults.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), child: Center( child: Column( children: [ SizedBox( width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + child: const CircularProgressIndicator(strokeWidth: 2), ), - SizedBox(height: 12), + const SizedBox(height: 12), Text( - '正在扫描附近设备...', - style: TextStyle(color: Color(0xFF86909C)), + _searchQuery.isNotEmpty ? '未找到匹配的设备' : '正在扫描附近设备...', + style: const TextStyle(color: Color(0xFF86909C)), ), ], ), @@ -581,10 +665,7 @@ class _BluetoothScanModalState extends State { ), child: const Text( '已连接', - style: TextStyle( - fontSize: 10, - color: Colors.white, - ), + style: TextStyle(fontSize: 10, color: Colors.white), ), ), ], diff --git a/lib/features/v2/my/data/datasources/impl/my_remote_datasource_impl.dart b/lib/features/v2/my/data/datasources/impl/my_remote_datasource_impl.dart index 1e97f1f4..59f7d0ff 100644 --- a/lib/features/v2/my/data/datasources/impl/my_remote_datasource_impl.dart +++ b/lib/features/v2/my/data/datasources/impl/my_remote_datasource_impl.dart @@ -57,7 +57,7 @@ class MyRemoteDataSourceImpl implements MyRemoteDataSource { id: 'about_us', title: '关于我们', icon: 'info', - subTitle: 'V2.1.0', + subTitle: 'V1.0.1', ), ]; } diff --git a/lib/features/v2/my/presentation/pages/system_settings_page.dart b/lib/features/v2/my/presentation/pages/system_settings_page.dart index 4a8983fb..0d717eee 100644 --- a/lib/features/v2/my/presentation/pages/system_settings_page.dart +++ b/lib/features/v2/my/presentation/pages/system_settings_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -10,6 +11,8 @@ import 'package:maibu_satabot_v2/core/storage/user_storage.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart'; import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart'; +import 'package:maibu_satabot_v2/core/update/update_cubit.dart'; +import 'package:maibu_satabot_v2/core/update/update_state.dart'; /// 系统设置综合页面 class SystemSettingsPage extends StatefulWidget { @@ -20,6 +23,25 @@ class SystemSettingsPage extends StatefulWidget { } class _SystemSettingsPageState extends State { + String _currentVersion = ''; + int _currentVersionCode = 0; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + try { + final String versionString = await rootBundle.loadString('pubspec.yaml'); + final versionMatch = RegExp(r'version:\s*(\d+\.\d+\.\d+)\+(\d+)').firstMatch(versionString); + setState(() { + _currentVersion = versionMatch?.group(1) ?? '1.0.0'; + _currentVersionCode = int.tryParse(versionMatch?.group(2) ?? '1') ?? 1; + }); + } catch (_) {} + } @override Widget build(BuildContext context) { return Scaffold( @@ -52,6 +74,9 @@ class _SystemSettingsPageState extends State { // 语言设置 _buildLanguageSection(), const SizedBox(height: 12), + // 版本更新 + _buildUpdateSection(), + const SizedBox(height: 12), // 退出登录 _buildLogoutButton(context), const SizedBox(height: 20), @@ -297,6 +322,105 @@ class _SystemSettingsPageState extends State { ); } + /// 版本更新 + Widget _buildUpdateSection() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.appColors.cardBackground, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: context.appColors.cardShadow, + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: BlocConsumer( + listener: (context, state) { + if (state is UpdateUpToDate) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('当前已是最新版本'), + backgroundColor: context.appColors.success, + duration: const Duration(seconds: 2), + ), + ); + } + }, + builder: (context, state) { + final isChecking = state is UpdateChecking; + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '版本更新', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: context.appColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + '当前版本: $_currentVersion ($_currentVersionCode)', + style: TextStyle( + fontSize: 13, + color: context.appColors.textTertiary, + ), + ), + const SizedBox(height: 2), + Text( + isChecking ? '正在检查更新...' : '点击检查是否有新版本', + style: TextStyle( + fontSize: 13, + color: context.appColors.textTertiary, + ), + ), + ], + ), + ), + if (isChecking) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + GestureDetector( + onTap: () { + context.read().checkUpdate(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color(0xFF165DFF), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '检查更新', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + /// 退出登录按钮 Widget _buildLogoutButton(BuildContext context) { return GestureDetector(