一次大的提交
This commit is contained in:
@@ -15,7 +15,7 @@ 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';
|
||||
|
||||
const int DATA_TIMEOUT_SECONDS = 5;
|
||||
const int DATA_TIMEOUT_SECONDS = 15;
|
||||
|
||||
class DeviceStatusModal extends StatefulWidget {
|
||||
const DeviceStatusModal({super.key});
|
||||
@@ -162,7 +162,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
|
||||
}
|
||||
|
||||
Widget _buildCardContentView(DeviceStatusState state) {
|
||||
if (_isDataTimeout || state is DeviceStatusInitial) {
|
||||
if (state is DeviceStatusInitial) {
|
||||
return _noDataWidget();
|
||||
}
|
||||
|
||||
@@ -445,11 +445,9 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
|
||||
builder: (context, state) {
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
_startDataTimeoutTimer();
|
||||
if (_isDataTimeout) {
|
||||
setState(() => _isDataTimeout = false);
|
||||
}
|
||||
if (state is DeviceStatusUpdated) {
|
||||
_startDataTimeoutTimer(); // 每次收到数据重置超时计时器
|
||||
_isDataTimeout = false; // 直接赋值,不调 setState(build 阶段禁止)
|
||||
_appendChartData(state);
|
||||
}
|
||||
|
||||
@@ -527,7 +525,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
|
||||
String satelliteCnt = _isDataTimeout ? '--' : '--';
|
||||
String headingStatus = _isDataTimeout ? "--" : "--";
|
||||
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
if (state is DeviceStatusUpdated) {
|
||||
headingStatus = state.status.headingStatus == 0
|
||||
? AppLocalizations.of(
|
||||
context,
|
||||
@@ -680,7 +678,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
|
||||
}
|
||||
|
||||
Widget _buildChartContentView(DeviceStatusState state) {
|
||||
if (_isDataTimeout || state is DeviceStatusInitial) {
|
||||
if (state is DeviceStatusInitial) {
|
||||
return _noDataWidget();
|
||||
}
|
||||
|
||||
|
||||
448
lib/core/bluetooth/ble_manager.dart
Normal file
448
lib/core/bluetooth/ble_manager.dart
Normal file
@@ -0,0 +1,448 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'bytes_util.dart';
|
||||
import 'protocol_parser.dart';
|
||||
|
||||
class BleManager {
|
||||
static final BleManager instance = BleManager._internal();
|
||||
BleManager._internal();
|
||||
|
||||
BluetoothDevice? _connectedDevice;
|
||||
BluetoothDevice? _connectingDevice;
|
||||
BluetoothCharacteristic? _writeCharacteristic;
|
||||
BluetoothCharacteristic? _readCharacteristic;
|
||||
|
||||
/// 有状态的协议解析器(支持 BLE 分片)
|
||||
final ProtocolParser _parser = ProtocolParser();
|
||||
|
||||
final Map<DeviceIdentifier, ScanResult> _scanResults = {};
|
||||
final StreamController<List<ScanResult>> _scanController =
|
||||
StreamController.broadcast();
|
||||
final StreamController<BlePacket> _packetController =
|
||||
StreamController.broadcast();
|
||||
final StreamController<BluetoothDevice?> _connectionController =
|
||||
StreamController.broadcast();
|
||||
final StreamController<BluetoothDevice?> _connectingController =
|
||||
StreamController.broadcast();
|
||||
|
||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||
StreamSubscription<List<int>>? _readSubscription;
|
||||
StreamSubscription<BluetoothAdapterState>? _adapterStateSubscription;
|
||||
StreamSubscription<BluetoothConnectionState>? _deviceConnectionSubscription;
|
||||
|
||||
/// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效
|
||||
int _scanGen = 0;
|
||||
|
||||
/// 协商后的 MTU 值,用于分片写入
|
||||
int _negotiatedMtu = 23;
|
||||
|
||||
/// 已收到的数据包存储(跨页面持久化)
|
||||
final List<BlePacket> receivedPacketStore = [];
|
||||
static const int _maxStoredPackets = 100;
|
||||
|
||||
void clearReceivedPackets() {
|
||||
receivedPacketStore.clear();
|
||||
}
|
||||
|
||||
bool get isConnected => _connectedDevice != null;
|
||||
BluetoothDevice? get connectedDevice => _connectedDevice;
|
||||
BluetoothDevice? get connectingDevice => _connectingDevice;
|
||||
Stream<List<ScanResult>> get scanResults => _scanController.stream;
|
||||
Stream<BlePacket> get packetStream => _packetController.stream;
|
||||
Stream<BluetoothAdapterState> get adapterState =>
|
||||
FlutterBluePlus.adapterState;
|
||||
|
||||
/// 连接状态变化流:连接成功时发出 device,断开时发出 null
|
||||
Stream<BluetoothDevice?> get connectionStream => _connectionController.stream;
|
||||
|
||||
/// 连接中状态流:开始连接时发出 device,连接完成/失败时发出 null
|
||||
Stream<BluetoothDevice?> get connectingStream => _connectingController.stream;
|
||||
|
||||
Future<bool> checkBluetooth() async {
|
||||
final state = await FlutterBluePlus.adapterState.first;
|
||||
return state == BluetoothAdapterState.on;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final connStatus = await Permission.bluetoothConnect.status;
|
||||
developer.log(
|
||||
'[BLE] bluetoothConnect status: $connStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (connStatus.isDenied) {
|
||||
developer.log('[BLE] requesting bluetoothConnect...', name: 'BleManager');
|
||||
await Permission.bluetoothConnect.request();
|
||||
}
|
||||
|
||||
final scanStatus = await Permission.bluetoothScan.status;
|
||||
developer.log(
|
||||
'[BLE] bluetoothScan status: $scanStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (scanStatus.isDenied) {
|
||||
developer.log('[BLE] requesting bluetoothScan...', name: 'BleManager');
|
||||
await Permission.bluetoothScan.request();
|
||||
}
|
||||
|
||||
final locStatus = await Permission.locationWhenInUse.status;
|
||||
developer.log(
|
||||
'[BLE] locationWhenInUse status: $locStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (locStatus.isDenied) {
|
||||
developer.log(
|
||||
'[BLE] requesting locationWhenInUse...',
|
||||
name: 'BleManager',
|
||||
);
|
||||
await Permission.locationWhenInUse.request();
|
||||
}
|
||||
|
||||
final allGranted =
|
||||
await Permission.bluetoothConnect.isGranted &&
|
||||
await Permission.bluetoothScan.isGranted;
|
||||
developer.log(
|
||||
'[BLE] all permissions granted: $allGranted',
|
||||
name: 'BleManager',
|
||||
);
|
||||
return allGranted;
|
||||
}
|
||||
|
||||
Future<void> openBluetooth() async {
|
||||
try {
|
||||
await FlutterBluePlus.turnOn();
|
||||
} catch (e) {
|
||||
await _goToSystemSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _goToSystemSettings() async {
|
||||
await openAppSettings();
|
||||
}
|
||||
|
||||
Future<void> _openLocationSettings() async {
|
||||
await openAppSettings();
|
||||
}
|
||||
|
||||
Future<bool> _checkLocationService() async {
|
||||
final locStatus = await Permission.location.serviceStatus;
|
||||
developer.log(
|
||||
'[BLE] location service status: $locStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
return locStatus == ServiceStatus.enabled;
|
||||
}
|
||||
|
||||
Future<void> startScan({bool continuous = true}) async {
|
||||
final int myGen = ++_scanGen;
|
||||
developer.log(
|
||||
'[BLE] startScan called, gen=$myGen, continuous=$continuous',
|
||||
name: 'BleManager',
|
||||
);
|
||||
// 先停止任何残留扫描,确保干净启动
|
||||
try { await FlutterBluePlus.stopScan(); } catch (_) {}
|
||||
_scanResults.clear();
|
||||
_scanController.add([]);
|
||||
|
||||
final isOn = await checkBluetooth();
|
||||
if (myGen != _scanGen) return;
|
||||
developer.log('[BLE] Bluetooth adapter on: $isOn', name: 'BleManager');
|
||||
if (!isOn) {
|
||||
developer.log(
|
||||
'[BLE] Bluetooth is OFF, aborting scan',
|
||||
name: 'BleManager',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final granted = await requestPermissions();
|
||||
if (myGen != _scanGen) return;
|
||||
developer.log('[BLE] Permissions granted: $granted', name: 'BleManager');
|
||||
if (!granted) {
|
||||
developer.log(
|
||||
'[BLE] Permissions not granted, aborting scan',
|
||||
name: 'BleManager',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final locEnabled = await _checkLocationService();
|
||||
if (myGen != _scanGen) return;
|
||||
if (!locEnabled) {
|
||||
developer.log(
|
||||
'[BLE] Location service OFF, directing to settings',
|
||||
name: 'BleManager',
|
||||
);
|
||||
await _openLocationSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
_scanSubscription?.cancel();
|
||||
_scanSubscription = FlutterBluePlus.scanResults.listen(
|
||||
(results) {
|
||||
for (final result in results) {
|
||||
_scanResults[result.device.remoteId] = result;
|
||||
}
|
||||
developer.log(
|
||||
'[BLE] 📡 扫描到 ${results.length} 个设备: ${results.map((r) => '${r.device.platformName.isNotEmpty ? r.device.platformName : r.device.advName.isNotEmpty ? r.device.advName : r.device.remoteId} (${r.rssi}dBm)').join(', ')}',
|
||||
name: 'BleManager',
|
||||
);
|
||||
_scanController.add(_scanResults.values.toList());
|
||||
},
|
||||
onError: (e) {
|
||||
developer.log('[BLE] scanResults error: $e', name: 'BleManager');
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (continuous) {
|
||||
developer.log('[BLE] Starting continuous scan...', name: 'BleManager');
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: const Duration(days: 1),
|
||||
androidUsesFineLocation: true,
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
androidLegacy: true,
|
||||
);
|
||||
} else {
|
||||
developer.log('[BLE] Starting 10s scan...', name: 'BleManager');
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: const Duration(seconds: 10),
|
||||
androidUsesFineLocation: true,
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
androidLegacy: true,
|
||||
);
|
||||
}
|
||||
developer.log('[BLE] startScan completed', name: 'BleManager');
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'[BLE] startScan error: $e\n$stackTrace',
|
||||
name: 'BleManager',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshScan() async {
|
||||
await stopScan();
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
await startScan(continuous: true);
|
||||
}
|
||||
|
||||
Future<void> stopScan() async {
|
||||
_scanGen++;
|
||||
developer.log('[BLE] stopScan, gen=$_scanGen', name: 'BleManager');
|
||||
_scanSubscription?.cancel();
|
||||
await FlutterBluePlus.stopScan();
|
||||
}
|
||||
|
||||
/// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败
|
||||
Future<String?> connect(BluetoothDevice device) async {
|
||||
// 标记正在连接,通知所有监听者
|
||||
_connectingDevice = device;
|
||||
_connectingController.add(device);
|
||||
try {
|
||||
await device
|
||||
.connect(license: License.nonprofit, mtu: 512)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
_connectedDevice = device;
|
||||
// 连接成功,立即停止扫描(避免扫描射频干扰导致连接断开)
|
||||
await stopScan();
|
||||
// 清除连接中状态
|
||||
_connectingDevice = null;
|
||||
_connectingController.add(null);
|
||||
// 监听设备连接状态,任何一方断开都能感知
|
||||
_deviceConnectionSubscription?.cancel();
|
||||
_deviceConnectionSubscription = device.connectionState.listen((state) {
|
||||
developer.log(
|
||||
'[BLE] device connectionState: $state',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (state == BluetoothConnectionState.disconnected) {
|
||||
_onDeviceDisconnected();
|
||||
}
|
||||
});
|
||||
_connectionController.add(device);
|
||||
await _discoverServices(device);
|
||||
// 确认协商后的 MTU
|
||||
try {
|
||||
final mtu = await device.requestMtu(512);
|
||||
_negotiatedMtu = mtu;
|
||||
developer.log('[BLE] MTU: $mtu', name: 'BleManager');
|
||||
} catch (e) {
|
||||
developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
|
||||
}
|
||||
return null; // 成功
|
||||
} catch (e) {
|
||||
// 连接失败,清除连接中状态
|
||||
_connectingDevice = null;
|
||||
_connectingController.add(null);
|
||||
// 返回具体的错误原因
|
||||
if (e is TimeoutException) {
|
||||
return '连接超时(15秒),请确认设备在附近且已开启';
|
||||
}
|
||||
return '连接失败: $e';
|
||||
}
|
||||
}
|
||||
|
||||
void _onDeviceDisconnected() {
|
||||
_connectedDevice = null;
|
||||
_connectingDevice = null;
|
||||
_writeCharacteristic = null;
|
||||
_readCharacteristic = null;
|
||||
_negotiatedMtu = 23;
|
||||
_readSubscription?.cancel();
|
||||
_deviceConnectionSubscription?.cancel();
|
||||
_connectionController.add(null);
|
||||
_connectingController.add(null);
|
||||
// 清空协议解析器缓冲区
|
||||
_parser.clear();
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
final device = _connectedDevice;
|
||||
if (device == null) return;
|
||||
developer.log('[BLE] 发起断开连接...', name: 'BleManager');
|
||||
await device.disconnect();
|
||||
// 等待连接状态流确认已断开,设置超时防止无限等待
|
||||
try {
|
||||
await device.connectionState
|
||||
.firstWhere((s) => s == BluetoothConnectionState.disconnected)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
developer.log('[BLE] 连接已确认断开', name: 'BleManager');
|
||||
} catch (_) {
|
||||
developer.log('[BLE] 等待断开超时,强制清理', name: 'BleManager');
|
||||
}
|
||||
_onDeviceDisconnected();
|
||||
}
|
||||
|
||||
Future<void> _discoverServices(BluetoothDevice device) async {
|
||||
final services = await device.discoverServices();
|
||||
for (final service in services) {
|
||||
for (final characteristic in service.characteristics) {
|
||||
if (characteristic.properties.write) {
|
||||
_writeCharacteristic = characteristic;
|
||||
}
|
||||
if (characteristic.properties.notify) {
|
||||
_readCharacteristic = characteristic;
|
||||
await characteristic.setNotifyValue(true);
|
||||
_readSubscription?.cancel();
|
||||
_readSubscription = characteristic.lastValueStream.listen((value) {
|
||||
_onDataReceived(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDataReceived(List<int> data) {
|
||||
developer.log(
|
||||
'[BLE] 📩 收: ${data.length}B ${_bytesToHex(data)}',
|
||||
name: 'BleManager',
|
||||
);
|
||||
_parser.append(data);
|
||||
final packets = _parser.parse();
|
||||
developer.log(
|
||||
'[BLE] 📦 解析 ${packets.length}包 (buf ${_parser.bufferLength}B)',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (packets.isEmpty && data.isNotEmpty) {
|
||||
developer.log(
|
||||
'[BLE] ⚠️ 非标准帧 raw(${data.length}B)',
|
||||
name: 'BleManager',
|
||||
);
|
||||
final packet = BlePacket(
|
||||
command: 0x00,
|
||||
payload: Uint8List.fromList(data),
|
||||
);
|
||||
_storePacket(packet);
|
||||
_packetController.add(packet);
|
||||
return;
|
||||
}
|
||||
for (final packet in packets) {
|
||||
developer.log(
|
||||
'[BLE] cmd=0x${packet.command.toRadixString(16).toUpperCase().padLeft(2, '0')} '
|
||||
'payload(${packet.payload.length}B): ${_bytesToHex(packet.payload)}',
|
||||
name: 'BleManager',
|
||||
);
|
||||
_storePacket(packet);
|
||||
_packetController.add(packet);
|
||||
}
|
||||
}
|
||||
|
||||
void _storePacket(BlePacket packet) {
|
||||
receivedPacketStore.insert(0, packet);
|
||||
if (receivedPacketStore.length > _maxStoredPackets) {
|
||||
receivedPacketStore.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
String _bytesToHex(List<int> bytes) {
|
||||
if (bytes.isEmpty) return '';
|
||||
return bytes
|
||||
.map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
Future<void> sendCommand(int command, List<int> payload) async {
|
||||
final char = _writeCharacteristic;
|
||||
if (char == null) {
|
||||
developer.log('[BLE] ❌ 写特征为空,无法发送', name: 'BleManager');
|
||||
return;
|
||||
}
|
||||
final frame = ProtocolParser.pack(command, payload);
|
||||
final crc = frame.length >= 5
|
||||
? 'CRC16=0x${frame[frame.length - 4].toRadixString(16).padLeft(2, '0')}${frame[frame.length - 3].toRadixString(16).padLeft(2, '0')}'
|
||||
: '';
|
||||
developer.log(
|
||||
'[BLE] 📤 发送: 0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')}, '
|
||||
'帧(${frame.length}B) $crc\n'
|
||||
' ${_bytesToHex(frame)}',
|
||||
name: 'BleManager',
|
||||
);
|
||||
// 分片写入:每次最多写 (MTU - 3) 字节,全部走 withResponse 确保可靠性
|
||||
final maxWriteLen = _negotiatedMtu - 3;
|
||||
final totalChunks = (frame.length + maxWriteLen - 1) ~/ maxWriteLen;
|
||||
if (totalChunks > 1) {
|
||||
developer.log(
|
||||
'[BLE] 🔀 分${totalChunks}片写入 (MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
|
||||
name: 'BleManager',
|
||||
);
|
||||
}
|
||||
for (int offset = 0; offset < frame.length; offset += maxWriteLen) {
|
||||
final end = (offset + maxWriteLen <= frame.length)
|
||||
? offset + maxWriteLen
|
||||
: frame.length;
|
||||
final chunk = frame.sublist(offset, end);
|
||||
if (totalChunks > 1) {
|
||||
final chunkIdx = offset ~/ maxWriteLen + 1;
|
||||
developer.log(
|
||||
'[BLE] 片$chunkIdx/$totalChunks: offset=$offset len=${chunk.length}B ${_bytesToHex(chunk)}',
|
||||
name: 'BleManager',
|
||||
);
|
||||
}
|
||||
await char.write(chunk, withoutResponse: false);
|
||||
}
|
||||
developer.log('[BLE] ✅ 写入完成', name: 'BleManager');
|
||||
}
|
||||
|
||||
Future<void> sendRawBytes(List<int> bytes) async {
|
||||
if (_writeCharacteristic == null) return;
|
||||
await _writeCharacteristic!.write(bytes, withoutResponse: false);
|
||||
}
|
||||
|
||||
Future<void> sendHeartbeat() async {
|
||||
await sendCommand(0xFF, []);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_scanSubscription?.cancel();
|
||||
_readSubscription?.cancel();
|
||||
_adapterStateSubscription?.cancel();
|
||||
_deviceConnectionSubscription?.cancel();
|
||||
_scanController.close();
|
||||
_packetController.close();
|
||||
_connectionController.close();
|
||||
_connectingController.close();
|
||||
}
|
||||
}
|
||||
722
lib/core/bluetooth/ble_protocol_decoder.dart
Normal file
722
lib/core/bluetooth/ble_protocol_decoder.dart
Normal file
@@ -0,0 +1,722 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:typed_data';
|
||||
import '../protocol/machine_protocol_constants.dart';
|
||||
import 'mc700_device_config.dart';
|
||||
|
||||
/// BLE 协议解析出的单个字段
|
||||
class BleField {
|
||||
final String label;
|
||||
final String value;
|
||||
const BleField({required this.label, required this.value});
|
||||
}
|
||||
|
||||
/// BLE 协议解析结果
|
||||
class BleDecodedResult {
|
||||
final List<BleField> fields;
|
||||
final String rawHex;
|
||||
|
||||
/// 0x05 读配置时携带的配置实体(可修改后回写)
|
||||
final Mc700DeviceConfig? configEntity;
|
||||
const BleDecodedResult({
|
||||
required this.fields,
|
||||
required this.rawHex,
|
||||
this.configEntity,
|
||||
});
|
||||
bool get isEmpty => fields.isEmpty;
|
||||
}
|
||||
|
||||
/// BLE 协议解析器
|
||||
/// 与 TCP net_message_dispatcher 使用相同的协议格式:AB AA cmd [payload] ... AA AB
|
||||
/// payload 通常为 UTF-8 逗号分隔文本,部分指令为二进制
|
||||
class BleProtocolDecoder {
|
||||
/// 入口:按命令类型分发解析
|
||||
static BleDecodedResult decode(int command, Uint8List payload) {
|
||||
final hex = _bytesToHex(payload);
|
||||
try {
|
||||
switch (command) {
|
||||
case MachineProtocolConstants.cmdStatusInfo:
|
||||
return _decodeStatusInfo(payload, hex);
|
||||
case MachineProtocolConstants.cmdRemoteControl:
|
||||
return _decodeRemoteControl(payload, hex);
|
||||
case MachineProtocolConstants.cmdGetId:
|
||||
return _decodeGetId(payload, hex);
|
||||
case MachineProtocolConstants.cmdGetAuth:
|
||||
return _decodeGetAuth(payload, hex);
|
||||
case MachineProtocolConstants.cmdReadConfig:
|
||||
return _decodeReadConfig(payload, hex);
|
||||
case MachineProtocolConstants.cmdWriteConfig:
|
||||
return _decodeWriteConfig(payload, hex);
|
||||
case MachineProtocolConstants.cmdHeartbeat:
|
||||
return _decodeHeartbeat(payload, hex);
|
||||
case MachineProtocolConstants.cmdPathPlanning:
|
||||
return _decodePathPlanning(payload, hex);
|
||||
case MachineProtocolConstants.cmdObstacleAvoid:
|
||||
return _decodeObstacleAvoid(payload, hex);
|
||||
default:
|
||||
return _decodeGeneric(command, payload, hex);
|
||||
}
|
||||
} catch (e) {
|
||||
return BleDecodedResult(
|
||||
fields: [const BleField(label: '解析错误', value: '异常')],
|
||||
rawHex: hex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 0x02 状态信息(与 TCP RunningStatusEntity.fromFields 一致,24字段逗号分隔) ───
|
||||
static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
|
||||
try {
|
||||
final text = utf8.decode(data);
|
||||
if (text.contains(',')) {
|
||||
final parts = text.split(',');
|
||||
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
final text = latin1.decode(data);
|
||||
if (text.contains(',')) {
|
||||
final parts = text.split(',');
|
||||
return BleDecodedResult(
|
||||
fields: _parseStatusFields(parts),
|
||||
rawHex: hex,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (data.isNotEmpty) {
|
||||
final status = data[0];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '状态码',
|
||||
value: '0x${status.toRadixString(16).toUpperCase().padLeft(2, '0')}',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 2) {
|
||||
final mode = data[1];
|
||||
const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '控制模式',
|
||||
value: modes[mode] ?? '未知(0x${mode.toRadixString(16)})',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 3)
|
||||
fields.add(BleField(label: '电量', value: '${data[2]}%'));
|
||||
if (data.length >= 4) {
|
||||
final fault = data[3];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '故障状态',
|
||||
value: fault == 0
|
||||
? '无故障'
|
||||
: '故障码: 0x${fault.toRadixString(16).toUpperCase().padLeft(2, '0')}',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 6) {
|
||||
final speed = (data[5] << 8) | data[4];
|
||||
fields.add(BleField(label: '速度', value: '$speed'));
|
||||
}
|
||||
if (data.length > 8) {
|
||||
try {
|
||||
final text = String.fromCharCodes(data.sublist(8));
|
||||
if (text.isNotEmpty && !text.contains('\x00')) {
|
||||
fields.add(BleField(label: '附加', value: text));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
/// 解析逗号分隔的 24 字段状态数据(与 RunningStatusEntity.fromFields 完全一致)
|
||||
static List<BleField> _parseStatusFields(List<String> parts) {
|
||||
while (parts.length < 24) {
|
||||
parts.add('');
|
||||
}
|
||||
|
||||
String controlModeText(String v) {
|
||||
return switch (v) {
|
||||
'0' => '待机',
|
||||
'1' => '遥控',
|
||||
'2' => '自动',
|
||||
'3' => '急停',
|
||||
_ => v.isNotEmpty ? v : '--',
|
||||
};
|
||||
}
|
||||
|
||||
String qualText(String v) {
|
||||
final q = int.tryParse(v) ?? -1;
|
||||
return switch (q) {
|
||||
0 => '无效',
|
||||
1 => '单点定位',
|
||||
2 => '差分定位',
|
||||
4 => '固定解',
|
||||
5 => '浮点解',
|
||||
_ => v.isNotEmpty ? v : '--',
|
||||
};
|
||||
}
|
||||
|
||||
String headingText(String v) {
|
||||
final h = int.tryParse(v) ?? -1;
|
||||
return switch (h) {
|
||||
0 => '未初始化',
|
||||
1 => '已初始化',
|
||||
_ => v.isNotEmpty ? v : '--',
|
||||
};
|
||||
}
|
||||
|
||||
String obstacleText(String v) {
|
||||
final o = int.tryParse(v) ?? -1;
|
||||
return switch (o) {
|
||||
0 => '无障碍',
|
||||
1 => '有障碍',
|
||||
_ => v.isNotEmpty ? v : '--',
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
BleField(label: '电压', value: '${_tryParseDouble(parts[0])} V'),
|
||||
BleField(label: '左目标速度', value: _tryParseDouble(parts[1])),
|
||||
BleField(label: '右目标速度', value: _tryParseDouble(parts[2])),
|
||||
BleField(label: '左实测速度', value: _tryParseDouble(parts[3])),
|
||||
BleField(label: '右实测速度', value: _tryParseDouble(parts[4])),
|
||||
BleField(label: '左电流', value: '${_tryParseDouble(parts[5])} A'),
|
||||
BleField(label: '右电流', value: '${_tryParseDouble(parts[6])} A'),
|
||||
BleField(label: '左电机温度', value: '${_tryParseDouble(parts[7])} ℃'),
|
||||
BleField(label: '右电机温度', value: '${_tryParseDouble(parts[8])} ℃'),
|
||||
BleField(label: '芯片温度', value: '${_tryParseDouble(parts[9])} ℃'),
|
||||
BleField(label: '偏航角', value: '${_tryParseDouble(parts[10])} °'),
|
||||
BleField(label: '俯仰角', value: '${_tryParseDouble(parts[11])} °'),
|
||||
BleField(label: '翻滚角', value: '${_tryParseDouble(parts[12])} °'),
|
||||
BleField(label: '卫星数量', value: parts[13].isNotEmpty ? parts[13] : '--'),
|
||||
BleField(label: '定位质量', value: qualText(parts[14])),
|
||||
BleField(label: '航向状态', value: headingText(parts[15])),
|
||||
BleField(label: '纬度', value: parts[16].isNotEmpty ? parts[16] : '--'),
|
||||
BleField(label: '经度', value: parts[17].isNotEmpty ? parts[17] : '--'),
|
||||
BleField(label: '时间戳', value: parts[18].isNotEmpty ? parts[18] : '--'),
|
||||
BleField(
|
||||
label: '割刀速度',
|
||||
value: parts[19].isNotEmpty ? '${parts[19]} rpm' : '--',
|
||||
),
|
||||
BleField(label: '控制模式', value: controlModeText(parts[20])),
|
||||
BleField(
|
||||
label: '电量',
|
||||
value: parts[21].isNotEmpty ? '${parts[21]}%' : '--',
|
||||
),
|
||||
BleField(label: '工作面积', value: parts[22].isNotEmpty ? parts[22] : '--'),
|
||||
BleField(label: '障碍物', value: obstacleText(parts[23])),
|
||||
];
|
||||
}
|
||||
|
||||
static String _tryParseDouble(String s) {
|
||||
if (s.isEmpty) return '--';
|
||||
final v = double.tryParse(s);
|
||||
if (v == null) return s;
|
||||
if (v == v.roundToDouble() && v.abs() < 10000) return v.toInt().toString();
|
||||
return v.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
// ─── 0x00 远程遥控 ───
|
||||
static BleDecodedResult _decodeRemoteControl(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
try {
|
||||
final text = utf8.decode(data, allowMalformed: true);
|
||||
if (text.contains(',')) {
|
||||
final parts = text.split(',');
|
||||
while (parts.length < 5) parts.add('');
|
||||
fields.add(
|
||||
BleField(label: '左右速度', value: parts[0].isNotEmpty ? parts[0] : '--'),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '前后速度', value: parts[1].isNotEmpty ? parts[1] : '--'),
|
||||
);
|
||||
final modeStr = switch (parts[2]) {
|
||||
'0' => '停止',
|
||||
'1' => '遥控',
|
||||
'2' => '自动',
|
||||
_ => parts[2].isNotEmpty ? parts[2] : '--',
|
||||
};
|
||||
fields.add(BleField(label: '控制模式', value: modeStr));
|
||||
if (parts.length > 3 && parts[3].isNotEmpty)
|
||||
fields.add(BleField(label: '参数4', value: parts[3]));
|
||||
if (parts.length > 4 && parts[4].isNotEmpty)
|
||||
fields.add(BleField(label: '参数5', value: parts[4]));
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (data.length >= 5) {
|
||||
fields.add(BleField(label: '左右速度', value: '${(data[1] << 8) | data[0]}'));
|
||||
fields.add(BleField(label: '前后速度', value: '${(data[3] << 8) | data[2]}'));
|
||||
const modes = {0: '停止', 1: '遥控', 2: '自动'};
|
||||
fields.add(
|
||||
BleField(label: '控制模式', value: modes[data[4]] ?? '未知(${data[4]})'),
|
||||
);
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 0x03 查询ID ───
|
||||
static BleDecodedResult _decodeGetId(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
try {
|
||||
final text = utf8.decode(data, allowMalformed: true);
|
||||
if (text.replaceAll('\x00', '').trim().isNotEmpty) {
|
||||
fields.add(
|
||||
BleField(label: '设备ID', value: text.replaceAll('\x00', '').trim()),
|
||||
);
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
final ascii = String.fromCharCodes(data);
|
||||
if (RegExp(r'^[\x20-\x7E]+$').hasMatch(ascii)) {
|
||||
fields.add(BleField(label: '设备ID', value: ascii));
|
||||
} else {
|
||||
fields.add(BleField(label: '设备ID(Hex)', value: hex));
|
||||
}
|
||||
} catch (_) {
|
||||
fields.add(BleField(label: '设备ID(Hex)', value: hex));
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 0x04 获取授权 ───
|
||||
static BleDecodedResult _decodeGetAuth(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
try {
|
||||
final text = utf8.decode(data, allowMalformed: true);
|
||||
final trimmed = text.replaceAll('\x00', '').trim();
|
||||
if (trimmed == '1' || trimmed.toLowerCase() == 'true') {
|
||||
fields.add(const BleField(label: '授权状态', value: '已授权'));
|
||||
} else if (trimmed == '0' || trimmed.toLowerCase() == 'false') {
|
||||
fields.add(const BleField(label: '授权状态', value: '未授权'));
|
||||
} else if (trimmed.isNotEmpty) {
|
||||
fields.add(BleField(label: '授权状态', value: trimmed));
|
||||
}
|
||||
if (fields.isNotEmpty)
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
} catch (_) {}
|
||||
|
||||
if (data.isNotEmpty) {
|
||||
if (data[0] == 0x01) {
|
||||
fields.add(const BleField(label: '授权状态', value: '已授权'));
|
||||
} else if (data[0] == 0x00) {
|
||||
fields.add(const BleField(label: '授权状态', value: '未授权'));
|
||||
} else {
|
||||
fields.add(
|
||||
BleField(label: '授权状态(Hex)', value: '0x${data[0].toRadixString(16)}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 0x05 读配置(按嵌入式协议规范精确解析,171字节) ───
|
||||
static BleDecodedResult _decodeReadConfig(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
if (data.isEmpty) {
|
||||
fields.add(const BleField(label: '状态', value: '空数据'));
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
final bd = ByteData.sublistView(data);
|
||||
final len = data.length;
|
||||
|
||||
// 1. UID固化标志 @payload[0] (帧字节3)
|
||||
if (len >= 1) {
|
||||
fields.add(
|
||||
BleField(
|
||||
label: 'UID固化标志',
|
||||
value: data[0] == 1 ? '已固化' : '未固化(${data[0]})',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 芯片UID @payload[1..45] (帧字节4-48, 45字节)
|
||||
if (len >= 46) {
|
||||
fields.add(
|
||||
BleField(label: '芯片UID', value: _readNullTermString(data, 1, 45)),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 遥控器通道配置 @payload[46..64] (帧字节49-67, 19字节)
|
||||
if (len >= 65) {
|
||||
fields.add(
|
||||
BleField(label: '遥控器通道配置', value: _bytesToHex(data.sublist(46, 65))),
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 字节68位域 @payload[65] (帧字节68)
|
||||
if (len >= 66) {
|
||||
final b = data[65];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '割刀电机模式',
|
||||
value: (b & 0x03) == 0 ? '纯电' : '油电(${b & 0x03})',
|
||||
),
|
||||
);
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '行走电机模式',
|
||||
value: ((b >> 2) & 0x03) == 0 ? '轮式' : '履带(${(b >> 2) & 0x03})',
|
||||
),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '左轮电机极性', value: (b & 0x10) == 0 ? '高极性' : '低极性(反取)'),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '右轮电机极性', value: (b & 0x20) == 0 ? '高极性' : '低极性(反取)'),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '左右轮通道交换', value: (b & 0x40) == 0 ? '不交换' : '通道互换'),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '联网目标', value: (b & 0x80) == 0 ? 'WiFi' : '4G'),
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 前进速度限制 @payload[66..67] (帧字节69-70, uint16 LE)
|
||||
if (len >= 68) {
|
||||
fields.add(
|
||||
BleField(label: '前进速度限制', value: '${bd.getUint16(66, Endian.little)}'),
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 转向速度限制 @payload[68..69] (帧字节71-72, uint16 LE)
|
||||
if (len >= 70) {
|
||||
fields.add(
|
||||
BleField(label: '转向速度限制', value: '${bd.getUint16(68, Endian.little)}'),
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 字节73位域 @payload[70] (帧字节73)
|
||||
if (len >= 71) {
|
||||
final b = data[70];
|
||||
fields.add(
|
||||
BleField(label: '割刀通道极性', value: (b & 0x01) == 0 ? '高极性' : '低极性(反取)'),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '风门通道极性', value: (b & 0x02) == 0 ? '高极性' : '低极性(反取)'),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '油门通道极性', value: (b & 0x04) == 0 ? '高极性' : '低极性(反取)'),
|
||||
);
|
||||
fields.add(BleField(label: '底盘升降保护时间', value: '${(b >> 3) & 0x1F} 秒'));
|
||||
fields.add(
|
||||
BleField(label: 'RTK配置', value: (b & 0x80) == 0 ? '单天线' : '双天线'),
|
||||
);
|
||||
}
|
||||
|
||||
// 8. 字节74位域 @payload[71] (帧字节74)
|
||||
if (len >= 72) {
|
||||
final b = data[71];
|
||||
fields.add(BleField(label: '割刀通道配置', value: '${b & 0x0F}'));
|
||||
fields.add(BleField(label: '风门通道配置', value: '${(b >> 4) & 0x0F}'));
|
||||
}
|
||||
|
||||
// 9. 字节75位域 @payload[72] (帧字节75)
|
||||
if (len >= 73) {
|
||||
final b = data[72];
|
||||
fields.add(BleField(label: '油门通道配置', value: '${b & 0x0F}'));
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '遥控器类型',
|
||||
value: (b >> 4) & 0x07 == 0 ? '飞控' : '自定义遥控器(${(b >> 4) & 0x07})',
|
||||
),
|
||||
);
|
||||
fields.add(
|
||||
BleField(label: '是否搭载继电器板', value: (b & 0x80) == 0 ? '未搭载' : '搭载'),
|
||||
);
|
||||
}
|
||||
|
||||
// 10. 字节76位域 @payload[73] (帧字节76)
|
||||
if (len >= 74) {
|
||||
final b = data[73];
|
||||
fields.add(BleField(label: '底盘升降通道配置', value: '${b & 0x0F}'));
|
||||
fields.add(BleField(label: '底盘通道配置', value: '${(b >> 4) & 0x0F}'));
|
||||
}
|
||||
|
||||
// 11. 字节77位域 @payload[74] (帧字节77)
|
||||
if (len >= 75) {
|
||||
final b = data[74];
|
||||
fields.add(BleField(label: '机械臂通道配置', value: '${b & 0x0F}'));
|
||||
fields.add(BleField(label: '燃油泵通道配置', value: '${(b >> 4) & 0x0F}'));
|
||||
}
|
||||
|
||||
// 12. WiFi名称 @payload[75..94] (帧字节78-97, 20字节)
|
||||
if (len >= 95) {
|
||||
fields.add(
|
||||
BleField(label: 'WiFi名称', value: _readNullTermString(data, 75, 20)),
|
||||
);
|
||||
}
|
||||
|
||||
// 13. WiFi密码 @payload[95..114] (帧字节98-117, 20字节)
|
||||
if (len >= 115) {
|
||||
fields.add(
|
||||
BleField(label: 'WiFi密码', value: _readNullTermString(data, 95, 20)),
|
||||
);
|
||||
}
|
||||
|
||||
// 14. 字节118位域 @payload[115] (帧字节118)
|
||||
if (len >= 116) {
|
||||
final b = data[115];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '电池类型',
|
||||
value: (b & 0x03) == 0 ? '铅酸' : '锂电(${b & 0x03})',
|
||||
),
|
||||
);
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '行走驱动配置',
|
||||
value: switch ((b >> 2) & 0x03) {
|
||||
0 => '风德控',
|
||||
1 => '山东神澜BLDC',
|
||||
2 => '山东神澜FOC',
|
||||
_ => '未知(${(b >> 2) & 0x03})',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 15. 转速比 @payload[116..119] (帧字节119-122, float LE)
|
||||
if (len >= 120) {
|
||||
final v = bd.getFloat32(116, Endian.little);
|
||||
fields.add(BleField(label: '转速比', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 16. 机器人长度 @payload[120..123] (帧字节123-126, float LE)
|
||||
if (len >= 124) {
|
||||
final v = bd.getFloat32(120, Endian.little);
|
||||
fields.add(BleField(label: '机器人长度', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 17. 机器人宽度 @payload[124..127] (帧字节127-130, float LE)
|
||||
if (len >= 128) {
|
||||
final v = bd.getFloat32(124, Endian.little);
|
||||
fields.add(BleField(label: '机器人宽度', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 18. 机器人高度 @payload[128..131] (帧字节131-134, float LE)
|
||||
if (len >= 132) {
|
||||
final v = bd.getFloat32(128, Endian.little);
|
||||
fields.add(BleField(label: '机器人高度', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 19. 割刀宽度 @payload[132..135] (帧字节135-138, float LE)
|
||||
if (len >= 136) {
|
||||
final v = bd.getFloat32(132, Endian.little);
|
||||
fields.add(BleField(label: '割刀宽度', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 20. 轮胎尺寸 @payload[136..139] (帧字节139-142, float LE)
|
||||
if (len >= 140) {
|
||||
final v = bd.getFloat32(136, Endian.little);
|
||||
fields.add(BleField(label: '轮胎尺寸', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 21. 左轮前进增益 @payload[140..143] (帧字节143-146, float LE)
|
||||
if (len >= 144) {
|
||||
final v = bd.getFloat32(140, Endian.little);
|
||||
fields.add(BleField(label: '左轮前进增益', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 22. 左轮后退增益 @payload[144..147] (帧字节147-150, float LE)
|
||||
if (len >= 148) {
|
||||
final v = bd.getFloat32(144, Endian.little);
|
||||
fields.add(BleField(label: '左轮后退增益', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 23. 右轮前进增益 @payload[148..151] (帧字节151-154, float LE)
|
||||
if (len >= 152) {
|
||||
final v = bd.getFloat32(148, Endian.little);
|
||||
fields.add(BleField(label: '右轮前进增益', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 24. 右轮后退增益 @payload[152..155] (帧字节155-158, float LE)
|
||||
if (len >= 156) {
|
||||
final v = bd.getFloat32(152, Endian.little);
|
||||
fields.add(BleField(label: '右轮后退增益', value: v.toStringAsFixed(2)));
|
||||
}
|
||||
|
||||
// 25. 固件版本 @payload[156..170] (帧字节159-173, 15字节)
|
||||
if (len >= 171) {
|
||||
fields.add(
|
||||
BleField(label: '固件版本', value: _readNullTermString(data, 156, 15)),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建配置实体(用于后续编辑和回写)
|
||||
Mc700DeviceConfig? configEntity;
|
||||
if (data.length >= 171) {
|
||||
try {
|
||||
configEntity = Mc700DeviceConfig.fromBytes(data);
|
||||
} catch (e) {
|
||||
developer.log('[BLE] 配置实体解析失败: $e', name: 'BleProtocolDecoder');
|
||||
}
|
||||
}
|
||||
|
||||
return BleDecodedResult(
|
||||
fields: fields,
|
||||
rawHex: hex,
|
||||
configEntity: configEntity,
|
||||
);
|
||||
}
|
||||
|
||||
/// 读取 null 终止字符串(最多读取 [maxLen] 字节)
|
||||
static String _readNullTermString(Uint8List data, int offset, int maxLen) {
|
||||
final buffer = StringBuffer();
|
||||
for (int i = 0; i < maxLen && offset + i < data.length; i++) {
|
||||
final b = data[offset + i];
|
||||
if (b == 0x00) break;
|
||||
if (b >= 0x20 && b <= 0x7E) {
|
||||
buffer.writeCharCode(b);
|
||||
} else {
|
||||
buffer.writeCharCode(0x2E);
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
// ─── 0x06 写配置 ───
|
||||
static BleDecodedResult _decodeWriteConfig(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
if (data.isNotEmpty) {
|
||||
final ok = data[0];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '写入结果',
|
||||
value: ok == 0x01
|
||||
? '成功'
|
||||
: ok == 0x00
|
||||
? '失败'
|
||||
: '0x${ok.toRadixString(16)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 0xFF 心跳包 ───
|
||||
static BleDecodedResult _decodeHeartbeat(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
if (data.isEmpty) {
|
||||
fields.add(const BleField(label: '心跳', value: '正常'));
|
||||
} else if (data.length >= 2) {
|
||||
final interval = (data[1] << 8) | data[0];
|
||||
fields.add(BleField(label: '心跳间隔', value: '${interval}ms'));
|
||||
} else {
|
||||
fields.add(BleField(label: '心跳数据', value: hex));
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 0x01 路径规划 ───
|
||||
static BleDecodedResult _decodePathPlanning(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
try {
|
||||
final text = utf8.decode(data, allowMalformed: true);
|
||||
if (text.contains(',')) {
|
||||
final parts = text.split(',');
|
||||
if (parts.length >= 2) {
|
||||
fields.add(BleField(label: '点编号', value: parts[0]));
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '状态',
|
||||
value: switch (parts[1]) {
|
||||
'1' => '已到达',
|
||||
'2' => '收到指令',
|
||||
_ => parts[1],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (parts.length >= 4) {
|
||||
fields.add(BleField(label: '纬度', value: parts[2]));
|
||||
fields.add(BleField(label: '经度', value: parts[3]));
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (data.length >= 3) {
|
||||
fields.add(BleField(label: '点编号', value: '${data[0]}'));
|
||||
final status = data.length > 1 ? data[1] : -1;
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '状态',
|
||||
value: status == 0x01
|
||||
? '已到达'
|
||||
: status == 0x02
|
||||
? '收到指令'
|
||||
: '0x${status.toRadixString(16)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 0x07 避障 ───
|
||||
static BleDecodedResult _decodeObstacleAvoid(Uint8List data, String hex) {
|
||||
final fields = <BleField>[];
|
||||
if (data.isNotEmpty) {
|
||||
final flag = data[0];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '避障状态',
|
||||
value: flag == 0x01
|
||||
? '触发'
|
||||
: flag == 0x00
|
||||
? '正常'
|
||||
: '0x${flag.toRadixString(16)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 3) {
|
||||
fields.add(BleField(label: '距离', value: '${(data[2] << 8) | data[1]}mm'));
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 通用/未知指令 ───
|
||||
static BleDecodedResult _decodeGeneric(
|
||||
int command,
|
||||
Uint8List data,
|
||||
String hex,
|
||||
) {
|
||||
final fields = <BleField>[];
|
||||
try {
|
||||
final text = utf8.decode(data, allowMalformed: true);
|
||||
final trimmed = text.replaceAll('\x00', '').trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '文本内容',
|
||||
value: trimmed.length > 50
|
||||
? '${trimmed.substring(0, 50)}...'
|
||||
: trimmed,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (fields.isEmpty) {
|
||||
fields.add(BleField(label: '数据长度', value: '${data.length}B'));
|
||||
}
|
||||
return BleDecodedResult(fields: fields, rawHex: hex);
|
||||
}
|
||||
|
||||
// ─── 工具方法 ───
|
||||
static String _bytesToHex(List<int> bytes) {
|
||||
if (bytes.isEmpty) return '';
|
||||
return bytes
|
||||
.map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
38
lib/core/bluetooth/bytes_util.dart
Normal file
38
lib/core/bluetooth/bytes_util.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class BytesUtil {
|
||||
static String bytesToHex(List<int> bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
|
||||
}
|
||||
|
||||
static Uint8List hexToBytes(String hex) {
|
||||
hex = hex.replaceAll(' ', '').replaceAll('-', '');
|
||||
if (hex.length % 2 != 0) hex = '0' + hex;
|
||||
final bytes = Uint8List(hex.length ~/ 2);
|
||||
for (int i = 0; i < hex.length ~/ 2; i++) {
|
||||
bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static String bytesToString(List<int> bytes) {
|
||||
try {
|
||||
return utf8.decode(bytes);
|
||||
} catch (_) {
|
||||
return String.fromCharCodes(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
static Uint8List stringToBytes(String str) {
|
||||
return Uint8List.fromList(utf8.encode(str));
|
||||
}
|
||||
|
||||
static List<int> toByteList(Uint8List data) {
|
||||
return List<int>.from(data);
|
||||
}
|
||||
|
||||
static String bytesToHexSpaced(List<int> bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
|
||||
}
|
||||
}
|
||||
402
lib/core/bluetooth/mc700_device_config.dart
Normal file
402
lib/core/bluetooth/mc700_device_config.dart
Normal file
@@ -0,0 +1,402 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// MC700设备配置实体类(与嵌入式协议规范一一对应)
|
||||
/// 所有偏移均为 payload 内偏移(ProtocolParser 已剥离帧头/命令/校验/帧尾)
|
||||
/// payload 共 171 字节,索引 0~170
|
||||
class Mc700DeviceConfig {
|
||||
// ====== 字段定义(按嵌入式协议规范) ======
|
||||
|
||||
// --- 基础标识 ---
|
||||
late int uidSolidifiedFlag; // payload[0] UID固化标志 (0=未固化, 1=已固化)
|
||||
late String chipUid; // payload[1..45] 芯片UID (45字节)
|
||||
late List<int> remoteChannelConfig; // payload[46..64] 遥控器通道配置 (19字节)
|
||||
|
||||
// --- 字节68位域 (payload[65]) ---
|
||||
late int knifeMotorMode; // bit0-1 割刀电机模式 (0=纯电, 1=油电)
|
||||
late int walkMotorMode; // bit2-3 行走电机模式 (0=轮式, 1=履带)
|
||||
late bool leftWheelPolarity; // bit4 左轮极性 (false=高, true=低)
|
||||
late bool rightWheelPolarity; // bit5 右轮极性 (false=高, true=低)
|
||||
late bool channelSwap; // bit6 左右轮通道交换
|
||||
late bool use4g; // bit7 联网目标 (false=WiFi, true=4G)
|
||||
|
||||
// --- 速度限制 ---
|
||||
late int forwardSpeedLimit; // payload[66..67] 前进速度限制 (uint16 LE)
|
||||
late int turnSpeedLimit; // payload[68..69] 转向速度限制 (uint16 LE)
|
||||
|
||||
// --- 字节73位域 (payload[70]) ---
|
||||
late bool knifeChannelPolarity; // bit0 割刀通道极性
|
||||
late bool fanChannelPolarity; // bit1 风门通道极性
|
||||
late bool throttleChannelPolarity; // bit2 油门通道极性
|
||||
late int liftProtectTime; // bit3-6 底盘升降保护时间 (秒)
|
||||
late bool dualRtk; // bit7 RTK配置 (false=单天线, true=双天线)
|
||||
|
||||
// --- 字节74位域 (payload[71]) ---
|
||||
late int knifeChannelConfig; // bit0-3 割刀通道配置
|
||||
late int fanChannelConfig; // bit4-7 风门通道配置
|
||||
|
||||
// --- 字节75位域 (payload[72]) ---
|
||||
late int throttleChannelConfig; // bit0-3 油门通道配置
|
||||
late int remoteType; // bit4-6 遥控器类型 (0=飞控, 1=自定义1)
|
||||
late bool relayBoard; // bit7 是否搭载继电器板
|
||||
|
||||
// --- 字节76位域 (payload[73]) ---
|
||||
late int chassisLiftChannel; // bit0-3 底盘升降通道配置
|
||||
late int chassisChannel; // bit4-7 底盘通道配置
|
||||
|
||||
// --- 字节77位域 (payload[74]) ---
|
||||
late int armChannel; // bit0-3 机械臂通道配置
|
||||
late int fuelPumpChannel; // bit4-7 燃油泵通道配置
|
||||
|
||||
// --- WiFi ---
|
||||
late String wifiName; // payload[75..94] WiFi名称 (20字节)
|
||||
late String wifiPassword; // payload[95..114] WiFi密码 (20字节)
|
||||
|
||||
// --- 字节118位域 (payload[115]) ---
|
||||
late int batteryType; // bit0-1 电池类型 (0=铅酸, 1=锂电)
|
||||
late int walkDriveConfig; // bit2-3 行走驱动配置
|
||||
|
||||
// --- 尺寸参数 (float LE) ---
|
||||
late double gearRatio; // payload[116..119] 转速比
|
||||
late double robotLength; // payload[120..123] 机器人长度
|
||||
late double robotWidth; // payload[124..127] 机器人宽度
|
||||
late double robotHeight; // payload[128..131] 机器人高度
|
||||
late double knifeWidth; // payload[132..135] 割刀宽度
|
||||
late double tireSize; // payload[136..139] 轮胎尺寸
|
||||
|
||||
// --- 增益 (float LE) ---
|
||||
late double leftForwardGain; // payload[140..143] 左轮前进增益
|
||||
late double leftBackwardGain; // payload[144..147] 左轮后退增益
|
||||
late double rightForwardGain; // payload[148..151] 右轮前进增益
|
||||
late double rightBackwardGain; // payload[152..155] 右轮后退增益
|
||||
|
||||
// --- 固件版本 ---
|
||||
late String firmwareVersion; // payload[156..170] 固件版本 (15字节)
|
||||
|
||||
Mc700DeviceConfig();
|
||||
|
||||
// ====== 从二进制数据解析(171字节 payload) ======
|
||||
factory Mc700DeviceConfig.fromBytes(Uint8List data) {
|
||||
if (data.length < 171) {
|
||||
throw Exception('数据长度不足171字节,实际${data.length}字节');
|
||||
}
|
||||
final bd = ByteData.sublistView(data);
|
||||
final obj = Mc700DeviceConfig();
|
||||
|
||||
// 1. UID固化标志 @0
|
||||
obj.uidSolidifiedFlag = bd.getUint8(0);
|
||||
// 2. 芯片UID @1..45 (45字节)
|
||||
obj.chipUid = _readNullTerminatedString(data, 1, 45);
|
||||
// 3. 遥控器通道配置 @46..64 (19字节)
|
||||
obj.remoteChannelConfig = List.unmodifiable(data.sublist(46, 65));
|
||||
|
||||
// 4. 字节68位域 @65
|
||||
final b68 = bd.getUint8(65);
|
||||
obj.knifeMotorMode = b68 & 0x03;
|
||||
obj.walkMotorMode = (b68 >> 2) & 0x03;
|
||||
obj.leftWheelPolarity = (b68 & 0x10) != 0;
|
||||
obj.rightWheelPolarity = (b68 & 0x20) != 0;
|
||||
obj.channelSwap = (b68 & 0x40) != 0;
|
||||
obj.use4g = (b68 & 0x80) != 0;
|
||||
|
||||
// 5. 前进速度限制 @66..67
|
||||
obj.forwardSpeedLimit = bd.getUint16(66, Endian.little);
|
||||
// 6. 转向速度限制 @68..69
|
||||
obj.turnSpeedLimit = bd.getUint16(68, Endian.little);
|
||||
|
||||
// 7. 字节73位域 @70
|
||||
final b73 = bd.getUint8(70);
|
||||
obj.knifeChannelPolarity = (b73 & 0x01) != 0;
|
||||
obj.fanChannelPolarity = (b73 & 0x02) != 0;
|
||||
obj.throttleChannelPolarity = (b73 & 0x04) != 0;
|
||||
obj.liftProtectTime = (b73 >> 3) & 0x1F;
|
||||
obj.dualRtk = (b73 & 0x80) != 0;
|
||||
|
||||
// 8. 字节74位域 @71
|
||||
final b74 = bd.getUint8(71);
|
||||
obj.knifeChannelConfig = b74 & 0x0F;
|
||||
obj.fanChannelConfig = (b74 >> 4) & 0x0F;
|
||||
|
||||
// 9. 字节75位域 @72
|
||||
final b75 = bd.getUint8(72);
|
||||
obj.throttleChannelConfig = b75 & 0x0F;
|
||||
obj.remoteType = (b75 >> 4) & 0x07;
|
||||
obj.relayBoard = (b75 & 0x80) != 0;
|
||||
|
||||
// 10. 字节76位域 @73
|
||||
final b76 = bd.getUint8(73);
|
||||
obj.chassisLiftChannel = b76 & 0x0F;
|
||||
obj.chassisChannel = (b76 >> 4) & 0x0F;
|
||||
|
||||
// 11. 字节77位域 @74
|
||||
final b77 = bd.getUint8(74);
|
||||
obj.armChannel = b77 & 0x0F;
|
||||
obj.fuelPumpChannel = (b77 >> 4) & 0x0F;
|
||||
|
||||
// 12. WiFi名称 @75..94 (20字节)
|
||||
obj.wifiName = _readNullTerminatedString(data, 75, 20);
|
||||
// 13. WiFi密码 @95..114 (20字节)
|
||||
obj.wifiPassword = _readNullTerminatedString(data, 95, 20);
|
||||
|
||||
// 14. 字节118位域 @115
|
||||
final b118 = bd.getUint8(115);
|
||||
obj.batteryType = b118 & 0x03;
|
||||
obj.walkDriveConfig = (b118 >> 2) & 0x03;
|
||||
|
||||
// 15. 转速比 @116..119
|
||||
obj.gearRatio = bd.getFloat32(116, Endian.little);
|
||||
// 16. 机器人长度 @120..123
|
||||
obj.robotLength = bd.getFloat32(120, Endian.little);
|
||||
// 17. 机器人宽度 @124..127
|
||||
obj.robotWidth = bd.getFloat32(124, Endian.little);
|
||||
// 18. 机器人高度 @128..131
|
||||
obj.robotHeight = bd.getFloat32(128, Endian.little);
|
||||
// 19. 割刀宽度 @132..135
|
||||
obj.knifeWidth = bd.getFloat32(132, Endian.little);
|
||||
// 20. 轮胎尺寸 @136..139
|
||||
obj.tireSize = bd.getFloat32(136, Endian.little);
|
||||
|
||||
// 21. 左轮前进增益 @140..143
|
||||
obj.leftForwardGain = bd.getFloat32(140, Endian.little);
|
||||
// 22. 左轮后退增益 @144..147
|
||||
obj.leftBackwardGain = bd.getFloat32(144, Endian.little);
|
||||
// 23. 右轮前进增益 @148..151
|
||||
obj.rightForwardGain = bd.getFloat32(148, Endian.little);
|
||||
// 24. 右轮后退增益 @152..155
|
||||
obj.rightBackwardGain = bd.getFloat32(152, Endian.little);
|
||||
|
||||
// 25. 固件版本 @156..170 (15字节)
|
||||
obj.firmwareVersion = _readNullTerminatedString(data, 156, 15);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ====== 转换回二进制字节(用于写配置下发) ======
|
||||
/// [originalPayload] 是上次读取的 171 字节 payload,用于保留未修改字段
|
||||
Uint8List toBytes(Uint8List originalPayload) {
|
||||
final config = Uint8List.fromList(originalPayload);
|
||||
final bd = ByteData.sublistView(config);
|
||||
|
||||
// UID固化标志 @0(独立字段,不属于字节68)
|
||||
bd.setUint8(0, uidSolidifiedFlag & 0x01);
|
||||
|
||||
// 字节68位域 @65
|
||||
int b68 = 0;
|
||||
b68 |= (knifeMotorMode & 0x03);
|
||||
b68 |= (walkMotorMode & 0x03) << 2;
|
||||
if (leftWheelPolarity) b68 |= 0x10;
|
||||
if (rightWheelPolarity) b68 |= 0x20;
|
||||
if (channelSwap) b68 |= 0x40;
|
||||
if (use4g) b68 |= 0x80;
|
||||
bd.setUint8(65, b68);
|
||||
|
||||
// 速度限制
|
||||
bd.setUint16(66, forwardSpeedLimit, Endian.little);
|
||||
bd.setUint16(68, turnSpeedLimit, Endian.little);
|
||||
|
||||
// 字节73位域 @70
|
||||
int b73 = (liftProtectTime & 0x1F) << 3;
|
||||
if (knifeChannelPolarity) b73 |= 0x01;
|
||||
if (fanChannelPolarity) b73 |= 0x02;
|
||||
if (throttleChannelPolarity) b73 |= 0x04;
|
||||
if (dualRtk) b73 |= 0x80;
|
||||
bd.setUint8(70, b73);
|
||||
|
||||
// 字节74位域 @71
|
||||
int b74 = knifeChannelConfig & 0x0F;
|
||||
b74 |= (fanChannelConfig & 0x0F) << 4;
|
||||
bd.setUint8(71, b74);
|
||||
|
||||
// 字节75位域 @72
|
||||
int b75 = throttleChannelConfig & 0x0F;
|
||||
b75 |= (remoteType & 0x07) << 4;
|
||||
if (relayBoard) b75 |= 0x80;
|
||||
bd.setUint8(72, b75);
|
||||
|
||||
// 字节76位域 @73
|
||||
int b76 = chassisLiftChannel & 0x0F;
|
||||
b76 |= (chassisChannel & 0x0F) << 4;
|
||||
bd.setUint8(73, b76);
|
||||
|
||||
// 字节77位域 @74
|
||||
int b77 = armChannel & 0x0F;
|
||||
b77 |= (fuelPumpChannel & 0x0F) << 4;
|
||||
bd.setUint8(74, b77);
|
||||
|
||||
// WiFi
|
||||
_writeNullTerminatedString(config, 75, wifiName, 20);
|
||||
_writeNullTerminatedString(config, 95, wifiPassword, 20);
|
||||
|
||||
// 字节118位域 @115
|
||||
int b118 = batteryType & 0x03;
|
||||
b118 |= (walkDriveConfig & 0x03) << 2;
|
||||
bd.setUint8(115, b118);
|
||||
|
||||
// 尺寸参数
|
||||
bd.setFloat32(116, gearRatio, Endian.little);
|
||||
bd.setFloat32(120, robotLength, Endian.little);
|
||||
bd.setFloat32(124, robotWidth, Endian.little);
|
||||
bd.setFloat32(128, robotHeight, Endian.little);
|
||||
bd.setFloat32(132, knifeWidth, Endian.little);
|
||||
bd.setFloat32(136, tireSize, Endian.little);
|
||||
|
||||
// 增益
|
||||
bd.setFloat32(140, leftForwardGain, Endian.little);
|
||||
bd.setFloat32(144, leftBackwardGain, Endian.little);
|
||||
bd.setFloat32(148, rightForwardGain, Endian.little);
|
||||
bd.setFloat32(152, rightBackwardGain, Endian.little);
|
||||
|
||||
// 固件版本
|
||||
_writeNullTerminatedString(config, 156, firmwareVersion, 15);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// 获取所有字段的 label→value 映射
|
||||
Map<String, String> toFieldMap() {
|
||||
return {
|
||||
'UID固化标志': '$uidSolidifiedFlag',
|
||||
'芯片UID': chipUid,
|
||||
'割刀电机模式': knifeMotorMode == 0 ? '纯电' : '油电($knifeMotorMode)',
|
||||
'行走电机模式': walkMotorMode == 0 ? '轮式' : '履带($walkMotorMode)',
|
||||
'左轮极性': leftWheelPolarity ? '低' : '高',
|
||||
'右轮极性': rightWheelPolarity ? '低' : '高',
|
||||
'通道交换': channelSwap ? '是' : '否',
|
||||
'联网目标': use4g ? '4G' : 'WiFi',
|
||||
'前进速度限制': '$forwardSpeedLimit',
|
||||
'转向速度限制': '$turnSpeedLimit',
|
||||
'割刀通道极性': knifeChannelPolarity ? '低' : '高',
|
||||
'风门通道极性': fanChannelPolarity ? '低' : '高',
|
||||
'油门通道极性': throttleChannelPolarity ? '低' : '高',
|
||||
'升降保护时间': '$liftProtectTime 秒',
|
||||
'RTK配置': dualRtk ? '双天线' : '单天线',
|
||||
'割刀通道配置': '$knifeChannelConfig',
|
||||
'风门通道配置': '$fanChannelConfig',
|
||||
'油门通道配置': '$throttleChannelConfig',
|
||||
'遥控器类型': '$remoteType',
|
||||
'搭载继电器板': relayBoard ? '是' : '否',
|
||||
'底盘升降通道': '$chassisLiftChannel',
|
||||
'底盘通道': '$chassisChannel',
|
||||
'机械臂通道': '$armChannel',
|
||||
'燃油泵通道': '$fuelPumpChannel',
|
||||
'WiFi名称': wifiName,
|
||||
'WiFi密码': wifiPassword,
|
||||
'电池类型': batteryType == 0 ? '铅酸' : '锂电',
|
||||
'行走驱动': walkDriveConfig.toString(),
|
||||
'转速比': gearRatio.toStringAsFixed(2),
|
||||
'机器人长度': robotLength.toStringAsFixed(2),
|
||||
'机器人宽度': robotWidth.toStringAsFixed(2),
|
||||
'机器人高度': robotHeight.toStringAsFixed(2),
|
||||
'割刀宽度': knifeWidth.toStringAsFixed(2),
|
||||
'轮胎尺寸': tireSize.toStringAsFixed(2),
|
||||
'左轮前进增益': leftForwardGain.toStringAsFixed(2),
|
||||
'左轮后退增益': leftBackwardGain.toStringAsFixed(2),
|
||||
'右轮前进增益': rightForwardGain.toStringAsFixed(2),
|
||||
'右轮后退增益': rightBackwardGain.toStringAsFixed(2),
|
||||
'固件版本': firmwareVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/// 根据字段标签设置新值(返回 null 表示成功,返回错误原因字符串)
|
||||
String? setField(String label, String value) {
|
||||
final intVal = int.tryParse(value);
|
||||
final doubleVal = double.tryParse(value);
|
||||
|
||||
switch (label) {
|
||||
case 'UID固化标志':
|
||||
if (intVal == null || intVal < 0 || intVal > 1) return '值范围: 0~1';
|
||||
uidSolidifiedFlag = intVal;
|
||||
return null;
|
||||
case '前进速度限制':
|
||||
if (intVal == null || intVal < 0 || intVal > 65535)
|
||||
return '值范围: 0~65535';
|
||||
forwardSpeedLimit = intVal;
|
||||
return null;
|
||||
case '转向速度限制':
|
||||
if (intVal == null || intVal < 0 || intVal > 65535)
|
||||
return '值范围: 0~65535';
|
||||
turnSpeedLimit = intVal;
|
||||
return null;
|
||||
case 'WiFi名称':
|
||||
if (value.length > 20) return '最多20字符';
|
||||
wifiName = value;
|
||||
return null;
|
||||
case 'WiFi密码':
|
||||
if (value.length > 20) return '最多20字符';
|
||||
wifiPassword = value;
|
||||
return null;
|
||||
case '转速比':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
gearRatio = doubleVal;
|
||||
return null;
|
||||
case '机器人长度':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
robotLength = doubleVal;
|
||||
return null;
|
||||
case '机器人宽度':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
robotWidth = doubleVal;
|
||||
return null;
|
||||
case '机器人高度':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
robotHeight = doubleVal;
|
||||
return null;
|
||||
case '割刀宽度':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
knifeWidth = doubleVal;
|
||||
return null;
|
||||
case '轮胎尺寸':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
tireSize = doubleVal;
|
||||
return null;
|
||||
case '左轮前进增益':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
leftForwardGain = doubleVal;
|
||||
return null;
|
||||
case '左轮后退增益':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
leftBackwardGain = doubleVal;
|
||||
return null;
|
||||
case '右轮前进增益':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
rightForwardGain = doubleVal;
|
||||
return null;
|
||||
case '右轮后退增益':
|
||||
if (doubleVal == null) return '请输入有效数字';
|
||||
rightBackwardGain = doubleVal;
|
||||
return null;
|
||||
case '固件版本':
|
||||
if (value.length > 15) return '最多15字符';
|
||||
firmwareVersion = value;
|
||||
return null;
|
||||
default:
|
||||
return '未知字段: $label';
|
||||
}
|
||||
}
|
||||
|
||||
// 工具:读取0结尾的ASCII字符串
|
||||
static String _readNullTerminatedString(
|
||||
Uint8List data,
|
||||
int offset,
|
||||
int maxLen,
|
||||
) {
|
||||
final end = data.indexOf(0, offset);
|
||||
final realEnd = (end == -1 || end > offset + maxLen)
|
||||
? offset + maxLen
|
||||
: end;
|
||||
return ascii.decode(data.sublist(offset, realEnd));
|
||||
}
|
||||
|
||||
// 工具:写入ASCII字符串,不足补0
|
||||
static void _writeNullTerminatedString(
|
||||
Uint8List data,
|
||||
int offset,
|
||||
String str,
|
||||
int maxLen,
|
||||
) {
|
||||
final bytes = ascii.encode(str);
|
||||
for (int i = 0; i < maxLen; i++) {
|
||||
data[offset + i] = i < bytes.length ? bytes[i] : 0x00;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
lib/core/bluetooth/protocol_parser.dart
Normal file
122
lib/core/bluetooth/protocol_parser.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class BlePacket {
|
||||
final int command;
|
||||
final Uint8List payload;
|
||||
const BlePacket({required this.command, required this.payload});
|
||||
}
|
||||
|
||||
/// 有状态的 BLE 协议解析器
|
||||
/// 支持分片接收:多次调用 [append] 累积数据,[parse] 提取完整帧
|
||||
class ProtocolParser {
|
||||
static const int _header1 = 0xAB;
|
||||
static const int _header2 = 0xAA;
|
||||
static const int _tail1 = 0xAA;
|
||||
static const int _tail2 = 0xAB;
|
||||
|
||||
final List<int> _buffer = [];
|
||||
|
||||
/// 清空缓冲区
|
||||
void clear() {
|
||||
_buffer.clear();
|
||||
}
|
||||
|
||||
/// 获取缓冲区长度
|
||||
int get bufferLength => _buffer.length;
|
||||
|
||||
/// 打包命令为字节帧(用于发送)
|
||||
/// CRC16 覆盖 command + payload 确保整帧完整性
|
||||
static Uint8List pack(int command, List<int> payload) {
|
||||
final dataToCheck = [command, ...payload];
|
||||
final crc = _crc16(dataToCheck);
|
||||
final builder = BytesBuilder()
|
||||
..addByte(_header1)
|
||||
..addByte(_header2)
|
||||
..addByte(command)
|
||||
..add(payload)
|
||||
..addByte(crc & 0xFF)
|
||||
..addByte((crc >> 8) & 0xFF)
|
||||
..addByte(_tail1)
|
||||
..addByte(_tail2);
|
||||
return builder.takeBytes();
|
||||
}
|
||||
|
||||
/// 追加新接收的数据到缓冲区
|
||||
void append(List<int> data) {
|
||||
_buffer.addAll(data);
|
||||
}
|
||||
|
||||
/// 从缓冲区解析所有完整的数据包
|
||||
/// 未完成的帧保留在缓冲区等待后续数据
|
||||
List<BlePacket> parse() {
|
||||
final List<BlePacket> packets = [];
|
||||
|
||||
while (_buffer.length >= 6) {
|
||||
// 1. 查找帧头
|
||||
int headIdx = -1;
|
||||
for (int i = 0; i < _buffer.length - 1; i++) {
|
||||
if (_buffer[i] == _header1 && _buffer[i + 1] == _header2) {
|
||||
headIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (headIdx == -1) {
|
||||
// 无有效帧头,清空缓冲区
|
||||
_buffer.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
// 移除帧头前的垃圾数据
|
||||
if (headIdx > 0) _buffer.removeRange(0, headIdx);
|
||||
|
||||
// 2. 查找帧尾
|
||||
int tailIdx = -1;
|
||||
for (int i = 2; i < _buffer.length - 1; i++) {
|
||||
if (_buffer[i] == _tail1 && _buffer[i + 1] == _tail2) {
|
||||
tailIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tailIdx == -1) {
|
||||
// 帧尾未找到,可能是分片数据,等待更多数据
|
||||
break;
|
||||
}
|
||||
|
||||
// 3. 提取数据包
|
||||
if (_buffer.length >= 5) {
|
||||
final cmd = _buffer[2];
|
||||
final payload = Uint8List.fromList(_buffer.sublist(3, tailIdx));
|
||||
packets.add(BlePacket(command: cmd, payload: payload));
|
||||
}
|
||||
|
||||
// 4. 移除已处理的帧
|
||||
_buffer.removeRange(0, tailIdx + 2);
|
||||
}
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
/// 便捷方法:追加数据并立即解析
|
||||
List<BlePacket> appendAndParse(List<int> data) {
|
||||
append(data);
|
||||
return parse();
|
||||
}
|
||||
|
||||
/// CRC16-Modbus: polynomial=0x8005, init=0xFFFF, refIn/refOut=true
|
||||
static int _crc16(List<int> data) {
|
||||
int crc = 0xFFFF;
|
||||
for (final b in data) {
|
||||
crc ^= b;
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((crc & 0x0001) != 0) {
|
||||
crc = (crc >> 1) ^ 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,14 @@ class HttpApiConsts {
|
||||
// 获取设备列表
|
||||
static const String getUserDevicesList = "$baseUrl/iot/device/list";
|
||||
// 绑定设备
|
||||
static const String bindDevice = "$baseUrl/forward/device/bind";
|
||||
static const String bindDevice = "$baseUrl/iot/device/bind";
|
||||
// 解绑设备
|
||||
static const String unbindDevice = "$baseUrl/forward/device/unbind";
|
||||
// 切换设备
|
||||
static const String switchDevice = "$baseUrl/forward/device/switchDevice";
|
||||
|
||||
// 获取光伏电站列表
|
||||
static const String getSiteList = "$baseUrl/system/site/list";
|
||||
static const String getSiteList = "$baseUrl/system/site/selectByUserId";
|
||||
|
||||
// 获取场站下的设备列表
|
||||
static const String getSiteDeviceList = "$baseUrl/iot/device/getSiteList";
|
||||
@@ -42,7 +42,8 @@ class HttpApiConsts {
|
||||
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask";
|
||||
|
||||
// 获取飞行任务详情
|
||||
static const String getFlightTaskDetail = "$baseUrl/iot/UAV/getFlightTaskDetail";
|
||||
static const String getFlightTaskDetail =
|
||||
"$baseUrl/iot/UAV/getFlightTaskDetail";
|
||||
|
||||
// 获取航线列表
|
||||
static const String getWayline = "$baseUrl/iot/UAV/getWayline";
|
||||
@@ -51,7 +52,8 @@ class HttpApiConsts {
|
||||
static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask";
|
||||
|
||||
// 更新飞行任务状态
|
||||
static const String updateFlightTaskStatus = "$baseUrl/iot/UAV/updateFlightTaskStatus";
|
||||
static const String updateFlightTaskStatus =
|
||||
"$baseUrl/iot/UAV/updateFlightTaskStatus";
|
||||
|
||||
// 切换无人机镜头获取视频流
|
||||
static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens";
|
||||
@@ -61,4 +63,56 @@ class HttpApiConsts {
|
||||
|
||||
// 飞行任务命令控制(暂停、返航等)
|
||||
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
|
||||
|
||||
/// 告警相关
|
||||
static const String alarmBaseUrl = "http://1.95.137.212:8081";
|
||||
// 获取告警工单配置列表
|
||||
static const String alarmOrderConfigList =
|
||||
"$alarmBaseUrl/iot/alarmOrderConfig/list";
|
||||
// 获取告警列表
|
||||
static const String alarmList = "$baseUrl/iot/alarm/list";
|
||||
// 获取告警详情
|
||||
static const String alarmDetail = "$baseUrl/iot/alarm";
|
||||
// 处理告警(确认/关闭等)
|
||||
static const String alarmHandle = "$baseUrl/iot/alarm/handle";
|
||||
|
||||
/// 工单相关
|
||||
// 获取工单模型配置列表
|
||||
static const String orderModelList = "$baseUrl/iot/orderModel/list";
|
||||
// 添加工单(上报)
|
||||
static const String workOrderAdd = "$baseUrl/iot/ioTworkOrder/add";
|
||||
// 获取工单列表
|
||||
static const String workOrderList = "$baseUrl/iot/ioTworkOrder/list";
|
||||
// 获取工单详情
|
||||
static const String workOrderDetail = "$baseUrl/iot/ioTworkOrder";
|
||||
// 派发工单
|
||||
static const String workOrderDispat = "$baseUrl/iot/ioTworkOrder/dispatch";
|
||||
// 挂起工单
|
||||
static const String workOrderSuspend = "$baseUrl/iot/ioTworkOrder/suspend";
|
||||
// 完成工单
|
||||
static const String workOrderComplete = "$baseUrl/iot/ioTworkOrder/complete";
|
||||
// 开始执行工单
|
||||
static const String workOrderStart = "$baseUrl/iot/ioTworkOrder/start";
|
||||
// 获取工单统计
|
||||
static const String workOrderCount = "$baseUrl/iot/ioTworkOrder/count";
|
||||
// 设备运行参数查询
|
||||
static const String deviceRunParamSelect =
|
||||
"$baseUrl/iot/deviceRunParam/selectByDeviceId";
|
||||
// 设备运行参数保存
|
||||
static const String deviceRunParamSave = "$baseUrl/iot/deviceRunParam/save";
|
||||
|
||||
// 设备操作权限校验
|
||||
static const String hasPermission = "$baseUrl/iot/device/hasPermission";
|
||||
|
||||
/// 用户相关
|
||||
// 获取用户列表
|
||||
static const String systemUserList = "$baseUrl/system/user/list";
|
||||
|
||||
/// 组织与场站相关
|
||||
// 获取组织列表
|
||||
static const String orgList = "$baseUrl/system/org/list";
|
||||
// 根据组织ID获取场站列表
|
||||
static const String siteListByOrgId = "$baseUrl/system/site/selectByOrgId";
|
||||
// 根据场站ID获取用户列表
|
||||
static const String userListBySiteId = "$baseUrl/system/user/list";
|
||||
}
|
||||
|
||||
@@ -52,15 +52,12 @@ import '../../features/devices/domain/usecases/get_device_location_usecase.dart'
|
||||
import '../../features/devices/domain/usecases/get_work_record_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/create_device_task_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/route_planning_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/save_work_record_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/select_work_record_usecase.dart';
|
||||
import '../../features/devices/domain/usecases/switch_device_usecase.dart';
|
||||
import '../../features/devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../features/remote_control/data/datasources/remote_http_datasource.dart';
|
||||
import '../../features/remote_control/data/datasources/remote_tcp_datasource.dart';
|
||||
import '../../features/remote_control/domain/usecase/remote_control_usecase.dart';
|
||||
import '../../features/remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../features/v2/home/data/datasources/home_remote_datasource.dart';
|
||||
import '../../features/v2/home/data/datasources/site_datasource.dart';
|
||||
@@ -96,6 +93,12 @@ import '../../features/v2/device_list/domain/usecases/return_home_usecase.dart';
|
||||
import '../../features/v2/device_list/presentation/bloc/drone_station_bloc.dart';
|
||||
import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
|
||||
import '../../features/v2/device_list/presentation/bloc/device_realtime_bloc.dart';
|
||||
import '../../features/v2/device_list/data/datasources/bind_device_datasource.dart';
|
||||
import '../../features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart';
|
||||
import '../../features/v2/device_list/data/repositories/bind_device_repository_impl.dart';
|
||||
import '../../features/v2/device_list/domain/repositories/bind_device_repository.dart';
|
||||
import '../../features/v2/device_list/domain/usecases/bind_device_usecases.dart';
|
||||
import '../../features/v2/device_list/presentation/cubit/bind_device_cubit.dart';
|
||||
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
|
||||
import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart';
|
||||
import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart';
|
||||
@@ -116,6 +119,18 @@ import '../../features/v2/message_center/data/repositories/message_center_reposi
|
||||
import '../../features/v2/message_center/domain/repositories/message_center_repository.dart';
|
||||
import '../../features/v2/message_center/domain/usecases/message_center_usecase.dart';
|
||||
import '../../features/v2/message_center/presentation/bloc/message_center_cubit.dart';
|
||||
import '../../features/v2/work_order/data/datasources/work_order_remote_datasource.dart';
|
||||
import '../../features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart';
|
||||
import '../../features/v2/work_order/data/repositories/work_order_repository_impl.dart';
|
||||
import '../../features/v2/work_order/domain/repositories/work_order_repository.dart';
|
||||
import '../../features/v2/work_order/domain/usecases/work_order_usecases.dart';
|
||||
import '../../features/v2/work_order/presentation/cubit/work_order_cubit.dart';
|
||||
import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart';
|
||||
import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart';
|
||||
import '../../features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart';
|
||||
import '../../features/v2/device_run_param/domain/repositories/device_run_param_repository.dart';
|
||||
import '../../features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart';
|
||||
import '../services/device_permission_service.dart';
|
||||
import '../../features/devices/data/datasources/device_task_datasource.dart';
|
||||
import '../../features/devices/data/repositories/device_task_repository_impl.dart';
|
||||
import '../../features/devices/domain/repositories/device_task_repository.dart';
|
||||
@@ -196,7 +211,9 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
|
||||
|
||||
/// 1.4 --- Route Observer (路由监听器) ---
|
||||
sl.registerLazySingleton<RouteObserver>(() => RouteObserver<ModalRoute<void>>());
|
||||
sl.registerLazySingleton<RouteObserver>(
|
||||
() => RouteObserver<ModalRoute<void>>(),
|
||||
);
|
||||
|
||||
/// 1.5 --- MQTT Data Sources ---
|
||||
sl.registerFactory<DroneOsdDataSource>(
|
||||
@@ -367,9 +384,7 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<PauseFlightTaskUseCase>(
|
||||
() => PauseFlightTaskUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<ReturnHomeUseCase>(
|
||||
() => ReturnHomeUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<ReturnHomeUseCase>(() => ReturnHomeUseCase(sl()));
|
||||
sl.registerFactory<DroneStationBloc>(
|
||||
() => DroneStationBloc(sl(), sl(), sl(), sl()),
|
||||
);
|
||||
@@ -384,7 +399,7 @@ Future<void> init() async {
|
||||
|
||||
/// Alarm Center V2
|
||||
sl.registerLazySingleton<AlarmRemoteDataSource>(
|
||||
() => AlarmRemoteDataSourceImpl(),
|
||||
() => AlarmRemoteDataSourceImpl(sl<Dio>()),
|
||||
);
|
||||
sl.registerLazySingleton<AlarmRepository>(() => AlarmRepositoryImpl(sl()));
|
||||
sl.registerLazySingleton<GetAlarmListUseCase>(
|
||||
@@ -393,13 +408,11 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<GetAlarmCountUseCase>(
|
||||
() => GetAlarmCountUseCase(sl()),
|
||||
);
|
||||
sl.registerFactory<AlarmCubit>(
|
||||
() => AlarmCubit(getAlarmListUseCase: sl(), getAlarmCountUseCase: sl()),
|
||||
);
|
||||
sl.registerFactory<AlarmCubit>(() => AlarmCubit(getAlarmListUseCase: sl()));
|
||||
|
||||
/// Alarm Detail V2
|
||||
sl.registerLazySingleton<AlarmDetailRemoteDataSource>(
|
||||
() => AlarmDetailRemoteDataSourceImpl(),
|
||||
() => AlarmDetailRemoteDataSourceImpl(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<AlarmDetailRepository>(
|
||||
() => AlarmDetailRepositoryImpl(sl()),
|
||||
@@ -410,11 +423,13 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<ConfirmAlarmUseCase>(
|
||||
() => ConfirmAlarmUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<HandleAlarmUseCase>(() => HandleAlarmUseCase(sl()));
|
||||
sl.registerLazySingleton<AIDiagnosisUseCase>(() => AIDiagnosisUseCase(sl()));
|
||||
sl.registerFactory<AlarmDetailCubit>(
|
||||
() => AlarmDetailCubit(
|
||||
getAlarmDetailUseCase: sl(),
|
||||
confirmAlarmUseCase: sl(),
|
||||
handleAlarmUseCase: sl(),
|
||||
aiDiagnosisUseCase: sl(),
|
||||
),
|
||||
);
|
||||
@@ -565,5 +580,84 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
|
||||
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
|
||||
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
|
||||
sl.registerFactory(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
|
||||
sl.registerLazySingleton(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
|
||||
|
||||
/// 11. 工单管理 (Work Order)
|
||||
sl.registerLazySingleton<WorkOrderRemoteDataSource>(
|
||||
() => WorkOrderRemoteDataSourceImpl(sl<Dio>()),
|
||||
);
|
||||
sl.registerLazySingleton<WorkOrderRepository>(
|
||||
() => WorkOrderRepositoryImpl(remoteDataSource: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<GetWorkOrderListUseCase>(
|
||||
() => GetWorkOrderListUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<GetWorkOrderDetailUseCase>(
|
||||
() => GetWorkOrderDetailUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<DispatchWorkOrderUseCase>(
|
||||
() => DispatchWorkOrderUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<SuspendWorkOrderUseCase>(
|
||||
() => SuspendWorkOrderUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<CompleteWorkOrderUseCase>(
|
||||
() => CompleteWorkOrderUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<StartWorkOrderUseCase>(
|
||||
() => StartWorkOrderUseCase(sl()),
|
||||
);
|
||||
sl.registerFactory<WorkOrderCubit>(
|
||||
() => WorkOrderCubit(
|
||||
getWorkOrderListUseCase: sl(),
|
||||
getWorkOrderDetailUseCase: sl(),
|
||||
dispatchWorkOrderUseCase: sl(),
|
||||
suspendWorkOrderUseCase: sl(),
|
||||
completeWorkOrderUseCase: sl(),
|
||||
startWorkOrderUseCase: sl(),
|
||||
),
|
||||
);
|
||||
|
||||
/// 12. 设备运行参数管理 (Device Run Param)
|
||||
sl.registerLazySingleton<DeviceRunParamRemoteDataSource>(
|
||||
() => DeviceRunParamRemoteDataSourceImpl(sl<Dio>()),
|
||||
);
|
||||
sl.registerLazySingleton<DeviceRunParamRepository>(
|
||||
() => DeviceRunParamRepositoryImpl(remoteDataSource: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<GetDeviceRunParamUseCase>(
|
||||
() => GetDeviceRunParamUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<SaveDeviceRunParamUseCase>(
|
||||
() => SaveDeviceRunParamUseCase(sl()),
|
||||
);
|
||||
|
||||
/// 13. 设备操作权限校验服务 (Device Permission Service)
|
||||
sl.registerLazySingleton<DevicePermissionService>(
|
||||
() => DevicePermissionService(sl<Dio>()),
|
||||
);
|
||||
|
||||
/// 14. 绑定智能装备 (Bind Device)
|
||||
sl.registerLazySingleton<BindDeviceDatasource>(
|
||||
() => BindDeviceDatasourceImpl(sl<Dio>()),
|
||||
);
|
||||
sl.registerLazySingleton<BindDeviceRepository>(
|
||||
() => BindDeviceRepositoryImpl(sl<BindDeviceDatasource>()),
|
||||
);
|
||||
sl.registerLazySingleton<GetOrgListUseCase>(() => GetOrgListUseCase(sl()));
|
||||
sl.registerLazySingleton<GetSitesByOrgUseCase>(
|
||||
() => GetSitesByOrgUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<GetUsersBySiteUseCase>(
|
||||
() => GetUsersBySiteUseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<BindDeviceV2UseCase>(
|
||||
() => BindDeviceV2UseCase(sl()),
|
||||
);
|
||||
sl.registerLazySingleton<IsDeviceAtSiteUseCase>(
|
||||
() => IsDeviceAtSiteUseCase(sl()),
|
||||
);
|
||||
sl.registerFactory<BindDeviceCubit>(
|
||||
() => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl<AppUserCubit>()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ class UserEntity extends Equatable {
|
||||
final String username;
|
||||
final String nickname;
|
||||
final String token;
|
||||
final int orgId; // 组织ID,用于获取场站列表
|
||||
final int orgId;
|
||||
final String? avatar;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String? roleKey;
|
||||
final int? siteId;
|
||||
|
||||
UserEntity({
|
||||
const UserEntity({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.nickname,
|
||||
@@ -19,6 +21,8 @@ class UserEntity extends Equatable {
|
||||
this.avatar,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.roleKey,
|
||||
this.siteId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -31,5 +35,33 @@ class UserEntity extends Equatable {
|
||||
orgId,
|
||||
email,
|
||||
phone,
|
||||
roleKey,
|
||||
siteId,
|
||||
];
|
||||
}
|
||||
|
||||
UserEntity copyWith({
|
||||
String? userId,
|
||||
String? username,
|
||||
String? nickname,
|
||||
String? token,
|
||||
int? orgId,
|
||||
String? avatar,
|
||||
String? email,
|
||||
String? phone,
|
||||
String? roleKey,
|
||||
int? siteId,
|
||||
}) {
|
||||
return UserEntity(
|
||||
userId: userId ?? this.userId,
|
||||
username: username ?? this.username,
|
||||
nickname: nickname ?? this.nickname,
|
||||
token: token ?? this.token,
|
||||
orgId: orgId ?? this.orgId,
|
||||
avatar: avatar ?? this.avatar,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
roleKey: roleKey ?? this.roleKey,
|
||||
siteId: siteId ?? this.siteId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,24 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../features/v2/device_list/domain/entities/drone_station_entity.dart';
|
||||
|
||||
/// 无人机轨迹点(轻量级,避免在核心层引入 latlong2 依赖)
|
||||
class DroneTrajectoryPoint {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double? heading;
|
||||
|
||||
const DroneTrajectoryPoint({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.heading,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'DroneTrajectoryPoint(lat=$latitude, lng=$longitude, heading=$heading)';
|
||||
}
|
||||
|
||||
/// 无人机任务信息
|
||||
class DroneTaskInfo {
|
||||
final String droneSn;
|
||||
@@ -33,9 +51,33 @@ class DroneTaskStateManager {
|
||||
final ValueNotifier<DroneTaskInfo?> _currentTaskInfo =
|
||||
ValueNotifier<DroneTaskInfo?>(null);
|
||||
|
||||
/// 任务下发成功后,等待无人机 OSD 推送数据(实时信息)
|
||||
final ValueNotifier<bool> _isWaitingForOsdPush = ValueNotifier<bool>(false);
|
||||
|
||||
/// 任务下发成功后,等待无人机视频流
|
||||
final ValueNotifier<bool> _isWaitingForVideo = ValueNotifier<bool>(false);
|
||||
|
||||
/// 无人机飞行轨迹点(跨页面持久化,退出视频页后不丢失)
|
||||
final ValueNotifier<List<DroneTrajectoryPoint>> _trajectoryPoints =
|
||||
ValueNotifier<List<DroneTrajectoryPoint>>([]);
|
||||
|
||||
/// 超时定时器,避免一直转圈(推送/视频迟迟不到)
|
||||
Timer? _osdWaitTimeoutTimer;
|
||||
Timer? _videoWaitTimeoutTimer;
|
||||
|
||||
/// 监听无人机任务信息变化
|
||||
ValueListenable<DroneTaskInfo?> get currentTaskInfo => _currentTaskInfo;
|
||||
|
||||
/// 监听“等待 OSD 推送”状态
|
||||
ValueListenable<bool> get isWaitingForOsdPush => _isWaitingForOsdPush;
|
||||
|
||||
/// 监听“等待视频流”状态
|
||||
ValueListenable<bool> get isWaitingForVideo => _isWaitingForVideo;
|
||||
|
||||
/// 监听无人机轨迹点
|
||||
ValueListenable<List<DroneTrajectoryPoint>> get trajectoryPoints =>
|
||||
_trajectoryPoints;
|
||||
|
||||
/// 获取当前无人机任务信息
|
||||
DroneTaskInfo? get currentInfo => _currentTaskInfo.value;
|
||||
|
||||
@@ -50,10 +92,84 @@ class DroneTaskStateManager {
|
||||
_currentTaskInfo.value = info;
|
||||
}
|
||||
|
||||
/// 任务下发成功后调用:开始监测无人机实时推送与视频流
|
||||
/// 在接口返回成功之后调用
|
||||
void markTaskIssued() {
|
||||
debugPrint('🚀 [DroneTaskStateManager] 任务已下发,开始监测推送数据与视频流');
|
||||
_isWaitingForOsdPush.value = true;
|
||||
_isWaitingForVideo.value = true;
|
||||
|
||||
_osdWaitTimeoutTimer?.cancel();
|
||||
_videoWaitTimeoutTimer?.cancel();
|
||||
|
||||
// 超时兜底:90 秒后仍未收到 OSD 推送,自动停止转圈
|
||||
_osdWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
|
||||
if (_isWaitingForOsdPush.value) {
|
||||
debugPrint('⚠️ [DroneTaskStateManager] 等待 OSD 推送超时,自动停止');
|
||||
_isWaitingForOsdPush.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 超时兜底:90 秒后仍未收到视频,自动停止 toast
|
||||
_videoWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
|
||||
if (_isWaitingForVideo.value) {
|
||||
debugPrint('⚠️ [DroneTaskStateManager] 等待视频流超时,自动停止');
|
||||
_isWaitingForVideo.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 收到无人机 OSD 推送数据后调用:停止转圈,恢复绿点
|
||||
void markOsdPushReceived() {
|
||||
if (_isWaitingForOsdPush.value) {
|
||||
debugPrint('✅ [DroneTaskStateManager] 收到 OSD 推送数据,停止监测');
|
||||
_isWaitingForOsdPush.value = false;
|
||||
_osdWaitTimeoutTimer?.cancel();
|
||||
_osdWaitTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 收到无人机视频流后调用:隐藏"视频获取中" toast
|
||||
void markVideoReceived() {
|
||||
if (_isWaitingForVideo.value) {
|
||||
debugPrint('✅ [DroneTaskStateManager] 收到视频流,停止监测');
|
||||
_isWaitingForVideo.value = false;
|
||||
_videoWaitTimeoutTimer?.cancel();
|
||||
_videoWaitTimeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加无人机轨迹点
|
||||
void addTrajectoryPoint(DroneTrajectoryPoint point) {
|
||||
final list = List<DroneTrajectoryPoint>.from(_trajectoryPoints.value);
|
||||
list.add(point);
|
||||
// 性能优化:只保留最近 1000 个点
|
||||
if (list.length > 1000) {
|
||||
list.removeAt(0);
|
||||
}
|
||||
_trajectoryPoints.value = list;
|
||||
}
|
||||
|
||||
/// 批量设置轨迹点(恢复历史轨迹时使用)
|
||||
void setTrajectoryPoints(List<DroneTrajectoryPoint> points) {
|
||||
_trajectoryPoints.value = List<DroneTrajectoryPoint>.from(points);
|
||||
}
|
||||
|
||||
/// 清空轨迹
|
||||
void clearTrajectory() {
|
||||
_trajectoryPoints.value = [];
|
||||
}
|
||||
|
||||
/// 清除无人机任务信息
|
||||
void clearDroneTaskInfo() {
|
||||
debugPrint('🛸 [DroneTaskStateManager] 清除无人机任务信息');
|
||||
_currentTaskInfo.value = null;
|
||||
_osdWaitTimeoutTimer?.cancel();
|
||||
_videoWaitTimeoutTimer?.cancel();
|
||||
_osdWaitTimeoutTimer = null;
|
||||
_videoWaitTimeoutTimer = null;
|
||||
_isWaitingForOsdPush.value = false;
|
||||
_isWaitingForVideo.value = false;
|
||||
}
|
||||
|
||||
/// 检查是否有当前任务
|
||||
|
||||
@@ -15,7 +15,13 @@ class DioClient {
|
||||
),
|
||||
);
|
||||
|
||||
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
|
||||
dio.interceptors.add(
|
||||
LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
requestHeader: true,
|
||||
),
|
||||
);
|
||||
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
@@ -30,23 +36,29 @@ class DioClient {
|
||||
return handler.next(options);
|
||||
},
|
||||
|
||||
// ======================
|
||||
// ✅ 关键:401 自动拦截
|
||||
// ======================
|
||||
onResponse: (response, handler) {
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final code = response.data['code'];
|
||||
if (code == 401 || code == 403) {
|
||||
print(
|
||||
'>>> [DIO] 🚨🚨🚨 收到业务错误码 $code,触发 Token 过期处理!URL: ${response.requestOptions.uri},时间: ${DateTime.now()}',
|
||||
);
|
||||
try {
|
||||
sl<AuthCubit>().tokenExpired();
|
||||
} catch (ex) {}
|
||||
}
|
||||
}
|
||||
return handler.next(response);
|
||||
},
|
||||
|
||||
onError: (DioException e, handler) async {
|
||||
// 401 = token 过期 / 未授权
|
||||
if (e.response?.statusCode == 401) {
|
||||
print('>>> [DIO] 🚨🚨🚨 收到 401 响应,触发自动退出登录!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}');
|
||||
if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
|
||||
print(
|
||||
'>>> [DIO] 🚨🚨🚨 收到 HTTP ${e.response?.statusCode},触发 Token 过期处理!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}',
|
||||
);
|
||||
try {
|
||||
// 调用 logout 清除本地缓存 + 跳登录
|
||||
await sl<AuthCubit>().logout();
|
||||
} catch (ex) {
|
||||
// 防止报错
|
||||
}
|
||||
sl<AuthCubit>().tokenExpired();
|
||||
} catch (ex) {}
|
||||
}
|
||||
|
||||
return handler.next(e);
|
||||
|
||||
@@ -28,6 +28,8 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
StreamSubscription<MqttMessage>? _subscription;
|
||||
String? _deviceSn;
|
||||
String? _gatewaySn;
|
||||
int _listenerCount = 0;
|
||||
bool _isDisposed = false;
|
||||
|
||||
DroneOsdDataSourceImpl(this.mqttClient);
|
||||
|
||||
@@ -42,10 +44,14 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
required String deviceSn,
|
||||
required String gatewaySn,
|
||||
}) async {
|
||||
if (_deviceSn == deviceSn &&
|
||||
if (_isDisposed) return;
|
||||
|
||||
if (_listenerCount > 0 &&
|
||||
_deviceSn == deviceSn &&
|
||||
_gatewaySn == gatewaySn &&
|
||||
_subscription != null) {
|
||||
debugPrint('[DroneOsdDataSource] already listening');
|
||||
_listenerCount++;
|
||||
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,10 +79,20 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
_subscription = mqttClient.messageStream?.listen((message) {
|
||||
_handleMessage(message);
|
||||
});
|
||||
|
||||
_listenerCount = 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopListening() async {
|
||||
_listenerCount--;
|
||||
if (_listenerCount > 0) {
|
||||
debugPrint('[DroneOsdDataSource] 引用计数-1: $_listenerCount (保留订阅)');
|
||||
return;
|
||||
}
|
||||
|
||||
_listenerCount = 0;
|
||||
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
@@ -94,26 +110,19 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
}
|
||||
|
||||
void _handleMessage(MqttMessage message) {
|
||||
if (_isDisposed) return;
|
||||
try {
|
||||
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
|
||||
final osdData = DroneOsdEntity.fromJson(jsonData);
|
||||
|
||||
// 🔥 重要修复:先判断 gatewaySn,再判断 deviceSn
|
||||
// 因为 topic 可能同时包含两者,但我们需要优先匹配机场
|
||||
if (_gatewaySn != null &&
|
||||
_gatewaySn!.isNotEmpty &&
|
||||
message.topic.contains(_gatewaySn!)) {
|
||||
debugPrint('🏢 [DroneOsdDataSource] 机场 OSD 更新');
|
||||
debugPrint('🏢 [DroneOsdDataSource] Topic: ${message.topic}');
|
||||
_stationOsdController.add(osdData);
|
||||
} else if (_deviceSn != null &&
|
||||
_deviceSn!.isNotEmpty &&
|
||||
message.topic.contains(_deviceSn!)) {
|
||||
debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
|
||||
debugPrint('🛸 [DroneOsdDataSource] Topic: ${message.topic}');
|
||||
_droneOsdController.add(osdData);
|
||||
} else {
|
||||
debugPrint('⚠️ [DroneOsdDataSource] 未知设备类型,Topic: ${message.topic}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e');
|
||||
@@ -121,8 +130,9 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
stopListening();
|
||||
_droneOsdController.close();
|
||||
_stationOsdController.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ abstract class TaskMessageDataSource {
|
||||
Stream<TaskArriveEntity> get taskArriveStream;
|
||||
Stream<RealTimeMessageEntity> get realTimeMessageStream;
|
||||
|
||||
Future<void> startListening({required String deviceId});
|
||||
Future<void> startListening({required String deviceId, int? taskId});
|
||||
Future<void> stopListening();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
|
||||
StreamSubscription<MqttMessage>? _subscription;
|
||||
String? _deviceId;
|
||||
int? _taskId;
|
||||
|
||||
TaskMessageDataSourceImpl(this.mqttClient);
|
||||
|
||||
@@ -40,9 +41,9 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
_realTimeMessageController.stream;
|
||||
|
||||
@override
|
||||
Future<void> startListening({required String deviceId}) async {
|
||||
if (_deviceId == deviceId && _subscription != null) {
|
||||
debugPrint('[TaskMessageDataSource] already listening: $deviceId');
|
||||
Future<void> startListening({required String deviceId, int? taskId}) async {
|
||||
if (_deviceId == deviceId && _taskId == taskId && _subscription != null) {
|
||||
debugPrint('[TaskMessageDataSource] already listening: deviceId=$deviceId, taskId=$taskId');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,18 +55,20 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
}
|
||||
|
||||
_deviceId = deviceId;
|
||||
_taskId = taskId;
|
||||
|
||||
final taskStatusTopic = 'task/$deviceId/status';
|
||||
final taskArriveTopic = 'task/$deviceId/arrive';
|
||||
// 🔥 status/arrive 话题使用 taskId,realTimeMessage 使用 deviceId
|
||||
final taskStatusTopic = taskId != null ? 'task/$taskId/status' : null;
|
||||
final taskArriveTopic = taskId != null ? 'task/$taskId/arrive' : null;
|
||||
final realTimeTopic = 'device/$deviceId/realTimeMessage';
|
||||
|
||||
debugPrint('📋 [TaskMessageDataSource] 开始监听:');
|
||||
debugPrint(' 任务状态: $taskStatusTopic');
|
||||
debugPrint(' 到达通知: $taskArriveTopic');
|
||||
if (taskStatusTopic != null) debugPrint(' 任务状态: $taskStatusTopic');
|
||||
if (taskArriveTopic != null) debugPrint(' 到达通知: $taskArriveTopic');
|
||||
debugPrint(' 实时消息: $realTimeTopic');
|
||||
|
||||
await mqttClient.subscribe(taskStatusTopic);
|
||||
await mqttClient.subscribe(taskArriveTopic);
|
||||
if (taskStatusTopic != null) await mqttClient.subscribe(taskStatusTopic);
|
||||
if (taskArriveTopic != null) await mqttClient.subscribe(taskArriveTopic);
|
||||
await mqttClient.subscribe(realTimeTopic);
|
||||
|
||||
_subscription = mqttClient.messageStream?.listen((message) {
|
||||
@@ -80,12 +83,16 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
|
||||
|
||||
if (_deviceId != null) {
|
||||
final deviceId = _deviceId!;
|
||||
await mqttClient.unsubscribe('task/$deviceId/status');
|
||||
await mqttClient.unsubscribe('task/$deviceId/arrive');
|
||||
// 🔥 status/arrive 用 taskId 取消订阅
|
||||
if (_taskId != null) {
|
||||
await mqttClient.unsubscribe('task/$_taskId/status');
|
||||
await mqttClient.unsubscribe('task/$_taskId/arrive');
|
||||
}
|
||||
await mqttClient.unsubscribe('device/$deviceId/realTimeMessage');
|
||||
}
|
||||
|
||||
_deviceId = null;
|
||||
_taskId = null;
|
||||
}
|
||||
|
||||
void _handleMessage(MqttMessage message) {
|
||||
|
||||
@@ -24,9 +24,9 @@ class TaskMessageRepositoryImpl implements TaskMessageRepository {
|
||||
Stream<TaskStatusEntity> get taskStatusStream => dataSource.taskStatusStream;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> startListening({required String deviceId}) async {
|
||||
Future<Either<Failure, void>> startListening({required String deviceId, int? taskId}) async {
|
||||
try {
|
||||
await dataSource.startListening(deviceId: deviceId);
|
||||
await dataSource.startListening(deviceId: deviceId, taskId: taskId);
|
||||
return right(null);
|
||||
} catch (e) {
|
||||
return left(Failure(e.toString()));
|
||||
|
||||
@@ -71,7 +71,7 @@ class MqttConfig extends Equatable {
|
||||
factory MqttConfig.taskMessage() {
|
||||
return const MqttConfig(
|
||||
host: '1.95.137.212',
|
||||
port: 59020,
|
||||
port: 1883,
|
||||
username: 'maibu',
|
||||
password: 'jsmbzn520',
|
||||
protocol: MqttProtocol.tcp,
|
||||
|
||||
@@ -11,6 +11,6 @@ abstract class TaskMessageRepository {
|
||||
Stream<TaskArriveEntity> get taskArriveStream;
|
||||
Stream<TaskStatusEntity> get taskStatusStream;
|
||||
|
||||
Future<Either<Failure, void>> startListening({required String deviceId});
|
||||
Future<Either<Failure, void>> startListening({required String deviceId, int? taskId});
|
||||
Future<void> stopListening();
|
||||
}
|
||||
|
||||
@@ -36,9 +36,15 @@ class TcpClient {
|
||||
|
||||
final ILoggerService _logger = GetIt.I<ILoggerService>();
|
||||
|
||||
// 新增:心跳定时器
|
||||
// 心跳超时定时器(收到服务端心跳后重置,超时则判定连接断开)
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _heartbeatSendTimer; // 🔥 主动发送心跳定时器:每5秒给服务端发一次,防止服务端判定客户端失效
|
||||
Timer? _reconnectTimer; // 重连定时器
|
||||
int _reconnectAttempt = 0; // 🔥 重连尝试次数,用于退避计算
|
||||
int _reconnectFailCount = 0; // 🔥 连续重连失败次数(含心跳超时),达到阈值后停止重连
|
||||
/// 🔥 重连耗尽回调:连续4次重连失败(含心跳超时)后触发,通知上层弹窗提示用户
|
||||
VoidCallback? onReconnectExhausted;
|
||||
static const Duration _heartbeatTimeout = Duration(seconds: 15); // 心跳超时时间
|
||||
|
||||
String? _lastHost;
|
||||
int? _lastPort;
|
||||
@@ -46,6 +52,7 @@ class TcpClient {
|
||||
bool _isSwitching = false;
|
||||
bool _connecting = false; // 🔥 是否正在建立连接(防止 connect() 和 connectBySwitch() 并发竞争)
|
||||
Completer<void>? _connectionCompleter; // 🔥 用于通知连接就绪
|
||||
int _connectionId = 0; // 🔥 连接代数:每次新建连接递增,旧socket回调发现ID不匹配则忽略
|
||||
|
||||
// 🔥 关键标志:记录客户端最后一次发送 0x03 认证包的时间
|
||||
// 用于区分"自身认证触发的 have_logged_in"和"真正的异地登录"
|
||||
@@ -95,6 +102,7 @@ class TcpClient {
|
||||
|
||||
bool get isConnected => _socket != null;
|
||||
void disconnects({bool forSwitch = false}) {
|
||||
_connectionId++; // 🔥 使旧socket回调失效
|
||||
if (forSwitch) {
|
||||
_isSwitching = true;
|
||||
//debugPrint('🚫 [TCP] 标记为切换断开,将禁止自动重连');
|
||||
@@ -103,7 +111,7 @@ class TcpClient {
|
||||
|
||||
// 🔥 关键:立即取消可能已经存在或即将触发的重连定时器
|
||||
_reconnectTimer?.cancel();
|
||||
_heartbeatTimer?.cancel();
|
||||
stopHeartbeat();
|
||||
|
||||
if (_socket != null) {
|
||||
//debugPrint('🔌 [TCP] 物理断开 Socket...');
|
||||
@@ -123,6 +131,7 @@ class TcpClient {
|
||||
/// 🔥 彻底断开 TCP(用于退出登录),清除所有重连能力
|
||||
void forceDisconnect() {
|
||||
_logger.logWithLevel('🛑 [TCP] 强制断开并清除重连能力', shouldLog: true);
|
||||
_connectionId++; // 🔥 使旧socket回调失效
|
||||
|
||||
// 1. 停止心跳
|
||||
stopHeartbeat();
|
||||
@@ -141,6 +150,8 @@ class TcpClient {
|
||||
_connecting = false;
|
||||
_connectionCompleter = null;
|
||||
_lastAuthPacketSentAt = null;
|
||||
_reconnectAttempt = 0; // 🔥 重置重连退避
|
||||
_reconnectFailCount = 0; // 🔥 重置重连失败计数
|
||||
|
||||
// 5. 销毁 Socket
|
||||
if (_socket != null) {
|
||||
@@ -187,7 +198,9 @@ class TcpClient {
|
||||
|
||||
_connecting = true;
|
||||
_connectionCompleter = Completer<void>();
|
||||
debugPrint('🔌 [TCP-connect] 设置 _connecting=true');
|
||||
_connectionId++; // 🔥 新一代连接,旧socket的回调将自动失效
|
||||
final myConnId = _connectionId;
|
||||
debugPrint('🔌 [TCP-connect] 设置 _connecting=true, connectionId=$myConnId');
|
||||
|
||||
_logger.logWithLevel('🔌 [TCP] 开始连接:$host:$port', shouldLog: true);
|
||||
|
||||
@@ -207,6 +220,9 @@ class TcpClient {
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
_connecting = false; // 🔥 连接成功,重置标志
|
||||
// 🔥 关键:在重置退避计数前,记住本次是否是重连(用于后续检测服务端残留session)
|
||||
final bool wasReconnecting = _reconnectAttempt > 0;
|
||||
_reconnectAttempt = 0; // 🔥 连接成功,重置重连退避
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.complete();
|
||||
debugPrint('✅ [TCP-connect] 连接就绪,通知等待方');
|
||||
@@ -225,6 +241,8 @@ class TcpClient {
|
||||
|
||||
// 开始认证tcp
|
||||
await _sendAuthPacket();
|
||||
// 🔥 启动心跳超时检测:等待服务端心跳,超时则触发重连
|
||||
startHeartbeat();
|
||||
_socket!.listen(
|
||||
(data) {
|
||||
//debugPrint('📥 [TCP] 收到原始数据:${data.length} 字节, 内容:$data');
|
||||
@@ -248,12 +266,23 @@ class TcpClient {
|
||||
shouldLog: false,
|
||||
);
|
||||
for (var packet in packets) {
|
||||
// 🔥 最根部日志:收到任何推送都打印
|
||||
// debugPrint('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}');
|
||||
// _logger.logWithLevel(
|
||||
// '📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}',
|
||||
// shouldLog: true,
|
||||
// );
|
||||
// 🔥 关键修复:重连期间收到 have_logged_in,拦截不分发给 AuthCubit
|
||||
// 必须在 _controller.add 之前检查,否则 AuthCubit 会判定为异地登录弹出退出弹窗
|
||||
if (packet.command == 0x12 && wasReconnecting) {
|
||||
try {
|
||||
final payloadStr = utf8.decode(packet.payload.length > 2
|
||||
? packet.payload.sublist(0, packet.payload.length - 2)
|
||||
: packet.payload);
|
||||
if (payloadStr.contains('have_logged_in')) {
|
||||
debugPrint('🔄 [TCP] 重连后收到 have_logged_in,服务端session残留!立即断开让服务端清理session');
|
||||
_logger.logWithLevel('🔄 [TCP] 重连后收到 have_logged_in,服务端session残留,强制重连', shouldLog: true);
|
||||
_handleReconnectSessionReset(myConnId);
|
||||
return; // 停止处理后续数据,不分发给任何监听器
|
||||
}
|
||||
} catch (_) {
|
||||
// 解析失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
if (!_controller.isClosed) {
|
||||
_controller.add(packet);
|
||||
@@ -264,12 +293,9 @@ class TcpClient {
|
||||
);
|
||||
}
|
||||
if (packet.command == 0xFF) {
|
||||
//debugPrint('收到服务端心跳,自动回复...');
|
||||
_logger.logWithLevel(
|
||||
'✅ [TCP] 收到服务端心跳,自动回复...',
|
||||
shouldLog: false,
|
||||
);
|
||||
debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
|
||||
sendHeartbeat(); // 回复 AB AA FF AA AB
|
||||
_resetHeartbeatTimeout(); // 重置超时计时器
|
||||
}
|
||||
if (packet.command == 0x03) {
|
||||
// debugPrint('⚠️ 收到认证响应:${packet.payload}');
|
||||
@@ -289,24 +315,28 @@ class TcpClient {
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
// TODO: 断线重连
|
||||
// debugPrint('来到断线重连!');
|
||||
// 🔥 关键修复:检查 connectionId,防止旧socket的回调污染新连接
|
||||
if (myConnId != _connectionId) {
|
||||
debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 的回调,当前 #$_connectionId,忽略');
|
||||
_logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
|
||||
return;
|
||||
}
|
||||
if (!_isSwitching) {
|
||||
//debugPrint('onDone❌ [TCP] 连接已断开!');
|
||||
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
|
||||
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: true);
|
||||
_handleDisconnect();
|
||||
}
|
||||
_isSwitching = false; // 重置标志,以免影响下次
|
||||
_isSwitching = false;
|
||||
},
|
||||
onError: (e) {
|
||||
//debugPrint('来到断线重连!error');
|
||||
if (myConnId != _connectionId) {
|
||||
debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 的回调,当前 #$_connectionId,忽略');
|
||||
return;
|
||||
}
|
||||
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
|
||||
if (!_isSwitching) {
|
||||
//debugPrint('❌ [TCP] 发生错误:$e');
|
||||
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
|
||||
_handleDisconnect();
|
||||
} // 统一走重连逻辑,保护 Controller 不被关闭
|
||||
_isSwitching = false; // 重置标志,以免影响下次
|
||||
}
|
||||
_isSwitching = false;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -318,13 +348,46 @@ class TcpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 重连后服务端session残留处理:立即销毁连接,等待服务端清理session后再重连
|
||||
/// 这是解决"退出登录重连就好"问题的核心:
|
||||
/// 退出登录 → forceDisconnect → 服务端有足够时间清理session → 重新登录 → 新session → 心跳正常
|
||||
/// 自动重连 → 5秒内重连 → 服务端session还在 → have_logged_in → 不重启心跳 → 超时循环
|
||||
void _handleReconnectSessionReset(int myConnId) {
|
||||
_connectionId++; // 使旧socket回调失效
|
||||
stopHeartbeat();
|
||||
if (_socket != null) {
|
||||
_socket!.destroy();
|
||||
_socket = null;
|
||||
}
|
||||
// 🔥 服务端session残留也算一次重连失败
|
||||
_reconnectFailCount++;
|
||||
debugPrint('📊 [TCP] session残留,重连失败计数: $_reconnectFailCount/4');
|
||||
if (_reconnectFailCount >= 4) {
|
||||
debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
|
||||
_logger.logWithLevel('🛑 [TCP] 重连失败已达 4 次,停止重连', shouldLog: true);
|
||||
onReconnectExhausted?.call();
|
||||
return;
|
||||
}
|
||||
// 🔥 递增重连尝试次数,让下次重连等待更长时间
|
||||
_reconnectAttempt++;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
//
|
||||
void _handleDisconnect() {
|
||||
_connectionId++; // 🔥 使旧socket的所有回调立即失效,防止onDone/onError污染新连接
|
||||
debugPrint('🔌 [TCP] _handleDisconnect, connectionId→$_connectionId');
|
||||
_logger.logWithLevel('🔌 [TCP] _handleDisconnect, connectionId→$_connectionId', shouldLog: true);
|
||||
stopHeartbeat();
|
||||
|
||||
_socket = null;
|
||||
// 注意:这里不要关闭 _controller!否则监听者会丢失数据流
|
||||
// _controller?.close();
|
||||
// 🔥 连续失败达到4次,停止重连,通知上层弹窗
|
||||
if (_reconnectFailCount >= 4) {
|
||||
debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
|
||||
_logger.logWithLevel('🛑 [TCP] 重连失败已达 4 次,停止重连', shouldLog: true);
|
||||
onReconnectExhausted?.call();
|
||||
return;
|
||||
}
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -333,15 +396,16 @@ class TcpClient {
|
||||
if (isUserSwitch) return;
|
||||
if (_reconnectTimer != null) return;
|
||||
|
||||
// debugPrint('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}'); // ✅ 检查 Host/Port 是否为空
|
||||
_logger.logWithLevel('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}');
|
||||
// 🔥 重连退避:5s → 10s → 20s → 30s(max)
|
||||
final delay = [5, 10, 20, 30][_reconnectAttempt.clamp(0, 3)];
|
||||
_reconnectAttempt++;
|
||||
_logger.logWithLevel('⏳ 调度重连:第$_reconnectAttempt次,${delay}s后重连,Host=${_lastHost}, Port=${_lastPort}');
|
||||
if (_lastHost == null || _lastPort == null) {
|
||||
//debugPrint('❌ 无法重连:Host 或 Port 为空!');
|
||||
_logger.logWithLevel('❌ 无法重连:Host 或 Port 为空!', shouldLog: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
||||
_reconnectTimer = Timer(Duration(seconds: delay), () async {
|
||||
_reconnectTimer = null;
|
||||
// 🔥 关键修复:定时器触发时检查是否已有连接,避免重复创建
|
||||
if (_socket != null) {
|
||||
@@ -355,7 +419,29 @@ class TcpClient {
|
||||
return;
|
||||
}
|
||||
_logger.logWithLevel('⏰ 定时器触发,开始执行重连...', shouldLog: true);
|
||||
connect(host: _lastHost!, port: _lastPort!);
|
||||
try {
|
||||
await connect(host: _lastHost!, port: _lastPort!);
|
||||
_reconnectAttempt = 0; // 🔥 重连成功,重置退避计数
|
||||
// 🔥 重连成功后,补调 HTTP switchDevice 告知服务端推送目标设备
|
||||
try {
|
||||
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
|
||||
final targetDevice = remoteControlCubit.state.targetDevice;
|
||||
if (targetDevice != null) {
|
||||
debugPrint('🔄 [TCP-重连] 补调 HTTP switchDevice: ${targetDevice.deviceName}');
|
||||
_logger.logWithLevel('🔄 [TCP-重连] 补调 HTTP switchDevice: ${targetDevice.deviceName}', shouldLog: true);
|
||||
await switchDeviceUseCase.deviceRepository.switchDevice('app', targetDevice.deviceName);
|
||||
debugPrint('✅ [TCP-重连] HTTP switchDevice 成功');
|
||||
} else {
|
||||
debugPrint('⚠️ [TCP-重连] targetDevice 为 null,跳过 switchDevice');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [TCP-重连] HTTP switchDevice 失败: $e');
|
||||
_logger.logWithLevel('❌ [TCP-重连] HTTP switchDevice 失败: $e', shouldLog: true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [TCP-重连] connect 失败: $e');
|
||||
_logger.logWithLevel('❌ [TCP-重连] connect 失败: $e', shouldLog: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -446,19 +532,47 @@ class TcpClient {
|
||||
..addByte(0xAA)
|
||||
..addByte(0xAB);
|
||||
_socket!.add(builder.takeBytes());
|
||||
debugPrint('💓 [TCP] 发送心跳 AB AA FF AA AB');
|
||||
}
|
||||
|
||||
// 新增:启动心跳(每 4 秒发送一次 0xFF 指令)
|
||||
/// 启动心跳:主动定时发送 + 超时检测
|
||||
/// 1. 每5秒主动给服务端发心跳,防止服务端判定客户端失效
|
||||
/// 2. 15秒内没收到服务端心跳,判定连接断开并重连
|
||||
void startHeartbeat({Duration interval = const Duration(seconds: 4)}) {
|
||||
if (_heartbeatTimer != null) return; // 防止重复启动
|
||||
debugPrint('⏰ [TCP] 启动心跳定时器,间隔: ${interval.inSeconds}秒');
|
||||
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...', shouldLog: true);
|
||||
_heartbeatTimer = Timer.periodic(interval, (_) {
|
||||
// 发送心跳帧:AB AA FF AA AB
|
||||
debugPrint('💓 [TCP] 发送心跳包 0xFF');
|
||||
sendHeartbeat();
|
||||
debugPrint('⏰ [TCP] 启动心跳:主动发送(5s) + 超时检测(${_heartbeatTimeout.inSeconds}s)');
|
||||
_logger.logWithLevel('⏰ [TCP] 启动心跳:主动发送(5s) + 超时检测(${_heartbeatTimeout.inSeconds}s)', shouldLog: true);
|
||||
|
||||
// 🔥 主动定时发送心跳给服务端(每5秒)
|
||||
_heartbeatSendTimer?.cancel();
|
||||
_heartbeatSendTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (_socket != null) {
|
||||
sendHeartbeat();
|
||||
}
|
||||
});
|
||||
|
||||
// 超时检测
|
||||
_resetHeartbeatTimeout();
|
||||
}
|
||||
|
||||
/// 重置心跳超时定时器
|
||||
/// 每次收到服务端心跳(0xFF)时调用,重新开始计时
|
||||
void _resetHeartbeatTimeout() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = Timer(_heartbeatTimeout, () {
|
||||
debugPrint('⚠️ [TCP] 心跳超时 ${_heartbeatTimeout.inSeconds}s 未收到服务端心跳,判定连接断开');
|
||||
_logger.logWithLevel('⚠️ [TCP] 心跳超时,判定连接断开', shouldLog: true);
|
||||
// 触发断开重连
|
||||
if (_socket != null) {
|
||||
_socket!.destroy();
|
||||
_socket = null;
|
||||
}
|
||||
// 🔥 累计重连失败次数(心跳超时 = 本次连接无效 = 一次失败)
|
||||
_reconnectFailCount++;
|
||||
debugPrint('📊 [TCP] 重连失败计数: $_reconnectFailCount/4');
|
||||
// 🔥 统一走普通断开重连路径(_scheduleReconnect 已包含 HTTP switchDevice)
|
||||
_handleDisconnect();
|
||||
});
|
||||
debugPrint("✅ TCP Connected - 心跳已启动");
|
||||
}
|
||||
|
||||
/// 🔥 封装完整的TCP初始化方法:连接 + 认证 + 心跳
|
||||
@@ -478,6 +592,8 @@ class TcpClient {
|
||||
|
||||
_connecting = true;
|
||||
_connectionCompleter = Completer<void>();
|
||||
_connectionId++; // 🔥 新一代连接,旧socket回调自动失效
|
||||
final myConnId = _connectionId;
|
||||
|
||||
try {
|
||||
// 更新状态为连接中
|
||||
@@ -516,7 +632,7 @@ class TcpClient {
|
||||
_logger.logWithLevel('✅ [TCP] 心跳已启动', shouldLog: true);
|
||||
|
||||
// 设置数据监听(复用现有的监听逻辑)
|
||||
_setupDataListener();
|
||||
_setupDataListener(myConnId);
|
||||
|
||||
_connecting = false;
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
@@ -538,7 +654,7 @@ class TcpClient {
|
||||
}
|
||||
|
||||
/// 设置数据监听器
|
||||
void _setupDataListener() {
|
||||
void _setupDataListener(int myConnId) {
|
||||
if (_socket == null) return;
|
||||
|
||||
_socket!.listen(
|
||||
@@ -557,7 +673,9 @@ class TcpClient {
|
||||
_controller.add(packet);
|
||||
}
|
||||
if (packet.command == 0xFF) {
|
||||
debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
|
||||
sendHeartbeat();
|
||||
_resetHeartbeatTimeout(); // 重置超时计时器
|
||||
}
|
||||
if (packet.command == 0x03) {
|
||||
_logger.logWithLevel(
|
||||
@@ -574,13 +692,23 @@ class TcpClient {
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
// 🔥 检查 connectionId,防止旧socket回调污染新连接
|
||||
if (myConnId != _connectionId) {
|
||||
debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
|
||||
_logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
|
||||
return;
|
||||
}
|
||||
if (!_isSwitching) {
|
||||
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
|
||||
_logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: true);
|
||||
_handleDisconnect();
|
||||
}
|
||||
_isSwitching = false;
|
||||
},
|
||||
onError: (e) {
|
||||
if (myConnId != _connectionId) {
|
||||
debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
|
||||
return;
|
||||
}
|
||||
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
|
||||
if (!_isSwitching) {
|
||||
_handleDisconnect();
|
||||
@@ -590,10 +718,12 @@ class TcpClient {
|
||||
);
|
||||
}
|
||||
|
||||
// 新增:停止心跳
|
||||
// 停止心跳(主动发送 + 超时检测全部停止)
|
||||
void stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_heartbeatSendTimer?.cancel();
|
||||
_heartbeatSendTimer = null;
|
||||
}
|
||||
|
||||
// void disconnect() {
|
||||
@@ -608,7 +738,8 @@ class TcpClient {
|
||||
//
|
||||
// }
|
||||
void disconnect() {
|
||||
debugPrint('🛑 [TCP] 主动断开连接...');
|
||||
_connectionId++; // 🔥 使旧socket回调失效
|
||||
debugPrint('🛑 [TCP] 主动断开连接... connectionId→$_connectionId');
|
||||
_logger.logWithLevel('🛑 [TCP] 主动断开连接...');
|
||||
|
||||
// 1. 停止心跳
|
||||
@@ -629,8 +760,8 @@ class TcpClient {
|
||||
_isSwitching = false;
|
||||
_connecting = false;
|
||||
_connectionCompleter = null;
|
||||
|
||||
// 5. 销毁 Socket
|
||||
_reconnectAttempt = 0; // 🔥 重置重连退避
|
||||
_reconnectFailCount = 0; // 🔥 重置重连失败计数
|
||||
if (_socket != null) {
|
||||
_socket!.destroy();
|
||||
_socket = null;
|
||||
@@ -736,7 +867,7 @@ class TcpClient {
|
||||
|
||||
_lastAuthPacketSentAt = DateTime.now(); // 🔥 记录发送 0x03 的时间
|
||||
_socket!.add(builder.takeBytes());
|
||||
//debugPrint('🔑 [TCP] 已发送认证包 (0x03): $authString');
|
||||
await _socket!.flush(); // 🔥 关键修复:强制 flush,确保认证包立即到达服务端
|
||||
_logger.logWithLevel('🔑 [TCP] 已发送认证包 (0x03): $authString');
|
||||
|
||||
// 🔥 关键修复:移除登录时自动获取设备列表和切换设备的逻辑
|
||||
@@ -850,7 +981,9 @@ class TcpClient {
|
||||
|
||||
isUserSwitch = true;
|
||||
_connecting = true; // 🔥 防止 connect() 并发创建新连接
|
||||
debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建');
|
||||
_connectionId++; // 🔥 新一代连接,旧socket回调自动失效
|
||||
final myConnId = _connectionId;
|
||||
debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建, connectionId=$myConnId');
|
||||
|
||||
// debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条
|
||||
_logger.logWithLevel('🔌被动 [TCP] 开始连接:$host:$port');
|
||||
@@ -908,10 +1041,9 @@ class TcpClient {
|
||||
);
|
||||
}
|
||||
if (packet.command == 0xFF) {
|
||||
//debugPrint('收到服务端心跳,自动回复...');
|
||||
_logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...');
|
||||
//
|
||||
debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
|
||||
sendHeartbeat(); // 回复 AB AA FF AA AB
|
||||
_resetHeartbeatTimeout(); // 重置超时计时器
|
||||
}
|
||||
if (packet.command == 0x03) {
|
||||
//debugPrint('⚠️ 收到认证响应:${packet.payload}');
|
||||
@@ -925,18 +1057,28 @@ class TcpClient {
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
// 🔥 检查 connectionId,防止旧socket回调污染新连接
|
||||
if (myConnId != _connectionId) {
|
||||
debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
|
||||
_logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
|
||||
return;
|
||||
}
|
||||
debugPrint('onDone [TCP] 被动连接已断开!_isSwitching=$_isSwitching');
|
||||
if (!_isSwitching) {
|
||||
_handleDisconnectBySwitch(deviceName);
|
||||
_handleDisconnect();
|
||||
} else {
|
||||
debugPrint('🚫 [TCP] 切换过程中的断开,忽略重连调度');
|
||||
_isSwitching = false;
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
if (myConnId != _connectionId) {
|
||||
debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
|
||||
return;
|
||||
}
|
||||
_logger.logWithLevel('❌ 被动[TCP] 发生错误:$e');
|
||||
if (!_isSwitching) {
|
||||
_handleDisconnectBySwitch(deviceName);
|
||||
_handleDisconnect();
|
||||
} else {
|
||||
debugPrint('🚫 [TCP] 切换过程中的错误,忽略重连调度');
|
||||
_isSwitching = false;
|
||||
@@ -951,8 +1093,9 @@ class TcpClient {
|
||||
rethrow; // 向上抛出连接异常
|
||||
}
|
||||
|
||||
// 🔥 关键修复:只有在所有操作成功后才重置 _connecting
|
||||
// 🔥 重置 _connecting 和 isUserSwitch
|
||||
_connecting = false;
|
||||
isUserSwitch = false; // 🔥 关键:切换完成后重置,后续超时走普通重连路径
|
||||
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
|
||||
_connectionCompleter!.complete();
|
||||
debugPrint('✅ [TCP-connectBySwitch] 连接就绪,通知等待方');
|
||||
@@ -1029,6 +1172,9 @@ class TcpClient {
|
||||
}
|
||||
|
||||
void _handleDisconnectBySwitch(String deviceName) {
|
||||
_connectionId++; // 🔥 使旧socket的所有回调立即失效
|
||||
debugPrint('🔌 [TCP] _handleDisconnectBySwitch, connectionId→$_connectionId');
|
||||
_logger.logWithLevel('🔌 [TCP] _handleDisconnectBySwitch, connectionId→$_connectionId', shouldLog: true);
|
||||
stopHeartbeat();
|
||||
|
||||
_socket = null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:maibu_satabot_v2/features/home/presentation/routes/home_routes.d
|
||||
import 'package:maibu_satabot_v2/features/main_container/presentation/pages/tab_settings_page.dart';
|
||||
import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/routes/alarm_center_routes.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/workorder/presentation/routes/workorder_routes.dart';
|
||||
|
||||
import '../../features/auth/presentation/bloc/auth_cubit.dart';
|
||||
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||
@@ -20,11 +21,13 @@ import 'go_router_refresh_stream.dart';
|
||||
GoRouter createRouter(AuthCubit authCubit) {
|
||||
// 调试:打印所有路由
|
||||
debugPrint('🔍 [Router] 开始创建路由配置...');
|
||||
debugPrint('🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}');
|
||||
debugPrint(
|
||||
'🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}',
|
||||
);
|
||||
for (var route in AlarmCenterRoutes.routes) {
|
||||
debugPrint('🔍 [Router] 路由: ${route.toString()}');
|
||||
}
|
||||
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
|
||||
initialLocation: RoutePaths.login,
|
||||
@@ -74,6 +77,7 @@ GoRouter createRouter(AuthCubit authCubit) {
|
||||
...AiRoutes.routes,
|
||||
...MyRoutes.routes,
|
||||
...AlarmCenterRoutes.routes,
|
||||
...WorkOrderRoutes.routes,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,4 +20,8 @@ class RoutePaths {
|
||||
static const settings = '/my/settings';
|
||||
|
||||
static const machineDetails = '/machine_details/machine_details';
|
||||
|
||||
/// 工单相关页面
|
||||
static const workOrder = '/workorder';
|
||||
static const workOrderDetail = '/workorder/detail/:orderId';
|
||||
}
|
||||
|
||||
69
lib/core/services/device_permission_service.dart
Normal file
69
lib/core/services/device_permission_service.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../consts/http_api_consts.dart';
|
||||
|
||||
/// 设备操作权限校验服务
|
||||
/// 接口: GET /iot/device/hasPermission?deviceId=xxx
|
||||
/// 通过条件: code==200 且 data==true,缺一不可
|
||||
/// 安全原则: 仅明确收到 code=200 && data=true 才放行,其余所有情况一律阻止
|
||||
class DevicePermissionService {
|
||||
final Dio _dio;
|
||||
|
||||
DevicePermissionService(this._dio);
|
||||
|
||||
/// 校验当前用户是否有权操作指定设备
|
||||
/// 返回 true 表示有权限,false 表示无权限或校验失败
|
||||
Future<bool> checkPermission(String deviceId) async {
|
||||
debugPrint('🔐 [权限校验] 开始校验 - deviceId: $deviceId');
|
||||
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.hasPermission,
|
||||
queryParameters: {'deviceId': deviceId},
|
||||
);
|
||||
|
||||
debugPrint('🔐 [权限校验] HTTP响应 - statusCode: ${response.statusCode}');
|
||||
|
||||
// HTTP 层面必须 200
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('🔐 [权限校验] ❌ HTTP状态码非200: ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
|
||||
final body = response.data;
|
||||
debugPrint('🔐 [权限校验] 响应体: $body');
|
||||
|
||||
// body 必须是 Map
|
||||
if (body is! Map<String, dynamic>) {
|
||||
debugPrint('🔐 [权限校验] ❌ 响应体格式异常,非Map类型');
|
||||
return false;
|
||||
}
|
||||
|
||||
final code = body['code'];
|
||||
final data = body['data'];
|
||||
|
||||
debugPrint('🔐 [权限校验] code=$code (type: ${code.runtimeType}), data=$data (type: ${data.runtimeType})');
|
||||
|
||||
// code 必须是 200(兼容 int 和 String)
|
||||
final codeMatch = code == 200 || code.toString() == '200';
|
||||
// data 必须是 true
|
||||
final dataMatch = data == true || data.toString() == 'true';
|
||||
|
||||
if (codeMatch && dataMatch) {
|
||||
debugPrint('🔐 [权限校验] ✅ 校验通过 - code=200, data=true');
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint('🔐 [权限校验] ❌ 校验未通过 - codeMatch=$codeMatch, dataMatch=$dataMatch');
|
||||
return false;
|
||||
} on DioException catch (e) {
|
||||
// 网络异常(断网、超时、服务器不可达等)
|
||||
debugPrint('🔐 [权限校验] ❌ DioException: ${e.type} - ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
// 任何未知异常
|
||||
debugPrint('🔐 [权限校验] ❌ 未知异常: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,22 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
super.avatar,
|
||||
super.email,
|
||||
super.phone,
|
||||
super.roleKey,
|
||||
super.siteId,
|
||||
});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
print(json);
|
||||
return UserModel(
|
||||
userId: json['userId'],
|
||||
username: json['username'],
|
||||
nickname: json['nickName'],
|
||||
token: json['token'],
|
||||
orgId: json['orgId'] ?? 0, // 从登录响应中获取 orgId
|
||||
userId: json['userId']?.toString() ?? '',
|
||||
username: json['username'] ?? '',
|
||||
nickname: json['nickName'] ?? '',
|
||||
token: json['token'] ?? '',
|
||||
orgId: (json['orgId'] as num?)?.toInt() ?? 0,
|
||||
avatar: json['avatar'],
|
||||
email: json['email'],
|
||||
phone: json['phone'],
|
||||
roleKey: json['roleKey'],
|
||||
siteId: (json['siteId'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +41,8 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
'avatar': avatar,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'roleKey': roleKey,
|
||||
'siteId': siteId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +56,8 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
avatar: avatar,
|
||||
email: email,
|
||||
phone: phone,
|
||||
roleKey: roleKey,
|
||||
siteId: siteId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +71,8 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
avatar: entity.avatar,
|
||||
email: entity.email,
|
||||
phone: entity.phone,
|
||||
roleKey: entity.roleKey,
|
||||
siteId: entity.siteId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
|
||||
|
||||
@@ -20,6 +23,11 @@ import '../../../devices/presentation/bloc/devices_cubit.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_event.dart';
|
||||
import '../../../devices/presentation/bloc/devices_state.dart';
|
||||
import '../../../devices/presentation/bloc/device_task_cubit.dart';
|
||||
import '../../../home/presentation/bloc/permission_request_bloc.dart';
|
||||
import '../../../my/presentation/bloc/my_cubit.dart';
|
||||
import '../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../../../core/network/mqtt/domain/interfaces/mqtt_client.dart';
|
||||
import '../../../../features/v2/site/presentation/cubit/site_cubit.dart';
|
||||
import '../../data/datasources/auth_tcp_datasource.dart';
|
||||
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
|
||||
@@ -39,7 +47,7 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
final ILoggerService _logger = GetIt.I<ILoggerService>();
|
||||
|
||||
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
|
||||
|
||||
|
||||
// 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送
|
||||
Completer<bool>? _loginVerificationCompleter;
|
||||
|
||||
@@ -56,11 +64,57 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
) : super(AuthInitial()) {
|
||||
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
|
||||
_listenToAuthResponse();
|
||||
// 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录
|
||||
tcp.onReconnectExhausted = _showReconnectFailedDialog;
|
||||
}
|
||||
|
||||
/// App 启动时检查本地缓存
|
||||
Future<void> appStarted() async {
|
||||
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
|
||||
|
||||
try {
|
||||
final prefs = GetIt.I<SharedPreferences>();
|
||||
|
||||
// Step 1: 先生成新的 session_id,保存旧的
|
||||
final oldSessionId = prefs.getString('current_session_id');
|
||||
final newSessionId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
await prefs.setString('current_session_id', newSessionId);
|
||||
debugPrint('📱 [AUTH] 新会话: $newSessionId,旧会话: $oldSessionId');
|
||||
|
||||
// Step 2: 检查是否从后台被杀
|
||||
final pendingKillLogout = prefs.getBool('pending_kill_logout') ?? false;
|
||||
final savedSessionId = prefs.getString('saved_session_id');
|
||||
|
||||
if (pendingKillLogout && savedSessionId != null && oldSessionId != null) {
|
||||
if (savedSessionId == oldSessionId) {
|
||||
// saved == old_current → App 在后台被杀,从未恢复过
|
||||
logger.logWithLevel('🔄 [AUTH] 检测到 APP 被后台杀死,执行退出登录', level: 'INFO');
|
||||
await prefs.setBool('pending_kill_logout', false);
|
||||
await prefs.remove('saved_session_id');
|
||||
await storage.deleteUser();
|
||||
emit(AuthUnauthenticated());
|
||||
return;
|
||||
} else {
|
||||
// saved != old_current → App 被杀前已恢复过,清除标记
|
||||
logger.logWithLevel(
|
||||
'📱 [AUTH] pending_kill_logout=true 但会话已恢复过,清除标记',
|
||||
level: 'INFO',
|
||||
);
|
||||
await prefs.setBool('pending_kill_logout', false);
|
||||
await prefs.remove('saved_session_id');
|
||||
}
|
||||
} else if (pendingKillLogout) {
|
||||
logger.logWithLevel(
|
||||
'📱 [AUTH] pending_kill_logout=true 但无会话信息,清除标记',
|
||||
level: 'INFO',
|
||||
);
|
||||
await prefs.setBool('pending_kill_logout', false);
|
||||
await prefs.remove('saved_session_id');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.logWithLevel('❌ [AUTH] 检查杀后台标记失败: $e', level: 'ERROR');
|
||||
}
|
||||
|
||||
try {
|
||||
final user = await storage.getUser();
|
||||
logger.logWithLevel(
|
||||
@@ -70,14 +124,21 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
);
|
||||
|
||||
if (user != null) {
|
||||
// 🔥 冷启动时不创建TCP连接,改为用户选择设备时再连接
|
||||
// 避免发送 0x03 触发服务端残留 session 的 have_logged_in
|
||||
|
||||
// 2. 同步全局 App 状态
|
||||
appCubit.setAuth(user);
|
||||
// 3. 进入已登录状态
|
||||
emit(AuthAuthenticated(user));
|
||||
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
|
||||
final isValid = await _verifyToken(user);
|
||||
if (isValid) {
|
||||
logger.logWithLevel('✅ [AUTH] Token 有效,恢复登录状态', level: 'INFO');
|
||||
appCubit.setAuth(user);
|
||||
emit(AuthAuthenticated(user));
|
||||
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
|
||||
} else {
|
||||
logger.logWithLevel('⚠️ [AUTH] Token 已过期,清除本地缓存', level: 'WARN');
|
||||
await storage.deleteUser();
|
||||
emit(AuthUnauthenticated());
|
||||
logger.logWithLevel(
|
||||
'⚠️ [AUTH] 应用启动 - Token 过期,进入未登录状态',
|
||||
level: 'INFO',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
|
||||
emit(AuthUnauthenticated());
|
||||
@@ -88,6 +149,42 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _verifyToken(UserEntity user) async {
|
||||
try {
|
||||
final verifyDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: HttpApiConsts.baseUrl,
|
||||
headers: {'Authorization': 'Bearer ${user.token}'},
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
final response = await verifyDio.get(
|
||||
HttpApiConsts.getUserDevicesList,
|
||||
queryParameters: {'tenantName': user.username},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic> && data['code'] == 401) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} on DioException catch (e) {
|
||||
final statusCode = e.response?.statusCode;
|
||||
debugPrint('>>> [AUTH] Token 验证失败: HTTP $statusCode');
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('>>> [AUTH] Token 验证异常: $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 当 HTTP 登录/注册成功后调用
|
||||
Future<void> loginSuccess(UserEntity user) async {
|
||||
await storage.saveUser(user);
|
||||
@@ -96,33 +193,33 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
try {
|
||||
debugPrint('>>> [AUTH] 登录成功,开始建立TCP连接...');
|
||||
_logger.logWithLevel('>>> [AUTH] 登录成功,开始建立TCP连接...', shouldLog: true);
|
||||
|
||||
|
||||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
||||
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
||||
|
||||
|
||||
// 🔥 关键:等待 2.5 秒,看是否收到 have_logged_in
|
||||
bool isKicked = await _waitForLoginVerification();
|
||||
|
||||
|
||||
if (isKicked) {
|
||||
// 🔥 不能进入 APP,必须清除所有登录信息
|
||||
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息...');
|
||||
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息', shouldLog: true);
|
||||
|
||||
|
||||
// 1. 删除已保存的用户信息
|
||||
await storage.deleteUser();
|
||||
debugPrint('✅ [AUTH] 已删除用户信息');
|
||||
|
||||
|
||||
// 2. 断开 TCP 连接
|
||||
tcp.forceDisconnect();
|
||||
debugPrint('✅ [AUTH] 已断开 TCP 连接');
|
||||
|
||||
|
||||
// 3. 显示 Toast
|
||||
_showLoginFailedToast("账号已在其他设备登录");
|
||||
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页');
|
||||
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页', shouldLog: true);
|
||||
return; // 停留在登录页
|
||||
return; // 停留在登录页
|
||||
}
|
||||
|
||||
|
||||
debugPrint('✅ [AUTH] TCP连接成功,验证通过');
|
||||
_logger.logWithLevel('✅ [AUTH] TCP连接成功,验证通过', shouldLog: true);
|
||||
} catch (e) {
|
||||
@@ -149,6 +246,42 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
|
||||
/// 🔥 Token 过期处理:弹出提示后退出登录
|
||||
Future<void> tokenExpired() async {
|
||||
_showTokenExpiredDialog();
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
logout();
|
||||
});
|
||||
}
|
||||
|
||||
void _showTokenExpiredDialog() {
|
||||
try {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('登录已过期'),
|
||||
content: const Text('账号登录状态已过期,请重新登录'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('>>> [AUTH] ❌ 显示 Token 过期弹窗失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 新增:显示登录失败 Toast(黑色背景,和登录错误提示一致)
|
||||
void _showLoginFailedToast(String message) {
|
||||
try {
|
||||
@@ -173,7 +306,7 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
try {
|
||||
debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗');
|
||||
_logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true);
|
||||
|
||||
|
||||
// 使用全局 navigatorKey 显示弹窗
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
@@ -190,9 +323,12 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
);
|
||||
} else {
|
||||
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出');
|
||||
_logger.logWithLevel('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出', shouldLog: true);
|
||||
_logger.logWithLevel(
|
||||
'>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出',
|
||||
shouldLog: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 🔥 延迟 2 秒后执行退出,给用户时间看到提示
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
debugPrint('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录');
|
||||
@@ -207,27 +343,142 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 TCP 重连耗尽弹窗:连续4次重连失败后提示用户退出登录
|
||||
void _showReconnectFailedDialog() {
|
||||
try {
|
||||
debugPrint('>>> [AUTH] 📢 重连耗尽,准备显示重连失败提示弹窗');
|
||||
_logger.logWithLevel('>>> [AUTH] 📢 重连耗尽,显示重连失败弹窗', shouldLog: true);
|
||||
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 48, color: Color(0xFFF53F3F)),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'连接异常',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'TCP重连认证无效请重新登陆',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
logout();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('退出登录', style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,直接退出登录');
|
||||
logout();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('>>> [AUTH] ❌ 显示重连失败弹窗失败:$e');
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 新增:清空所有业务状态,防止数据泄露到新账户
|
||||
void _clearAllBusinessState() {
|
||||
try {
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
||||
final siteCubit = GetIt.I<SiteCubit>();
|
||||
// 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等)
|
||||
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
|
||||
remoteControlCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态');
|
||||
|
||||
// 1. 清空设备列表和选中设备
|
||||
// 2. 清空设备列表和选中设备
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
devicesCubit.emit(const DevicesState());
|
||||
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||||
|
||||
// 2. 清空设备实时状态
|
||||
// 3. 清空设备实时状态(图表数据等)
|
||||
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
||||
deviceStatusBloc.add(DeviceStatusReset());
|
||||
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||||
|
||||
// 3. 清空全局选中的场站
|
||||
siteCubit.clearSelectedSite();
|
||||
debugPrint('✅ [AUTH] 已清空 SiteCubit 选中状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 选中状态');
|
||||
// 4. 清空场站所有数据(列表、选中状态、_hasLoadedSites 标记)
|
||||
final siteCubit = GetIt.I<SiteCubit>();
|
||||
siteCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 SiteCubit 所有数据');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 所有数据');
|
||||
|
||||
// 5. 清空设备任务(taskPool、currentTask、currentTaskId 等)
|
||||
final deviceTaskCubit = GetIt.I<DeviceTaskCubit>();
|
||||
deviceTaskCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
|
||||
|
||||
// 6. 清空权限请求弹窗状态
|
||||
final permissionRequestBloc = GetIt.I<PermissionRequestBloc>();
|
||||
permissionRequestBloc.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
|
||||
|
||||
// 7. 清空我的页面数据(昵称等个人信息)
|
||||
final myCubit = GetIt.I<MyCubit>();
|
||||
myCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 MyCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 MyCubit 状态');
|
||||
|
||||
// 8. 断开 MQTT 连接(避免新用户收到上个用户的实时推送)
|
||||
try {
|
||||
final droneOsdClient = GetIt.I<MqttClient>(instanceName: 'droneOsdClient');
|
||||
if (droneOsdClient.isConnected) {
|
||||
droneOsdClient.disconnect();
|
||||
debugPrint('✅ [AUTH] 已断开 droneOsdClient MQTT');
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
final taskMessageClient = GetIt.I<MqttClient>(instanceName: 'taskMessageClient');
|
||||
if (taskMessageClient.isConnected) {
|
||||
taskMessageClient.disconnect();
|
||||
debugPrint('✅ [AUTH] 已断开 taskMessageClient MQTT');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 9. 清除 SharedPreferences 会话相关 key
|
||||
final prefs = GetIt.I<SharedPreferences>();
|
||||
prefs.remove('current_session_id');
|
||||
prefs.remove('pending_kill_logout');
|
||||
prefs.remove('saved_session_id');
|
||||
debugPrint('✅ [AUTH] 已清除 SharedPreferences 会话 key');
|
||||
|
||||
debugPrint('✅ [AUTH] 所有业务状态已清空');
|
||||
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
|
||||
@@ -240,15 +491,15 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
|
||||
Future<bool> _waitForLoginVerification() async {
|
||||
_loginVerificationCompleter = Completer<bool>();
|
||||
|
||||
|
||||
// 等待 2.5 秒
|
||||
await Future.delayed(const Duration(milliseconds: 2500));
|
||||
|
||||
|
||||
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
|
||||
if (!_loginVerificationCompleter!.isCompleted) {
|
||||
_loginVerificationCompleter!.complete(false);
|
||||
}
|
||||
|
||||
|
||||
return _loginVerificationCompleter!.future;
|
||||
}
|
||||
|
||||
@@ -290,35 +541,46 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
|
||||
if (respond == 'have_logged_in') {
|
||||
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
|
||||
if (_loginVerificationCompleter != null && !_loginVerificationCompleter!.isCompleted) {
|
||||
debugPrint('>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP');
|
||||
if (_loginVerificationCompleter != null &&
|
||||
!_loginVerificationCompleter!.isCompleted) {
|
||||
debugPrint(
|
||||
'>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP',
|
||||
);
|
||||
_logger.logWithLevel(
|
||||
'🛡️ [AUTH] 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入',
|
||||
shouldLog: true,
|
||||
);
|
||||
_loginVerificationCompleter!.complete(false); // 标记为可以进入
|
||||
_loginVerificationCompleter!.complete(false); // 标记为可以进入
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 🔥 已登录阶段策略:检查是否处于安全模式
|
||||
if (_isSafeMode) {
|
||||
print('>>> [AUTH] 🛡️ 安全模式下拦截 have_logged_in 推送,防止控制中断');
|
||||
_logger.logWithLevel(
|
||||
'🛡️ [AUTH] 安全模式下拦截异地登录推送',
|
||||
level: 'WARN',
|
||||
);
|
||||
_logger.logWithLevel('🛡️ [AUTH] 安全模式下拦截异地登录推送', level: 'WARN');
|
||||
return; // 拦截退出逻辑
|
||||
}
|
||||
|
||||
|
||||
print('>>> [AUTH] ⚠️ 已登录状态下收到 TCP 0x12 指令 respond=have_logged_in');
|
||||
debugPrint('>>> [AUTH] ℹ️ 暂时忽略该推送,观察是否影响业务操作...');
|
||||
_logger.logWithLevel(
|
||||
'⚠️ [AUTH] 已登录状态收到 have_logged_in,暂不处理,观察业务影响',
|
||||
'⚠️ [AUTH] 已登录状态收到 have_logged_in,弹出异地登录提示',
|
||||
level: 'WARN',
|
||||
);
|
||||
|
||||
// 如果你希望依然保持严格的安全策略,可以取消下面注释恢复退出逻辑:
|
||||
// _showKickOutDialog();
|
||||
|
||||
// 🔥 关键:检查是否是自身 TCP 重连触发的 have_logged_in
|
||||
// 如果是自身刚发送 0x03 认证包引起的,忽略这次推送
|
||||
if (tcp.isOwnAuthTriggeredKick()) {
|
||||
debugPrint('>>> [AUTH] 🛡️ 检测到是自身认证触发的 have_logged_in,忽略');
|
||||
_logger.logWithLevel(
|
||||
'🛡️ [AUTH] 自身认证触发的 have_logged_in,忽略',
|
||||
level: 'INFO',
|
||||
);
|
||||
tcp.clearAuthTimestamp();
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 弹出"账号被顶下线"提示,2秒后执行退出登录
|
||||
_showKickOutDialog();
|
||||
} else {
|
||||
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
|
||||
}
|
||||
|
||||
@@ -28,15 +28,16 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
// if (user != null) {
|
||||
// devicesCubit.fetchAllDevices(user.username);
|
||||
// }
|
||||
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
|
||||
result.fold((failure) => emit(LoginFailure(failure.message)), (user) async {
|
||||
// 先更新全局用户状态(确保首页能获取到 token)
|
||||
authCubit.appCubit.setAuth(user);
|
||||
// 发出登录成功状态(触发页面跳转)
|
||||
emit(LoginSuccess(user));
|
||||
// 后台异步执行 TCP 连接等初始化操作(不阻塞登录流程)
|
||||
authCubit.loginSuccess(user).catchError((e) {
|
||||
// 🔥 先等待 TCP 连接和认证完成,确保 AuthCubit 变为 AuthAuthenticated
|
||||
// 再触发页面导航,避免 GoRouter 拦截踢回登录页
|
||||
await authCubit.loginSuccess(user).catchError((e) {
|
||||
print('TCP 初始化失败: $e');
|
||||
});
|
||||
// 发出登录成功状态(触发页面跳转)
|
||||
emit(LoginSuccess(user));
|
||||
});
|
||||
} catch (e) {
|
||||
emit(LoginFailure(e.toString()));
|
||||
|
||||
@@ -52,9 +52,11 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
}) async {
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/deviceTaskPool';
|
||||
final response = await dio.get(
|
||||
_logger.logWithLevel('[getDeviceTaskPool] 请求: POST $url');
|
||||
_logger.logWithLevel('[getDeviceTaskPool] 参数: userId=$userId, siteId=$siteId, orgId=$orgId');
|
||||
final response = await dio.post(
|
||||
url,
|
||||
queryParameters: {
|
||||
data: {
|
||||
'userId': userId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
@@ -89,30 +91,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
};
|
||||
_logger.logWithLevel('[cancelTask] 请求: POST $url');
|
||||
_logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}');
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
|
||||
final response = await dio.post(
|
||||
url,
|
||||
data: {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
},
|
||||
);
|
||||
final response = await dio.post(url, data: body);
|
||||
_logger.logWithLevel('[cancelTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] == 200) {
|
||||
_logger.logWithLevel('[cancelTask] 结果: 成功');
|
||||
return data['data'] as bool? ?? false;
|
||||
} else {
|
||||
_logger.logWithLevel('[cancelTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
|
||||
throw Exception(data['msg'] ?? '取消任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 取消任务失败: $e');
|
||||
_logger.logWithLevel('[cancelTask] 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -124,30 +129,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
};
|
||||
_logger.logWithLevel('[pauseTask] 请求: POST $url');
|
||||
_logger.logWithLevel('[pauseTask] 参数: ${jsonEncode(body)}');
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
|
||||
final response = await dio.post(
|
||||
url,
|
||||
data: {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
},
|
||||
);
|
||||
final response = await dio.post(url, data: body);
|
||||
_logger.logWithLevel('[pauseTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] == 200) {
|
||||
_logger.logWithLevel('[pauseTask] 结果: 成功');
|
||||
return data['data'] as bool? ?? false;
|
||||
} else {
|
||||
_logger.logWithLevel('[pauseTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
|
||||
throw Exception(data['msg'] ?? '暂停任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 暂停任务失败: $e');
|
||||
_logger.logWithLevel('[pauseTask] 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -159,30 +167,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
};
|
||||
_logger.logWithLevel('[recoveryTask] 请求: POST $url');
|
||||
_logger.logWithLevel('[recoveryTask] 参数: ${jsonEncode(body)}');
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
|
||||
final response = await dio.post(
|
||||
url,
|
||||
data: {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
},
|
||||
);
|
||||
final response = await dio.post(url, data: body);
|
||||
_logger.logWithLevel('[recoveryTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] == 200) {
|
||||
_logger.logWithLevel('[recoveryTask] 结果: 成功, data=${jsonEncode(data['data'])}');
|
||||
return data['data'] as Map<String, dynamic>? ?? {};
|
||||
} else {
|
||||
_logger.logWithLevel('[recoveryTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
|
||||
throw Exception(data['msg'] ?? '恢复任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 恢复任务失败: $e');
|
||||
_logger.logWithLevel('[recoveryTask] 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dar
|
||||
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/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/network/net_message_dispatcher.dart';
|
||||
@@ -27,6 +28,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 保存订阅引用,用于管理生命周期
|
||||
StreamSubscription? _tcpSubscription;
|
||||
StreamSubscription? _mqttArriveSubscription;
|
||||
StreamSubscription? _mqttStatusSubscription;
|
||||
|
||||
// 🔥 节流相关:500ms节流控制0x02数据推送频率
|
||||
Timer? _throttleTimer;
|
||||
@@ -34,6 +36,9 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
RunningStatusEntity? _cachedStatus;
|
||||
GPSEntity? _cachedGps;
|
||||
|
||||
// 🔥 调试计数器:跟踪0x02收包序号,排查断断续续问题
|
||||
int _packetSeq = 0;
|
||||
|
||||
// 🔥 当前监听的设备ID
|
||||
String? _currentDeviceId;
|
||||
|
||||
@@ -46,6 +51,9 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 初始化MQTT到达点监听
|
||||
_initMqttArriveListener();
|
||||
|
||||
// 🔥 初始化MQTT任务状态监听(接收完成推送)
|
||||
_initMqttStatusListener();
|
||||
|
||||
// 保留事件处理(用于手动重置等场景)
|
||||
on<DeviceStatusReset>(_handleReset);
|
||||
on<DeviceStatusLoaded>(_handleDeviceStatusLoaded);
|
||||
@@ -89,23 +97,27 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 关键修复:直接监听 tcpClient.packetStream,不经过 dispatcher 的 filtered stream
|
||||
// 这样即使没有其他监听者,TCP流也不会暂停
|
||||
_tcpSubscription = tcpClient.packetStream
|
||||
.where((p) => p.command == 0x02)
|
||||
.where((p) {
|
||||
final is02 = p.command == 0x02;
|
||||
if (is02) {
|
||||
_packetSeq++;
|
||||
debugPrint('📥 [0x02] #$_packetSeq 收到原始TCP包 | payload长度=${p.payload.length} | ${DateTime.now().toString().substring(11, 19)}');
|
||||
}
|
||||
return is02;
|
||||
})
|
||||
.map((p) {
|
||||
try {
|
||||
final result = utf8.decode(p.payload, allowMalformed: true);
|
||||
// debugPrint('✅ [DeviceStatusBloc] 收到0x02数据: $result');
|
||||
return result;
|
||||
} catch (e) {
|
||||
// debugPrint('❌ [DeviceStatusBloc] 解码失败: $e');
|
||||
debugPrint('❌ [0x02] #$_packetSeq 解码失败: $e, payload前20字节=${p.payload.take(20).toList()}');
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.listen(
|
||||
(message) {
|
||||
//debugPrint('📩 [DeviceStatusBloc] 直接收到0x02数据,长度:${message.length}');
|
||||
|
||||
if (message.isEmpty) {
|
||||
//debugPrint('⚠️ [DeviceStatusBloc] 消息为空,跳过');
|
||||
debugPrint('⚠️ [0x02] #$_packetSeq 解码后为空,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +126,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
final fields = message.trim().split(',');
|
||||
|
||||
if (fields.length < 18) {
|
||||
//debugPrint('⚠️ [DeviceStatusBloc] 字段不足:${fields.length},期望≥18');
|
||||
debugPrint('⚠️ [0x02] #$_packetSeq 字段不足:${fields.length},期望≥18, 原始数据前100字符=${message.substring(0, message.length > 100 ? 100 : message.length)}');
|
||||
// 🔥 错误不节流,立即emit以便UI显示错误
|
||||
if (!isClosed) {
|
||||
emit(DeviceStatusError('字段不足,期望≥18,实际:${fields.length}'));
|
||||
@@ -125,6 +137,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
final status = RunningStatusEntity.fromFields(fields);
|
||||
final gps = GPSEntity(status.latitude, status.longitude);
|
||||
|
||||
debugPrint('✅ [0x02] #$_packetSeq 解析成功 | 字段数=${fields.length} | 电压=${status.voltage}V 电量=${status.battery}% 控制模式=${status.controlMode} | 节流等待${_throttleDuration.inMilliseconds}ms');
|
||||
|
||||
// 🔥 缓存最新数据用于节流发射
|
||||
_cachedStatus = RunningStatusEntity(
|
||||
voltage: status.voltage,
|
||||
@@ -154,13 +168,14 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
);
|
||||
_cachedGps = GPSEntity(status.latitude, status.longitude);
|
||||
|
||||
// 🔥 节流:取消之前的timer,重新计时500ms
|
||||
_throttleTimer?.cancel();
|
||||
_throttleTimer = Timer(_throttleDuration, () {
|
||||
_emitCachedStatus();
|
||||
});
|
||||
// 🔥 节流:如果定时器已在运行,只更新缓存不重置;否则启动新的500ms节流周期
|
||||
if (_throttleTimer == null || !_throttleTimer!.isActive) {
|
||||
_throttleTimer = Timer(_throttleDuration, () {
|
||||
_emitCachedStatus();
|
||||
});
|
||||
}
|
||||
} catch (e, stack) {
|
||||
//debugPrint('❌ [DeviceStatusBloc] 直接解析异常:$e\n$stack');
|
||||
debugPrint('❌ [0x02] #$_packetSeq 解析异常:$e');
|
||||
// _logger.log('❌ [DeviceStatusBloc] 直接解析异常:$e');
|
||||
// 🔥 解析错误不节流,立即emit以便UI显示错误
|
||||
if (!isClosed) {
|
||||
@@ -225,6 +240,60 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
_logger.log('✅ [DeviceStatusBloc] MQTT到达点监听器已建立完成');
|
||||
}
|
||||
|
||||
// 🔥 初始化MQTT任务状态监听(接收 task/+/status 完成推送)
|
||||
void _initMqttStatusListener() {
|
||||
debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
|
||||
_logger.log('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
|
||||
|
||||
_mqttStatusSubscription = _taskMessageRepo.taskStatusStream.listen(
|
||||
(TaskStatusEntity status) {
|
||||
debugPrint(
|
||||
'📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
|
||||
);
|
||||
_logger.log(
|
||||
'📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
|
||||
);
|
||||
|
||||
// 检查设备ID是否匹配当前监听的设备
|
||||
if (_currentDeviceId != null && status.deviceId != _currentDeviceId) {
|
||||
debugPrint(
|
||||
'⚠️ [DeviceStatusBloc] 任务状态设备ID不匹配,跳过 - 当前:$_currentDeviceId, 收到:${status.deviceId}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 状态为 FINISH 表示任务完成
|
||||
if (status.status == 'FINISH') {
|
||||
debugPrint('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
|
||||
_logger.log('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
|
||||
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
devicesCubit.finishWork();
|
||||
|
||||
// 🔥 无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
|
||||
if (!isClosed) {
|
||||
emit(DeviceStatusUpdated(
|
||||
_cachedStatus ?? RunningStatusEntity(),
|
||||
_cachedGps ?? GPSEntity(0.0, 0.0),
|
||||
));
|
||||
debugPrint('📤 [DeviceStatusBloc] 已 emit 完成信号,触发 UI 更新');
|
||||
}
|
||||
|
||||
Future.delayed(Duration(seconds: 1), () {
|
||||
devicesCubit.resetWorkStatus();
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
|
||||
_logger.log('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
|
||||
_logger.log('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
|
||||
}
|
||||
|
||||
// 🔥 设置当前监听的设备ID(用于过滤MQTT消息)
|
||||
void setListeningDeviceId(String deviceId) {
|
||||
debugPrint('📱 [DeviceStatusBloc] 设置监听设备ID: $deviceId');
|
||||
@@ -235,7 +304,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 节流发射:500ms到期后发射缓存的最新数据
|
||||
void _emitCachedStatus() {
|
||||
if (_cachedStatus != null && _cachedGps != null && !isClosed) {
|
||||
// debugPrint('📤 [DeviceStatusBloc] 🔥节流发射 - 电压:${_cachedStatus!.voltage}, 电量:${_cachedStatus!.battery}');
|
||||
debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}');
|
||||
emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!));
|
||||
}
|
||||
}
|
||||
@@ -327,6 +396,16 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
devicesCubit.finishWork();
|
||||
|
||||
// 🔥 关键:无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
|
||||
if (!isClosed) {
|
||||
emit(DeviceStatusUpdated(
|
||||
_cachedStatus ?? RunningStatusEntity(),
|
||||
_cachedGps ?? GPSEntity(0.0, 0.0),
|
||||
));
|
||||
debugPrint('📤 [DeviceStatusBloc] 已 emit 停止信号,触发 UI 更新');
|
||||
}
|
||||
|
||||
Future.delayed(Duration(seconds: 1), () {
|
||||
devicesCubit.resetWorkStatus();
|
||||
});
|
||||
@@ -341,6 +420,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 清理MQTT订阅
|
||||
_mqttArriveSubscription?.cancel();
|
||||
_mqttArriveSubscription = null;
|
||||
_mqttStatusSubscription?.cancel();
|
||||
_mqttStatusSubscription = null;
|
||||
|
||||
// 🔥 清理节流timer和缓存
|
||||
_throttleTimer?.cancel();
|
||||
|
||||
@@ -96,7 +96,7 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
taskPool: taskList,
|
||||
currentTask: currentTask,
|
||||
currentTaskId: currentTask?.id,
|
||||
activeTasks: activeTasks, // 🔥 保存所有活跃任务列表
|
||||
activeTasks: activeTasks,
|
||||
));
|
||||
},
|
||||
);
|
||||
@@ -340,4 +340,12 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
));
|
||||
_logger.logWithLevel('🧹 清除当前任务');
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态
|
||||
void clearAll() {
|
||||
if (!isClosed) {
|
||||
emit(const DeviceTaskState());
|
||||
}
|
||||
_logger.logWithLevel('🧹 [DeviceTaskCubit] clearAll - 所有状态已重置');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,13 +665,14 @@ class DevicesCubit extends Cubit<DevicesState> {
|
||||
|
||||
/// 🔥 启动MQTT到达点监听(用于路径规划动画)
|
||||
/// [deviceId] - 目标设备ID,即targetDevice的deviceId
|
||||
Future<void> startListeningMqttArrive({required String deviceId}) async {
|
||||
debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId');
|
||||
_logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId');
|
||||
/// [taskId] - 任务ID,用于订阅 task/{taskId}/status 和 task/{taskId}/arrive
|
||||
Future<void> startListeningMqttArrive({required String deviceId, required int taskId}) async {
|
||||
debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId, taskId: $taskId');
|
||||
_logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId, taskId: $taskId');
|
||||
|
||||
try {
|
||||
// 启动MQTT订阅
|
||||
await _taskMessageRepo.startListening(deviceId: deviceId);
|
||||
await _taskMessageRepo.startListening(deviceId: deviceId, taskId: taskId);
|
||||
|
||||
// 设置DeviceStatusBloc监听的设备ID
|
||||
_deviceStatusBloc.setListeningDeviceId(deviceId);
|
||||
|
||||
@@ -28,6 +28,7 @@ class PermissionRequestBloc
|
||||
// 事件处理
|
||||
on<PermissionRequestReceived>(_handleRequestReceived);
|
||||
on<PermissionDialogDismissed>(_handleDialogDismissed);
|
||||
on<PermissionClearAll>((event, emit) => emit(const PermissionRequestInitial()));
|
||||
}
|
||||
|
||||
// 🔥 通过 NetMessageDispatcher 监听 TCP 0x12 指令,解析权限请求
|
||||
@@ -116,4 +117,11 @@ class PermissionRequestBloc
|
||||
_permissionSubscription?.cancel();
|
||||
_initPermissionListener();
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态
|
||||
void clearAll() {
|
||||
if (!isClosed) {
|
||||
add(const PermissionClearAll());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,8 @@ class PermissionDialogDismissed extends PermissionRequestEvent {
|
||||
@override
|
||||
List<Object?> get props => [agree, deviceId];
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态
|
||||
class PermissionClearAll extends PermissionRequestEvent {
|
||||
const PermissionClearAll();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class _RoutePlanPageState extends State<RoutePlanPage> {
|
||||
final remoteControlState = context.watch<RemoteControlCubit>().state;
|
||||
final targetDevice = remoteControlState.targetDevice;
|
||||
|
||||
debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}');
|
||||
// debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}');
|
||||
|
||||
// 检查是否有选中的设备
|
||||
if (targetDevice == null) {
|
||||
|
||||
@@ -23,8 +23,8 @@ import '../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_event.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_state.dart';
|
||||
|
||||
// 配置:数据超时时间(5秒)
|
||||
const int DATA_TIMEOUT_SECONDS = 5;
|
||||
// 配置:数据超时时间(15秒)
|
||||
const int DATA_TIMEOUT_SECONDS = 15;
|
||||
|
||||
class RunningStatusPage extends StatefulWidget {
|
||||
const RunningStatusPage({super.key});
|
||||
@@ -483,7 +483,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
// ====================== 图表视图 ======================
|
||||
Widget _buildChartContentView(DeviceStatusState state) {
|
||||
// 修改1:超时/无数据时显示暂无数据,而非loading
|
||||
if (_isDataTimeout || state is DeviceStatusInitial) {
|
||||
if (state is DeviceStatusInitial) {
|
||||
return _noDataWidget();
|
||||
}
|
||||
|
||||
@@ -710,7 +710,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
// ====================== 卡片视图 ======================
|
||||
Widget _buildCardContentView(DeviceStatusState state) {
|
||||
// 修改2:卡片视图同样替换loading为暂无数据
|
||||
if (_isDataTimeout || state is DeviceStatusInitial) {
|
||||
if (state is DeviceStatusInitial) {
|
||||
return _noDataWidget();
|
||||
}
|
||||
|
||||
@@ -904,7 +904,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
String satelliteCnt = _isDataTimeout ? '--' : '--';
|
||||
String headingStatus = _isDataTimeout ? "--" : "--";
|
||||
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
if (state is DeviceStatusUpdated) {
|
||||
headingStatus = state.status.headingStatus == 0 ? AppLocalizations.of(context).translate('running_status.not_initialized') : AppLocalizations.of(context).translate('running_status.initialized');
|
||||
int qualValue = 0;
|
||||
try {
|
||||
@@ -917,9 +917,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
|
||||
// 收到新数据,重置超时计时器和状态
|
||||
_startDataTimeoutTimer();
|
||||
if (_isDataTimeout) {
|
||||
setState(() => _isDataTimeout = false);
|
||||
}
|
||||
_isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState
|
||||
} else if (!_isDataTimeout && state is DeviceStatusError) {
|
||||
qual = '-';
|
||||
satelliteCnt = '-';
|
||||
@@ -1017,7 +1015,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
});
|
||||
}
|
||||
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
if (state is DeviceStatusUpdated) {
|
||||
debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据');
|
||||
// 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发
|
||||
if (_hasSeededFromCache) {
|
||||
@@ -1028,10 +1026,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
}
|
||||
// 🔥 关键:收到数据后立即重置超时计时器
|
||||
_startDataTimeoutTimer();
|
||||
// 如果之前是超时状态,现在恢复
|
||||
if (_isDataTimeout) {
|
||||
setState(() => _isDataTimeout = false);
|
||||
}
|
||||
_isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState
|
||||
}
|
||||
return Container(margin: const EdgeInsets.all(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state));
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:maibu_satabot_v2/components/tcp_status_indicator.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart';
|
||||
|
||||
@@ -80,6 +80,7 @@ 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
|
||||
|
||||
// 保持 PlotData 类不变
|
||||
class PlotData {
|
||||
@@ -246,7 +247,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
});
|
||||
|
||||
// 🔥 关键修复:开始监听路径规划指令应答
|
||||
_setupPathPlanningListener();
|
||||
// 🔥 已禁用:新流程使用 HTTP/MQTT 管理任务,不再需要 TCP 路径规划指令应答监听
|
||||
// 保留 TCP 监听会导致机器正常 TCP 0x01 响应触发 finishWork(),错误地将作业状态重置为 idle
|
||||
// _setupPathPlanningListener();
|
||||
|
||||
// 监听地图移动事件,实时更新连线
|
||||
_mapController.mapEventStream.listen((event) {
|
||||
@@ -309,6 +312,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
await prefs.remove(kSavedSelectedPlot);
|
||||
await prefs.remove(kSavedIsPanelOpen);
|
||||
await prefs.remove(kSavedCurrentWorkMode);
|
||||
await prefs.remove(kSavedDeviceTaskIds); // 🔥 清除 taskId 持久化数据
|
||||
} catch (e) {
|
||||
debugPrint('清空本地数据失败:$e');
|
||||
}
|
||||
@@ -595,11 +599,69 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
} else {
|
||||
prefs.remove(kSavedSelectedPlot);
|
||||
}
|
||||
|
||||
// 🔥 5. 持久化 taskId(按 deviceId 隔离)
|
||||
try {
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
final deviceId = context.read<RemoteControlCubit>().state.targetDevice?.deviceName;
|
||||
if (taskId != null && deviceId != null) {
|
||||
final existingJson = prefs.getString(kSavedDeviceTaskIds);
|
||||
Map<String, dynamic> taskIdMap = {};
|
||||
if (existingJson != null) {
|
||||
taskIdMap = jsonDecode(existingJson) as Map<String, dynamic>;
|
||||
}
|
||||
taskIdMap[deviceId] = taskId;
|
||||
prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
|
||||
debugPrint('💾 [持久化] taskId 已保存: deviceId=$deviceId, taskId=$taskId');
|
||||
} else {
|
||||
debugPrint('💾 [持久化] 跳过: taskId=$taskId, deviceId=$deviceId');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('💾 [持久化] taskId 保存失败: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('保存本地数据失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 从 SharedPreferences 恢复指定设备的 taskId
|
||||
Future<int?> _restoreTaskIdFromLocal(String deviceId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(kSavedDeviceTaskIds);
|
||||
if (json != null) {
|
||||
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
|
||||
final taskId = taskIdMap[deviceId];
|
||||
if (taskId is int) return taskId;
|
||||
if (taskId is String) return int.tryParse(taskId);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('📥 [恢复] taskId 恢复失败: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 🔥 从 SharedPreferences 清除指定设备的 taskId
|
||||
Future<void> _clearTaskIdFromLocal(String deviceId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(kSavedDeviceTaskIds);
|
||||
if (json != null) {
|
||||
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
|
||||
taskIdMap.remove(deviceId);
|
||||
if (taskIdMap.isEmpty) {
|
||||
await prefs.remove(kSavedDeviceTaskIds);
|
||||
} else {
|
||||
await prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
|
||||
}
|
||||
debugPrint('🧹 [清除] taskId 已从本地移除: deviceId=$deviceId');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('🧹 [清除] taskId 清除失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 新增:计算坐标列表的边界范围 ==========
|
||||
LatLngBounds? calculateBounds(List<LatLng> points) {
|
||||
if (points.isEmpty) return null;
|
||||
@@ -1823,8 +1885,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
_traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE);
|
||||
tracePoint = _traceManager.getTracePoint();
|
||||
gctracePoint = batchWgs84ToGcj02(tracePoint!);
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
|
||||
_logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
|
||||
// _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
|
||||
|
||||
//_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新
|
||||
if (_isValidLatLng(gcjPoint.latitude, gcjPoint.longitude)) {
|
||||
@@ -2230,7 +2292,12 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('设备号: ${task.deviceId}'),
|
||||
Text(
|
||||
'设备号: ${task.deviceId}',
|
||||
softWrap: true,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -2323,7 +2390,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
// 🔥 6. 先过滤出活跃任务,让用户选择
|
||||
debugPrint('🔍 [开始作业] 正在查询活跃任务...');
|
||||
debugPrint('══════════ [开始作业] 开始 ══════════');
|
||||
debugPrint('🔍 [开始作业] 步骤0: 查询活跃任务, deviceId=$deviceId');
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
|
||||
@@ -2345,9 +2413,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
taskCubit.selectTask(selectedTask);
|
||||
} else if (activeTasks.length == 1) {
|
||||
selectedTask = activeTasks.first;
|
||||
taskCubit.selectTask(selectedTask); // 🔥 单任务也要存入 cubit
|
||||
debugPrint('✅ [开始作业] 自动选择唯一任务 #${selectedTask.id}');
|
||||
} else {
|
||||
debugPrint('ℹ️ [开始作业] 无活跃任务,将直接创建新任务');
|
||||
// 🔥 无活跃任务:先清除旧的本地 taskId,再创建新任务
|
||||
debugPrint('ℹ️ [开始作业] 无活跃任务,清除旧 taskId 后创建新任务');
|
||||
await _clearTaskIdFromLocal(deviceId);
|
||||
taskCubit.clearCurrentTask();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
@@ -2362,28 +2434,31 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
debugPrint(' ├─ orgId: $orgId');
|
||||
debugPrint(' └─ taskId: ${selectedTask?.id ?? "(无,将新建)"}');
|
||||
|
||||
// 8. 更新UI状态
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
isStartWork = true;
|
||||
_workStatus = WorkStatus.working;
|
||||
_traceManager.reset();
|
||||
tracePoint?.clear();
|
||||
gctracePoint?.clear();
|
||||
});
|
||||
|
||||
// 9. 更新应用状态
|
||||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||||
// 8. 重置轨迹 + 切换到定位模式(关键:防止飞过去的路径被画出来)
|
||||
debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到定位模式');
|
||||
_traceManager.reset();
|
||||
_traceManager.setMode(TPMode.LOCATION);
|
||||
tracePoint?.clear();
|
||||
gctracePoint?.clear();
|
||||
|
||||
// 10. 如果池子里已有活跃任务,直接使用;否则创建新任务
|
||||
debugPrint('🔀 [开始作业] 步骤2: 确认任务来源');
|
||||
if (selectedTask != null) {
|
||||
// 🔥 验证 taskId 是否有效
|
||||
debugPrint('📋 [开始作业] 步骤2: 使用已有任务 #${selectedTask.id}');
|
||||
if (selectedTask.id == null) {
|
||||
debugPrint('⚠️ [开始作业] 活跃任务无有效 taskId,无法控制');
|
||||
_showPageToast(message: "已有任务在其他平台执行,暂不可控制", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
// 🔥 池子里已有任务,直接使用,不需要再创建
|
||||
debugPrint('✅ [开始作业] 使用已有任务 #${selectedTask.id},跳过创建');
|
||||
debugPrint('✅ [开始作业] 步骤2完成: 使用已有任务 #${selectedTask.id},跳过创建');
|
||||
// taskId 已由 selectTask 存入 cubit
|
||||
} else {
|
||||
// 🔥 池子里没有,创建新任务
|
||||
debugPrint('🆕 [开始作业] 池子为空,创建新任务...');
|
||||
debugPrint('🆕 [开始作业] 步骤2a: 池子为空,创建新任务...');
|
||||
try {
|
||||
debugPrint('📤 [开始作业] 调用 createDeviceTask API, deviceId=$deviceId, routeId=$routeId');
|
||||
final result = await sl<CreateDeviceTaskUseCase>().execute(
|
||||
deviceId: deviceId,
|
||||
routeId: routeId,
|
||||
@@ -2392,50 +2467,80 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
);
|
||||
|
||||
var needRequery = false;
|
||||
var failed = false;
|
||||
result.fold(
|
||||
(failure) {
|
||||
(failure) {
|
||||
final failMsg = failure.toString();
|
||||
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
|
||||
needRequery = true;
|
||||
return;
|
||||
}
|
||||
failed = true;
|
||||
debugPrint('[开始作业] 创建失败: $failMsg');
|
||||
_showPageToast(message: '作业启动失败', type: ToastType.error);
|
||||
taskCubit.clearCurrentTask();
|
||||
return;
|
||||
debugPrint('⚠️ [开始作业] createDeviceTask API 失败: $failMsg');
|
||||
// 🔥 无论什么失败(网络错误/任务已存在等),都尝试重查池子
|
||||
// 网络错误时服务端可能已创建成功,只是响应丢失
|
||||
needRequery = true;
|
||||
},
|
||||
(taskId) {
|
||||
(taskId) {
|
||||
debugPrint('✅ [开始作业] createDeviceTask 成功, taskId=$taskId');
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
},
|
||||
);
|
||||
|
||||
if (failed) return;
|
||||
if (needRequery) {
|
||||
debugPrint('🔁 [开始作业] 步骤2b: API失败,重新查询池子(服务端可能已创建)...');
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final existingTasks = taskCubit.state.activeTasks;
|
||||
if (existingTasks.isNotEmpty) {
|
||||
taskCubit.selectTask(existingTasks.first);
|
||||
debugPrint('✅ [开始作业] 重查成功,使用已有任务 #${existingTasks.first.id}');
|
||||
// 🔥 继续执行后续 UI更新 + MQTT + 保存逻辑
|
||||
} else {
|
||||
debugPrint('[开始作业] needRequery 后仍无活跃任务');
|
||||
_showPageToast(message: '未找到活跃任务', type: ToastType.warn);
|
||||
debugPrint('❌ [开始作业] 步骤2b失败: 重查后仍无活跃任务,终止');
|
||||
_showPageToast(message: '作业启动失败,未找到活跃任务', type: ToastType.error);
|
||||
taskCubit.clearCurrentTask();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ [开始作业] 异常: $e');
|
||||
debugPrint('❌ [开始作业] 步骤2异常: $e');
|
||||
_showPageToast(message: "作业启动异常: $e", type: ToastType.error);
|
||||
taskCubit.clearCurrentTask();
|
||||
// 🔥 不重置 _workStatus,保持按钮可见
|
||||
return;
|
||||
}
|
||||
} // end if/else 任务确认
|
||||
|
||||
// 🔥 安全检查:taskId 必须有效才能更新 UI 为作业中
|
||||
if (taskCubit.state.currentTaskId == null) {
|
||||
debugPrint('❌ [开始作业] 步骤3前检查: taskId 为空,任务未就绪,终止');
|
||||
_showPageToast(message: '作业启动失败,未获取到任务ID', type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 到这里说明任务已就绪(无论是已有还是新建),刷新一次任务池显示最新状态
|
||||
taskCubit.fetchAndFilterTask(deviceId);
|
||||
// 🔥 步骤3: 任务确认成功,更新UI状态
|
||||
debugPrint('🎯 [开始作业] 步骤3: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}');
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
isStartWork = true;
|
||||
_workStatus = WorkStatus.working;
|
||||
});
|
||||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||||
|
||||
// 🔥 步骤4: 刷新任务池(保护 taskId 不被覆盖)
|
||||
debugPrint('🔄 [开始作业] 步骤4: 刷新任务池');
|
||||
final _confirmedTaskId = taskCubit.state.currentTaskId;
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) {
|
||||
taskCubit.updateCurrentTaskId(_confirmedTaskId);
|
||||
debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId');
|
||||
}
|
||||
|
||||
// 🔥 步骤5: 启动MQTT到达点监听
|
||||
debugPrint('📡 [开始作业] 步骤5: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId');
|
||||
try {
|
||||
await context.read<DevicesCubit>().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!);
|
||||
debugPrint('✅ [开始作业] MQTT到达点监听已启动');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)');
|
||||
}
|
||||
|
||||
// 🔥 步骤6: 持久化状态
|
||||
debugPrint('💾 [开始作业] 步骤6: 持久化状态, taskId=${taskCubit.state.currentTaskId}');
|
||||
_showPageToast(message: "作业已开始", type: ToastType.success);
|
||||
_saveDataToLocal();
|
||||
|
||||
@@ -2448,9 +2553,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
_traceManager.setMode(TPMode.LOCATION);
|
||||
tracePoint = _traceManager.getTracePoint();
|
||||
gctracePoint = batchWgs84ToGcj02(tracePoint!);
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
|
||||
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
@@ -2492,9 +2597,21 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
var taskId = taskCubit.state.currentTaskId;
|
||||
|
||||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||||
if (taskId == null) {
|
||||
_showPageToast(message: "无可用任务", type: ToastType.warn);
|
||||
debugPrint('📥 [暂停作业] taskId 为空,尝试从本地恢复...');
|
||||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
if (taskId != null) {
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
debugPrint('📥 [暂停作业] 从本地恢复 taskId: $taskId');
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId == null) {
|
||||
debugPrint('❌ [暂停作业] taskId 为空,无法操控');
|
||||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2535,11 +2652,22 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
var taskId = taskCubit.state.currentTaskId;
|
||||
debugPrint('[停止作业] taskId: $taskId');
|
||||
|
||||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||||
if (taskId == null) {
|
||||
debugPrint('[停止作业] ❌ taskId 为 null,退出');
|
||||
_showPageToast(message: "无可用任务", type: ToastType.warn);
|
||||
debugPrint('📥 [停止作业] taskId 为空,尝试从本地恢复...');
|
||||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
if (taskId != null) {
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
debugPrint('📥 [停止作业] 从本地恢复 taskId: $taskId');
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId == null) {
|
||||
debugPrint('[停止作业] ❌ taskId 为空,无法操控');
|
||||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2567,6 +2695,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
debugPrint('[停止作业] cancelTask 返回成功');
|
||||
taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务
|
||||
debugPrint('[停止作业] clearCurrentTask 完成');
|
||||
await _clearTaskIdFromLocal(deviceId); // 🔥 同步清除本地持久化
|
||||
|
||||
// 🔥 停止MQTT到达点监听
|
||||
debugPrint('[停止作业] 停止MQTT到达点监听...');
|
||||
await context.read<DevicesCubit>().stopListeningMqttArrive();
|
||||
debugPrint('[停止作业] MQTT到达点监听已停止');
|
||||
|
||||
_showPageToast(message: "作业已停止", type: ToastType.success);
|
||||
debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令');
|
||||
|
||||
@@ -2613,9 +2748,21 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
var taskId = taskCubit.state.currentTaskId;
|
||||
|
||||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||||
if (taskId == null) {
|
||||
_showPageToast(message: "无可用任务", type: ToastType.warn);
|
||||
debugPrint('📥 [继续作业] taskId 为空,尝试从本地恢复...');
|
||||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
if (taskId != null) {
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
debugPrint('📥 [继续作业] 从本地恢复 taskId: $taskId');
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId == null) {
|
||||
debugPrint('❌ [继续作业] taskId 为空,无法操控');
|
||||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2660,18 +2807,18 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
List<dynamic> _parseStartWorkListFromPathData(
|
||||
List<Map<String, dynamic>>? pathData,
|
||||
) {
|
||||
debugPrint(
|
||||
'🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
|
||||
// );
|
||||
if (pathData == null || pathData.isEmpty) {
|
||||
debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
|
||||
// debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
|
||||
return [];
|
||||
}
|
||||
|
||||
final firstRecord = pathData.first;
|
||||
debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
|
||||
// debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
|
||||
final nestedJsonRaw = firstRecord['jsonData'];
|
||||
debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
|
||||
// debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
|
||||
|
||||
// 🔥 兼容两种格式:String(需 jsonDecode)和 Map(已解析)
|
||||
Map<String, dynamic> parsedJson;
|
||||
@@ -2679,31 +2826,31 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
try {
|
||||
final decoded = jsonDecode(nestedJsonRaw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
|
||||
// debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
|
||||
return [];
|
||||
}
|
||||
parsedJson = decoded;
|
||||
} catch (_) {
|
||||
debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
|
||||
// debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
|
||||
return [];
|
||||
}
|
||||
} else if (nestedJsonRaw is Map<String, dynamic>) {
|
||||
parsedJson = nestedJsonRaw;
|
||||
} else {
|
||||
debugPrint(
|
||||
'❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
|
||||
);
|
||||
// debugPrint(
|
||||
// '❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
|
||||
// );
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
|
||||
// debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
|
||||
|
||||
final planModel = parsedJson['planModel'];
|
||||
// 🔥 安全解析:支持 int 和 String 类型
|
||||
final int planModelValue = int.tryParse(planModel?.toString() ?? '0') ?? 0;
|
||||
debugPrint(
|
||||
'🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
|
||||
// );
|
||||
final isBow = planModelValue == WorkMode.bow.value;
|
||||
|
||||
List<dynamic> pathList = [];
|
||||
@@ -2728,15 +2875,15 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
|
||||
if (isBow) {
|
||||
final result = pathList.isNotEmpty ? pathList : outerList;
|
||||
debugPrint(
|
||||
'✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
|
||||
// );
|
||||
return result;
|
||||
} else {
|
||||
final result = outerList.isNotEmpty ? outerList : pathList;
|
||||
debugPrint(
|
||||
'✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
|
||||
// );
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2750,19 +2897,20 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
|
||||
// 🔥 核心修复:从 state.pathData 实时计算 startWorkList
|
||||
// 确保 BlocBuilder 触发时数据已就绪,不再依赖 onTap 的异步赋值时序
|
||||
debugPrint(
|
||||
'🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
|
||||
// );
|
||||
final parsedList = _parseStartWorkListFromPathData(state.pathData);
|
||||
if (parsedList.isNotEmpty) {
|
||||
startWorkList = parsedList; // 同步到类字段,供 _saveDataToLocal 等方法使用
|
||||
debugPrint(
|
||||
'✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
|
||||
// );
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
|
||||
// );
|
||||
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
@@ -2953,37 +3101,34 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'设备: ${currentTask.deviceId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
Text(
|
||||
'设备: ${currentTask.deviceId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
softWrap: true,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
4,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -3672,6 +3817,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
DeviceStatusUpdated? updatedState;
|
||||
//有停止信号
|
||||
if (isFinishWork) {
|
||||
// 🔥 清除 taskId 持久化数据(任务已完成)
|
||||
final finishDeviceId = context.read<RemoteControlCubit>().state.targetDevice?.deviceName;
|
||||
if (finishDeviceId != null) {
|
||||
sl<DeviceTaskCubit>().clearCurrentTask();
|
||||
_clearTaskIdFromLocal(finishDeviceId);
|
||||
debugPrint('🏁 [完成] 任务已到达终点,清除 taskId: deviceId=$finishDeviceId');
|
||||
}
|
||||
// 🔥 关键:用微任务延迟执行状态更新,避开构建阶段
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
@@ -3685,7 +3837,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
tracePoint?.clear();
|
||||
gctracePoint?.clear();
|
||||
});
|
||||
_showPageToast(message: "作业已停止", type: ToastType.error);
|
||||
_showPageToast(message: "作业已完成", type: ToastType.success);
|
||||
|
||||
// 延迟重置轨迹管理器
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
|
||||
@@ -349,7 +349,7 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
|
||||
streamUrl: _videoStreamUrl,
|
||||
showLeftPip: false, // 不显示悬浮小窗
|
||||
showRightPip: false,
|
||||
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
|
||||
mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment, // 🔥 根据视角切换画面
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black87,
|
||||
|
||||
@@ -45,4 +45,11 @@ class MyCubit extends Cubit<MyState> {
|
||||
emit(state.copyWith(isLoading: true, errorMessage: ''));
|
||||
// 解绑逻辑(如需保留,需补充 UnbindDeviceUsecase 依赖注入)
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态(昵称等个人信息)
|
||||
void clearAll() {
|
||||
if (!isClosed) {
|
||||
emit(const MyState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,4 +1070,28 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
debugPrint('🗑️ [RemoteControl] 缓存已清空 - voltage, battery, controlMode, ping');
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态(比 _clearAllCache 更彻底,重置全部 state 字段)
|
||||
void clearAll() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
|
||||
_cacheVoltage = null;
|
||||
_cacheBattery = null;
|
||||
_cacheCtrlMode = null;
|
||||
_cachePing = null;
|
||||
_lastUiUpdateTime = null;
|
||||
_lastStatusPushTime = null;
|
||||
_currentOriginX = 0;
|
||||
_currentOriginY = 0;
|
||||
|
||||
if (!isClosed) {
|
||||
emit(RemoteControlState(
|
||||
controlEntity: MachineControlStatusEntity(),
|
||||
runningStatusModel: RunningStatusModel(),
|
||||
));
|
||||
}
|
||||
|
||||
debugPrint('🗑️ [RemoteControl] clearAll - 所有状态已重置为初始值');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -119,8 +119,6 @@ class _RightJoystickAreaState extends State<RightJoystickArea> {
|
||||
}
|
||||
|
||||
void _triggerVibration() {
|
||||
Vibration.hasVibrator().then((has) {
|
||||
if (has ?? false) Vibration.vibrate(duration: 12);
|
||||
});
|
||||
Vibration.vibrate(duration: 12);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,14 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
Widget _buildQuadrantView({required Alignment alignment}) {
|
||||
if (_renderer.srcObject == null) return Container(color: Colors.black);
|
||||
|
||||
// 🔥 俯视(center):显示完整视频帧,不做2倍裁剪
|
||||
if (alignment == Alignment.center) {
|
||||
return RepaintBoundary(
|
||||
child: RTCVideoView(_renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false),
|
||||
);
|
||||
}
|
||||
|
||||
// 前/后/左/右:2倍放大后裁剪对应象限
|
||||
return RepaintBoundary(
|
||||
child: ClipRect(
|
||||
child: FractionallySizedBox(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
|
||||
abstract class BindDeviceDatasource {
|
||||
Future<List<OrgEntity>> getOrgList();
|
||||
Future<List<SiteEntity>> getSitesByOrgId(int orgId);
|
||||
Future<List<UserSimpleEntity>> getUsersBySiteId(int siteId);
|
||||
Future<bool> isDeviceAtSite({required String deviceId, required int siteId});
|
||||
Future<void> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:dio/dio.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/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
|
||||
import '../../../domain/entities/bind_device_entities.dart';
|
||||
import '../bind_device_datasource.dart';
|
||||
|
||||
class BindDeviceDatasourceImpl implements BindDeviceDatasource {
|
||||
final Dio _dio;
|
||||
|
||||
BindDeviceDatasourceImpl(this._dio);
|
||||
|
||||
Future<String?> _getToken() async {
|
||||
final token = sl<AppUserCubit>().state.user?.token;
|
||||
if (token != null) return token;
|
||||
final user = await sl<UserStorage>().getUser();
|
||||
return user?.token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrgEntity>> getOrgList() async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.orgList,
|
||||
queryParameters: {'pageNum': 1, 'pageSize': 1000},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '获取组织列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows
|
||||
.map((e) => OrgEntity.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SiteEntity>> getSitesByOrgId(int orgId) async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.siteListByOrgId,
|
||||
queryParameters: {'id': orgId},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '获取场站列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows
|
||||
.map((e) => SiteEntity.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UserSimpleEntity>> getUsersBySiteId(int siteId) async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.userListBySiteId,
|
||||
queryParameters: {'siteId': siteId, 'pageNum': 1, 'pageSize': 1000},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '获取人员列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows
|
||||
.map((e) => UserSimpleEntity.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isDeviceAtSite({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.getSiteDeviceList,
|
||||
queryParameters: {'siteId': siteId, 'pageSize': 9999, 'pageNum': 1},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) return false;
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) return false;
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows.any((e) => e['deviceId']?.toString() == deviceId);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.bindDevice,
|
||||
data: {
|
||||
'deviceIds': deviceIds,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
'userId': userId,
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '绑定设备失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
import '../../domain/repositories/bind_device_repository.dart';
|
||||
import '../datasources/bind_device_datasource.dart';
|
||||
|
||||
class BindDeviceRepositoryImpl implements BindDeviceRepository {
|
||||
final BindDeviceDatasource _datasource;
|
||||
|
||||
BindDeviceRepositoryImpl(this._datasource);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<OrgEntity>>> getOrgList() async {
|
||||
try {
|
||||
final result = await _datasource.getOrgList();
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<SiteEntity>>> getSitesByOrgId(int orgId) async {
|
||||
try {
|
||||
final result = await _datasource.getSitesByOrgId(orgId);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<UserSimpleEntity>>> getUsersBySiteId(
|
||||
int siteId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _datasource.getUsersBySiteId(siteId);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> isDeviceAtSite({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _datasource.isDeviceAtSite(
|
||||
deviceId: deviceId,
|
||||
siteId: siteId,
|
||||
);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
}) async {
|
||||
try {
|
||||
await _datasource.bindDevice(
|
||||
deviceIds: deviceIds,
|
||||
orgId: orgId,
|
||||
siteId: siteId,
|
||||
userId: userId,
|
||||
);
|
||||
return const Right(null);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
class OrgEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const OrgEntity({required this.id, required this.name});
|
||||
|
||||
static int _parseId(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory OrgEntity.fromJson(Map<String, dynamic> json) {
|
||||
return OrgEntity(
|
||||
id: _parseId(json['id']),
|
||||
name: json['name'] ?? json['orgName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SiteEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const SiteEntity({required this.id, required this.name});
|
||||
|
||||
static int _parseId(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory SiteEntity.fromJson(Map<String, dynamic> json) {
|
||||
return SiteEntity(
|
||||
id: _parseId(json['id']),
|
||||
name: json['name'] ?? json['siteName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSimpleEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const UserSimpleEntity({required this.id, required this.name});
|
||||
|
||||
static int _parseId(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory UserSimpleEntity.fromJson(Map<String, dynamic> json) {
|
||||
return UserSimpleEntity(
|
||||
id: _parseId(json['userId'] ?? json['id']),
|
||||
name: json['nickName'] ?? json['name'] ?? json['username'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ class UAVDetailEntity extends Equatable {
|
||||
final double? homeDistance;
|
||||
final double? liveCapacity;
|
||||
final String? rainfall;
|
||||
final String? airConditionerStatus;
|
||||
final String? hangarStatus;
|
||||
final List<CameraInfo>? gatewayCameraList;
|
||||
final List<CameraInfo>? droneCameraList;
|
||||
final int? orgId;
|
||||
@@ -117,6 +119,8 @@ class UAVDetailEntity extends Equatable {
|
||||
this.homeDistance,
|
||||
this.liveCapacity,
|
||||
this.rainfall,
|
||||
this.airConditionerStatus,
|
||||
this.hangarStatus,
|
||||
this.gatewayCameraList,
|
||||
this.droneCameraList,
|
||||
this.orgId,
|
||||
@@ -166,6 +170,8 @@ class UAVDetailEntity extends Equatable {
|
||||
? (json['live_capacity'] as num).toDouble()
|
||||
: null,
|
||||
rainfall: json['rainfall']?.toString(),
|
||||
airConditionerStatus: json['air_conditioner_status'] ?? '',
|
||||
hangarStatus: json['hangar_status'] ?? '',
|
||||
gatewayCameraList: json['gateway_camera_list'] != null
|
||||
? (json['gateway_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
@@ -203,6 +209,8 @@ class UAVDetailEntity extends Equatable {
|
||||
'home_distance': homeDistance,
|
||||
'live_capacity': liveCapacity,
|
||||
'rainfall': rainfall,
|
||||
'air_conditioner_status': airConditionerStatus,
|
||||
'hangar_status': hangarStatus,
|
||||
'gateway_camera_list': gatewayCameraList?.map((c) => c.toJson()).toList(),
|
||||
'drone_camera_list': droneCameraList,
|
||||
'orgId': orgId,
|
||||
@@ -233,6 +241,8 @@ class UAVDetailEntity extends Equatable {
|
||||
homeDistance,
|
||||
liveCapacity,
|
||||
rainfall,
|
||||
airConditionerStatus,
|
||||
hangarStatus,
|
||||
gatewayCameraList,
|
||||
droneCameraList,
|
||||
orgId,
|
||||
@@ -260,6 +270,8 @@ class DroneStationEntity extends Equatable {
|
||||
final double? homeDistance; // 距离home点距离
|
||||
final double? liveCapacity; // 实时容量
|
||||
final double? rainfall; // 降雨量
|
||||
final String? airConditionerStatus; // 机场空调状态
|
||||
final String? hangarStatus; // 机库状态
|
||||
final List<CameraInfo>? gatewayCameraList; // 网关摄像头列表
|
||||
final List<dynamic>? droneCameraList; // 无人机摄像头列表
|
||||
final int orgId; // 组织ID
|
||||
@@ -284,6 +296,8 @@ class DroneStationEntity extends Equatable {
|
||||
this.homeDistance,
|
||||
this.liveCapacity,
|
||||
this.rainfall,
|
||||
this.airConditionerStatus,
|
||||
this.hangarStatus,
|
||||
this.gatewayCameraList,
|
||||
this.droneCameraList,
|
||||
required this.orgId,
|
||||
@@ -328,6 +342,8 @@ class DroneStationEntity extends Equatable {
|
||||
rainfall: json['rainfall'] != null
|
||||
? (json['rainfall'] as num).toDouble()
|
||||
: null,
|
||||
airConditionerStatus: json['air_conditioner_status'] ?? '',
|
||||
hangarStatus: json['hangar_status'] ?? '',
|
||||
gatewayCameraList: json['gateway_camera_list'] != null
|
||||
? (json['gateway_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
@@ -398,6 +414,8 @@ class DroneStationEntity extends Equatable {
|
||||
homeDistance,
|
||||
liveCapacity,
|
||||
rainfall,
|
||||
airConditionerStatus,
|
||||
hangarStatus,
|
||||
gatewayCameraList,
|
||||
droneCameraList,
|
||||
orgId,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../../core/error/failure.dart';
|
||||
import '../entities/bind_device_entities.dart';
|
||||
|
||||
abstract class BindDeviceRepository {
|
||||
Future<Either<Failure, List<OrgEntity>>> getOrgList();
|
||||
Future<Either<Failure, List<SiteEntity>>> getSitesByOrgId(int orgId);
|
||||
Future<Either<Failure, List<UserSimpleEntity>>> getUsersBySiteId(int siteId);
|
||||
Future<Either<Failure, bool>> isDeviceAtSite({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
});
|
||||
Future<Either<Failure, void>> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
|
||||
import '../entities/bind_device_entities.dart';
|
||||
import '../repositories/bind_device_repository.dart';
|
||||
|
||||
class GetOrgListUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
GetOrgListUseCase(this._repository);
|
||||
Future<Either<Failure, List<OrgEntity>>> call() => _repository.getOrgList();
|
||||
}
|
||||
|
||||
class GetSitesByOrgUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
GetSitesByOrgUseCase(this._repository);
|
||||
Future<Either<Failure, List<SiteEntity>>> call(int orgId) =>
|
||||
_repository.getSitesByOrgId(orgId);
|
||||
}
|
||||
|
||||
class GetUsersBySiteUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
GetUsersBySiteUseCase(this._repository);
|
||||
Future<Either<Failure, List<UserSimpleEntity>>> call(int siteId) =>
|
||||
_repository.getUsersBySiteId(siteId);
|
||||
}
|
||||
|
||||
class BindDeviceV2UseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
BindDeviceV2UseCase(this._repository);
|
||||
Future<Either<Failure, void>> call({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
}) => _repository.bindDevice(
|
||||
deviceIds: deviceIds,
|
||||
orgId: orgId,
|
||||
siteId: siteId,
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
|
||||
class IsDeviceAtSiteUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
IsDeviceAtSiteUseCase(this._repository);
|
||||
Future<Either<Failure, bool>> call({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
}) => _repository.isDeviceAtSite(deviceId: deviceId, siteId: siteId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class PauseFlightTaskUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
PauseFlightTaskUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, Map<String, dynamic>>> execute({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
return await repository.pauseFlightTask(deviceSn: deviceSn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class ReturnHomeUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
ReturnHomeUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, Map<String, dynamic>>> execute({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
return await repository.returnHome(deviceSn: deviceSn);
|
||||
}
|
||||
}
|
||||
@@ -43,27 +43,37 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
DeviceStatusRefresh event,
|
||||
Emitter<DeviceStatusState> emit,
|
||||
) async {
|
||||
if (state is DeviceStatusLoaded) {
|
||||
final currentState = state as DeviceStatusLoaded;
|
||||
// 🔥 切换电站时,即便当前是 Loading/Initial 也需要重新拉取
|
||||
final currentSiteId = (state is DeviceStatusLoaded)
|
||||
? (state as DeviceStatusLoaded).siteId
|
||||
: null;
|
||||
final targetSiteId = event.siteId ?? currentSiteId;
|
||||
final currentSelectedType = (state is DeviceStatusLoaded)
|
||||
? (state as DeviceStatusLoaded).selectedType
|
||||
: 'all';
|
||||
|
||||
try {
|
||||
final response = await getDeviceStatusDataUseCase.execute(
|
||||
siteId: currentState.siteId,
|
||||
typeFilter: currentState.selectedType == 'all'
|
||||
? null
|
||||
: currentState.selectedType,
|
||||
);
|
||||
// 切换电站时显示 Loading,让用户感知到正在刷新
|
||||
if (event.siteId != null) {
|
||||
emit(const DeviceStatusLoading());
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(
|
||||
deviceStatus: response.status,
|
||||
devices: response.devices,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(DeviceStatusError(
|
||||
message: ErrorHandler.getErrorMessage(e),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
));
|
||||
}
|
||||
try {
|
||||
final response = await getDeviceStatusDataUseCase.execute(
|
||||
siteId: targetSiteId,
|
||||
typeFilter: currentSelectedType == 'all' ? null : currentSelectedType,
|
||||
);
|
||||
|
||||
emit(DeviceStatusLoaded(
|
||||
deviceStatus: response.status,
|
||||
devices: response.devices,
|
||||
siteId: targetSiteId,
|
||||
selectedType: currentSelectedType,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(DeviceStatusError(
|
||||
message: ErrorHandler.getErrorMessage(e),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ class DeviceStatusLoadData extends DeviceStatusEvent {
|
||||
}
|
||||
|
||||
class DeviceStatusRefresh extends DeviceStatusEvent {
|
||||
const DeviceStatusRefresh();
|
||||
/// 可选:切换电站时传入新 siteId 重新请求;不传则用当前 state 中的 siteId
|
||||
final int? siteId;
|
||||
|
||||
const DeviceStatusRefresh({this.siteId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [siteId];
|
||||
}
|
||||
|
||||
class DeviceStatusChangeType extends DeviceStatusEvent {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
import '../../domain/usecases/bind_device_usecases.dart';
|
||||
import 'bind_device_state.dart';
|
||||
|
||||
class BindDeviceCubit extends Cubit<BindDeviceState> {
|
||||
final GetOrgListUseCase _getOrgListUseCase;
|
||||
final GetSitesByOrgUseCase _getSitesByOrgUseCase;
|
||||
final GetUsersBySiteUseCase _getUsersBySiteUseCase;
|
||||
final BindDeviceV2UseCase _bindDeviceUseCase;
|
||||
final IsDeviceAtSiteUseCase _isDeviceAtSiteUseCase;
|
||||
final AppUserCubit _appUserCubit;
|
||||
|
||||
BindDeviceCubit(
|
||||
this._getOrgListUseCase,
|
||||
this._getSitesByOrgUseCase,
|
||||
this._getUsersBySiteUseCase,
|
||||
this._bindDeviceUseCase,
|
||||
this._isDeviceAtSiteUseCase,
|
||||
this._appUserCubit,
|
||||
) : super(const BindDeviceState());
|
||||
|
||||
UserEntity? get _currentUser => _appUserCubit.state.user;
|
||||
String get _roleKey => _currentUser?.roleKey ?? 'user';
|
||||
|
||||
Future<void> init() async {
|
||||
final user = _currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
switch (_roleKey) {
|
||||
case 'admin':
|
||||
await _loadAllOrgs();
|
||||
break;
|
||||
case 'manager':
|
||||
await _loadForManager(user);
|
||||
break;
|
||||
case 'siteManager':
|
||||
await _loadForSiteManager(user);
|
||||
break;
|
||||
case 'user':
|
||||
await _loadForUser(user);
|
||||
break;
|
||||
default:
|
||||
await _loadForUser(user);
|
||||
}
|
||||
|
||||
emit(state.copyWith(isLoading: false));
|
||||
}
|
||||
|
||||
Future<void> _loadAllOrgs() async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) => emit(state.copyWith(orgList: orgs)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForManager(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) async {
|
||||
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
|
||||
await _loadSitesByOrg(user.orgId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForSiteManager(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) async {
|
||||
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
|
||||
await _loadSitesAndUsers(user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForUser(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) async {
|
||||
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
|
||||
await _loadSitesAndUsers(user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSitesByOrg(int orgId) async {
|
||||
final result = await _getSitesByOrgUseCase(orgId);
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(sites) => emit(state.copyWith(siteList: sites)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSitesAndUsers(UserEntity user) async {
|
||||
final sitesResult = await _getSitesByOrgUseCase(user.orgId);
|
||||
sitesResult.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(sites) async {
|
||||
emit(state.copyWith(siteList: sites, selectedSiteId: user.siteId));
|
||||
if (user.siteId != null) {
|
||||
await _loadUsers(user);
|
||||
} else {
|
||||
final currentUser = UserSimpleEntity(
|
||||
id: int.tryParse(user.userId) ?? 0,
|
||||
name: user.nickname,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: [currentUser],
|
||||
selectedUserId: currentUser.id,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadUsers(UserEntity user) async {
|
||||
final result = await _getUsersBySiteUseCase(user.siteId!);
|
||||
final currentUser = UserSimpleEntity(
|
||||
id: int.tryParse(user.userId) ?? 0,
|
||||
name: user.nickname,
|
||||
);
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(users) {
|
||||
final allUsers = <UserSimpleEntity>[];
|
||||
final seenIds = <int>{};
|
||||
for (final u in users) {
|
||||
if (u.id != currentUser.id && !seenIds.contains(u.id)) {
|
||||
allUsers.add(u);
|
||||
seenIds.add(u.id);
|
||||
}
|
||||
}
|
||||
if (!seenIds.contains(currentUser.id)) {
|
||||
allUsers.add(currentUser);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: allUsers,
|
||||
selectedUserId: currentUser.id,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onOrgChanged(int? orgId) async {
|
||||
if (orgId == null) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedOrgId: orgId,
|
||||
selectedSiteId: null,
|
||||
selectedUserId: null,
|
||||
siteList: [],
|
||||
userList: [],
|
||||
),
|
||||
);
|
||||
|
||||
final result = await _getSitesByOrgUseCase(orgId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(sites) => emit(state.copyWith(siteList: sites)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onSiteChanged(int? siteId) async {
|
||||
if (siteId == null) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedSiteId: siteId,
|
||||
selectedUserId: null,
|
||||
userList: [],
|
||||
),
|
||||
);
|
||||
|
||||
final result = await _getUsersBySiteUseCase(siteId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(users) => emit(state.copyWith(userList: users)),
|
||||
);
|
||||
}
|
||||
|
||||
void onUserChanged(int? userId) {
|
||||
emit(state.copyWith(selectedUserId: userId));
|
||||
}
|
||||
|
||||
Future<bool> submitBind(String deviceId) async {
|
||||
if (state.selectedOrgId == null ||
|
||||
state.selectedSiteId == null ||
|
||||
state.selectedUserId == null) {
|
||||
emit(state.copyWith(errorMessage: '请选择完整的绑定信息'));
|
||||
return false;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isSubmitting: true, errorMessage: null));
|
||||
|
||||
final existResult = await _isDeviceAtSiteUseCase(
|
||||
deviceId: deviceId,
|
||||
siteId: state.selectedSiteId!,
|
||||
);
|
||||
|
||||
final isAtSite = existResult.fold((failure) => false, (exists) => exists);
|
||||
|
||||
if (isAtSite) {
|
||||
emit(state.copyWith(isSubmitting: false, errorMessage: '该设备已在当前场站,无需绑定'));
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await _bindDeviceUseCase(
|
||||
deviceIds: [deviceId],
|
||||
orgId: state.selectedOrgId!,
|
||||
siteId: state.selectedSiteId!,
|
||||
userId: state.selectedUserId!,
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
emit(
|
||||
state.copyWith(isSubmitting: false, errorMessage: failure.message),
|
||||
);
|
||||
return false;
|
||||
},
|
||||
(_) {
|
||||
emit(state.copyWith(isSubmitting: false, isSuccess: true));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool get isOrgEnabled => _roleKey == 'admin';
|
||||
bool get isSiteEnabled => _roleKey == 'admin' || _roleKey == 'manager';
|
||||
bool get isUserEnabled =>
|
||||
_roleKey == 'admin' || _roleKey == 'manager' || _roleKey == 'siteManager';
|
||||
|
||||
String get roleKey => _roleKey;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
|
||||
class BindDeviceState extends Equatable {
|
||||
final List<OrgEntity> orgList;
|
||||
final List<SiteEntity> siteList;
|
||||
final List<UserSimpleEntity> userList;
|
||||
final int? selectedOrgId;
|
||||
final int? selectedSiteId;
|
||||
final int? selectedUserId;
|
||||
final bool isLoading;
|
||||
final bool isSubmitting;
|
||||
final String? errorMessage;
|
||||
final bool isSuccess;
|
||||
|
||||
const BindDeviceState({
|
||||
this.orgList = const [],
|
||||
this.siteList = const [],
|
||||
this.userList = const [],
|
||||
this.selectedOrgId,
|
||||
this.selectedSiteId,
|
||||
this.selectedUserId,
|
||||
this.isLoading = false,
|
||||
this.isSubmitting = false,
|
||||
this.errorMessage,
|
||||
this.isSuccess = false,
|
||||
});
|
||||
|
||||
BindDeviceState copyWith({
|
||||
List<OrgEntity>? orgList,
|
||||
List<SiteEntity>? siteList,
|
||||
List<UserSimpleEntity>? userList,
|
||||
int? selectedOrgId,
|
||||
int? selectedSiteId,
|
||||
int? selectedUserId,
|
||||
bool? isLoading,
|
||||
bool? isSubmitting,
|
||||
String? errorMessage,
|
||||
bool? isSuccess,
|
||||
}) {
|
||||
return BindDeviceState(
|
||||
orgList: orgList ?? this.orgList,
|
||||
siteList: siteList ?? this.siteList,
|
||||
userList: userList ?? this.userList,
|
||||
selectedOrgId: selectedOrgId ?? this.selectedOrgId,
|
||||
selectedSiteId: selectedSiteId ?? this.selectedSiteId,
|
||||
selectedUserId: selectedUserId ?? this.selectedUserId,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
errorMessage: errorMessage,
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
orgList,
|
||||
siteList,
|
||||
userList,
|
||||
selectedOrgId,
|
||||
selectedSiteId,
|
||||
selectedUserId,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
isSuccess,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
|
||||
import '../cubit/bind_device_cubit.dart';
|
||||
import '../cubit/bind_device_state.dart';
|
||||
|
||||
class BindDevicePage extends StatefulWidget {
|
||||
final String scanResult;
|
||||
|
||||
const BindDevicePage({super.key, required this.scanResult});
|
||||
|
||||
@override
|
||||
State<BindDevicePage> createState() => _BindDevicePageState();
|
||||
}
|
||||
|
||||
class _BindDevicePageState extends State<BindDevicePage> {
|
||||
late final BindDeviceCubit _cubit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<BindDeviceCubit>();
|
||||
_cubit.init();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => _cubit,
|
||||
child: BlocListener<BindDeviceCubit, BindDeviceState>(
|
||||
listener: (context, state) {
|
||||
if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
|
||||
}
|
||||
if (state.isSuccess) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('绑定成功')));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFF1D2129),
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'绑定智能装备',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: BlocBuilder<BindDeviceCubit, BindDeviceState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRequiredField(
|
||||
label: '已选装备 ID',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE5E6EB),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.scanResult,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildRequiredField(
|
||||
label: '所属组织',
|
||||
child: _buildOrgDropdown(state),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildField(
|
||||
label: '所属场站',
|
||||
child: _buildSiteDropdown(state),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildField(
|
||||
label: '负责人',
|
||||
child: _buildUserDropdown(state),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: state.isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFE5E6EB),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.isSubmitting
|
||||
? null
|
||||
: _handleSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: state.isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确定',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrgDropdown(BindDeviceState state) {
|
||||
final enabled = _cubit.isOrgEnabled;
|
||||
final selectedName = state.selectedOrgId != null
|
||||
? state.orgList
|
||||
.where((org) => org.id == state.selectedOrgId)
|
||||
.fold<String?>(null, (_, org) => org.name)
|
||||
: null;
|
||||
|
||||
if (!enabled && selectedName != null) {
|
||||
return _buildReadonlyField(selectedName);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: state.selectedOrgId,
|
||||
decoration: _dropdownDecoration(enabled),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
|
||||
),
|
||||
items: state.orgList.map((org) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: org.id,
|
||||
child: Text(
|
||||
org.name,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: enabled
|
||||
? (v) {
|
||||
if (v != null) _cubit.onOrgChanged(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSiteDropdown(BindDeviceState state) {
|
||||
final enabled = _cubit.isSiteEnabled && state.selectedOrgId != null;
|
||||
final selectedName = state.selectedSiteId != null
|
||||
? state.siteList
|
||||
.where((site) => site.id == state.selectedSiteId)
|
||||
.fold<String?>(null, (_, site) => site.name)
|
||||
: null;
|
||||
|
||||
if (!enabled && selectedName != null) {
|
||||
return _buildReadonlyField(selectedName);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: state.selectedSiteId,
|
||||
decoration: _dropdownDecoration(enabled),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
|
||||
),
|
||||
items: state.siteList.map((site) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: site.id,
|
||||
child: Text(
|
||||
site.name,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: enabled
|
||||
? (v) {
|
||||
if (v != null) _cubit.onSiteChanged(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserDropdown(BindDeviceState state) {
|
||||
final enabled = _cubit.isUserEnabled && state.selectedSiteId != null;
|
||||
final selectedName = state.selectedUserId != null
|
||||
? state.userList
|
||||
.where((user) => user.id == state.selectedUserId)
|
||||
.fold<String?>(null, (_, user) => user.name)
|
||||
: null;
|
||||
|
||||
if (!enabled && selectedName != null) {
|
||||
return _buildReadonlyField(selectedName);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: state.selectedUserId,
|
||||
decoration: _dropdownDecoration(enabled),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
|
||||
),
|
||||
items: state.userList.map((user) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: user.id,
|
||||
child: Text(
|
||||
user.name,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: enabled
|
||||
? (v) {
|
||||
_cubit.onUserChanged(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadonlyField(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.lock, size: 16, color: Color(0xFFC9CDD4)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _dropdownDecoration(bool enabled) {
|
||||
return InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: enabled ? '请选择' : '请选择',
|
||||
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequiredField({required String label, required Widget child}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: '* ',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFF53F3F),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1D2129),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({required String label, required Widget child}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1D2129),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
_cubit.submitBind(widget.scanResult);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../core/managers/drone_task_state_manager.dart';
|
||||
|
||||
class CreateTaskPage extends StatefulWidget {
|
||||
final String sn;
|
||||
@@ -430,6 +431,9 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
final taskUuid = jsonData['data']['task_uuid'];
|
||||
print('✅ [CreateTask] 任务创建成功, task_uuid: $taskUuid');
|
||||
|
||||
// 🔥 任务下发成功,开始监测无人机实时推送与视频流
|
||||
droneTaskStateManager.markTaskIssued();
|
||||
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -570,8 +574,8 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
wayline['waylineUuid'] ??
|
||||
wayline['id'] ??
|
||||
'';
|
||||
// print('🔍 [CreateTask] 航线数据: $wayline');
|
||||
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
|
||||
// print('🔍 [CreateTask] 航线数据: $wayline');
|
||||
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
|
||||
return ListTile(
|
||||
title: Text(waylineName),
|
||||
subtitle: waylineUuid.isNotEmpty
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/app/app_user_cubit.dart';
|
||||
import '../../../../../core/bluetooth/ble_manager.dart';
|
||||
import '../../../../../components/tcp_status_indicator.dart';
|
||||
import '../../../../../components/device_status_modal.dart';
|
||||
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
|
||||
import '../../../site/presentation/widgets/site_selector_widget.dart';
|
||||
import '../bloc/device_status_bloc.dart' as DeviceListBloc;
|
||||
import '../bloc/device_status_event.dart' as DeviceListEvent;
|
||||
import '../bloc/device_status_state.dart' as DeviceListState;
|
||||
@@ -20,11 +23,16 @@ import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入
|
||||
import '../widgets/device_item_widget.dart';
|
||||
import '../widgets/drone_station_item_card.dart';
|
||||
import '../widgets/robot_item_card.dart'; // 🔥 添加 RobotItemCard 导入
|
||||
import '../widgets/bluetooth_scan_modal.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入
|
||||
import 'robot_list_page.dart';
|
||||
import 'robot_control_page.dart';
|
||||
import 'drone_station_detail_page.dart';
|
||||
import 'qr_scanner_page.dart';
|
||||
import 'bind_device_page.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../../../devices/domain/entities/device_entity.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// 设备状态页面 - 使用 BLoC 模式
|
||||
class DeviceStatusPage extends StatelessWidget {
|
||||
@@ -45,9 +53,70 @@ class DeviceStatusPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 设备状态视图
|
||||
class DeviceStatusView extends StatelessWidget {
|
||||
class DeviceStatusView extends StatefulWidget {
|
||||
const DeviceStatusView({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceStatusView> createState() => _DeviceStatusViewState();
|
||||
}
|
||||
|
||||
class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
/// 🔥 监听电站切换:只要电站发生变化,就根据当前选中的标签栏自动刷新对应接口
|
||||
StreamSubscription<SiteState>? _siteSub;
|
||||
|
||||
/// 当前缓存的 siteId,用于判断是否真的发生了变化
|
||||
int? _currentSiteId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentSiteId = sl<SiteCubit>().state.selectedSite?.id;
|
||||
_siteSub = sl<SiteCubit>().stream.listen((siteState) {
|
||||
if (!mounted) return;
|
||||
final newSiteId = siteState.selectedSite?.id;
|
||||
// 只有 siteId 真正变化时才刷新(避免无意义的重复请求)
|
||||
if (newSiteId != _currentSiteId) {
|
||||
_currentSiteId = newSiteId;
|
||||
_refreshCurrentTabForSiteChange(newSiteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_siteSub?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 🔥 电站切换后,根据当前选中的标签栏自动刷新对应接口
|
||||
void _refreshCurrentTabForSiteChange(int? newSiteId) {
|
||||
final bloc = context.read<DeviceListBloc.DeviceStatusBloc>();
|
||||
final state = bloc.state;
|
||||
// 取出当前选中的标签类型;若 BLoC 还未加载完成则默认 'all'
|
||||
final selectedType = (state is DeviceListState.DeviceStatusLoaded)
|
||||
? state.selectedType
|
||||
: 'all';
|
||||
|
||||
debugPrint(
|
||||
'🔄 [DeviceStatus] 电站切换 → siteId=$newSiteId, 当前标签=$selectedType, 自动刷新',
|
||||
);
|
||||
|
||||
// 1. 主设备列表(all / inverter / combiner_box / module / monitor 共用 DeviceStatusBloc)
|
||||
// 直接派发 Refresh 事件并带上新 siteId
|
||||
bloc.add(DeviceListEvent.DeviceStatusRefresh(siteId: newSiteId));
|
||||
|
||||
// 2. robot / drone_station 由各自 BlocProvider 创建,通过 setState + ValueKey 重建子树
|
||||
// 使其用新 siteId 重新加载数据
|
||||
if (selectedType == 'robot' ||
|
||||
selectedType == 'drone_station' ||
|
||||
selectedType == 'all') {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 机器人设备名称前缀,用于从通用设备列表中过滤掉机器人
|
||||
static const _robotPrefixes = [
|
||||
'RCHETD-CN',
|
||||
@@ -109,31 +178,63 @@ class DeviceStatusView extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).translate('device_list_v2.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
const Flexible(child: SiteSelectorWidget(compact: true)),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 1, height: 20, color: const Color(0xFFE5E6EB)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'TCP',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
),
|
||||
child: TcpStatusIndicator(
|
||||
size: 12,
|
||||
onTap: () => _showDeviceStatusModal(context),
|
||||
// 🔥 注释掉原有的 TCP 指示灯
|
||||
// const Text(
|
||||
// 'TCP',
|
||||
// style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
// ),
|
||||
// const SizedBox(width: 4),
|
||||
// Container(
|
||||
// padding: const EdgeInsets.all(4),
|
||||
// decoration: BoxDecoration(
|
||||
// shape: BoxShape.circle,
|
||||
// color: Colors.blue.withOpacity(0.1),
|
||||
// ),
|
||||
// child: TcpStatusIndicator(
|
||||
// size: 12,
|
||||
// onTap: () => _showDeviceStatusModal(context),
|
||||
// ),
|
||||
// ),
|
||||
GestureDetector(
|
||||
onTap: () => _showAddMenu(context),
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE5E6EB),
|
||||
width: 1,
|
||||
),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Color(0xFF4E5969),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -146,56 +247,107 @@ class DeviceStatusView extends StatelessWidget {
|
||||
Widget _buildSearchBar(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8, 16.0, 8),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.search_hint'),
|
||||
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF86909C),
|
||||
size: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.search_hint'),
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFF86909C),
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF86909C),
|
||||
size: 24,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusSearch(
|
||||
_searchController.text,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF2F3F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFE5E6EB),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFE5E6EB),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
onSubmitted: (value) {
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusSearch(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
suffixIcon: TextButton(
|
||||
onPressed: () {
|
||||
// TODO: 触发搜索
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (context) => const BluetoothScanModal(),
|
||||
).whenComplete(() {
|
||||
// 弹窗关闭时立即停止扫描(无论以什么方式关闭)
|
||||
BleManager.instance.stopScan();
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('device_list_v2.search'),
|
||||
style: const TextStyle(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFF165DFF).withOpacity(0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth,
|
||||
color: Color(0xFF165DFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF2F3F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(color: Color(0xFF165DFF), width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
onSubmitted: (value) {
|
||||
// 🔥 点击键盘确定键时触发搜索
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusSearch(value),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -290,14 +442,20 @@ class DeviceStatusView extends StatelessWidget {
|
||||
BuildContext context,
|
||||
DeviceListState.DeviceStatusState state,
|
||||
) {
|
||||
// 🔥 用当前 siteId 作为 Key,电站切换时强制重建子树(重建对应 BlocProvider)
|
||||
final siteId = _currentSiteId;
|
||||
|
||||
if (state is DeviceListState.DeviceStatusLoaded &&
|
||||
state.selectedType == 'robot') {
|
||||
return const RobotListPage();
|
||||
return RobotListPage(key: ValueKey('robot_$siteId'));
|
||||
}
|
||||
|
||||
if (state is DeviceListState.DeviceStatusLoaded &&
|
||||
state.selectedType == 'drone_station') {
|
||||
return _buildDroneStationList(context);
|
||||
return _buildDroneStationList(
|
||||
context,
|
||||
key: ValueKey('drone_station_$siteId'),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildDeviceList(context, state);
|
||||
@@ -398,6 +556,8 @@ class DeviceStatusView extends StatelessWidget {
|
||||
debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId');
|
||||
|
||||
return BlocProvider(
|
||||
// 🔥 电站切换时通过 key 变化强制重建 BlocProvider,重新用新 siteId 加载
|
||||
key: ValueKey('embedded_robot_$siteId'),
|
||||
create: (_) =>
|
||||
sl<RobotListBloc>()..add(RobotListLoadData(siteId: siteId)),
|
||||
child: BlocConsumer<RobotListBloc, RobotListState>(
|
||||
@@ -441,16 +601,49 @@ class DeviceStatusView extends StatelessWidget {
|
||||
return RobotItemCard(
|
||||
name: robot.name,
|
||||
id: robot.id,
|
||||
alias: robot.alias,
|
||||
type: robot.type,
|
||||
status: robot.status,
|
||||
battery: robot.battery,
|
||||
task: robot.task,
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
debugPrint(
|
||||
'🔴🔴🔴 [全部-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}',
|
||||
);
|
||||
|
||||
// 1. 将当前机器人设置为全局待控制设备
|
||||
final device = DeviceEntity(
|
||||
deviceName: robot.name,
|
||||
productId: -1,
|
||||
productName: robot.type,
|
||||
tenantId: 0,
|
||||
tenantName: '',
|
||||
status: robot.status == '在线' ? 1 : 0,
|
||||
onlineStatus: robot.status == '在线' ? 1 : 0,
|
||||
);
|
||||
|
||||
final remoteCubit = GetIt.I<RemoteControlCubit>();
|
||||
remoteCubit.setTargetDevice(device);
|
||||
debugPrint(
|
||||
'✅ [全部-EmbeddedRobotList] setTargetDevice 已调用',
|
||||
);
|
||||
|
||||
// 2. 跳转到机器人控制页面
|
||||
final robotMap = {
|
||||
'name': robot.name,
|
||||
'id': robot.id,
|
||||
'alias': robot.alias,
|
||||
'type': robot.type,
|
||||
'status': robot.status,
|
||||
'battery': robot.battery,
|
||||
'task': robot.task,
|
||||
};
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
RobotControlPage(robot: robot.toJson()),
|
||||
RobotControlPage(robot: robotMap),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -470,7 +663,11 @@ class DeviceStatusView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDroneStationList(BuildContext context, {bool embedded = false}) {
|
||||
Widget _buildDroneStationList(
|
||||
BuildContext context, {
|
||||
bool embedded = false,
|
||||
Key? key,
|
||||
}) {
|
||||
// 从全局 SiteCubit 获取选中的场站 ID
|
||||
final selectedSite = sl<SiteCubit>().state.selectedSite;
|
||||
|
||||
@@ -493,6 +690,7 @@ class DeviceStatusView extends StatelessWidget {
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
key: key,
|
||||
create: (_) =>
|
||||
sl<DroneStationBloc>()..add(DroneStationLoadData(selectedSite.id)),
|
||||
child: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
@@ -692,6 +890,96 @@ class DeviceStatusView extends StatelessWidget {
|
||||
}
|
||||
|
||||
// 显示设备状态模态框 - 从底部滑出
|
||||
/// 显示添加菜单(扫一扫、添加设备)
|
||||
void _showAddMenu(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black38,
|
||||
builder: (ctx) => Stack(
|
||||
children: [
|
||||
// 点击遮罩关闭
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 50,
|
||||
right: 16,
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black26,
|
||||
child: SizedBox(
|
||||
width: 150,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildMenuOption(
|
||||
ctx,
|
||||
icon: Icons.qr_code_scanner,
|
||||
label: '扫一扫',
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const QrScannerPage(),
|
||||
),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BindDevicePage(scanResult: result),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFFF2F3F5)),
|
||||
_buildMenuOption(
|
||||
ctx,
|
||||
icon: Icons.add_circle_outline,
|
||||
label: '添加设备',
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
// TODO: 添加设备功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuOption(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFF4E5969), size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF1D2129)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeviceStatusModal(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -794,9 +1082,11 @@ class DeviceStatusView extends StatelessWidget {
|
||||
if (isRobot) {
|
||||
// 跳转到机器人控制页面
|
||||
debugPrint('🚀 [DeviceStatusPage] 跳转到机器人控制页面');
|
||||
final deviceAlias = device.deviceAlias ?? '';
|
||||
final robotMap = {
|
||||
'name': deviceName,
|
||||
'id': deviceId,
|
||||
'alias': deviceAlias,
|
||||
'type': deviceType,
|
||||
'status': device.status ?? '在线',
|
||||
'battery': 100.0, // DeviceEntity 没有 battery 字段,使用默认值
|
||||
|
||||
@@ -30,7 +30,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
FlightTaskDetailEntity? _detailTask; // 详情数据
|
||||
bool _isLoading = false;
|
||||
final Dio _dio = Dio();
|
||||
|
||||
|
||||
// 🔥 任务状态管理
|
||||
bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮)
|
||||
bool _isReturning = false; // 是否正在返航中
|
||||
@@ -220,9 +220,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
print('🔍 [DroneMissionControl] 开始暂停任务, deviceSn: ${_detailTask!.sn}');
|
||||
|
||||
final useCase = GetIt.I<PauseFlightTaskUseCase>();
|
||||
final result = await useCase.execute(
|
||||
deviceSn: _detailTask!.sn,
|
||||
);
|
||||
final result = await useCase.execute(deviceSn: _detailTask!.sn);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
@@ -737,7 +735,9 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
child: ElevatedButton(
|
||||
onPressed: _isPaused ? _resumeTask : _pauseTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _isPaused ? const Color(0xFF165DFF) : const Color(0xFFFF7D00),
|
||||
backgroundColor: _isPaused
|
||||
? const Color(0xFF165DFF)
|
||||
: const Color(0xFFFF7D00),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -747,7 +747,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
child: Text(
|
||||
_isPaused ? '恢复任务' : '暂停任务',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -756,8 +759,14 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
child: OutlinedButton(
|
||||
onPressed: _isReturning ? null : _returnHome,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _isReturning ? const Color(0xFF86909C) : const Color(0xFF4E5969),
|
||||
side: BorderSide(color: _isReturning ? const Color(0xFFE5E6EB) : const Color(0xFFC9CDD4)),
|
||||
foregroundColor: _isReturning
|
||||
? const Color(0xFF86909C)
|
||||
: const Color(0xFF4E5969),
|
||||
side: BorderSide(
|
||||
color: _isReturning
|
||||
? const Color(0xFFE5E6EB)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -765,7 +774,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
child: Text(
|
||||
_isReturning ? '已在返航' : '返航降落',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/managers/drone_task_state_manager.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
import '../widgets/drone_station_osd_card.dart'; // 🔥 添加机场 OSD 卡片
|
||||
import '../widgets/drone_osd_card.dart'; // 🔥 添加无人机 OSD 卡片
|
||||
import '../widgets/drone_station_osd_card.dart';
|
||||
import '../widgets/drone_osd_card.dart';
|
||||
import 'drone_video_control_page.dart';
|
||||
import 'drone_mission_control_page.dart';
|
||||
import 'drone_monitor_page.dart';
|
||||
@@ -26,16 +28,17 @@ class DroneStationDetailPage extends StatefulWidget {
|
||||
class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
|
||||
// 无人机详情数据
|
||||
UAVDetailEntity? _detail;
|
||||
String? _droneSn;
|
||||
|
||||
// 无人机状态轮询计时器
|
||||
Timer? _droneStatusPollingTimer;
|
||||
|
||||
// 🔥 标记是否已经初始化过(用于判断是否从其他页面返回)
|
||||
bool _hasInitialized = false;
|
||||
|
||||
late DroneOsdDataSource _osdDataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _stationOsdSubscription;
|
||||
final ValueNotifier<Map<String, dynamic>> _stationHostData =
|
||||
ValueNotifier<Map<String, dynamic>>({});
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -46,20 +49,78 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
// 🔥 标记已初始化
|
||||
_hasInitialized = true;
|
||||
|
||||
// 启动无人机状态轮询(每5秒刷新一次)
|
||||
// 🔥 已禁用自动轮询,改为手动下拉刷新
|
||||
// _startDroneStatusPolling();
|
||||
_osdDataSource = sl<DroneOsdDataSource>();
|
||||
_startStationOsdListening();
|
||||
|
||||
_hasInitialized = true;
|
||||
}
|
||||
|
||||
void _startStationOsdListening() {
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: '',
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
|
||||
_stationOsdSubscription = _osdDataSource.stationOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
_parseAndUpdateHostData(osd);
|
||||
});
|
||||
}
|
||||
|
||||
void _parseAndUpdateHostData(DroneOsdEntity osd) {
|
||||
final data = osd.rawData;
|
||||
final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null;
|
||||
if (hostData == null || hostData is! Map) return;
|
||||
|
||||
final Map<String, dynamic> parsed = {};
|
||||
|
||||
parsed['environment_temperature'] =
|
||||
(hostData['environment_temperature'] as num?)?.toDouble();
|
||||
parsed['humidity'] = (hostData['humidity'] as num?)?.toDouble();
|
||||
parsed['wind_speed'] = (hostData['wind_speed'] as num?)?.toDouble();
|
||||
parsed['rainfall'] = hostData['rainfall']?.toString();
|
||||
parsed['cover_state'] = hostData['cover_state']?.toString();
|
||||
parsed['drone_in_dock'] = hostData['drone_in_dock']?.toString();
|
||||
parsed['temperature'] = (hostData['temperature'] as num?)?.toDouble();
|
||||
parsed['putter_state'] = hostData['putter_state']?.toString();
|
||||
parsed['supplement_light_state'] = hostData['supplement_light_state']
|
||||
?.toString();
|
||||
parsed['alarm_state'] = hostData['alarm_state']?.toString();
|
||||
parsed['emergency_stop_state'] = hostData['emergency_stop_state']
|
||||
?.toString();
|
||||
parsed['silent_mode'] = hostData['silent_mode']?.toString();
|
||||
parsed['mode_code'] = hostData['mode_code']?.toString();
|
||||
parsed['heading'] = (hostData['heading'] as num?)?.toDouble();
|
||||
parsed['height'] = (hostData['height'] as num?)?.toDouble();
|
||||
parsed['latitude'] = (hostData['latitude'] as num?)?.toDouble();
|
||||
parsed['longitude'] = (hostData['longitude'] as num?)?.toDouble();
|
||||
parsed['home_position_is_valid'] = hostData['home_position_is_valid']
|
||||
?.toString();
|
||||
parsed['battery_store_mode'] = hostData['battery_store_mode']?.toString();
|
||||
parsed['first_power_on'] = hostData['first_power_on']?.toString();
|
||||
parsed['drone_charge_state'] = hostData['drone_charge_state'];
|
||||
parsed['air_conditioner'] = hostData['air_conditioner'];
|
||||
parsed['network_state'] = hostData['network_state'];
|
||||
parsed['position_state'] = hostData['position_state'];
|
||||
parsed['storage'] = hostData['storage'];
|
||||
parsed['sub_device'] = hostData['sub_device'];
|
||||
parsed['alternate_land_point'] = hostData['alternate_land_point'];
|
||||
|
||||
if (hostData['air_conditioner'] is Map) {
|
||||
final ac = hostData['air_conditioner'] as Map;
|
||||
parsed['air_conditioner_state'] = ac['air_conditioner_state']?.toString();
|
||||
parsed['air_conditioner_switch_time'] = ac['switch_time']?.toString();
|
||||
}
|
||||
|
||||
_stationHostData.value = Map<String, dynamic>.from(parsed);
|
||||
}
|
||||
|
||||
/// 🔥 页面重新激活时调用(从其他页面返回时)
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
|
||||
// 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新)
|
||||
if (_hasInitialized && _bloc.state is UAVDetailLoaded) {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据');
|
||||
@@ -75,7 +136,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
/// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新)
|
||||
void _refreshData() {
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据');
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
@@ -87,9 +148,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stationOsdSubscription?.cancel();
|
||||
_osdDataSource.stopListening();
|
||||
_stationHostData.dispose();
|
||||
_bloc.close();
|
||||
// 🔥 已禁用自动轮询,无需停止
|
||||
// _droneStatusPollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -216,27 +278,41 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
}
|
||||
|
||||
Widget _buildContent(UAVDetailEntity detail) {
|
||||
_detail = detail; // 保存详情数据供其他方法使用
|
||||
_droneSn = detail.deviceSn; // 保存无人机序列号
|
||||
_detail = detail;
|
||||
_droneSn = detail.deviceSn;
|
||||
|
||||
if (_droneSn != null && _droneSn!.isNotEmpty) {
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: _droneSn!,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
color: const Color(0xFF165DFF),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAirportStatusCard(detail),
|
||||
ValueListenableBuilder<Map<String, dynamic>>(
|
||||
valueListenable: _stationHostData,
|
||||
builder: (context, hostData, _) {
|
||||
return _buildAirportStatusCard(detail, hostData);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 添加机场 OSD 实时数据卡片
|
||||
DroneStationOsdCard(
|
||||
stationOsdStream: _osdDataSource.stationOsdStream,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isOnline: detail.isOnline,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 添加无人机 OSD 实时数据卡片(无人机在线时显示)
|
||||
DroneOsdCard(
|
||||
droneOsdStream: _osdDataSource.droneOsdStream,
|
||||
deviceSn: widget.station.deviceSn,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isDroneOnline: detail.isDroneOnline,
|
||||
onRefresh: _refreshData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildMonitorCard(),
|
||||
@@ -253,10 +329,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
/// 处理下拉刷新
|
||||
Future<void> _handleRefresh() async {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 开始下拉刷新');
|
||||
|
||||
|
||||
// 🔥 创建一个 Completer 来等待 Bloc 状态更新
|
||||
final completer = Completer<void>();
|
||||
|
||||
|
||||
// 监听 Bloc 状态变化
|
||||
final subscription = _bloc.stream.listen((state) {
|
||||
if (state is UAVDetailLoaded || state is UAVDetailError) {
|
||||
@@ -265,7 +341,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 重新加载无人机详情
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
@@ -273,7 +349,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
// 🔥 等待数据加载完成(最多等待5秒)
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
@@ -281,10 +357,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// 取消订阅
|
||||
subscription.cancel();
|
||||
|
||||
|
||||
debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成');
|
||||
}
|
||||
|
||||
@@ -343,7 +419,90 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAirportStatusCard(UAVDetailEntity detail) {
|
||||
Widget _buildAirportStatusCard(
|
||||
UAVDetailEntity detail,
|
||||
Map<String, dynamic> hostData,
|
||||
) {
|
||||
final envTemp = hostData['environment_temperature'];
|
||||
final humidity = hostData['humidity'];
|
||||
final windSpeed = hostData['wind_speed'];
|
||||
final rainfall = hostData['rainfall'];
|
||||
final coverState = hostData['cover_state'];
|
||||
final droneInDock = hostData['drone_in_dock'];
|
||||
final acState = hostData['air_conditioner_state'];
|
||||
final alarmState = hostData['alarm_state'];
|
||||
|
||||
String envTempStr = '未知';
|
||||
if (envTemp != null) {
|
||||
envTempStr = '${envTemp.toStringAsFixed(1)}°C';
|
||||
} else if (detail.environmentTemperature != null) {
|
||||
envTempStr = '${detail.environmentTemperature}°C';
|
||||
}
|
||||
|
||||
String humidityStr = '未知';
|
||||
if (humidity != null) {
|
||||
humidityStr = '${humidity.toStringAsFixed(0)}%';
|
||||
}
|
||||
|
||||
String windStr = '未知';
|
||||
if (windSpeed != null) {
|
||||
windStr = '${windSpeed.toStringAsFixed(1)} m/s';
|
||||
} else if (detail.windSpeed != null) {
|
||||
windStr = '${detail.windSpeed} m/s';
|
||||
}
|
||||
|
||||
String rainfallStr = '未知';
|
||||
if (rainfall != null) {
|
||||
rainfallStr = _formatRainfall(rainfall);
|
||||
} else if (detail.rainfall != null) {
|
||||
rainfallStr = _formatRainfall(detail.rainfall);
|
||||
}
|
||||
|
||||
String coverStr = '未知';
|
||||
Color coverColor = const Color(0xFF86909C);
|
||||
if (coverState != null) {
|
||||
final state = int.tryParse(coverState) ?? -1;
|
||||
if (state == 1) {
|
||||
coverStr = '开启';
|
||||
coverColor = const Color(0xFFFF7D00);
|
||||
} else if (state == 0) {
|
||||
coverStr = '关闭';
|
||||
coverColor = const Color(0xFF00B42A);
|
||||
}
|
||||
}
|
||||
|
||||
String droneDockStr = '未知';
|
||||
Color droneDockColor = const Color(0xFF86909C);
|
||||
if (droneInDock != null) {
|
||||
final state = int.tryParse(droneInDock) ?? -1;
|
||||
if (state == 1) {
|
||||
droneDockStr = '在库内';
|
||||
droneDockColor = const Color(0xFF00B42A);
|
||||
} else if (state == 0) {
|
||||
droneDockStr = '出库';
|
||||
droneDockColor = const Color(0xFFFF7D00);
|
||||
}
|
||||
}
|
||||
|
||||
String acStr = '未知';
|
||||
Color acColor = const Color(0xFF86909C);
|
||||
if (acState != null) {
|
||||
final state = int.tryParse(acState) ?? -1;
|
||||
if (state == 1) {
|
||||
acStr = '开启';
|
||||
acColor = const Color(0xFF00B42A);
|
||||
} else if (state == 0) {
|
||||
acStr = '关闭';
|
||||
acColor = const Color(0xFF86909C);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasAlarm = false;
|
||||
if (alarmState != null) {
|
||||
final state = int.tryParse(alarmState) ?? 0;
|
||||
hasAlarm = state != 0;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -371,6 +530,37 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (hasAlarm)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF53F3F).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Color(0xFFF53F3F),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'告警',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFF53F3F),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -392,9 +582,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, color: Color(0xFFF2F3F5)),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow(
|
||||
'机场名称',
|
||||
detail.callsign.isNotEmpty ? detail.callsign : '未知',
|
||||
@@ -413,17 +601,13 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
? '${detail.capacityPercent}%'
|
||||
: '未知',
|
||||
),
|
||||
_buildInfoRow(
|
||||
'环境温度',
|
||||
detail.environmentTemperature != null
|
||||
? '${detail.environmentTemperature}°C'
|
||||
: '未知',
|
||||
),
|
||||
_buildInfoRow(
|
||||
'风速',
|
||||
detail.windSpeed != null ? '${detail.windSpeed} m/s' : '未知',
|
||||
),
|
||||
_buildInfoRow('降雨量', _formatRainfall(detail.rainfall)),
|
||||
_buildInfoRowColor('环境温度', envTempStr, const Color(0xFF165DFF)),
|
||||
_buildInfoRowColor('湿度', humidityStr, const Color(0xFF00B42A)),
|
||||
_buildInfoRowColor('风速', windStr, const Color(0xFF722ED1)),
|
||||
_buildInfoRowColor('降雨量', rainfallStr, const Color(0xFF00B42A)),
|
||||
_buildInfoRowColor('机库舱门', coverStr, coverColor),
|
||||
_buildInfoRowColor('无人机位置', droneDockStr, droneDockColor),
|
||||
_buildInfoRowColor('空调状态', acStr, acColor),
|
||||
_buildInfoRow('网络状态', detail.networkState?.toString() ?? '未知'),
|
||||
_buildPositionStateRow('位置状态', detail.positionState),
|
||||
],
|
||||
@@ -431,6 +615,41 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRowColor(String label, String value, Color valueColor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
':',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFC0C4CC)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: valueColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPositionStateRow(String label, PositionState? positionState) {
|
||||
String value = '未知';
|
||||
if (positionState != null) {
|
||||
|
||||
@@ -229,6 +229,18 @@ class _DroneStationStatusPageState extends State<DroneStationStatusPage> {
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow('舱门状态', '关闭', status: '正常'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow(
|
||||
'机场空调',
|
||||
_uavDetail?.airConditionerStatus ?? '未开启',
|
||||
status: (_uavDetail?.airConditionerStatus == '开启') ? '正常' : '关闭',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow(
|
||||
'机库状态',
|
||||
_uavDetail?.hangarStatus ?? '关闭',
|
||||
status: (_uavDetail?.hangarStatus == '开启') ? '正常' : '关闭',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRowWithProgress('充电状态', '充电中', '85%'),
|
||||
const SizedBox(height: 12),
|
||||
_buildWeatherRow(),
|
||||
@@ -319,8 +331,6 @@ class _DroneStationStatusPageState extends State<DroneStationStatusPage> {
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDroneBatteryRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/managers/drone_task_state_manager.dart';
|
||||
import '../../../../../core/network/dio_client.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
@@ -47,6 +52,11 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
List<CameraInfo> _effectiveCameraList = []; // 实际使用的摄像头列表
|
||||
List<CameraInfo>? _backupCameraList; // 备选摄像头列表(网关摄像头)
|
||||
|
||||
// 暂停状态
|
||||
bool _isPaused = false;
|
||||
// 任务下发后等待视频流(用于显示“无人机已启动 视频获取中”toast)
|
||||
bool _isWaitingForVideo = false;
|
||||
|
||||
// 火山引擎 RTC
|
||||
volc.RTCEngine? _rtcEngine;
|
||||
volc.RTCRoom? _rtcRoom;
|
||||
@@ -64,6 +74,9 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
List<LatLng> _trajectoryPoints = [];
|
||||
LatLng? _currentPosition;
|
||||
double? _currentHeading;
|
||||
|
||||
/// 从全局管理器恢复的轨迹点(避免退出后丢失)
|
||||
bool _hasRestoredTrajectory = false;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription;
|
||||
DroneOsdDataSource? _droneOsdDataSource;
|
||||
|
||||
@@ -72,6 +85,23 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
|
||||
// 🔥 初始化"等待视频流"状态(任务下发后进入此页面时显示 toast)
|
||||
_isWaitingForVideo = droneTaskStateManager.isWaitingForVideo.value;
|
||||
droneTaskStateManager.isWaitingForVideo.addListener(_onVideoWaitingChanged);
|
||||
|
||||
// 🔥 从全局管理器恢复历史轨迹(退出视频页后重新进入时不丢失)
|
||||
final savedPoints = droneTaskStateManager.trajectoryPoints.value;
|
||||
if (savedPoints.isNotEmpty) {
|
||||
_trajectoryPoints = savedPoints
|
||||
.map((p) => LatLng(p.latitude, p.longitude))
|
||||
.toList();
|
||||
_currentPosition = _trajectoryPoints.last;
|
||||
_hasRestoredTrajectory = true;
|
||||
debugPrint(
|
||||
'🗺️ [DroneVideoControlPage] 恢复历史轨迹: ${_trajectoryPoints.length} 个点',
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 初始化 MQTT OSD 数据源
|
||||
_droneOsdDataSource = sl<DroneOsdDataSource>();
|
||||
_startOsdListening();
|
||||
@@ -123,11 +153,101 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
// 默认加载广角镜头
|
||||
if (_currentCamera != null) {
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
} else {
|
||||
// 摄像头列表为空(任务下发后无人机刚上线,详情接口尚未返回摄像头数据)
|
||||
// 自动获取无人机详情,拿到摄像头列表
|
||||
_fetchDroneDetailAndLoadCamera();
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动获取无人机详情,拿到摄像头列表
|
||||
/// 任务下发后无人机刚上线,传入的 cameraList 可能为空
|
||||
Future<void> _fetchDroneDetailAndLoadCamera() async {
|
||||
debugPrint('🔍 [DroneVideoControlPage] 摄像头列表为空,自动获取无人机详情');
|
||||
try {
|
||||
final response = await Dio().get(
|
||||
HttpApiConsts.getUAVDetail,
|
||||
queryParameters: {
|
||||
'gatewaySn': widget.gatewaySn,
|
||||
'deviceSn': widget.droneSn,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> jsonData = (response.data is String)
|
||||
? json.decode(response.data)
|
||||
: Map<String, dynamic>.from(response.data);
|
||||
|
||||
if (jsonData['code'] == 0 || jsonData['code'] == 200) {
|
||||
final detailData = jsonData['data'];
|
||||
final Map<String, dynamic> detailMap = (detailData is Map)
|
||||
? Map<String, dynamic>.from(detailData)
|
||||
: <String, dynamic>{};
|
||||
|
||||
final droneDetail = UAVDetailEntity.fromJson(detailMap);
|
||||
final cameras = droneDetail.droneCameraList ?? [];
|
||||
|
||||
debugPrint('✅ [DroneVideoControlPage] 获取到摄像头列表: ${cameras.length}');
|
||||
|
||||
if (cameras.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
_effectiveCameraList = cameras;
|
||||
_currentCamera = cameras.first;
|
||||
_useBackupSource = false;
|
||||
});
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无人机摄像头仍然为空,尝试网关摄像头
|
||||
if (mounted) {
|
||||
_tryGatewayCameraFallback();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [DroneVideoControlPage] 获取无人机详情失败: $e');
|
||||
if (mounted) {
|
||||
_tryGatewayCameraFallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试使用网关摄像头作为备选
|
||||
void _tryGatewayCameraFallback() {
|
||||
if (widget.gatewayCameraList != null &&
|
||||
widget.gatewayCameraList!.isNotEmpty &&
|
||||
_currentCamera == null) {
|
||||
debugPrint('⚠️ [DroneVideoControlPage] 使用网关摄像头作为备选');
|
||||
setState(() {
|
||||
_effectiveCameraList = widget.gatewayCameraList!;
|
||||
_currentCamera = _effectiveCameraList.first;
|
||||
_useBackupSource = true;
|
||||
});
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = '没有可用的摄像头';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onVideoWaitingChanged() {
|
||||
if (!mounted) return;
|
||||
final waiting = droneTaskStateManager.isWaitingForVideo.value;
|
||||
if (waiting != _isWaitingForVideo) {
|
||||
setState(() {
|
||||
_isWaitingForVideo = waiting;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
droneTaskStateManager.isWaitingForVideo.removeListener(
|
||||
_onVideoWaitingChanged,
|
||||
);
|
||||
_osdSubscription?.cancel();
|
||||
_droneOsdDataSource?.dispose();
|
||||
_mapController?.dispose();
|
||||
@@ -253,13 +373,16 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
gatewaySn: widget.gatewaySn,
|
||||
);
|
||||
|
||||
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen((osdData) {
|
||||
if (!mounted) return;
|
||||
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
|
||||
_handleOsdUpdate(osdData);
|
||||
}, onError: (error) {
|
||||
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
|
||||
});
|
||||
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen(
|
||||
(osdData) {
|
||||
if (!mounted) return;
|
||||
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
|
||||
_handleOsdUpdate(osdData);
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('✅ [DroneVideoControlPage] OSD 监听已启动');
|
||||
}
|
||||
@@ -268,103 +391,110 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
void _handleOsdUpdate(DroneOsdEntity osdData) {
|
||||
// 从 rawData 中提取位置信息
|
||||
final rawData = osdData.rawData;
|
||||
|
||||
|
||||
// 🔥 尝试从嵌套结构中获取经纬度
|
||||
double? lat;
|
||||
double? lng;
|
||||
double? heading;
|
||||
|
||||
|
||||
// 路径1: rawData['data']['host']['99-0-0']['measure_target_latitude'] (无人机)
|
||||
if (rawData['data'] is Map &&
|
||||
(rawData['data'] as Map)['host'] is Map) {
|
||||
if (rawData['data'] is Map && (rawData['data'] as Map)['host'] is Map) {
|
||||
final host = (rawData['data'] as Map)['host'] as Map;
|
||||
|
||||
|
||||
// 尝试从 99-0-0 载荷获取(无人机)
|
||||
if (host.containsKey('99-0-0') && host['99-0-0'] is Map) {
|
||||
final payload = host['99-0-0'] as Map;
|
||||
lat = (payload['measure_target_latitude'] as num?)?.toDouble();
|
||||
lng = (payload['measure_target_longitude'] as num?)?.toDouble();
|
||||
debugPrint('✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng');
|
||||
debugPrint(
|
||||
'✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 如果 99-0-0 中没有,尝试从 host 直接获取(机场)
|
||||
if (lat == null || lng == null) {
|
||||
lat = (host['latitude'] as num?)?.toDouble();
|
||||
lng = (host['longitude'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) {
|
||||
debugPrint('✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng');
|
||||
debugPrint(
|
||||
'✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取航向角
|
||||
heading = (host['attitude_head'] as num?)?.toDouble() ??
|
||||
(host['heading'] as num?)?.toDouble();
|
||||
heading =
|
||||
(host['attitude_head'] as num?)?.toDouble() ??
|
||||
(host['heading'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
|
||||
// 兼容旧格式:直接从 rawData 获取
|
||||
if (lat == null || lng == null) {
|
||||
lat = lat ?? (rawData['latitude'] as num?)?.toDouble() ??
|
||||
(rawData['lat'] as num?)?.toDouble();
|
||||
lng = lng ?? (rawData['longitude'] as num?)?.toDouble() ??
|
||||
(rawData['lng'] as num?)?.toDouble() ??
|
||||
(rawData['lon'] as num?)?.toDouble();
|
||||
heading = heading ?? (rawData['heading'] as num?)?.toDouble() ??
|
||||
(rawData['attitudeHeading'] as num?)?.toDouble();
|
||||
lat =
|
||||
lat ??
|
||||
(rawData['latitude'] as num?)?.toDouble() ??
|
||||
(rawData['lat'] as num?)?.toDouble();
|
||||
lng =
|
||||
lng ??
|
||||
(rawData['longitude'] as num?)?.toDouble() ??
|
||||
(rawData['lng'] as num?)?.toDouble() ??
|
||||
(rawData['lon'] as num?)?.toDouble();
|
||||
heading =
|
||||
heading ??
|
||||
(rawData['heading'] as num?)?.toDouble() ??
|
||||
(rawData['attitudeHeading'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
|
||||
debugPrint('🛰️ [DroneVideoControlPage] 收到 OSD 数据');
|
||||
debugPrint(' lat=$lat, lng=$lng, heading=$heading');
|
||||
debugPrint(' 当前轨迹点数: ${_trajectoryPoints.length}');
|
||||
debugPrint(' 当前位置: $_currentPosition');
|
||||
|
||||
|
||||
// 验证位置有效性
|
||||
if (lat != null && lng != null && lat.abs() <= 90 && lng.abs() <= 180) {
|
||||
final newPos = LatLng(lat, lng);
|
||||
|
||||
|
||||
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
|
||||
bool shouldAddPoint = false;
|
||||
if (_trajectoryPoints.isEmpty) {
|
||||
shouldAddPoint = true;
|
||||
} else {
|
||||
final distance = _calculateDistance(
|
||||
_trajectoryPoints.last.latitude,
|
||||
_trajectoryPoints.last.longitude,
|
||||
lat,
|
||||
lng,
|
||||
);
|
||||
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
|
||||
shouldAddPoint = distance > 0.5;
|
||||
}
|
||||
|
||||
if (shouldAddPoint) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
// 性能优化:只保留最近 1000 个点
|
||||
if (_trajectoryPoints.length > 1000) {
|
||||
_trajectoryPoints.removeAt(0);
|
||||
}
|
||||
// 🔥 同步到全局管理器(退出页面后不丢失)
|
||||
droneTaskStateManager.addTrajectoryPoint(
|
||||
DroneTrajectoryPoint(latitude: lat, longitude: lng, heading: heading),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
// 更新当前位置(驱动飞机 Marker)
|
||||
_currentPosition = newPos;
|
||||
_currentHeading = heading;
|
||||
|
||||
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
|
||||
if (_trajectoryPoints.isEmpty) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
debugPrint('✅ [DroneVideoControlPage] 添加第一个轨迹点: $newPos');
|
||||
|
||||
// 🔥 重要:第一个点添加后,等待 UI 构建完成再移动地图
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapController != null && mounted) {
|
||||
_mapController!.move(newPos, 18); // 缩放到 18 级
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 首次定位到: $newPos');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
final distance = _calculateDistance(
|
||||
_trajectoryPoints.last.latitude,
|
||||
_trajectoryPoints.last.longitude,
|
||||
lat!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
|
||||
lng!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
|
||||
);
|
||||
debugPrint(' 📏 距离上一个点: ${distance.toStringAsFixed(2)} 米');
|
||||
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
|
||||
if (distance > 0.5) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
debugPrint('✅ [DroneVideoControlPage] 添加新轨迹点,当前总数: ${_trajectoryPoints.length}');
|
||||
// 性能优化:只保留最近 1000 个点
|
||||
if (_trajectoryPoints.length > 1000) {
|
||||
_trajectoryPoints.removeAt(0);
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [DroneVideoControlPage] 距离不足0.5米(${distance.toStringAsFixed(2)}m),跳过此点');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 地图跟随:后续点也移动地图(保持飞机在视野中)
|
||||
|
||||
// 地图跟随:保持飞机在视野中
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapController != null && mounted) {
|
||||
_mapController!.move(newPos, _mapController!.camera.zoom);
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 地图已移动到: $newPos');
|
||||
if (_trajectoryPoints.length == 1) {
|
||||
_mapController!.move(newPos, 18); // 首次定位缩放到 18 级
|
||||
} else {
|
||||
_mapController!.move(newPos, _mapController!.camera.zoom);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -373,12 +503,20 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
}
|
||||
|
||||
/// 🔥 计算两点之间的距离(米)
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
double _calculateDistance(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const p = 0.017453292519943295; // Math.PI / 180
|
||||
final a = 0.5 -
|
||||
final a =
|
||||
0.5 -
|
||||
cos((lat2 - lat1) * p) / 2 +
|
||||
cos(lat1 * p) * cos(lat2 * p) * (1 - cos((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * asin(sqrt(a)) * 1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
|
||||
return 12742 *
|
||||
asin(sqrt(a)) *
|
||||
1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
|
||||
}
|
||||
|
||||
// 初始化火山引擎事件处理器
|
||||
@@ -416,6 +554,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
// 收到视频流,隐藏“无人机已启动 视频获取中”toast
|
||||
droneTaskStateManager.markVideoReceived();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -664,58 +804,111 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
});
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
});
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
|
||||
if (state.videoStream.urlType == 'volc') {
|
||||
_destroyRtcEngine();
|
||||
_initRtcEngine(state.videoStream);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = '不支持的 URL 类型: ${state.videoStream.urlType}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = '暂无视频';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildVideoPlayer(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
_buildTrajectoryMap(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlightData(),
|
||||
const SizedBox(height: 12),
|
||||
_buildAIResults(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 摇杆控制(单独一行)
|
||||
_buildJoystickControl(),
|
||||
const SizedBox(height: 16),
|
||||
_buildBottomToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (state.videoStream.urlType == 'volc') {
|
||||
_destroyRtcEngine();
|
||||
_initRtcEngine(state.videoStream);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
'不支持的 URL 类型: ${state.videoStream.urlType}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = '暂无视频';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildVideoPlayer(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
_buildTrajectoryMap(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlightData(),
|
||||
const SizedBox(height: 12),
|
||||
_buildAIResults(),
|
||||
const SizedBox(height: 12),
|
||||
// 抓拍/录像/变焦/补光灯(挪到滚动区内)
|
||||
_buildBottomToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 🔥 固定底部栏:方向控制 + 暂停 + 返航
|
||||
_buildFixedBottomBar(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isWaitingForVideo)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(child: _buildVideoWaitingToast()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 任务下发后等待视频流时的居中提示
|
||||
Widget _buildVideoWaitingToast() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE61D2129),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x29000000),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'无人机已启动 视频获取中',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1073,7 +1266,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
debugPrint(' currentPosition: $_currentPosition');
|
||||
debugPrint(' currentHeading: $_currentHeading');
|
||||
debugPrint(' trajectoryPoints.length: ${_trajectoryPoints.length}');
|
||||
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
@@ -1087,9 +1280,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
// 🔥 地图
|
||||
FlutterMap(
|
||||
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
|
||||
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
|
||||
options: MapOptions(
|
||||
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialCenter:
|
||||
_currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
@@ -1098,7 +1292,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
userAgentPackageName: 'com.example.app',
|
||||
),
|
||||
@@ -1178,7 +1373,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -1206,81 +1404,138 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 摇杆控制(单独一行)
|
||||
Widget _buildJoystickControl() {
|
||||
/// 🔥 固定底部栏:暂停 + 返航(不随页面滚动)
|
||||
Widget _buildFixedBottomBar() {
|
||||
return Container(
|
||||
height: 200,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC9CDD4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
// 暂停/恢复按钮
|
||||
_buildActionBtn(
|
||||
label: _isPaused ? '恢复' : '暂停',
|
||||
icon: _isPaused ? Icons.play_arrow : Icons.pause,
|
||||
color: _isPaused
|
||||
? const Color(0xFF00B42A)
|
||||
: const Color(0xFFFF7D00),
|
||||
onTap: () {
|
||||
if (_isPaused) {
|
||||
_sendFlightCommand('flighttask_recovery');
|
||||
} else {
|
||||
_sendFlightCommand('flighttask_pause');
|
||||
}
|
||||
setState(() => _isPaused = !_isPaused);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_left,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_right,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
// 返航按钮
|
||||
_buildActionBtn(
|
||||
label: '返航',
|
||||
icon: Icons.flight_land,
|
||||
color: const Color(0xFF165DFF),
|
||||
onTap: () {
|
||||
_sendFlightCommand('return_home');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 发送飞行指令
|
||||
Future<void> _sendFlightCommand(String command) async {
|
||||
try {
|
||||
final dio = DioClient.create();
|
||||
final response = await dio.post(
|
||||
'http://1.95.137.212:8081/iot/UAV/flightTaskCommand',
|
||||
data: {'command': command, 'deviceSn': widget.droneSn},
|
||||
);
|
||||
|
||||
debugPrint('✅ 飞行指令发送成功: $command, deviceSn: ${widget.droneSn}');
|
||||
debugPrint('响应: ${response.data}');
|
||||
|
||||
String message;
|
||||
switch (command) {
|
||||
case 'flighttask_pause':
|
||||
message = '已发送暂停指令';
|
||||
break;
|
||||
case 'flighttask_recovery':
|
||||
message = '已发送恢复指令';
|
||||
break;
|
||||
case 'return_home':
|
||||
message = '命令下达成功';
|
||||
break;
|
||||
default:
|
||||
message = '指令发送成功';
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ 飞行指令发送失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('指令发送失败'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActionBtn({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapAndJoystick() {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -1300,7 +1555,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialCenter:
|
||||
_currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
@@ -1309,7 +1565,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
userAgentPackageName: 'com.example.app',
|
||||
),
|
||||
@@ -1357,7 +1614,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -1389,14 +1649,20 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'轨迹点: ${_trajectoryPoints.length}',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
class QrScannerPage extends StatefulWidget {
|
||||
const QrScannerPage({super.key});
|
||||
|
||||
@override
|
||||
State<QrScannerPage> createState() => _QrScannerPageState();
|
||||
}
|
||||
|
||||
class _QrScannerPageState extends State<QrScannerPage> {
|
||||
final MobileScannerController _controller = MobileScannerController();
|
||||
bool _isScanned = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_isScanned) return;
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
if (barcodes.isNotEmpty) {
|
||||
final String? code = barcodes.first.rawValue;
|
||||
if (code != null && code.isNotEmpty) {
|
||||
_isScanned = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context, code);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(controller: _controller, onDetect: _onDetect),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'扫一扫',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 250,
|
||||
height: 250,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
right: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
right: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'将二维码放入框内,即可自动扫描',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.flash_on,
|
||||
label: '手电筒',
|
||||
onTap: () async {
|
||||
await _controller.toggleTorch();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../../../core/bluetooth/ble_manager.dart';
|
||||
import '../pages/ble_device_detail_page.dart';
|
||||
|
||||
class BluetoothScanModal extends StatefulWidget {
|
||||
const BluetoothScanModal({super.key});
|
||||
|
||||
@override
|
||||
State<BluetoothScanModal> createState() => _BluetoothScanModalState();
|
||||
}
|
||||
|
||||
class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
|
||||
List<ScanResult> _scanResults = [];
|
||||
bool _isConnecting = false;
|
||||
BluetoothDevice? _connectingDevice;
|
||||
BluetoothDevice? _connectedDevice;
|
||||
int _connectingCountdown = 15;
|
||||
Timer? _connectingTimer;
|
||||
bool _isStartingScan = false;
|
||||
|
||||
StreamSubscription<BluetoothAdapterState>? _stateSubscription;
|
||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||
StreamSubscription<BluetoothDevice?>? _connectionSubscription;
|
||||
StreamSubscription<BluetoothDevice?>? _connectingSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initBluetooth();
|
||||
}
|
||||
|
||||
Future<void> _initBluetooth() async {
|
||||
// 先设置扫描结果监听,确保 startScan 前订阅已就绪
|
||||
_scanSubscription = BleManager.instance.scanResults.listen((results) {
|
||||
if (mounted) {
|
||||
final mgrConnected = BleManager.instance.connectedDevice;
|
||||
setState(() {
|
||||
_connectedDevice = mgrConnected;
|
||||
_scanResults = _sortResults(results);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 先设置连接状态监听
|
||||
_connectionSubscription = BleManager.instance.connectionStream.listen((
|
||||
device,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connectedDevice = device;
|
||||
_isConnecting = false;
|
||||
if (device != null) {
|
||||
_scanResults = _sortResults(_scanResults);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听连接中状态(跨页面同步:详情页连接时列表页也能看到"连接中")
|
||||
_connectingSubscription = BleManager.instance.connectingStream.listen((
|
||||
device,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (device != null) {
|
||||
_isConnecting = true;
|
||||
_connectingDevice = device;
|
||||
} else {
|
||||
_isConnecting = false;
|
||||
_connectingDevice = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 初始检查蓝牙状态并启动扫描(只调用一次)
|
||||
final currentState = await BleManager.instance.checkBluetooth();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_adapterState = currentState
|
||||
? BluetoothAdapterState.on
|
||||
: BluetoothAdapterState.off;
|
||||
});
|
||||
if (currentState) {
|
||||
_startScan();
|
||||
}
|
||||
}
|
||||
|
||||
// 监听后续蓝牙开关变化(用户操作)
|
||||
_stateSubscription = BleManager.instance.adapterState.listen((state) {
|
||||
if (mounted) {
|
||||
setState(() => _adapterState = state);
|
||||
if (state == BluetoothAdapterState.on) {
|
||||
_startScan();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
if (_isStartingScan) return;
|
||||
_isStartingScan = true;
|
||||
try {
|
||||
await BleManager.instance.requestPermissions();
|
||||
if (!mounted) return;
|
||||
await BleManager.instance.startScan(continuous: true);
|
||||
} finally {
|
||||
_isStartingScan = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshScan() async {
|
||||
setState(() {
|
||||
_scanResults = [];
|
||||
});
|
||||
await BleManager.instance.refreshScan();
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
Future<void> _openBluetooth() async {
|
||||
await BleManager.instance.openBluetooth();
|
||||
}
|
||||
|
||||
/// 排序:已连接设备排第一,有名称设备优先
|
||||
List<ScanResult> _sortResults(List<ScanResult> results) {
|
||||
final mgrConnected = BleManager.instance.connectedDevice;
|
||||
final sorted = List<ScanResult>.from(results);
|
||||
sorted.sort((a, b) {
|
||||
// 第一级:已连接排最前
|
||||
final aConnected = a.device.remoteId == mgrConnected?.remoteId;
|
||||
final bConnected = b.device.remoteId == mgrConnected?.remoteId;
|
||||
if (aConnected && !bConnected) return -1;
|
||||
if (!aConnected && bConnected) return 1;
|
||||
// 第二级:有名称的排前面
|
||||
final aHasName = _hasDeviceName(a);
|
||||
final bHasName = _hasDeviceName(b);
|
||||
if (aHasName && !bHasName) return -1;
|
||||
if (!aHasName && bHasName) return 1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// 判断设备是否有可读名称(非 MAC 地址)
|
||||
bool _hasDeviceName(ScanResult r) {
|
||||
final advData = r.advertisementData;
|
||||
return advData.advName.isNotEmpty ||
|
||||
r.device.platformName.isNotEmpty ||
|
||||
(advData.localName?.isNotEmpty == true) ||
|
||||
r.device.advName.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 从详情页返回时同步连接状态(包括连接中和已连接)
|
||||
void _syncConnectedDevice() {
|
||||
final mgrConnected = BleManager.instance.connectedDevice;
|
||||
final mgrConnecting = BleManager.instance.connectingDevice;
|
||||
if (mgrConnected?.remoteId != _connectedDevice?.remoteId ||
|
||||
mgrConnecting?.remoteId != _connectingDevice?.remoteId) {
|
||||
setState(() {
|
||||
_connectedDevice = mgrConnected;
|
||||
_connectingDevice = mgrConnecting;
|
||||
_isConnecting = mgrConnecting != null;
|
||||
_scanResults = _sortResults(_scanResults);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectDevice(ScanResult result) async {
|
||||
// 如果已连接其他设备,弹出提示
|
||||
final currentConnected = BleManager.instance.connectedDevice;
|
||||
if (currentConnected != null &&
|
||||
currentConnected.remoteId != result.device.remoteId) {
|
||||
final connName = currentConnected.platformName.isNotEmpty
|
||||
? currentConnected.platformName
|
||||
: '${currentConnected.remoteId}';
|
||||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('已有设备连接'),
|
||||
content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_connectingDevice = result.device;
|
||||
_connectingCountdown = 15;
|
||||
});
|
||||
|
||||
// 启动倒计时
|
||||
_connectingTimer?.cancel();
|
||||
_connectingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (_connectingCountdown > 0) {
|
||||
_connectingCountdown--;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
final error = await BleManager.instance.connect(result.device);
|
||||
|
||||
// 取消倒计时
|
||||
_connectingTimer?.cancel();
|
||||
_connectingTimer = null;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_connectingDevice = null;
|
||||
if (error == null) {
|
||||
_connectedDevice = result.device;
|
||||
}
|
||||
});
|
||||
|
||||
if (error == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'已连接 ${result.device.platformName.isEmpty ? result.device.remoteId : result.device.platformName}',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (error != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectDevice() async {
|
||||
await BleManager.instance.disconnect();
|
||||
if (mounted) {
|
||||
setState(() => _connectedDevice = null);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isBluetoothOn => _adapterState == BluetoothAdapterState.on;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSubscription?.cancel();
|
||||
_scanSubscription?.cancel();
|
||||
_connectionSubscription?.cancel();
|
||||
_connectingSubscription?.cancel();
|
||||
_connectingTimer?.cancel();
|
||||
unawaited(BleManager.instance.stopScan());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
// 弹窗关闭时立即停止扫描,不等待 dispose() 动画延迟
|
||||
BleManager.instance.stopScan();
|
||||
},
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
_buildStatusCard(),
|
||||
if (_connectedDevice != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildConnectedDeviceCard(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_buildDeviceList(),
|
||||
const SizedBox(height: 12),
|
||||
_buildTipBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1677FF),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'蓝牙设备',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_connectedDevice != null
|
||||
? '当前已连接: ${_connectedDevice!.platformName.isEmpty ? _connectedDevice!.remoteId : _connectedDevice!.platformName}'
|
||||
: _isBluetoothOn
|
||||
? '正在扫描周边设备...'
|
||||
: '请先打开蓝牙',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.white, size: 22),
|
||||
onPressed: _isBluetoothOn ? _refreshScan : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () {
|
||||
BleManager.instance.stopScan();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard() {
|
||||
final isOn = _isBluetoothOn;
|
||||
final hasConnected = _connectedDevice != null;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isOn ? const Color(0xFF165DFF) : const Color(0xFF86909C),
|
||||
),
|
||||
child: const Icon(Icons.bluetooth, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOn ? '蓝牙已开启' : '蓝牙未打开',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasConnected
|
||||
? '已连接设备'
|
||||
: isOn
|
||||
? '正在扫描周边设备'
|
||||
: '请打开蓝牙以扫描附近设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
isOn
|
||||
? Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF00B42A),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: _openBluetooth,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF165DFF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'去打开',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceList() {
|
||||
// 过滤掉已连接设备(它已在状态卡片下方单独显示)
|
||||
final otherResults = _connectedDevice != null
|
||||
? _scanResults
|
||||
.where((r) => r.device.remoteId != _connectedDevice!.remoteId)
|
||||
.toList()
|
||||
: _scanResults;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'附近设备',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!_isBluetoothOn)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
size: 48,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'蓝牙未开启,无法扫描设备',
|
||||
style: TextStyle(color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (otherResults.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在扫描附近设备...',
|
||||
style: TextStyle(color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (int i = 0; i < otherResults.length; i++) ...[
|
||||
_buildDeviceItem(otherResults[i]),
|
||||
if (i < otherResults.length - 1) const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 已连接设备卡片(始终显示在状态卡片下方,不依赖扫描结果)
|
||||
Widget _buildConnectedDeviceCard() {
|
||||
final device = _connectedDevice!;
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: (device.advName.isNotEmpty ? device.advName : '${device.remoteId}');
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(device: device),
|
||||
),
|
||||
).then((_) => _syncConnectedDevice());
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0FFF4),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF00B42A), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00B42A).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_connected,
|
||||
color: Color(0xFF00B42A),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00B42A),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${device.remoteId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _disconnectDevice,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00B42A),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'断开',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceItem(ScanResult result) {
|
||||
final device = result.device;
|
||||
final advData = result.advertisementData;
|
||||
// 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的
|
||||
final name = advData.advName.isNotEmpty
|
||||
? advData.advName
|
||||
: (device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: (advData.localName?.isNotEmpty == true
|
||||
? advData.localName!
|
||||
: (device.advName.isNotEmpty
|
||||
? device.advName
|
||||
: '${device.remoteId}')));
|
||||
final isConnected = _connectedDevice?.remoteId == device.remoteId;
|
||||
final isConnecting =
|
||||
_connectingDevice?.remoteId == device.remoteId && _isConnecting;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
BleDeviceDetailPage(device: device, scanResult: result),
|
||||
),
|
||||
).then((_) => _syncConnectedDevice());
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF165DFF).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.devices,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${device.remoteId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (isConnected) {
|
||||
_disconnectDevice();
|
||||
} else if (!isConnecting) {
|
||||
_connectDevice(result);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isConnected
|
||||
? const Color(0xFF00B42A)
|
||||
: const Color(0xFFFF7D00),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: isConnecting
|
||||
? Text(
|
||||
'${_connectingCountdown}s',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
isConnected ? '已连接' : '连接',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTipBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'请选择现场本机设备,避免连接无关设备',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFFF7D00)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,11 @@ import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_state.dart'; // 🔥 导入 AppUserState
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
|
||||
import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart';
|
||||
import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart';
|
||||
|
||||
/// 机器人顶部信息卡片
|
||||
class RobotHeaderCard extends StatefulWidget {
|
||||
class RobotHeaderCard extends StatefulWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
const RobotHeaderCard({super.key, required this.robot});
|
||||
@@ -58,20 +60,26 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.robot['name'] as String,
|
||||
(widget.robot['alias'] as String?)?.isNotEmpty == true
|
||||
? widget.robot['alias'] as String
|
||||
: '暂无别名',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ID: ${widget.robot['id']}',
|
||||
widget.robot['name'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -115,7 +123,23 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 设置图标
|
||||
const Icon(Icons.settings, size: 20, color: Color(0xFF86909C)),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RobotParamSettingsPage(
|
||||
robot: widget.robot,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.settings,
|
||||
size: 20,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../home/presentation/pages/running_status_page.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 机器人状态栏
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../../components/device_status_modal.dart';
|
||||
import '../../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../../devices/presentation/bloc/device_status_state.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
|
||||
/// 机器人状态栏 - 实时显示设备推送的运行状态
|
||||
class RobotStatusBar extends StatelessWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
@@ -11,67 +18,127 @@ class RobotStatusBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const RunningStatusPage(),
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (BuildContext ctx) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: GetIt.I<DeviceStatusBloc>()),
|
||||
BlocProvider.value(value: GetIt.I<RemoteControlCubit>()),
|
||||
],
|
||||
child: const DeviceStatusModal(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
child: StreamBuilder<DeviceStatusState>(
|
||||
stream: GetIt.I<DeviceStatusBloc>().stream,
|
||||
initialData: GetIt.I<DeviceStatusBloc>().state,
|
||||
builder: (context, snapshot) {
|
||||
final state = snapshot.data;
|
||||
|
||||
String speed = '--';
|
||||
String mode = '--';
|
||||
String battery = '--';
|
||||
String signal = '--';
|
||||
Color signalColor = const Color(0xFF86909C);
|
||||
Color batteryColor = const Color(0xFF86909C);
|
||||
|
||||
if (state is DeviceStatusUpdated) {
|
||||
final s = state.status;
|
||||
speed = s.leftMeasureSpeed.toStringAsFixed(0);
|
||||
mode = s.controlMode == '3' ? '远程' : '本地';
|
||||
battery = s.battery.isNotEmpty ? s.battery : '--';
|
||||
|
||||
final qual = s.qual;
|
||||
if (qual >= 4) {
|
||||
signal = '强';
|
||||
signalColor = const Color(0xFF00B42A);
|
||||
} else if (qual >= 2) {
|
||||
signal = '中';
|
||||
signalColor = const Color(0xFFFF7D00);
|
||||
} else if (qual >= 1) {
|
||||
signal = '弱';
|
||||
signalColor = const Color(0xFFF53F3F);
|
||||
} else {
|
||||
signal = '无';
|
||||
signalColor = const Color(0xFF86909C);
|
||||
}
|
||||
|
||||
final batValue = int.tryParse(battery) ?? 0;
|
||||
if (batValue > 50) {
|
||||
batteryColor = const Color(0xFF00B42A);
|
||||
} else if (batValue > 20) {
|
||||
batteryColor = const Color(0xFFFF7D00);
|
||||
} else if (batValue > 0) {
|
||||
batteryColor = const Color(0xFFF53F3F);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildStatusItem(
|
||||
label: '速度',
|
||||
value: '1.2',
|
||||
unit: 'm/s',
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildStatusItem(
|
||||
label: '转速',
|
||||
value: speed,
|
||||
unit: 'rpm',
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '模式',
|
||||
value: mode,
|
||||
unit: '',
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '电量',
|
||||
value: battery,
|
||||
unit: '%',
|
||||
valueColor: batteryColor,
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '信号',
|
||||
value: signal,
|
||||
unit: '',
|
||||
valueColor: signalColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '里程',
|
||||
value: '2.36',
|
||||
unit: 'km',
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '电量',
|
||||
value: '82',
|
||||
unit: '%',
|
||||
valueColor: const Color(0xFF00B42A),
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '信号',
|
||||
value: '强',
|
||||
unit: '',
|
||||
valueColor: const Color(0xFF00B42A),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -88,27 +155,31 @@ class RobotStatusBar extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor ?? const Color(0xFF1D2129),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor ?? const Color(0xFF1D2129),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty)
|
||||
Text(
|
||||
unit,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: valueColor ?? const Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../models/device_run_param_model.dart';
|
||||
|
||||
abstract class DeviceRunParamRemoteDataSource {
|
||||
Future<Either<Failure, DeviceRunParamModel>> getByDeviceId(String deviceId);
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
import '../models/device_run_param_model.dart';
|
||||
import 'device_run_param_remote_datasource.dart';
|
||||
|
||||
class DeviceRunParamRemoteDataSourceImpl
|
||||
implements DeviceRunParamRemoteDataSource {
|
||||
DeviceRunParamRemoteDataSourceImpl(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DeviceRunParamModel>> getByDeviceId(
|
||||
String deviceId,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.deviceRunParamSelect,
|
||||
queryParameters: {'deviceId': deviceId},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final code = body['code'];
|
||||
if (code != null && code.toString() == '200') {
|
||||
final data = body['data'] as Map<String, dynamic>? ?? {};
|
||||
return right(DeviceRunParamModel.fromJson(data));
|
||||
} else {
|
||||
return left(Failure(body['msg'] ?? '获取设备运行参数失败'));
|
||||
}
|
||||
} else {
|
||||
return left(Failure('HTTP错误: ${response.statusCode}'));
|
||||
}
|
||||
} catch (e) {
|
||||
return left(Failure('获取设备运行参数异常: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.deviceRunParamSave,
|
||||
data: params,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final code = body['code'];
|
||||
if (code != null && code.toString() == '200') {
|
||||
return right(true);
|
||||
} else {
|
||||
return left(Failure(body['msg'] ?? '保存设备运行参数失败'));
|
||||
}
|
||||
} else {
|
||||
return left(Failure('HTTP错误: ${response.statusCode}'));
|
||||
}
|
||||
} catch (e) {
|
||||
return left(Failure('保存设备运行参数异常: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
|
||||
class DeviceRunParamModel extends DeviceRunParamEntity {
|
||||
DeviceRunParamModel({
|
||||
required super.id,
|
||||
required super.deviceId,
|
||||
required super.siteId,
|
||||
required super.orgId,
|
||||
required super.runSpeed,
|
||||
required super.leftForwardGain,
|
||||
required super.leftBackwardGain,
|
||||
required super.rightForwardGain,
|
||||
required super.rightBackwardGain,
|
||||
required super.rawData,
|
||||
});
|
||||
|
||||
factory DeviceRunParamModel.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceRunParamModel(
|
||||
id: (json['id'] as dynamic)?.toInt() ?? 0,
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
siteId: (json['siteId'] as dynamic)?.toInt() ?? 0,
|
||||
orgId: (json['orgId'] as dynamic)?.toInt() ?? 0,
|
||||
runSpeed: (json['runSpeed'] as dynamic)?.toDouble() ?? 0.0,
|
||||
leftForwardGain:
|
||||
(json['leftForwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
leftBackwardGain:
|
||||
(json['leftBackwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
rightForwardGain:
|
||||
(json['rightForwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
rightBackwardGain:
|
||||
(json['rightBackwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
rawData: Map<String, dynamic>.from(json),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'deviceId': deviceId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
'runSpeed': runSpeed,
|
||||
'leftForwardGain': leftForwardGain,
|
||||
'leftBackwardGain': leftBackwardGain,
|
||||
'rightForwardGain': rightForwardGain,
|
||||
'rightBackwardGain': rightBackwardGain,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
import '../../domain/repositories/device_run_param_repository.dart';
|
||||
import '../datasources/device_run_param_remote_datasource.dart';
|
||||
|
||||
class DeviceRunParamRepositoryImpl implements DeviceRunParamRepository {
|
||||
DeviceRunParamRepositoryImpl({required this.remoteDataSource});
|
||||
|
||||
final DeviceRunParamRemoteDataSource remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DeviceRunParamEntity>> getByDeviceId(
|
||||
String deviceId,
|
||||
) async {
|
||||
final result = await remoteDataSource.getByDeviceId(deviceId);
|
||||
return result.fold(
|
||||
(failure) => left(failure),
|
||||
(model) => right(model),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params) async {
|
||||
return await remoteDataSource.save(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// 设备运行参数实体
|
||||
class DeviceRunParamEntity {
|
||||
final int id;
|
||||
final String deviceId;
|
||||
final int siteId;
|
||||
final int orgId;
|
||||
final double runSpeed;
|
||||
final double leftForwardGain;
|
||||
final double leftBackwardGain;
|
||||
final double rightForwardGain;
|
||||
final double rightBackwardGain;
|
||||
/// API 返回的全部原始字段,用于页面展示
|
||||
final Map<String, dynamic> rawData;
|
||||
|
||||
DeviceRunParamEntity({
|
||||
required this.id,
|
||||
required this.deviceId,
|
||||
required this.siteId,
|
||||
required this.orgId,
|
||||
required this.runSpeed,
|
||||
required this.leftForwardGain,
|
||||
required this.leftBackwardGain,
|
||||
required this.rightForwardGain,
|
||||
required this.rightBackwardGain,
|
||||
required this.rawData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../entities/device_run_param_entity.dart';
|
||||
|
||||
abstract class DeviceRunParamRepository {
|
||||
Future<Either<Failure, DeviceRunParamEntity>> getByDeviceId(String deviceId);
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../entities/device_run_param_entity.dart';
|
||||
import '../repositories/device_run_param_repository.dart';
|
||||
|
||||
class GetDeviceRunParamUseCase {
|
||||
final DeviceRunParamRepository repository;
|
||||
|
||||
GetDeviceRunParamUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, DeviceRunParamEntity>> execute(String deviceId) async {
|
||||
return await repository.getByDeviceId(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
class SaveDeviceRunParamUseCase {
|
||||
final DeviceRunParamRepository repository;
|
||||
|
||||
SaveDeviceRunParamUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, bool>> execute(Map<String, dynamic> params) async {
|
||||
return await repository.save(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../../core/app/app_user_cubit.dart';
|
||||
import '../../../site/presentation/cubit/site_cubit.dart';
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
import '../../domain/usecases/device_run_param_usecases.dart';
|
||||
import '../../../../../core/services/device_permission_service.dart';
|
||||
|
||||
class RobotParamSettingsPage extends StatefulWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
const RobotParamSettingsPage({super.key, required this.robot});
|
||||
|
||||
@override
|
||||
State<RobotParamSettingsPage> createState() => _RobotParamSettingsPageState();
|
||||
}
|
||||
|
||||
class _RobotParamSettingsPageState extends State<RobotParamSettingsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
String? _errorMessage;
|
||||
DeviceRunParamEntity? _param;
|
||||
|
||||
// 表单控制器(仅可编辑的5个字段)
|
||||
late TextEditingController _runSpeedController;
|
||||
late TextEditingController _leftForwardGainController;
|
||||
late TextEditingController _leftBackwardGainController;
|
||||
late TextEditingController _rightForwardGainController;
|
||||
late TextEditingController _rightBackwardGainController;
|
||||
|
||||
String get _deviceId => widget.robot['name'] as String? ?? '';
|
||||
|
||||
// ============ 字段定义 ============
|
||||
static const _editableKeys = {
|
||||
'runSpeed',
|
||||
'leftForwardGain',
|
||||
'leftBackwardGain',
|
||||
'rightForwardGain',
|
||||
'rightBackwardGain',
|
||||
};
|
||||
|
||||
static const _skipKeys = {
|
||||
'createBy', 'createTime', 'updateBy', 'updateTime', 'delFlag', 'remark',
|
||||
};
|
||||
|
||||
static const _fieldLabels = {
|
||||
'id': 'ID',
|
||||
'deviceId': '设备ID',
|
||||
'siteId': '场站ID',
|
||||
'orgId': '组织ID',
|
||||
'runSpeed': '运行速度',
|
||||
'header1': 'Header1',
|
||||
'header2': 'Header2',
|
||||
'cmd': 'CMD',
|
||||
'chipUidSign': '芯片UID标记',
|
||||
'chipUid': '芯片UID',
|
||||
'remoteConfig': '远程配置',
|
||||
'knifeMotorMode': '刀盘电机模式',
|
||||
'walkMotorMode': '行走电机模式',
|
||||
'leftMotorReverse': '左电机反转',
|
||||
'rightMotorReverse': '右电机反转',
|
||||
'swapChannel': '交换通道',
|
||||
'use4G': '使用4G',
|
||||
'forwardSpeedLimit': '前进限速',
|
||||
'turnSpeedLimit': '转弯限速',
|
||||
'knifePolarity': '刀盘极性',
|
||||
'fanPolarity': '风扇极性',
|
||||
'throttlePolarity': '油门极性',
|
||||
'liftProtectTime': '升降保护时间',
|
||||
'dualRtk': '双RTK',
|
||||
'knifeChannel': '刀盘通道',
|
||||
'fanChannel': '风扇通道',
|
||||
'throttleChannel': '油门通道',
|
||||
'remoteType': '遥控类型',
|
||||
'relayBoard': '继电器板',
|
||||
'liftChannel': '升降通道',
|
||||
'chassisChannel': '底盘通道',
|
||||
'armChannel': '机械臂通道',
|
||||
'fuelPumpChannel': '燃油泵通道',
|
||||
'wifiName': 'WiFi名称',
|
||||
'wifiPassword': 'WiFi密码',
|
||||
'batteryType': '电池类型',
|
||||
'driveType': '驱动类型',
|
||||
'gearRatio': '齿轮比',
|
||||
'robotLength': '机器人长度',
|
||||
'robotWidth': '机器人宽度',
|
||||
'robotHeight': '机器人高度',
|
||||
'knifeWidth': '刀盘宽度',
|
||||
'tyreSize': '轮胎尺寸',
|
||||
'leftForwardGain': '左轮前进',
|
||||
'leftBackwardGain': '左轮后退',
|
||||
'rightForwardGain': '右轮前进',
|
||||
'rightBackwardGain': '右轮后退',
|
||||
'firmwareVersion': '固件版本',
|
||||
'crc16': 'CRC16',
|
||||
'tail1': '尾部1',
|
||||
'tail2': '尾部2',
|
||||
};
|
||||
|
||||
// ============ 生命周期 ============
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_initControllers();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _initControllers() {
|
||||
_runSpeedController = TextEditingController();
|
||||
_leftForwardGainController = TextEditingController();
|
||||
_leftBackwardGainController = TextEditingController();
|
||||
_rightForwardGainController = TextEditingController();
|
||||
_rightBackwardGainController = TextEditingController();
|
||||
}
|
||||
|
||||
void _fillControllers(DeviceRunParamEntity param) {
|
||||
_runSpeedController.text = param.runSpeed.toStringAsFixed(0);
|
||||
_leftForwardGainController.text = param.leftForwardGain.toString();
|
||||
_leftBackwardGainController.text = param.leftBackwardGain.toString();
|
||||
_rightForwardGainController.text = param.rightForwardGain.toString();
|
||||
_rightBackwardGainController.text = param.rightBackwardGain.toString();
|
||||
}
|
||||
|
||||
TextEditingController? _controllerForKey(String key) {
|
||||
switch (key) {
|
||||
case 'runSpeed': return _runSpeedController;
|
||||
case 'leftForwardGain': return _leftForwardGainController;
|
||||
case 'leftBackwardGain': return _leftBackwardGainController;
|
||||
case 'rightForwardGain': return _rightForwardGainController;
|
||||
case 'rightBackwardGain': return _rightBackwardGainController;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _showReadonlyTip() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('暂不支持修改'),
|
||||
duration: Duration(seconds: 1),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.only(bottom: 80, left: 80, right: 80),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_runSpeedController.dispose();
|
||||
_leftForwardGainController.dispose();
|
||||
_leftBackwardGainController.dispose();
|
||||
_rightForwardGainController.dispose();
|
||||
_rightBackwardGainController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ============ 数据加载 ============
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final useCase = GetIt.I<GetDeviceRunParamUseCase>();
|
||||
final result = await useCase.execute(_deviceId);
|
||||
|
||||
if (!mounted) return;
|
||||
result.fold(
|
||||
(failure) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = failure.message;
|
||||
});
|
||||
},
|
||||
(param) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_param = param;
|
||||
});
|
||||
_fillControllers(param);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 保存 ============
|
||||
Future<void> _handleSave() async {
|
||||
if (_isSaving || _param == null) return;
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
// 🔐 前置权限校验:只有 code=200 && data=true 才允许保存
|
||||
final permissionService = GetIt.I<DevicePermissionService>();
|
||||
final hasPermission = await permissionService.checkPermission(_deviceId);
|
||||
|
||||
if (!mounted) return;
|
||||
if (!hasPermission) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('权限校验未通过,无法保存', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限通过提示
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('权限通过'),
|
||||
],
|
||||
),
|
||||
content: const Text('您有权限操作此设备'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final siteId = GetIt.I<SiteCubit>().state.selectedSite?.id ?? 0;
|
||||
final orgId = GetIt.I<AppUserCubit>().state.user?.orgId ?? 0;
|
||||
|
||||
final useCase = GetIt.I<SaveDeviceRunParamUseCase>();
|
||||
final params = <String, dynamic>{
|
||||
'id': _param!.id,
|
||||
'deviceId': _deviceId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
'runSpeed': int.tryParse(_runSpeedController.text) ?? _param!.runSpeed,
|
||||
'leftForwardGain':
|
||||
double.tryParse(_leftForwardGainController.text) ??
|
||||
_param!.leftForwardGain,
|
||||
'leftBackwardGain':
|
||||
double.tryParse(_leftBackwardGainController.text) ??
|
||||
_param!.leftBackwardGain,
|
||||
'rightForwardGain':
|
||||
double.tryParse(_rightForwardGainController.text) ??
|
||||
_param!.rightForwardGain,
|
||||
'rightBackwardGain':
|
||||
double.tryParse(_rightBackwardGainController.text) ??
|
||||
_param!.rightBackwardGain,
|
||||
};
|
||||
|
||||
final result = await useCase.execute(params);
|
||||
|
||||
if (!mounted) return;
|
||||
result.fold(
|
||||
(failure) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('保存失败: ${failure.message}', isError: true);
|
||||
},
|
||||
(success) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('保存成功');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('保存异常: $e', isError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ UI ============
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0.5,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'参数设置',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_deviceId,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(44),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: const Color(0xFF165DFF),
|
||||
indicatorWeight: 2,
|
||||
labelColor: const Color(0xFF165DFF),
|
||||
unselectedLabelColor: const Color(0xFF86909C),
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: '参数设置'),
|
||||
Tab(text: '增益参数设置'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildParamTab(),
|
||||
_buildGainParamTab(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 参数设置 Tab ============
|
||||
Widget _buildParamTab() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Color(0xFF86909C)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadData,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final raw = _param!.rawData;
|
||||
final keys = raw.keys
|
||||
.where((k) => !_skipKeys.contains(k))
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(keys.length, (i) {
|
||||
final key = keys[i];
|
||||
final label = _fieldLabels[key] ?? key;
|
||||
final isEditable = _editableKeys.contains(key);
|
||||
final isLast = i == keys.length - 1;
|
||||
final value = raw[key];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildFieldRow(key, label, value, isEditable),
|
||||
if (!isLast) _buildDivider(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 增益参数 Tab ============
|
||||
Widget _buildGainParamTab() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.construction, size: 48, color: Color(0xFFC9CDD4)),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'暂未开放',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 单行字段 ============
|
||||
Widget _buildFieldRow(
|
||||
String key,
|
||||
String label,
|
||||
dynamic value,
|
||||
bool editable,
|
||||
) {
|
||||
final controller = editable ? _controllerForKey(key) : null;
|
||||
final displayValue = value?.toString() ?? '-';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF4E5969),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: editable
|
||||
? TextField(
|
||||
controller: controller,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
|
||||
],
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
decoration: _inputDecoration(),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: _showReadonlyTip,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: Text(
|
||||
displayValue,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration() {
|
||||
return const InputDecoration(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
filled: true,
|
||||
fillColor: Color(0xFFF7F8FA),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFF165DFF)),
|
||||
),
|
||||
isDense: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(height: 1, color: Color(0xFFF2F3F5));
|
||||
}
|
||||
|
||||
// ============ 底部保存按钮 ============
|
||||
Widget _buildBottomBar() {
|
||||
final bottom = MediaQuery.of(context).padding.bottom;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottom),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading || _isSaving ? null : _handleSave,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'保存',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import '../../domain/entities/site_entity.dart';
|
||||
|
||||
abstract class SiteDataSource {
|
||||
Future<List<SiteEntity>> getSiteList(int orgId);
|
||||
Future<List<SiteEntity>> getSiteList(String userId);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
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/v2/home/data/datasources/site_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
|
||||
|
||||
@@ -14,42 +15,38 @@ class SiteDataSourceImpl implements SiteDataSource {
|
||||
SiteDataSourceImpl(this.dio, this._userStorage, this._appUserCubit);
|
||||
|
||||
@override
|
||||
Future<List<SiteEntity>> getSiteList(int orgId) async {
|
||||
Future<List<SiteEntity>> getSiteList(String userId) async {
|
||||
print('🔍 [SiteDataSource] 开始获取 Token...');
|
||||
print('🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}');
|
||||
|
||||
// 优先从全局状态获取 Token(更快更可靠)
|
||||
print(
|
||||
'🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}',
|
||||
);
|
||||
|
||||
var token = _appUserCubit.state.user?.token;
|
||||
|
||||
print('🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
|
||||
|
||||
// 如果全局状态没有,再从本地存储获取
|
||||
|
||||
print(
|
||||
'🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
|
||||
);
|
||||
|
||||
if (token == null) {
|
||||
print('⚠️ [SiteDataSource] AppUserCubit 没有 Token,尝试从本地存储获取...');
|
||||
final user = await _userStorage.getUser();
|
||||
token = user?.token;
|
||||
print('🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
|
||||
print(
|
||||
'🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
|
||||
);
|
||||
}
|
||||
|
||||
print('🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
|
||||
|
||||
// 构建查询参数:orgId 为 0 时不传递
|
||||
final queryParams = <String, dynamic>{
|
||||
'pageNum': 1,
|
||||
'pageSize': 9999,
|
||||
};
|
||||
|
||||
if (orgId != 0) {
|
||||
queryParams['orgId'] = orgId;
|
||||
}
|
||||
|
||||
print(
|
||||
'🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
|
||||
);
|
||||
|
||||
final queryParams = <String, dynamic>{'userId': userId};
|
||||
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getSiteList,
|
||||
queryParameters: queryParams,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
},
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -59,17 +56,27 @@ class SiteDataSourceImpl implements SiteDataSource {
|
||||
|
||||
final responseData = response.data;
|
||||
|
||||
if (responseData['code'] == 401 || responseData['code'] == 403) {
|
||||
print(
|
||||
'🚨 [SiteDataSource] 收到认证错误码 ${responseData['code']},触发 Token 过期处理',
|
||||
);
|
||||
try {
|
||||
GetIt.I<AuthCubit>().tokenExpired();
|
||||
} catch (e) {}
|
||||
throw Exception('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '业务异常');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = responseData['rows'] ?? [];
|
||||
|
||||
final List<dynamic> rows = responseData['data'] ?? [];
|
||||
|
||||
print('📤 [SiteDataSource] 接口返回原始数据:');
|
||||
for (var i = 0; i < rows.length && i < 3; i++) {
|
||||
print(' 场站$i: ${rows[i]}');
|
||||
}
|
||||
|
||||
|
||||
return rows.map((item) => SiteEntity.fromJson(item)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ class SiteRepositoryImpl implements SiteRepository {
|
||||
SiteRepositoryImpl(this.dataSource);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId) async {
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(String userId) async {
|
||||
try {
|
||||
final sites = await dataSource.getSiteList(orgId);
|
||||
final sites = await dataSource.getSiteList(userId);
|
||||
return Right(sites);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
|
||||
@@ -5,5 +5,5 @@ import '../../../../../core/error/failure.dart';
|
||||
import '../entities/site_entity.dart';
|
||||
|
||||
abstract class SiteRepository {
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId);
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(String userId);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ class GetSiteListUseCase {
|
||||
|
||||
GetSiteListUseCase(this.repository);
|
||||
|
||||
// pageNum 和 pageSize 固定,orgId 从登录用户信息中获取
|
||||
Future<Either<Failure, List<SiteEntity>>> call(int orgId) async {
|
||||
return await repository.getSiteList(orgId);
|
||||
// userId 从登录用户信息中获取
|
||||
Future<Either<Failure, List<SiteEntity>>> call(String userId) async {
|
||||
return await repository.getSiteList(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
final AppUserCubit appUserCubit;
|
||||
final SiteCubit siteCubit;
|
||||
|
||||
HomeV2Bloc(this.getHomeDataUseCase, this.getSiteListUseCase, this.appUserCubit, this.siteCubit) : super(const HomeV2Initial()) {
|
||||
HomeV2Bloc(
|
||||
this.getHomeDataUseCase,
|
||||
this.getSiteListUseCase,
|
||||
this.appUserCubit,
|
||||
this.siteCubit,
|
||||
) : super(const HomeV2Initial()) {
|
||||
on<HomeV2LoadData>(_onLoadData);
|
||||
on<HomeV2Refresh>(_onRefresh);
|
||||
on<HomeV2ToggleTrendType>(_onToggleTrendType);
|
||||
@@ -28,26 +33,25 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
|
||||
final user = appUserCubit.state.user;
|
||||
if (user == null) {
|
||||
emit(const HomeV2Error(
|
||||
message: '用户未登录',
|
||||
shouldShowError: true,
|
||||
));
|
||||
emit(const HomeV2Error(message: '用户未登录', shouldShowError: true));
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行加载首页数据和场站列表
|
||||
final homeResult = await getHomeDataUseCase(const NoParams());
|
||||
final siteResult = await getSiteListUseCase(user.orgId); // 使用用户的 orgId
|
||||
final siteResult = await getSiteListUseCase(user.userId); // 使用用户的 userId
|
||||
|
||||
homeResult.fold(
|
||||
(failure) => emit(HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
)),
|
||||
(failure) => emit(
|
||||
HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
),
|
||||
),
|
||||
(homeData) {
|
||||
List<SiteEntity> sites = [];
|
||||
SiteEntity? selectedSite;
|
||||
|
||||
|
||||
siteResult.fold(
|
||||
(failure) {
|
||||
print('加载场站列表失败: ${failure.message}');
|
||||
@@ -56,7 +60,7 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
sites = siteList;
|
||||
// 从全局 SiteCubit 获取之前选中的场站
|
||||
final savedSelectedSite = siteCubit.state.selectedSite;
|
||||
|
||||
|
||||
// 尝试找到之前选中的场站
|
||||
if (savedSelectedSite != null && siteList.isNotEmpty) {
|
||||
selectedSite = siteList.firstWhere(
|
||||
@@ -67,19 +71,21 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
// 没有选中过,默认选中第一个
|
||||
selectedSite = siteList.isNotEmpty ? siteList.first : null;
|
||||
}
|
||||
|
||||
|
||||
// 更新全局 SiteCubit 的选中状态
|
||||
if (selectedSite != null) {
|
||||
siteCubit.selectSite(selectedSite!);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
emit(HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
));
|
||||
|
||||
emit(
|
||||
HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -90,29 +96,28 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
) async {
|
||||
if (state is HomeV2Loaded) {
|
||||
final currentState = state as HomeV2Loaded;
|
||||
|
||||
|
||||
final user = appUserCubit.state.user;
|
||||
if (user == null) {
|
||||
emit(const HomeV2Error(
|
||||
message: '用户未登录',
|
||||
shouldShowError: true,
|
||||
));
|
||||
emit(const HomeV2Error(message: '用户未登录', shouldShowError: true));
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行刷新首页数据和电站列表
|
||||
final homeResult = await getHomeDataUseCase(const NoParams());
|
||||
final siteResult = await getSiteListUseCase(user.orgId);
|
||||
final siteResult = await getSiteListUseCase(user.userId);
|
||||
|
||||
homeResult.fold(
|
||||
(failure) => emit(HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
)),
|
||||
(failure) => emit(
|
||||
HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
),
|
||||
),
|
||||
(homeData) {
|
||||
List<SiteEntity> sites = currentState.sites;
|
||||
SiteEntity? selectedSite = currentState.selectedSite;
|
||||
|
||||
|
||||
siteResult.fold(
|
||||
(failure) {
|
||||
print('刷新场站列表失败: ${failure.message}');
|
||||
@@ -129,20 +134,22 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
} else if (siteList.isNotEmpty) {
|
||||
selectedSite = siteList.first;
|
||||
}
|
||||
|
||||
|
||||
// 更新全局 SiteCubit 的选中状态
|
||||
if (selectedSite != null) {
|
||||
siteCubit.selectSite(selectedSite!);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
emit(HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
trendType: currentState.trendType,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
));
|
||||
|
||||
emit(
|
||||
HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
trendType: currentState.trendType,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_bloc
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_event.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/site/presentation/widgets/site_selector_widget.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_card.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/stats_grid.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/work_order_card.dart';
|
||||
@@ -60,9 +61,16 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.orange),
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: const TextStyle(fontSize: 14, color: Colors.grey)),
|
||||
Text(
|
||||
state.message,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _bloc.add(const HomeV2LoadData()),
|
||||
@@ -91,47 +99,8 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: _showPlantSelector,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: StreamBuilder<SiteState>(
|
||||
stream: sl<SiteCubit>().stream,
|
||||
builder: (context, snapshot) {
|
||||
final siteState =
|
||||
snapshot.data ??
|
||||
sl<SiteCubit>().state;
|
||||
return Text(
|
||||
siteState.selectedSite?.siteName ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate(
|
||||
'home_v2.select_site',
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 20,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: SiteSelectorWidget()),
|
||||
const SizedBox(width: 12),
|
||||
TcpStatusIndicator(
|
||||
onTap: () => _showDeviceStatusModal(context),
|
||||
),
|
||||
@@ -252,7 +221,9 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
}
|
||||
|
||||
// 🔥 Initial/Loading 状态显示加载指示器
|
||||
return const Center(child: CircularProgressIndicator(color: Color(0xFF165DFF)));
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,179 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../../core/error/failure.dart';
|
||||
import '../../../../../../core/app/app_user_cubit.dart';
|
||||
import '../models/report_model.dart';
|
||||
import 'report_remote_datasource.dart';
|
||||
|
||||
/// 上报远程数据源实现(模拟接口请求)
|
||||
/// 上报远程数据源实现
|
||||
class ReportRemoteDataSourceImpl implements ReportRemoteDataSource {
|
||||
@override
|
||||
Future<Either<Failure, bool>> submitReport(ReportModel report) async {
|
||||
// 模拟网络请求延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
print('========================================');
|
||||
print('[上报工单] 开始提交');
|
||||
print('[上报工单] 请求URL: ${HttpApiConsts.workOrderAdd}');
|
||||
print('[上报工单] 请求方法: POST (multipart/form-data)');
|
||||
try {
|
||||
// 1. 构造 workOrder JSON(直接映射 IOTWorkOrder 实体)
|
||||
print('[上报工单] 入参: siteId=${report.siteId}, siteName=${report.siteName}, reportType=${report.reportType}');
|
||||
print('[上报工单] 入参: deviceId=${report.deviceId}, deviceName=${report.deviceName}');
|
||||
print('[上报工单] 入参: description=${report.description}, problemLevel=${report.problemLevel}');
|
||||
print('[上报工单] 入参: mediaUrls数量=${report.mediaUrls?.length ?? 0}');
|
||||
final workOrder = <String, dynamic>{
|
||||
'siteId': report.siteId,
|
||||
'siteName': report.siteName ?? '',
|
||||
'sourceType': 6, // 6=人工创建
|
||||
'orderType': report.reportType ?? '',
|
||||
'deviceId': report.deviceId ?? '',
|
||||
'deviceName': report.deviceName ?? '',
|
||||
'taskDescription': report.description ?? '',
|
||||
'priorityLevel': report.problemLevel ?? 'INFO',
|
||||
'orderTitle': '${report.reportType ?? '上报'} - ${report.deviceName ?? ''}',
|
||||
'orderStatus': 1, // 1=待处理
|
||||
'imgUrl': <String>[],
|
||||
'videoUrl': <String>[],
|
||||
};
|
||||
final workOrderJson = jsonEncode(workOrder);
|
||||
print('[上报工单] workOrder JSON: $workOrderJson');
|
||||
|
||||
// 模拟成功返回
|
||||
return right(true);
|
||||
// 2. 分离图片和视频
|
||||
final imagePaths = <String>[];
|
||||
final videoPaths = <String>[];
|
||||
final imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic'];
|
||||
final videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', '3gp'];
|
||||
|
||||
// 模拟失败情况(取消注释以测试错误处理)
|
||||
// return left(ServerFailure('提交失败,请重试'));
|
||||
if (report.mediaUrls != null) {
|
||||
for (final path in report.mediaUrls!) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
if (videoExts.contains(ext)) {
|
||||
videoPaths.add(path);
|
||||
print('[上报工单] 识别为视频: $path');
|
||||
} else {
|
||||
imagePaths.add(path);
|
||||
print('[上报工单] 识别为图片: $path');
|
||||
}
|
||||
}
|
||||
}
|
||||
print('[上报工单] 图片: ${imagePaths.length}个, 视频: ${videoPaths.length}个');
|
||||
|
||||
// 3. 创建 multipart 请求
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse(HttpApiConsts.workOrderAdd),
|
||||
);
|
||||
|
||||
// 4. 添加 Authorization 认证头
|
||||
final userToken = GetIt.I<AppUserCubit>().state.user?.token;
|
||||
if (userToken != null) {
|
||||
request.headers['Authorization'] = 'Bearer $userToken';
|
||||
print('[上报工单] Authorization token: ${userToken.substring(0, userToken.length > 20 ? 20 : userToken.length)}...');
|
||||
} else {
|
||||
print('[上报工单] WARNING: Token为空');
|
||||
}
|
||||
|
||||
// 5. 处理图片文件(后端要求 file 字段必须存在)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final firstImage = imagePaths.first;
|
||||
final file = File(firstImage);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
print('[上报工单] 上传图片: $firstImage, 大小: ${bytes.length} bytes');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: firstImage.split('/').last,
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
} else {
|
||||
print('[上报工单] WARNING: 图片文件不存在: $firstImage');
|
||||
}
|
||||
} else {
|
||||
print('[上报工单] 无图片,使用空文件占位');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
<int>[],
|
||||
filename: 'empty.jpg',
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
}
|
||||
|
||||
// 6. 处理视频文件(后端要求 video 字段必须存在)
|
||||
if (videoPaths.isNotEmpty) {
|
||||
final firstVideo = videoPaths.first;
|
||||
final file = File(firstVideo);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
print('[上报工单] 上传视频: $firstVideo, 大小: ${bytes.length} bytes');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
bytes,
|
||||
filename: firstVideo.split('/').last,
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
} else {
|
||||
print('[上报工单] WARNING: 视频文件不存在: $firstVideo');
|
||||
}
|
||||
} else {
|
||||
print('[上报工单] 无视频,使用空文件占位');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
<int>[],
|
||||
filename: 'empty.mp4',
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
}
|
||||
|
||||
// 7. 添加 workOrder JSON(对应前端 new Blob,filename 为空字符串)
|
||||
print('[上报工单] workOrder JSON长度: ${workOrderJson.length} chars');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'workOrder',
|
||||
utf8.encode(workOrderJson),
|
||||
filename: '',
|
||||
contentType: http.MediaType('application', 'json'),
|
||||
));
|
||||
|
||||
print('[上报工单] 请求字段: ${request.fields.keys.toList()}');
|
||||
print('[上报工单] 文件字段: ${request.files.map((f) => f.field).toList()}');
|
||||
print('[上报工单] 开始发送请求...');
|
||||
|
||||
// 8. 发送请求
|
||||
final http.StreamedResponse response = await request.send();
|
||||
final String responseBody = await response.stream.bytesToString();
|
||||
|
||||
print('[上报工单] 响应状态码: ${response.statusCode}');
|
||||
print('[上报工单] 响应头: ${response.headers}');
|
||||
print('[上报工单] 响应体: $responseBody');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final respJson = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final code = respJson['code'];
|
||||
print('[上报工单] 业务code: $code');
|
||||
if (code != null && code.toString() == '200') {
|
||||
print('[上报工单] 提交成功');
|
||||
print('========================================');
|
||||
return right(true);
|
||||
} else {
|
||||
final msg = respJson['msg'] ?? respJson['message'] ?? '提交失败';
|
||||
print('[上报工单] 业务失败: $msg');
|
||||
print('========================================');
|
||||
return left(Failure(msg.toString()));
|
||||
}
|
||||
} else {
|
||||
print('[上报工单] HTTP错误: ${response.statusCode}');
|
||||
print('========================================');
|
||||
return left(Failure('服务器错误: HTTP ${response.statusCode}, 响应: $responseBody'));
|
||||
}
|
||||
} catch (e) {
|
||||
print('[上报工单] 异常: $e');
|
||||
print('[上报工单] 异常类型: ${e.runtimeType}');
|
||||
print('========================================');
|
||||
return left(Failure('提交失败: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import '../../domain/entities/report_entity.dart';
|
||||
class ReportModel {
|
||||
final String? location;
|
||||
final String? reportType;
|
||||
final int? siteId;
|
||||
final String? siteName;
|
||||
final String? deviceId;
|
||||
final String? deviceName;
|
||||
final String? description;
|
||||
final List<String>? mediaUrls;
|
||||
final String? problemLevel;
|
||||
@@ -12,7 +15,10 @@ class ReportModel {
|
||||
ReportModel({
|
||||
this.location,
|
||||
this.reportType,
|
||||
this.siteId,
|
||||
this.siteName,
|
||||
this.deviceId,
|
||||
this.deviceName,
|
||||
this.description,
|
||||
this.mediaUrls,
|
||||
this.problemLevel,
|
||||
@@ -23,7 +29,10 @@ class ReportModel {
|
||||
return ReportModel(
|
||||
location: json['location'] as String?,
|
||||
reportType: json['reportType'] as String?,
|
||||
siteId: json['siteId'] as int?,
|
||||
siteName: json['siteName'] as String?,
|
||||
deviceId: json['deviceId'] as String?,
|
||||
deviceName: json['deviceName'] as String?,
|
||||
description: json['description'] as String?,
|
||||
mediaUrls: (json['mediaUrls'] as List<dynamic>?)?.cast<String>(),
|
||||
problemLevel: json['problemLevel'] as String?,
|
||||
@@ -35,7 +44,10 @@ class ReportModel {
|
||||
return {
|
||||
'location': location,
|
||||
'reportType': reportType,
|
||||
'siteId': siteId,
|
||||
'siteName': siteName,
|
||||
'deviceId': deviceId,
|
||||
'deviceName': deviceName,
|
||||
'description': description,
|
||||
'mediaUrls': mediaUrls,
|
||||
'problemLevel': problemLevel,
|
||||
@@ -47,7 +59,10 @@ class ReportModel {
|
||||
return ReportEntity(
|
||||
location: location,
|
||||
reportType: reportType,
|
||||
siteId: siteId,
|
||||
siteName: siteName,
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
description: description,
|
||||
mediaUrls: mediaUrls,
|
||||
problemLevel: problemLevel,
|
||||
@@ -59,7 +74,10 @@ class ReportModel {
|
||||
return ReportModel(
|
||||
location: entity.location,
|
||||
reportType: entity.reportType,
|
||||
siteId: entity.siteId,
|
||||
siteName: entity.siteName,
|
||||
deviceId: entity.deviceId,
|
||||
deviceName: entity.deviceName,
|
||||
description: entity.description,
|
||||
mediaUrls: entity.mediaUrls,
|
||||
problemLevel: entity.problemLevel,
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../data/datasources/report_remote_datasource.dart';
|
||||
import '../data/datasources/report_remote_datasource_impl.dart';
|
||||
import '../data/repositories/report_repository_impl.dart';
|
||||
import '../domain/repositories/report_repository.dart';
|
||||
import '../domain/usecases/submit_report_usecase.dart';
|
||||
import '../presentation/cubit/report_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
|
||||
/// 上报模块依赖注入
|
||||
class ReportDependencyInjector {
|
||||
/// 创建 ReportCubit 实例
|
||||
static ReportCubit createReportCubit() {
|
||||
final sl = GetIt.I;
|
||||
|
||||
// 创建数据源
|
||||
final ReportRemoteDataSource remoteDataSource = ReportRemoteDataSourceImpl();
|
||||
final ReportRemoteDataSource remoteDataSource =
|
||||
ReportRemoteDataSourceImpl();
|
||||
|
||||
// 创建仓储
|
||||
final ReportRepository repository = ReportRepositoryImpl(
|
||||
@@ -18,9 +25,16 @@ class ReportDependencyInjector {
|
||||
);
|
||||
|
||||
// 创建用例
|
||||
final SubmitReportUseCase submitReportUseCase = SubmitReportUseCase(repository);
|
||||
final SubmitReportUseCase submitReportUseCase = SubmitReportUseCase(
|
||||
repository,
|
||||
);
|
||||
|
||||
// 创建 Cubit
|
||||
return ReportCubit(submitReportUseCase: submitReportUseCase);
|
||||
// 创建 Cubit(注入 Dio、AppUserCubit、UserStorage)
|
||||
return ReportCubit(
|
||||
submitReportUseCase: submitReportUseCase,
|
||||
dio: sl<Dio>(),
|
||||
appUserCubit: sl<AppUserCubit>(),
|
||||
userStorage: sl<UserStorage>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
class ReportEntity {
|
||||
final String? location;
|
||||
final String? reportType;
|
||||
final int? siteId;
|
||||
final String? siteName;
|
||||
final String? deviceId;
|
||||
final String? deviceName;
|
||||
final String? description;
|
||||
final List<String>? mediaUrls;
|
||||
final String? problemLevel;
|
||||
@@ -10,7 +13,10 @@ class ReportEntity {
|
||||
const ReportEntity({
|
||||
this.location,
|
||||
this.reportType,
|
||||
this.siteId,
|
||||
this.siteName,
|
||||
this.deviceId,
|
||||
this.deviceName,
|
||||
this.description,
|
||||
this.mediaUrls,
|
||||
this.problemLevel,
|
||||
@@ -19,7 +25,10 @@ class ReportEntity {
|
||||
ReportEntity copyWith({
|
||||
String? location,
|
||||
String? reportType,
|
||||
int? siteId,
|
||||
String? siteName,
|
||||
String? deviceId,
|
||||
String? deviceName,
|
||||
String? description,
|
||||
List<String>? mediaUrls,
|
||||
String? problemLevel,
|
||||
@@ -27,7 +36,10 @@ class ReportEntity {
|
||||
return ReportEntity(
|
||||
location: location ?? this.location,
|
||||
reportType: reportType ?? this.reportType,
|
||||
siteId: siteId ?? this.siteId,
|
||||
siteName: siteName ?? this.siteName,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
description: description ?? this.description,
|
||||
mediaUrls: mediaUrls ?? this.mediaUrls,
|
||||
problemLevel: problemLevel ?? this.problemLevel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局颜色常量
|
||||
class AppColors {
|
||||
@@ -27,26 +27,32 @@ class AppDimensions {
|
||||
|
||||
/// 上报类型枚举
|
||||
enum ReportType {
|
||||
deviceFault('report.device_fault', Icons.notifications_active),
|
||||
patrolRecord('report.patrol_record', Icons.description),
|
||||
hiddenDanger('report.hidden_danger', Icons.shield),
|
||||
defectReport('report.defect_report', Icons.build);
|
||||
mowerError('MOWER_ERROR', '割草机故障', Icons.notifications_active),
|
||||
uavError('UAV_ERROR', '无人机故障', Icons.flight),
|
||||
inspectionTask('INSPECTION_TASK', '巡检', Icons.search),
|
||||
componentDefect('COMPONENT_DEFECT', '光伏组件故障', Icons.power),
|
||||
cleanTask('CLEAN_TASK', '清洗任务', Icons.cleaning_services),
|
||||
maintainTask('MAINTAIN_TASK', '维护任务', Icons.handyman),
|
||||
repairTask('REPAIR_TASK', '维修任务', Icons.build),
|
||||
other('OTHER', '其他', Icons.more_horiz);
|
||||
|
||||
final String labelKey;
|
||||
final String orderType;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
const ReportType(this.labelKey, this.icon);
|
||||
const ReportType(this.orderType, this.label, this.icon);
|
||||
}
|
||||
|
||||
/// 问题等级枚举
|
||||
enum ProblemLevel {
|
||||
normal('report.normal', Color(0xFF86909C), Color(0xFFE5E6EB)),
|
||||
important('report.important', Color(0xFFFF7D00), Color(0xFFE5E6EB)),
|
||||
urgent('report.urgent', Color(0xFFF53F3F), Color(0xFFF53F3F));
|
||||
info('INFO', '信息', Color(0xFF165DFF), Color(0xFF165DFF)),
|
||||
warning('WARNING', '警告', Color(0xFFFF7D00), Color(0xFFFF7D00)),
|
||||
error('ERROR', '错误', Color(0xFFF53F3F), Color(0xFFF53F3F));
|
||||
|
||||
final String labelKey;
|
||||
final String level;
|
||||
final String label;
|
||||
final Color textColor;
|
||||
final Color borderColor;
|
||||
|
||||
const ProblemLevel(this.labelKey, this.textColor, this.borderColor);
|
||||
const ProblemLevel(this.level, this.label, this.textColor, this.borderColor);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,174 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/entities/report_entity.dart';
|
||||
import '../../domain/usecases/submit_report_usecase.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
import '../states/report_state.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
import '../../../home/domain/usecases/get_site_list_usecase.dart';
|
||||
import '../../../home/domain/repositories/site_repository.dart';
|
||||
import '../../../home/data/repositories/site_repository_impl.dart';
|
||||
import '../../../home/data/datasources/site_datasource_impl.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import '../../../device_list/data/models/robot_data_model.dart';
|
||||
import '../../../device_list/domain/entities/drone_station_entity.dart';
|
||||
|
||||
/// 上报页面 Cubit
|
||||
class ReportCubit extends Cubit<ReportState> {
|
||||
final SubmitReportUseCase submitReportUseCase;
|
||||
final Dio dio;
|
||||
final AppUserCubit appUserCubit;
|
||||
final UserStorage userStorage;
|
||||
|
||||
ReportCubit({required this.submitReportUseCase})
|
||||
: super(ReportFormState(report: ReportEntity()));
|
||||
ReportCubit({
|
||||
required this.submitReportUseCase,
|
||||
required this.dio,
|
||||
required this.appUserCubit,
|
||||
required this.userStorage,
|
||||
}) : super(ReportFormState(report: ReportEntity())) {
|
||||
_loadSiteList();
|
||||
}
|
||||
|
||||
Future<void> _loadSiteList() async {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(currentState.copyWith(sites: []));
|
||||
|
||||
try {
|
||||
final siteDataSource = SiteDataSourceImpl(
|
||||
dio,
|
||||
userStorage,
|
||||
appUserCubit,
|
||||
);
|
||||
final siteRepository = SiteRepositoryImpl(siteDataSource);
|
||||
final getSiteListUseCase = GetSiteListUseCase(siteRepository);
|
||||
|
||||
final user = appUserCubit.state.user;
|
||||
if (user == null) {
|
||||
emit(currentState.copyWith(sites: []));
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await getSiteListUseCase(user.userId);
|
||||
result.fold(
|
||||
(failure) {
|
||||
emit(currentState.copyWith(sites: []));
|
||||
},
|
||||
(sites) {
|
||||
SiteEntity? selectedSite;
|
||||
if (sites.isNotEmpty) {
|
||||
selectedSite = sites.first;
|
||||
}
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
location: selectedSite?.siteName ?? '请选择场站',
|
||||
report: currentState.report.copyWith(
|
||||
siteId: selectedSite?.id,
|
||||
siteName: selectedSite?.siteName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
emit(currentState.copyWith(sites: []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadDeviceList(int siteId) async {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(ReportDevicesLoading());
|
||||
|
||||
try {
|
||||
final robotResponse = await dio.get(
|
||||
HttpApiConsts.getRobotList,
|
||||
queryParameters: {'siteId': siteId, 'pageSize': 9999, 'pageNum': 1},
|
||||
);
|
||||
|
||||
final droneResponse = await dio.get(
|
||||
HttpApiConsts.getSiteUAVList,
|
||||
queryParameters: {'siteId': siteId},
|
||||
);
|
||||
|
||||
List<RobotDataModel> robots = [];
|
||||
List<DroneStationEntity> drones = [];
|
||||
|
||||
if (robotResponse.statusCode == 200 &&
|
||||
robotResponse.data['code'] == 200) {
|
||||
final List<dynamic> rows = robotResponse.data['rows'] ?? [];
|
||||
robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
if (droneResponse.statusCode == 200 &&
|
||||
droneResponse.data['code'] == 200) {
|
||||
final List<dynamic> rows = droneResponse.data['rows'] ?? [];
|
||||
drones = rows
|
||||
.map((item) => DroneStationEntity.fromJson(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(robots: robots, drones: drones));
|
||||
} catch (e) {
|
||||
emit(currentState.copyWith(robots: [], drones: []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择上报类型
|
||||
void selectReportType(ReportType type) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedReportType: type,
|
||||
report: currentState.report.copyWith(reportType: type.labelKey),
|
||||
report: currentState.report.copyWith(reportType: type.orderType),
|
||||
selectedDevice: null,
|
||||
selectedDeviceId: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择设备
|
||||
void selectDevice(String device) {
|
||||
void selectSite(SiteEntity site) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedDevice: device,
|
||||
report: currentState.report.copyWith(deviceId: device),
|
||||
selectedSite: site,
|
||||
location: site.siteName,
|
||||
report: currentState.report.copyWith(
|
||||
siteId: site.id,
|
||||
siteName: site.siteName,
|
||||
),
|
||||
selectedDevice: null,
|
||||
selectedDeviceId: null,
|
||||
robots: [],
|
||||
drones: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void selectDevice(String deviceName, String deviceId) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedDevice: deviceName,
|
||||
selectedDeviceId: deviceId,
|
||||
report: currentState.report.copyWith(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新位置
|
||||
void updateLocation(String location) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -50,7 +181,6 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新问题描述
|
||||
void updateDescription(String description) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -62,20 +192,18 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择问题等级
|
||||
void selectProblemLevel(ProblemLevel level) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedProblemLevel: level,
|
||||
report: currentState.report.copyWith(problemLevel: level.labelKey),
|
||||
report: currentState.report.copyWith(problemLevel: level.level),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加媒体文件
|
||||
void addMediaFile(String filePath) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -90,7 +218,6 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除媒体文件
|
||||
void removeMediaFile(int index) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -105,43 +232,68 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交上报
|
||||
Future<void> submitReport() async {
|
||||
if (state is! ReportFormState) return;
|
||||
|
||||
final currentState = state as ReportFormState;
|
||||
|
||||
// 验证必填项
|
||||
if (currentState.selectedSite == null) {
|
||||
emit(currentState.copyWith(errorMessage: '请选择场站'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedReportType == null) {
|
||||
emit(currentState.copyWith(errorMessage: '请选择上报类型'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedDevice == null ||
|
||||
currentState.selectedDevice!.isEmpty) {
|
||||
emit(const ReportFailure('请选择设备'));
|
||||
emit(currentState.copyWith(errorMessage: '请选择设备'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.report.description == null ||
|
||||
currentState.report.description!.isEmpty) {
|
||||
emit(const ReportFailure('请填写问题描述'));
|
||||
emit(currentState.copyWith(errorMessage: '请填写问题描述'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedProblemLevel == null) {
|
||||
emit(const ReportFailure('请选择问题等级'));
|
||||
emit(currentState.copyWith(errorMessage: '请选择问题等级'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 开始提交
|
||||
emit(ReportSubmitting());
|
||||
|
||||
final result = await submitReportUseCase.execute(currentState.report);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(ReportFailure(failure.message)),
|
||||
(success) => emit(ReportSuccess()),
|
||||
(failure) => emit(currentState.copyWith(errorMessage: failure.message)),
|
||||
(success) {
|
||||
// 提交成功:清空表单,保留场站列表
|
||||
emit(currentState.copyWith(
|
||||
report: ReportEntity(),
|
||||
selectedReportType: null,
|
||||
selectedProblemLevel: null,
|
||||
selectedDevice: null,
|
||||
selectedDeviceId: null,
|
||||
mediaFiles: [],
|
||||
successMessage: '提交成功',
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 重置表单
|
||||
void clearMessages() {
|
||||
if (state is ReportFormState) {
|
||||
final s = state as ReportFormState;
|
||||
emit(s.copyWith(errorMessage: null, successMessage: null));
|
||||
}
|
||||
}
|
||||
|
||||
void resetForm() {
|
||||
emit(ReportFormState(report: ReportEntity()));
|
||||
_loadSiteList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../cubit/report_cubit.dart';
|
||||
import '../states/report_state.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
@@ -14,7 +13,6 @@ import '../widgets/level_selector.dart';
|
||||
import 'media_preview_page.dart';
|
||||
import '../../di/report_di.dart';
|
||||
|
||||
/// 现场上报主页面
|
||||
class ReportPage extends StatelessWidget {
|
||||
const ReportPage({super.key});
|
||||
|
||||
@@ -38,14 +36,30 @@ class _ReportPageContent extends StatelessWidget {
|
||||
backgroundColor: AppColors.cardBackground,
|
||||
body: BlocConsumer<ReportCubit, ReportState>(
|
||||
listener: (context, state) {
|
||||
if (state is ReportSuccess) {
|
||||
_showSuccessDialog(context);
|
||||
} else if (state is ReportFailure) {
|
||||
_showErrorDialog(context, state.message);
|
||||
if (state is ReportFormState) {
|
||||
if (state.successMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.successMessage!),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
context.read<ReportCubit>().clearMessages();
|
||||
} else if (state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage!),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
context.read<ReportCubit>().clearMessages();
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is ReportSubmitting) {
|
||||
if (state is ReportSubmitting || state is ReportDevicesLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
@@ -55,7 +69,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
final cubit = context.read<ReportCubit>();
|
||||
return Column(
|
||||
children: [
|
||||
_buildAppBar(context, state.location, cubit),
|
||||
_buildAppBar(context, state, cubit),
|
||||
Expanded(child: _buildFormContent(context, state)),
|
||||
],
|
||||
);
|
||||
@@ -68,10 +82,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建导航栏
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
BuildContext context,
|
||||
String location,
|
||||
ReportFormState state,
|
||||
ReportCubit cubit,
|
||||
) {
|
||||
return PreferredSize(
|
||||
@@ -86,13 +99,12 @@ class _ReportPageContent extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 第一行:标题 + 提交按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).translate('report.title'),
|
||||
style: const TextStyle(
|
||||
const Text(
|
||||
'现场上报',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -102,9 +114,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
onTap: () {
|
||||
context.read<ReportCubit>().submitReport();
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('report.submit'),
|
||||
style: const TextStyle(
|
||||
child: const Text(
|
||||
'提交',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -114,10 +126,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 第二行:位置信息(可点击)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_showLocationPicker(context, cubit);
|
||||
_showSitePicker(context, cubit, state);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -129,7 +140,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
location,
|
||||
state.location,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -156,7 +167,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建表单内容
|
||||
Widget _buildFormContent(BuildContext context, ReportFormState state) {
|
||||
final cubit = context.read<ReportCubit>();
|
||||
|
||||
@@ -168,30 +178,23 @@ class _ReportPageContent extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 上报类型选择
|
||||
ReportTypeSelector(
|
||||
selectedType: state.selectedReportType,
|
||||
onSelected: (type) => cubit.selectReportType(type),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 设备选择
|
||||
DeviceSelector(
|
||||
selectedDevice: state.selectedDevice,
|
||||
onTap: () {
|
||||
_showDevicePicker(context, cubit);
|
||||
_showDevicePicker(context, cubit, state);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 问题描述
|
||||
DescriptionInput(
|
||||
description: state.report.description,
|
||||
onChanged: (value) => cubit.updateDescription(value),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 媒体上传
|
||||
MediaUploader(
|
||||
mediaFiles: state.mediaFiles,
|
||||
onCameraTap: () {
|
||||
@@ -209,8 +212,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
onRemove: (index) => cubit.removeMediaFile(index),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 问题等级选择
|
||||
LevelSelector(
|
||||
selectedLevel: state.selectedProblemLevel,
|
||||
onSelected: (level) => cubit.selectProblemLevel(level),
|
||||
@@ -221,40 +222,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建位置信息行
|
||||
Widget _buildLocationRow(BuildContext context, String location) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.horizontalPadding,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.borderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x0D000000),
|
||||
blurRadius: AppDimensions.cardShadowBlur,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, color: AppColors.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 选择图片
|
||||
Future<void> _pickImage(ReportCubit cubit) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
@@ -273,7 +240,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择视频
|
||||
Future<void> _pickVideo(ReportCubit cubit) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
@@ -290,7 +256,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览媒体文件
|
||||
void _previewMedia(BuildContext context, List<String> mediaFiles, int index) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -301,11 +266,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 从相册选择
|
||||
Future<void> _pickFromGallery(BuildContext context, ReportCubit cubit) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
// 显示选择图片或视频的选项
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
@@ -314,9 +277,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: Text(
|
||||
AppLocalizations.of(context).translate('report.select_image'),
|
||||
),
|
||||
title: const Text('选择图片'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final XFile? image = await picker.pickImage(
|
||||
@@ -332,9 +293,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.video_library),
|
||||
title: Text(
|
||||
AppLocalizations.of(context).translate('report.select_video'),
|
||||
),
|
||||
title: const Text('选择视频'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final XFile? video = await picker.pickVideo(
|
||||
@@ -354,123 +313,246 @@ class _ReportPageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示位置选择器
|
||||
void _showLocationPicker(BuildContext context, ReportCubit cubit) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
final locations = [
|
||||
loc.translate('report.main_building_a'),
|
||||
loc.translate('report.main_building_b'),
|
||||
loc.translate('report.boiler_room'),
|
||||
loc.translate('report.turbine_room'),
|
||||
loc.translate('report.control_room'),
|
||||
loc.translate('report.power_distribution_room'),
|
||||
];
|
||||
void _showSitePicker(
|
||||
BuildContext context,
|
||||
ReportCubit cubit,
|
||||
ReportFormState currentState,
|
||||
) {
|
||||
if (currentState.sites.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('暂无场站数据')));
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.select_location')),
|
||||
content: SingleChildScrollView(
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: locations.map((location) {
|
||||
return ListTile(
|
||||
title: Text(location),
|
||||
onTap: () {
|
||||
cubit.updateLocation(location);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
children: [
|
||||
const Text(
|
||||
'选择场站',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: currentState.sites.length,
|
||||
itemBuilder: (context, index) {
|
||||
final site = currentState.sites[index];
|
||||
final isSelected = currentState.selectedSite?.id == site.id;
|
||||
|
||||
return ListTile(
|
||||
title: Text(site.siteName),
|
||||
subtitle:
|
||||
site.siteCode != null && site.siteCode!.isNotEmpty
|
||||
? Text('站点编码: ${site.siteCode}')
|
||||
: null,
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Color(0xFF165DFF))
|
||||
: null,
|
||||
onTap: () {
|
||||
cubit.selectSite(site);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDevicePicker(
|
||||
BuildContext context,
|
||||
ReportCubit cubit,
|
||||
ReportFormState state,
|
||||
) {
|
||||
final selectedType = state.selectedReportType;
|
||||
final selectedSite = state.selectedSite;
|
||||
|
||||
if (selectedSite == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先选择场站')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先选择上报类型')));
|
||||
return;
|
||||
}
|
||||
|
||||
bool isRobotType = selectedType == ReportType.mowerError;
|
||||
bool isDroneType = selectedType == ReportType.uavError;
|
||||
|
||||
if (!isRobotType && !isDroneType) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('选择设备'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('设备一'),
|
||||
onTap: () {
|
||||
cubit.selectDevice('设备一', 'device_1');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('设备二'),
|
||||
onTap: () {
|
||||
cubit.selectDevice('设备二', 'device_2');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('设备三'),
|
||||
onTap: () {
|
||||
cubit.selectDevice('设备三', 'device_3');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(loc.translate('report.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// 显示设备选择器
|
||||
void _showDevicePicker(BuildContext context, ReportCubit cubit) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
final devices = [
|
||||
loc.translate('report.boiler_1'),
|
||||
loc.translate('report.boiler_2'),
|
||||
loc.translate('report.turbine_1'),
|
||||
loc.translate('report.generator_1'),
|
||||
loc.translate('report.transformer_1'),
|
||||
loc.translate('report.water_pump_1'),
|
||||
];
|
||||
Future<void> loadAndShowDevices() async {
|
||||
await cubit.loadDeviceList(selectedSite.id);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.select_device')),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: devices.map((device) {
|
||||
return ListTile(
|
||||
title: Text(device),
|
||||
onTap: () {
|
||||
cubit.selectDevice(device);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(loc.translate('report.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final newState = cubit.state;
|
||||
if (newState is! ReportFormState) return;
|
||||
|
||||
/// 显示成功对话框
|
||||
void _showSuccessDialog(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.submit_success')),
|
||||
content: Text(loc.translate('report.submit_success_message')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context); // 返回上一页
|
||||
},
|
||||
child: Text(loc.translate('report.confirm')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
List<Widget> deviceList = [];
|
||||
|
||||
/// 显示错误对话框
|
||||
void _showErrorDialog(BuildContext context, String message) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.submit_failed')),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(loc.translate('report.confirm')),
|
||||
if (isRobotType) {
|
||||
if (newState.robots.isEmpty) {
|
||||
deviceList = [
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'暂无机器人数据',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
deviceList = newState.robots
|
||||
.map(
|
||||
(robot) => ListTile(
|
||||
title: Text(robot.name),
|
||||
subtitle: robot.alias != null && robot.alias!.isNotEmpty
|
||||
? Text(robot.alias!)
|
||||
: null,
|
||||
trailing: Text(robot.status),
|
||||
onTap: () {
|
||||
cubit.selectDevice(robot.name, robot.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
} else if (isDroneType) {
|
||||
if (newState.drones.isEmpty) {
|
||||
deviceList = [
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'暂无无人机数据',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
deviceList = newState.drones
|
||||
.map(
|
||||
(drone) => ListTile(
|
||||
title: Text(drone.callsign),
|
||||
subtitle: Text(drone.deviceSn),
|
||||
onTap: () {
|
||||
cubit.selectDevice(drone.callsign, drone.deviceSn);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isRobotType ? '选择机器人' : '选择无人机',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: ListView(shrinkWrap: true, children: deviceList),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
loadAndShowDevices();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/report_entity.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
import '../../../device_list/data/models/robot_data_model.dart';
|
||||
import '../../../device_list/domain/entities/drone_station_entity.dart';
|
||||
|
||||
/// 上报页面状态抽象类
|
||||
abstract class ReportState extends Equatable {
|
||||
@@ -32,6 +35,11 @@ class ReportFailure extends ReportState {
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// 设备列表加载状态
|
||||
class ReportDevicesLoading extends ReportState {
|
||||
const ReportDevicesLoading();
|
||||
}
|
||||
|
||||
/// 表单数据状态
|
||||
class ReportFormState extends ReportState {
|
||||
final ReportEntity report;
|
||||
@@ -39,7 +47,14 @@ class ReportFormState extends ReportState {
|
||||
final ProblemLevel? selectedProblemLevel;
|
||||
final String location;
|
||||
final String? selectedDevice;
|
||||
final String? selectedDeviceId;
|
||||
final List<String> mediaFiles;
|
||||
final List<SiteEntity> sites;
|
||||
final SiteEntity? selectedSite;
|
||||
final List<RobotDataModel> robots;
|
||||
final List<DroneStationEntity> drones;
|
||||
final String? errorMessage;
|
||||
final String? successMessage;
|
||||
|
||||
const ReportFormState({
|
||||
required this.report,
|
||||
@@ -47,7 +62,14 @@ class ReportFormState extends ReportState {
|
||||
this.selectedProblemLevel,
|
||||
this.location = '江苏省苏州市吴中区',
|
||||
this.selectedDevice,
|
||||
this.selectedDeviceId,
|
||||
this.mediaFiles = const [],
|
||||
this.sites = const [],
|
||||
this.selectedSite,
|
||||
this.robots = const [],
|
||||
this.drones = const [],
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
ReportFormState copyWith({
|
||||
@@ -56,7 +78,14 @@ class ReportFormState extends ReportState {
|
||||
ProblemLevel? selectedProblemLevel,
|
||||
String? location,
|
||||
String? selectedDevice,
|
||||
String? selectedDeviceId,
|
||||
List<String>? mediaFiles,
|
||||
List<SiteEntity>? sites,
|
||||
SiteEntity? selectedSite,
|
||||
List<RobotDataModel>? robots,
|
||||
List<DroneStationEntity>? drones,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
}) {
|
||||
return ReportFormState(
|
||||
report: report ?? this.report,
|
||||
@@ -64,7 +93,14 @@ class ReportFormState extends ReportState {
|
||||
selectedProblemLevel: selectedProblemLevel ?? this.selectedProblemLevel,
|
||||
location: location ?? this.location,
|
||||
selectedDevice: selectedDevice ?? this.selectedDevice,
|
||||
selectedDeviceId: selectedDeviceId ?? this.selectedDeviceId,
|
||||
mediaFiles: mediaFiles ?? this.mediaFiles,
|
||||
sites: sites ?? this.sites,
|
||||
selectedSite: selectedSite ?? this.selectedSite,
|
||||
robots: robots ?? this.robots,
|
||||
drones: drones ?? this.drones,
|
||||
errorMessage: errorMessage,
|
||||
successMessage: successMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +111,13 @@ class ReportFormState extends ReportState {
|
||||
selectedProblemLevel,
|
||||
location,
|
||||
selectedDevice,
|
||||
selectedDeviceId,
|
||||
mediaFiles,
|
||||
sites,
|
||||
selectedSite,
|
||||
robots,
|
||||
drones,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,27 +40,35 @@ class DeviceSelector extends StatelessWidget {
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedDevice ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('report.please_select_device'),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: selectedDevice != null
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textHint,
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
selectedDevice ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('report.please_select_device'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: selectedDevice != null
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textHint,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
|
||||
/// 问题等级选择器
|
||||
class LevelSelector extends StatelessWidget {
|
||||
final ProblemLevel? selectedLevel;
|
||||
final Function(ProblemLevel) onSelected;
|
||||
@@ -15,7 +13,6 @@ class LevelSelector extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.horizontalPadding,
|
||||
@@ -35,9 +32,9 @@ class LevelSelector extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
loc.translate('report.problem_level'),
|
||||
style: const TextStyle(
|
||||
const Text(
|
||||
'问题等级',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -54,8 +51,8 @@ class LevelSelector extends StatelessWidget {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: AppDimensions.buttonHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected && level == ProblemLevel.urgent
|
||||
? AppColors.danger.withOpacity(0.1)
|
||||
color: isSelected
|
||||
? level.textColor.withOpacity(0.1)
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
@@ -69,7 +66,7 @@ class LevelSelector extends StatelessWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
loc.translate(level.labelKey),
|
||||
level.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isSelected
|
||||
|
||||
@@ -53,127 +53,131 @@ class MediaUploader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// 拍照按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.camera_alt,
|
||||
label: loc.translate('report.take_photo'),
|
||||
onTap: onCameraTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 录像按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.videocam,
|
||||
label: loc.translate('report.record_video'),
|
||||
onTap: onVideoTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 已上传的媒体预览
|
||||
...mediaFiles.asMap().entries.map((entry) {
|
||||
final String filePath = entry.value;
|
||||
final bool isNetworkImage = filePath.startsWith('http');
|
||||
final bool isVideo = _isVideoFile(filePath);
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Row(
|
||||
children: [
|
||||
// 拍照按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.camera_alt,
|
||||
label: loc.translate('report.take_photo'),
|
||||
onTap: onCameraTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 录像按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.videocam,
|
||||
label: loc.translate('report.record_video'),
|
||||
onTap: onVideoTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 已上传的媒体预览
|
||||
...mediaFiles.asMap().entries.map((entry) {
|
||||
final String filePath = entry.value;
|
||||
final bool isNetworkImage = filePath.startsWith('http');
|
||||
final bool isVideo = _isVideoFile(filePath);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onTap?.call(entry.key),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
color: AppColors.cardBackground,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
child: isVideo
|
||||
? _buildVideoThumbnail(filePath)
|
||||
: (isNetworkImage
|
||||
? Image.network(
|
||||
filePath,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.file(
|
||||
File(filePath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => onRemove(entry.key),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
return GestureDetector(
|
||||
onTap: () => onTap?.call(entry.key),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
color: AppColors.cardBackground,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
child: isVideo
|
||||
? _buildVideoThumbnail(filePath)
|
||||
: (isNetworkImage
|
||||
? Image.network(
|
||||
filePath,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.file(
|
||||
File(filePath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 视频播放图标
|
||||
if (isVideo)
|
||||
Positioned(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
alignment: Alignment.center,
|
||||
top: 4,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => onRemove(entry.key),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black38,
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// 添加按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.add,
|
||||
label: '',
|
||||
onTap: onAddFromGallery,
|
||||
isAddButton: true,
|
||||
),
|
||||
],
|
||||
// 视频播放图标
|
||||
if (isVideo)
|
||||
Positioned(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black38,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// 添加按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.add,
|
||||
label: '',
|
||||
onTap: onAddFromGallery,
|
||||
isAddButton: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
|
||||
/// 上报类型选择器
|
||||
class ReportTypeSelector extends StatelessWidget {
|
||||
final ReportType? selectedType;
|
||||
final Function(ReportType) onSelected;
|
||||
@@ -19,22 +17,24 @@ class ReportTypeSelector extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.horizontalPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: ReportType.values.map((type) {
|
||||
final isSelected = selectedType == type;
|
||||
return Flexible(
|
||||
flex: 1,
|
||||
child: GestureDetector(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Row(
|
||||
children: ReportType.values.map((type) {
|
||||
final isSelected = selectedType == type;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelected(type),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
color: isSelected
|
||||
? AppColors.primary.withOpacity(0.1)
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
@@ -60,13 +60,13 @@ class ReportTypeSelector extends StatelessWidget {
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: AppColors.textHint,
|
||||
size: 26,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
AppLocalizations.of(context).translate(type.labelKey),
|
||||
type.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
@@ -82,9 +82,9 @@ class ReportTypeSelector extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,29 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../../../core/network/tcp/tcp_client.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
import '../../../home/domain/usecases/get_site_list_usecase.dart';
|
||||
import '../../../../../core/app/app_user_cubit.dart';
|
||||
|
||||
class SiteState {
|
||||
final List<SiteEntity> sites;
|
||||
final SiteEntity? selectedSite;
|
||||
final bool isLoading;
|
||||
|
||||
const SiteState({
|
||||
this.sites = const [],
|
||||
this.selectedSite,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
SiteState copyWith({
|
||||
List<SiteEntity>? sites,
|
||||
SiteEntity? selectedSite,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return SiteState(
|
||||
sites: sites ?? this.sites,
|
||||
selectedSite: selectedSite ?? this.selectedSite,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,9 +34,47 @@ class SiteState {
|
||||
class SiteCubit extends Cubit<SiteState> {
|
||||
final SharedPreferences sharedPreferences;
|
||||
static const String _selectedSiteIdKey = 'selected_site_id';
|
||||
bool _hasLoadedSites = false;
|
||||
|
||||
SiteCubit(this.sharedPreferences) : super(const SiteState());
|
||||
|
||||
/// 加载场站列表(首次调用时请求接口,之后不重复请求)
|
||||
Future<void> loadSites() async {
|
||||
if (_hasLoadedSites) return;
|
||||
_hasLoadedSites = true;
|
||||
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
final userId = GetIt.I<AppUserCubit>().state.user?.userId;
|
||||
if (userId == null) {
|
||||
emit(state.copyWith(isLoading: false));
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await GetIt.I<GetSiteListUseCase>().call(userId);
|
||||
result.fold(
|
||||
(failure) {
|
||||
debugPrint('❌ [SiteCubit] 加载场站列表失败: ${failure.message}');
|
||||
emit(state.copyWith(isLoading: false));
|
||||
},
|
||||
(sites) {
|
||||
updateSites(sites);
|
||||
emit(state.copyWith(isLoading: false));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SiteCubit] 加载场站列表异常: $e');
|
||||
emit(state.copyWith(isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制重新加载场站列表
|
||||
Future<void> reloadSites() async {
|
||||
_hasLoadedSites = false;
|
||||
await loadSites();
|
||||
}
|
||||
|
||||
/// 更新场站列表
|
||||
void updateSites(List<SiteEntity> sites) {
|
||||
// 尝试恢复之前选中的场站
|
||||
@@ -52,8 +96,10 @@ class SiteCubit extends Cubit<SiteState> {
|
||||
/// 选择场站(持久化)
|
||||
void selectSite(SiteEntity site) {
|
||||
debugPrint('🏭 [SiteCubit] ========== 切换场站 ==========');
|
||||
debugPrint('🏭 [SiteCubit] 从 ${state.selectedSite?.siteName ?? "无"} 切换到 ${site.siteName}');
|
||||
|
||||
debugPrint(
|
||||
'🏭 [SiteCubit] 从 ${state.selectedSite?.siteName ?? "无"} 切换到 ${site.siteName}',
|
||||
);
|
||||
|
||||
// 🔥 关键修复:只有切换到不同场站时才断开TCP
|
||||
if (state.selectedSite?.id != site.id) {
|
||||
final tcpClient = GetIt.I<TcpClient>();
|
||||
@@ -67,7 +113,7 @@ class SiteCubit extends Cubit<SiteState> {
|
||||
} else {
|
||||
debugPrint('✅ [SiteCubit] 相同场站,保持TCP连接状态');
|
||||
}
|
||||
|
||||
|
||||
sharedPreferences.setInt(_selectedSiteIdKey, site.id);
|
||||
emit(state.copyWith(selectedSite: site));
|
||||
debugPrint('✅ [SiteCubit] 场站切换完成');
|
||||
@@ -81,6 +127,7 @@ class SiteCubit extends Cubit<SiteState> {
|
||||
|
||||
/// 清空所有场站数据(退出登录时调用)
|
||||
void clearAll() {
|
||||
_hasLoadedSites = false;
|
||||
sharedPreferences.remove(_selectedSiteIdKey);
|
||||
emit(const SiteState());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
|
||||
|
||||
class SiteSelectorWidget extends StatefulWidget {
|
||||
final bool compact;
|
||||
final bool enabled;
|
||||
final void Function(SiteEntity site)? onSiteSelected;
|
||||
|
||||
const SiteSelectorWidget({
|
||||
super.key,
|
||||
this.compact = false,
|
||||
this.enabled = true,
|
||||
this.onSiteSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SiteSelectorWidget> createState() => _SiteSelectorWidgetState();
|
||||
}
|
||||
|
||||
class _SiteSelectorWidgetState extends State<SiteSelectorWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
GetIt.I<SiteCubit>().loadSites();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<SiteState>(
|
||||
stream: GetIt.I<SiteCubit>().stream,
|
||||
builder: (context, snapshot) {
|
||||
final siteState = snapshot.data ?? GetIt.I<SiteCubit>().state;
|
||||
final selectedSite = siteState.selectedSite;
|
||||
final sites = siteState.sites;
|
||||
|
||||
if (widget.compact) {
|
||||
return _buildCompact(context, selectedSite, sites);
|
||||
}
|
||||
return _buildNormal(context, selectedSite, sites);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 紧凑模式(用于空间有限的场景)
|
||||
Widget _buildCompact(
|
||||
BuildContext context,
|
||||
SiteEntity? selectedSite,
|
||||
List<SiteEntity> sites,
|
||||
) {
|
||||
const iconSize = 16.0;
|
||||
const spacing = 2.0;
|
||||
final text = selectedSite?.siteName ?? '请选择场站';
|
||||
|
||||
return InkWell(
|
||||
onTap: widget.enabled ? () => _showSitePicker(context, sites) : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxTextWidth = constraints.maxWidth - iconSize - spacing;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: spacing),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: iconSize,
|
||||
color: widget.enabled
|
||||
? const Color(0xFF4E5969)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 普通模式
|
||||
Widget _buildNormal(
|
||||
BuildContext context,
|
||||
SiteEntity? selectedSite,
|
||||
List<SiteEntity> sites,
|
||||
) {
|
||||
const iconSize = 18.0;
|
||||
const spacing = 3.0;
|
||||
final text = selectedSite?.siteName ?? '请选择场站';
|
||||
|
||||
return InkWell(
|
||||
onTap: widget.enabled ? () => _showSitePicker(context, sites) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxTextWidth = constraints.maxWidth - iconSize - spacing;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: spacing),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: iconSize,
|
||||
color: widget.enabled
|
||||
? const Color(0xFF4E5969)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出场站选择底部弹窗
|
||||
void _showSitePicker(BuildContext context, List<SiteEntity> sites) {
|
||||
if (sites.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('暂无场站数据')));
|
||||
return;
|
||||
}
|
||||
|
||||
final siteCubit = GetIt.I<SiteCubit>();
|
||||
final currentSiteId = siteCubit.state.selectedSite?.id;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
const Text(
|
||||
'选择场站',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 22,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
// 场站列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: sites.length,
|
||||
itemBuilder: (context, index) {
|
||||
final site = sites[index];
|
||||
final isSelected = currentSiteId == site.id;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFFE8F3FF)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 2,
|
||||
),
|
||||
title: Text(
|
||||
site.siteName,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected
|
||||
? const Color(0xFF165DFF)
|
||||
: const Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 20,
|
||||
color: Color(0xFF165DFF),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
siteCubit.selectSite(site);
|
||||
widget.onSiteSelected?.call(site);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ abstract class AlarmDetailRemoteDataSource {
|
||||
/// 获取告警详情
|
||||
Future<AlarmDetailModel> getAlarmDetail(String alarmId);
|
||||
|
||||
/// 确认告警
|
||||
Future<bool> confirmAlarm(String alarmId);
|
||||
/// 确认告警(处理中)
|
||||
Future<Map<String, dynamic>> confirmAlarm(String alarmId);
|
||||
|
||||
/// 处理告警(已关闭)
|
||||
Future<Map<String, dynamic>> handleAlarm(Map<String, dynamic> handleData);
|
||||
|
||||
/// AI诊断
|
||||
Future<String> aiDiagnosis(String alarmId);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../../domain/entities/alarm_dispatch_entity.dart';
|
||||
|
||||
/// 告警派发远程数据源抽象
|
||||
abstract class AlarmDispatchRemoteDataSource {
|
||||
Future<Either<Failure, bool>> dispatchWorkOrder(AlarmDispatchEntity entity);
|
||||
}
|
||||
@@ -2,9 +2,17 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_mod
|
||||
|
||||
/// 告警远程数据源抽象
|
||||
abstract class AlarmRemoteDataSource {
|
||||
/// 获取告警列表
|
||||
Future<List<AlarmModel>> getAlarmList();
|
||||
/// 获取告警列表(支持分页)
|
||||
Future<List<AlarmModel>> getAlarmList({
|
||||
int? siteId,
|
||||
int? configId,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
});
|
||||
|
||||
/// 获取告警统计
|
||||
Future<AlarmCountModel> getAlarmCount();
|
||||
Future<AlarmCountModel> getAlarmCount({int? siteId});
|
||||
|
||||
/// 获取告警工单配置列表
|
||||
Future<List<AlarmOrderConfigModel>> getAlarmOrderConfigList(int siteId);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,118 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_detail_model.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_handle_model.dart';
|
||||
|
||||
/// 告警详情远程数据源实现(模拟数据)
|
||||
/// 告警详情远程数据源实现
|
||||
class AlarmDetailRemoteDataSourceImpl implements AlarmDetailRemoteDataSource {
|
||||
AlarmDetailRemoteDataSourceImpl(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<AlarmDetailModel> getAlarmDetail(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final uri = '${HttpApiConsts.alarmDetail}/$alarmId';
|
||||
debugPrint('=== 告警详情请求 ===');
|
||||
debugPrint('uri: `$uri`');
|
||||
|
||||
return AlarmDetailModel(
|
||||
id: alarmId,
|
||||
title: '逆变器离网告警',
|
||||
level: '严重',
|
||||
deviceInfo: '光伏区A / 逆变器 INV-001',
|
||||
occurTime: '2025-05-19T09:24:30',
|
||||
alarmStatus: '未处理',
|
||||
recoverTime: null,
|
||||
duration: '36分钟',
|
||||
affectRange: '光伏区A(2.6 MW)',
|
||||
description: '逆变器与电网失去连接,功率输出为0。',
|
||||
suggestions: [
|
||||
'检查逆变器侧并网开关状态',
|
||||
'检查电网电压及频率是否异常',
|
||||
],
|
||||
historyData: HistoryDataModel(
|
||||
power: [
|
||||
MetricPointModel(time: '08:24', value: 1050),
|
||||
MetricPointModel(time: '08:54', value: 980),
|
||||
MetricPointModel(time: '09:04', value: 1020),
|
||||
MetricPointModel(time: '09:14', value: 990),
|
||||
MetricPointModel(time: '09:19', value: 1010),
|
||||
MetricPointModel(time: '09:24', value: 100),
|
||||
MetricPointModel(time: '09:24:30', value: 0),
|
||||
],
|
||||
voltage: [],
|
||||
frequency: [],
|
||||
),
|
||||
final response = await _dio.get(uri);
|
||||
|
||||
debugPrint('statusCode: ${response.statusCode}');
|
||||
debugPrint('statusMessage: ${response.statusMessage}');
|
||||
debugPrint('Response Text: ${response.data}');
|
||||
debugPrint('=== 告警详情响应结束 ===');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '获取告警详情失败');
|
||||
}
|
||||
|
||||
return AlarmDetailModel.fromJson(
|
||||
responseData['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> confirmAlarm(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return true;
|
||||
Future<Map<String, dynamic>> confirmAlarm(String alarmId) async {
|
||||
final requestData = AlarmHandleModel(alarmId: alarmId, handleStatus: 2);
|
||||
|
||||
debugPrint('=== 确认告警请求 ===');
|
||||
debugPrint('uri: ${HttpApiConsts.alarmHandle}');
|
||||
debugPrint('requestData: ${requestData.toJson()}');
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.alarmHandle,
|
||||
data: requestData.toJson(),
|
||||
);
|
||||
|
||||
debugPrint('statusCode: ${response.statusCode}');
|
||||
debugPrint('statusMessage: ${response.statusMessage}');
|
||||
debugPrint('Response Data: ${response.data}');
|
||||
debugPrint('=== 确认告警响应结束 ===');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '确认告警失败');
|
||||
}
|
||||
|
||||
return responseData as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> handleAlarm(
|
||||
Map<String, dynamic> handleData,
|
||||
) async {
|
||||
debugPrint('=== 处理告警请求 ===');
|
||||
debugPrint('uri: ${HttpApiConsts.alarmHandle}');
|
||||
debugPrint('requestData: $handleData');
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.alarmHandle,
|
||||
data: handleData,
|
||||
);
|
||||
|
||||
debugPrint('statusCode: ${response.statusCode}');
|
||||
debugPrint('statusMessage: ${response.statusMessage}');
|
||||
debugPrint('Response Data: ${response.data}');
|
||||
debugPrint('=== 处理告警响应结束 ===');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '处理告警失败');
|
||||
}
|
||||
|
||||
return responseData as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> aiDiagnosis(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
return 'AI分析结果:\n\n根据历史数据分析,该告警可能是由于以下原因导致:\n1. 电网侧电压波动超过阈值\n2. 逆变器保护机制触发\n3. 并网开关异常断开\n\n建议优先检查并网开关状态和电网电压稳定性。';
|
||||
final response = await _dio.get(
|
||||
'${HttpApiConsts.alarmDetail}/$alarmId/aiDiagnosis',
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? 'AI诊断失败');
|
||||
}
|
||||
|
||||
return responseData['data'] as String? ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../../core/error/failure.dart';
|
||||
import '../../../../../../core/app/app_user_cubit.dart';
|
||||
import '../../../domain/entities/alarm_dispatch_entity.dart';
|
||||
import '../alarm_dispatch_remote_datasource.dart';
|
||||
|
||||
/// 告警派发远程数据源实现
|
||||
class AlarmDispatchRemoteDataSourceImpl
|
||||
implements AlarmDispatchRemoteDataSource {
|
||||
@override
|
||||
Future<Either<Failure, bool>> dispatchWorkOrder(
|
||||
AlarmDispatchEntity entity,
|
||||
) async {
|
||||
print('========================================');
|
||||
print('[告警派发工单] 开始提交');
|
||||
print('[告警派发工单] 请求URL: ${HttpApiConsts.workOrderAdd}');
|
||||
|
||||
try {
|
||||
// 格式化计划时间
|
||||
String formatDateTime(DateTime? dt) {
|
||||
if (dt == null) return '';
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:'
|
||||
'${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// 构造 workOrder JSON
|
||||
final workOrder = <String, dynamic>{
|
||||
'siteId': entity.siteId,
|
||||
'siteName': entity.siteName ?? '',
|
||||
'sourceType': 7, // 7=告警关联
|
||||
'orderType': entity.orderType ?? '',
|
||||
'deviceId': entity.deviceId ?? '',
|
||||
'deviceName': entity.deviceName ?? '',
|
||||
'taskDescription': entity.description ?? '',
|
||||
'priorityLevel': entity.problemLevel ?? 'INFO',
|
||||
'orderTitle': '${entity.orderType ?? '告警派发'} - ${entity.deviceName ?? ''}',
|
||||
'orderStatus': 1, // 1=待处理
|
||||
'imgUrl': <String>[],
|
||||
'videoUrl': <String>[],
|
||||
'alarmId': entity.alarmId,
|
||||
'alarmNo': entity.alarmNo ?? '',
|
||||
'planStartTime': formatDateTime(entity.planStartTime),
|
||||
'planEndTime': formatDateTime(entity.planEndTime),
|
||||
};
|
||||
final workOrderJson = jsonEncode(workOrder);
|
||||
print('[告警派发工单] workOrder JSON: $workOrderJson');
|
||||
|
||||
// 分离图片和视频
|
||||
final imagePaths = <String>[];
|
||||
final videoPaths = <String>[];
|
||||
const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', '3gp'];
|
||||
|
||||
if (entity.mediaUrls != null) {
|
||||
for (final path in entity.mediaUrls!) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
if (videoExts.contains(ext)) {
|
||||
videoPaths.add(path);
|
||||
} else {
|
||||
imagePaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
print('[告警派发工单] 图片: ${imagePaths.length}个, 视频: ${videoPaths.length}个');
|
||||
|
||||
// 创建 multipart 请求
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse(HttpApiConsts.workOrderAdd),
|
||||
);
|
||||
|
||||
// Authorization
|
||||
final userToken = GetIt.I<AppUserCubit>().state.user?.token;
|
||||
if (userToken != null) {
|
||||
request.headers['Authorization'] = 'Bearer $userToken';
|
||||
}
|
||||
|
||||
// 图片文件
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final firstImage = imagePaths.first;
|
||||
final file = File(firstImage);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: firstImage.split('/').last,
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
<int>[],
|
||||
filename: 'empty.jpg',
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
}
|
||||
|
||||
// 视频文件
|
||||
if (videoPaths.isNotEmpty) {
|
||||
final firstVideo = videoPaths.first;
|
||||
final file = File(firstVideo);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
bytes,
|
||||
filename: firstVideo.split('/').last,
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
<int>[],
|
||||
filename: 'empty.mp4',
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
}
|
||||
|
||||
// workOrder JSON
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'workOrder',
|
||||
utf8.encode(workOrderJson),
|
||||
filename: '',
|
||||
contentType: http.MediaType('application', 'json'),
|
||||
));
|
||||
|
||||
print('[告警派发工单] 开始发送请求...');
|
||||
|
||||
final http.StreamedResponse response = await request.send();
|
||||
final String responseBody = await response.stream.bytesToString();
|
||||
|
||||
print('[告警派发工单] 响应状态码: ${response.statusCode}');
|
||||
print('[告警派发工单] 响应体: $responseBody');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final respJson = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final code = respJson['code'];
|
||||
if (code != null && code.toString() == '200') {
|
||||
print('[告警派发工单] 提交成功');
|
||||
print('========================================');
|
||||
return right(true);
|
||||
} else {
|
||||
final msg = respJson['msg'] ?? respJson['message'] ?? '提交失败';
|
||||
print('[告警派发工单] 业务失败: $msg');
|
||||
print('========================================');
|
||||
return left(Failure(msg.toString()));
|
||||
}
|
||||
} else {
|
||||
print('[告警派发工单] HTTP错误: ${response.statusCode}');
|
||||
print('========================================');
|
||||
return left(
|
||||
Failure('服务器错误: HTTP ${response.statusCode}'),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[告警派发工单] 异常: $e');
|
||||
print('========================================');
|
||||
return left(Failure('提交失败: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,56 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_model.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
|
||||
/// 告警远程数据源实现(模拟数据)
|
||||
/// 告警远程数据源实现
|
||||
class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource {
|
||||
@override
|
||||
Future<List<AlarmModel>> getAlarmList() async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
AlarmRemoteDataSourceImpl(this._dio);
|
||||
|
||||
return [
|
||||
AlarmModel(
|
||||
id: '1',
|
||||
title: '逆变器INV-001通讯异常',
|
||||
area: 'A区',
|
||||
device: 'INV-001',
|
||||
time: '05-21 10:23',
|
||||
level: AlarmLevel.danger,
|
||||
status: AlarmStatus.unconfirmed,
|
||||
aiDiagnosis: 'INV-001通讯异常可能由网络波动或采集器故障引起,建议检查网络连接。',
|
||||
),
|
||||
AlarmModel(
|
||||
id: '2',
|
||||
title: '汇流箱SCB-003过温告警',
|
||||
area: 'B区',
|
||||
device: 'SCB-003',
|
||||
time: '05-21 09:45',
|
||||
level: AlarmLevel.warning,
|
||||
status: AlarmStatus.unconfirmed,
|
||||
),
|
||||
AlarmModel(
|
||||
id: '3',
|
||||
title: '组件串STR-024功率异常',
|
||||
area: 'C区',
|
||||
device: 'STR-024',
|
||||
time: '05-21 08:32',
|
||||
level: AlarmLevel.low,
|
||||
status: AlarmStatus.confirmed,
|
||||
),
|
||||
AlarmModel(
|
||||
id: '4',
|
||||
title: '环境辐照度传感器离线',
|
||||
area: 'D区',
|
||||
device: 'SEN-001',
|
||||
time: '05-21 07:15',
|
||||
level: AlarmLevel.info,
|
||||
status: AlarmStatus.confirmed,
|
||||
),
|
||||
];
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<AlarmModel>> getAlarmList({
|
||||
int? siteId,
|
||||
int? configId,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (siteId != null) queryParams['siteId'] = siteId;
|
||||
if (configId != null) queryParams['configId'] = configId;
|
||||
if (page != null) queryParams['page'] = page;
|
||||
if (pageSize != null) queryParams['pageSize'] = pageSize;
|
||||
|
||||
debugPrint('=== 获取告警列表请求 ===');
|
||||
debugPrint('uri: ${HttpApiConsts.alarmList}');
|
||||
debugPrint('queryParams: $queryParams');
|
||||
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.alarmList,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '获取告警列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = responseData['rows'] ?? [];
|
||||
return rows
|
||||
.map((item) => AlarmModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AlarmCountModel> getAlarmCount() async {
|
||||
Future<AlarmCountModel> getAlarmCount({int? siteId}) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
@@ -62,4 +61,31 @@ class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource {
|
||||
todayNew: 12,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AlarmOrderConfigModel>> getAlarmOrderConfigList(
|
||||
int siteId,
|
||||
) async {
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.alarmOrderConfigList,
|
||||
data: {'siteId': siteId},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '获取告警工单配置失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = responseData['rows'] ?? [];
|
||||
return rows
|
||||
.map(
|
||||
(item) =>
|
||||
AlarmOrderConfigModel.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user