蓝牙绑定设备+场站切换支持高清图层的一对一显示+设备实时状态的部分显示

This commit is contained in:
2026-09-02 16:58:45 +08:00
parent 61bce2c1cf
commit 5141d6e83f
10 changed files with 458 additions and 133 deletions

View File

@@ -231,8 +231,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.target_speed') +
"\n(rpm)",
),
_buildTableCell(status.leftTargetSpeed.toStringAsFixed(2)),
_buildTableCell(status.rightTargetSpeed.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
TableRow(
@@ -243,8 +243,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.measure_speed') +
"\n(rpm)",
),
_buildTableCell(status.leftMeasureSpeed.toStringAsFixed(2)),
_buildTableCell(status.rightMeasureSpeed.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
TableRow(
@@ -255,8 +255,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.current') +
"(A)",
),
_buildTableCell(status.leftCurrent.toStringAsFixed(2)),
_buildTableCell(status.rightCurrent.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
TableRow(
@@ -267,8 +267,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.motor_temp') +
"(°C)",
),
_buildTableCell(status.leftMotorTemp.toStringAsFixed(2)),
_buildTableCell(status.rightMotorTemp.toStringAsFixed(2)),
_buildTableCell('--'),
_buildTableCell('--'),
],
),
],
@@ -318,7 +318,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.pitch_angle') +
"(°)",
),
_buildTableCell(status.pitch.toStringAsFixed(2)),
_buildTableCell('--'),
],
),
TableRow(
@@ -329,7 +329,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.roll_angle') +
"(°)",
),
_buildTableCell(status.roll.toStringAsFixed(2)),
_buildTableCell('--'),
],
),
TableRow(
@@ -351,7 +351,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.battery') +
"(%)",
),
_buildTableCell(status.battery),
_buildTableCell('--'),
],
),
TableRow(
@@ -362,7 +362,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.chip_temp') +
"(°C)",
),
_buildTableCell(status.chipTemp.toStringAsFixed(2)),
_buildTableCell('--'),
],
),
TableRow(
@@ -373,7 +373,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.knife_speed') +
"(rpm)",
),
_buildTableCell(status.knifeCuttingSpeed),
_buildTableCell('--'),
],
),
TableRow(
@@ -383,11 +383,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
context,
).translate('running_status.control_mode'),
),
_buildTableCell(
AppLocalizations.of(context).translate(
_getControlModeKey(int.parse(status.controlMode)),
),
),
_buildTableCell('--'),
],
),
TableRow(
@@ -524,28 +520,13 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
// Status Bar
BlocBuilder<DeviceStatusBloc, DeviceStatusState>(
builder: (context, state) {
String qual = _isDataTimeout ? '--' : '--';
String satelliteCnt = _isDataTimeout ? '--' : '--';
String headingStatus = _isDataTimeout ? "--" : "--";
// 🔥 headingStatus/satelliteCnt 来自 realtime/post(尚未推送)
String satelliteCnt = '--';
String headingStatus = '--';
// 🔥 定位质量来自 location/post 的 fix_status
String qual = '--';
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 {
qualValue = int.parse(state.status.qual.toString());
} catch (e) {
qualValue = 0;
}
qual = AppLocalizations.of(
context,
).translate(_getLocationQualityKey(qualValue));
satelliteCnt = state.status.satelliteCnt.toString();
qual = _getLocationQualityText(state.status.qual);
}
return Container(
@@ -1173,15 +1154,20 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
);
}
String _getLocationQualityKey(int qualValue) {
/// 🔥 fix_status 枚举翻译(来自 MQTT location/post)
String _getLocationQualityText(int qualValue) {
switch (qualValue) {
case 0:
return 'running_status.invalid';
case 4:
case 5:
return 'running_status.valid';
default:
return 'common.unknown';
case 0: return '无定位';
case 1: return '单点定位';
case 2: return '差分定位';
case 3: return '惯性导航';
case 4: return 'RTK固定解';
case 5: return 'RTK浮点解';
case 6: return '单点(无航向)';
case 7: return '差分(无航向)';
case 8: return 'RTK固定(无航向)';
case 9: return 'RTK浮点(无航向)';
default: return '--';
}
}
}

View File

@@ -1,7 +1,7 @@
class EnvConfig {
static const String environment = String.fromEnvironment(
'ENV',
defaultValue: 'prod',
defaultValue: 'test',
);
static String get sentryDsn {

View File

@@ -14,6 +14,10 @@ class MowerLocationEntity extends Equatable {
/// 速度
final double? speed;
/// 定位质量(fix_status)
final String? fixStatus;
final String? timestamp;
/// 原始报文,便于解析格式确认后补充字段
@@ -25,6 +29,7 @@ class MowerLocationEntity extends Equatable {
this.altitude,
this.heading,
this.speed,
this.fixStatus,
this.timestamp,
this.rawData = const {},
});
@@ -38,6 +43,7 @@ class MowerLocationEntity extends Equatable {
altitude: _toDouble(json['altitude'] ?? json['height']),
heading: _toDouble(json['heading'] ?? json['yaw']),
speed: _toDouble(json['speed']),
fixStatus: json['fix_status']?.toString(),
timestamp: json['timestamp']?.toString(),
rawData: json,
);
@@ -50,5 +56,5 @@ class MowerLocationEntity extends Equatable {
}
@override
List<Object?> get props => [latitude, longitude, altitude, heading, speed, timestamp];
List<Object?> get props => [latitude, longitude, altitude, heading, speed, fixStatus, timestamp];
}

View File

@@ -262,12 +262,13 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 更新 _cachedGps
_cachedGps = GPSEntity(location.latitude!, location.longitude!);
// 更新 _cachedStatus 中的航向角和坐标(复用已有缓存或新建空壳)
// 更新 _cachedStatus 中的航向角、坐标、定位质量(复用已有缓存或新建空壳)
final base = _cachedStatus ?? RunningStatusEntity();
_cachedStatus = base.copyWith(
latitude: location.latitude!,
longitude: location.longitude!,
yaw: location.heading ?? base.yaw,
qual: _fixStatusToQual(location.fixStatus, base.qual),
);
// 500ms节流:与 TCP 0x02 时代逻辑一致
@@ -397,6 +398,24 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_currentDeviceId = deviceId;
}
/// 🔥 将 MQTT location/post 的 fix_status 字符串映射为 qual 整数值
int _fixStatusToQual(String? fixStatus, int fallback) {
if (fixStatus == null) return fallback;
switch (fixStatus) {
case 'no_fix': return 0;
case 'single': return 1;
case 'dgnss': return 2;
case 'ins': return 3;
case 'rtk_fixed': return 4;
case 'rtk_float': return 5;
case 'single_no_heading': return 6;
case 'dgnss_no_heading': return 7;
case 'rtk_fixed_no_heading': return 8;
case 'rtk_float_no_heading': return 9;
default: return fallback;
}
}
// 🔥 节流发射:500ms到期后发射缓存的最新数据
void _emitCachedStatus() {
if (_cachedStatus != null && _cachedGps != null && !isClosed) {

View File

@@ -7,17 +7,46 @@ import 'package:latlong2/latlong.dart';
/// GeoServer 基地址(公司端)
const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver';
/// 首期苏州区域图层标识
const String kSuzhouLayerName = 'workSpace:suzhou';
/// 各场站高清图层的真实范围(取自 GeoServer GetCapabilities,原生 EPSG:4326)
/// key = 后端下发的 siteMapName(形如 workSpace:suzhou)
final Map<String, LatLngBounds> kStationLayerBounds = {
'workSpace:liaoding': LatLngBounds(
const LatLng(41.19998369457442, 120.59074514010273),
const LatLng(41.202002066385475, 120.59343722827124),
),
'workSpace:loudi': LatLngBounds(
const LatLng(27.846682995656035, 111.52814279952669),
const LatLng(27.847939704917945, 111.52928081352842),
),
'workSpace:neimeng': LatLngBounds(
const LatLng(41.321581124009896, 112.62468603332228),
const LatLng(41.325573311689986, 112.63034797059984),
),
'workSpace:result': LatLngBounds(
const LatLng(27.36490549568922, 116.84490756003203),
const LatLng(27.36673908366302, 116.84661654295478),
),
'workSpace:sanxiamen': LatLngBounds(
const LatLng(34.732748259670494, 111.43051478686061),
const LatLng(34.734205267863345, 111.43201022647355),
),
'workSpace:suzhou': LatLngBounds(
const LatLng(31.082450793459877, 120.83906484264836),
const LatLng(31.084141983135456, 120.84164596446476),
),
'workSpace:zhongke': LatLngBounds(
const LatLng(32.0406442971753, 120.8057183175354),
const LatLng(32.045144217672636, 120.81100452381844),
),
};
/// 苏州图层实际覆盖范围(取自 GetCapabilities,约 250m × 190m 的小地块)
final LatLngBounds kSuzhouBounds = LatLngBounds(
const LatLng(31.082450793459877, 120.83906484264836),
const LatLng(31.084141983135456, 120.84164596446476),
);
/// 场站中心点(真实地块中心)
const LatLng kSuzhouCenter = LatLng(31.083296, 120.840355);
/// 取某场站图层的范围中心点(用作进场默认定位);未知/为空返回 null
LatLng? stationLayerCenter(String? siteMapName) {
if (siteMapName == null) return null;
final b = kStationLayerBounds[siteMapName];
if (b == null) return null;
return LatLng((b.south + b.north) / 2, (b.west + b.east) / 2);
}
/// 叠加层取瓦片方式开关:
/// - false(当前):WMS GetMap 兜底,不依赖 GWC 缓存
@@ -29,8 +58,14 @@ const bool kUseWmtsTile = false;
/// 请求 EPSG:3857(WMS 1.1.1 / 1.3.0 均验证过)返回纯白图。
/// 因此这里按瓦片 XYZ 反算经纬度 bbox,以 EPSG:4326 请求 WMS。
class GeoWmsProvider extends TileProvider {
GeoWmsProvider({this.maxLevel = 21});
GeoWmsProvider({
required this.layerName,
required this.bounds,
this.maxLevel = 21,
});
final String layerName;
final LatLngBounds bounds;
final int maxLevel;
/// Web 墨卡托地球周长(米)
@@ -64,10 +99,10 @@ class GeoWmsProvider extends TileProvider {
final northLat = _mercatorYToLat(northM);
// 与图层实际范围不相交的瓦片直接返回透明图,不发无谓请求
final intersects = westLng < kSuzhouBounds.east &&
eastLng > kSuzhouBounds.west &&
southLat < kSuzhouBounds.north &&
northLat > kSuzhouBounds.south;
final intersects = westLng < bounds.east &&
eastLng > bounds.west &&
southLat < bounds.north &&
northLat > bounds.south;
if (!intersects) {
return MemoryImage(TileProvider.transparentImage);
}
@@ -77,7 +112,7 @@ class GeoWmsProvider extends TileProvider {
'SERVICE': 'WMS',
'REQUEST': 'GetMap',
'VERSION': '1.1.1',
'LAYERS': kSuzhouLayerName,
'LAYERS': layerName,
'BBOX': '${westLng.toStringAsFixed(6)},${southLat.toStringAsFixed(6)},'
'${eastLng.toStringAsFixed(6)},${northLat.toStringAsFixed(6)}',
'WIDTH': '256',
@@ -92,11 +127,18 @@ class GeoWmsProvider extends TileProvider {
}
}
/// GeoServer 苏州无人机高清影像叠加层(WGS84 基准,覆盖在 ESRI 底图上)
///
/// 地块很小(约 250m),zoom 16 以上才有意义,默认 minZoom = 16
/// GeoServer 场站无人机高清影像叠加层(WGS84 基准,覆盖在 ESRI 底图上)
/// 按当前选中场站的 siteMapName 动态加载对应图层;地块很小,默认 minZoom = 16
class GeoSuzhouOverlay extends StatelessWidget {
const GeoSuzhouOverlay({super.key, this.minZoom = 16, this.maxZoom = 21});
const GeoSuzhouOverlay({
super.key,
this.siteMapName,
this.minZoom = 16,
this.maxZoom = 21,
});
/// 当前选中场站对应的 GeoServer 图层名(形如 workSpace:suzhou)
final String? siteMapName;
/// 小于该级别不请求瓦片(地块太小,低层级看不见)
final double minZoom;
@@ -106,28 +148,39 @@ class GeoSuzhouOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final layer = siteMapName;
final bounds = layer == null ? null : kStationLayerBounds[layer];
// 未选中场站或图层未知 → 不渲染叠加层
if (layer == null || bounds == null) {
return const SizedBox.shrink();
}
if (kUseWmtsTile) {
// === WMTS 版:公司把 EPSG:3857 gridset 加入 GWC 并 Seed 后生效 ===
return TileLayer(
urlTemplate: '$kGeoserverBase/gwc/service/wmts'
'?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
'&LAYER=$kSuzhouLayerName&TILEMATRIXSET=EPSG:3857'
'&LAYER=$layer&TILEMATRIXSET=EPSG:3857'
'&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png',
userAgentPackageName: 'com.example.app',
minZoom: minZoom,
maxZoom: maxZoom,
maxNativeZoom: maxZoom.toInt(),
tileBounds: kSuzhouBounds,
tileBounds: bounds,
);
}
// === WMS 版(当前兜底):按原生 EPSG:4326 请求,GeoServer 实时渲染 ===
return TileLayer(
tileProvider: GeoWmsProvider(maxLevel: maxZoom.toInt()),
tileProvider: GeoWmsProvider(
layerName: layer,
bounds: bounds,
maxLevel: maxZoom.toInt(),
),
userAgentPackageName: 'com.example.app',
minZoom: minZoom,
maxZoom: maxZoom,
maxNativeZoom: maxZoom.toInt(),
tileBounds: kSuzhouBounds,
tileBounds: bounds,
);
}
}

View File

@@ -492,7 +492,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
// ==================== ✅ 核心修复 ====================
// 1. 从缓存恢复后,强制让 _markedPoints 同步给外边框(解决不显示)
if (_markedPoints.isNotEmpty && gcjOuterPoints.isEmpty) {
gcjOuterPoints = List.from(_markedPoints);
// 🔥 覆盖模式:兜底恢复时同样闭合边界,避免重启后 outer 缺一边
gcjOuterPoints = _closeBowRing(_markedPoints);
}
// 2. 关键:同步机器人模式坐标(保存时边框不丢失)
@@ -924,6 +925,156 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
}
/// 🔥 保存地块:打印即将 POST 给后端的完整请求详情
/// 用途:验证覆盖模式下 jsonData.outer / jsonData.path 是否已首尾闭合(A B C → A B C A)
/// ⚠️ 只读取已构造好的 Dart 对象和 MultipartFile 的元信息(field/filename/contentType/length),
/// 绝不调用 MultipartFile.finalize()——那会消费 ByteStream,导致后续 request.send() 失败。
/// (旁边的 _debugPrintMultipartRequest 正是踩了这个坑,故未启用)
void _logSaveRequestDetail({
required http.MultipartRequest request,
required Map<String, dynamic> savePath,
required String workRecordJson,
required String plotName,
required Object? siteId,
}) {
// debugPrint 单条过长会被 logcat 截断,长文本统一按行拆开输出
void printLines(String text) {
for (final line in text.split('\n')) {
debugPrint(line);
}
}
debugPrint('📤 [保存地块-请求详情] ================ BEGIN ================');
debugPrint('📤 [请求行] ${request.method} ${request.url}');
// 请求头:Authorization 脱敏,避免 token 明文落日志
final Map<String, String> safeHeaders = Map<String, String>.from(
request.headers,
);
final String? auth = safeHeaders['Authorization'];
if (auth != null) {
safeHeaders['Authorization'] = auth.length > 18
? '${auth.substring(0, 14)}...${auth.substring(auth.length - 4)} (已脱敏, 原长${auth.length})'
: '(已脱敏)';
}
debugPrint('📤 [请求头] $safeHeaders');
debugPrint(
'📤 [上下文] workName=$plotName, siteId=$siteId, '
'workMode=$_currentWorkMode, robotMode=$_currentRobotMode, '
'areaMode=$_currentAreaMode, planModel=${savePath['planModel']}',
);
// multipart 各 part 的元信息(不消费流)
debugPrint('📤 [multipart] part 数量: ${request.files.length}');
for (var i = 0; i < request.files.length; i++) {
final http.MultipartFile f = request.files[i];
debugPrint(
'📤 [multipart] part[$i] field=${f.field}, '
'filename="${f.filename ?? ''}", contentType=${f.contentType}, '
'大小=${f.length} 字节 (${(f.length / 1024).toStringAsFixed(1)} KB)',
);
}
// 逐点打印 outer / path,并标注首尾,肉眼即可确认是否闭合
void printPoints(String field) {
final List<dynamic> list = (savePath[field] as List?) ?? <dynamic>[];
debugPrint('📤 [jsonData.$field] 点数=${list.length}');
for (var i = 0; i < list.length; i++) {
final Map<dynamic, dynamic> p = list[i] as Map;
final String tag = list.length == 1
? ' ← 唯一的点'
: (i == 0
? ' ← 第一个点'
: (i == list.length - 1 ? ' ← 最后一个点' : ''));
debugPrint(
'📤 [jsonData.$field] [$i] lat=${p['lat']}, lng=${p['lng']}$tag',
);
}
}
printPoints('outer');
printPoints('path');
// 闭环校验结论
bool isClosed(String field) {
final List<dynamic> list = (savePath[field] as List?) ?? <dynamic>[];
if (list.length < 4) return false;
final Map<dynamic, dynamic> first = list.first as Map;
final Map<dynamic, dynamic> last = list.last as Map;
return first['lat'] == last['lat'] && first['lng'] == last['lng'];
}
// 🔥 首尾并排比对:把 [0] 和 [最后一个] 的实际坐标上下两行打出来,
// 直接肉眼确认“第一个点被当作最后一个点追加到了末尾”
void printHeadTail(String field) {
final List<dynamic> list = (savePath[field] as List?) ?? <dynamic>[];
if (list.isEmpty) {
debugPrint('🔒 [首尾比对] $field:空数组,无比对对象');
return;
}
if (list.length == 1) {
debugPrint('🔒 [首尾比对] $field:只有 1 个点,不构成环');
return;
}
final Map<dynamic, dynamic> f = list.first as Map;
final Map<dynamic, dynamic> l = list.last as Map;
final int lastIdx = list.length - 1;
final bool same = f['lat'] == l['lat'] && f['lng'] == l['lng'];
debugPrint('🔒 [首尾比对] ---- $field (共 ${list.length} 个点) ----');
debugPrint('🔒 [首尾比对] $field[0] lat=${f['lat']}, lng=${f['lng']} ← 第一个点');
debugPrint('🔒 [首尾比对] $field[$lastIdx] lat=${l['lat']}, lng=${l['lng']} ← 最后一个点');
debugPrint(
'🔒 [首尾比对] 结论:$field[$lastIdx] ${same ? "==" : "!="} $field[0] → '
'${same ? "✅ 最后一个点就是第一个点(已闭环 ABCA)" : "❌ 两点不同(未闭环 ABC)"}',
);
if (same) {
debugPrint(
'🔒 [首尾比对] 即:用户实际打了 $lastIdx 个不同顶点,'
'末尾第 $lastIdx 位是自动追加的闭合点(与[0]完全相同)',
);
}
}
if (_currentWorkMode == WorkMode.bow) {
printHeadTail('outer');
printHeadTail('path');
final bool outerOk = isClosed('outer');
final bool pathOk = isClosed('path');
debugPrint(
'🔒 [闭环校验] 覆盖模式要求首尾同点 → '
'outer=${outerOk ? "✅已闭合" : "❌未闭合"}, '
'path=${pathOk ? "✅已闭合" : "❌未闭合"}',
);
if (!outerOk || !pathOk) {
debugPrint(
'🔒 [闭环校验] ⚠️ 未闭合原因排查:打点数=${_markedPoints.length}(需≥3), '
'robotMode打点数=${_robotModeWgsPoints.length}, '
'workMode=$_currentWorkMode(需为 WorkMode.bow)'
'${outerOk || pathOk ? "" : ";若两者都❌,说明 _closeBowRing 未执行追加,往上翻 🔗 [闭合] 日志看跳过原因"}',
);
}
} else {
debugPrint(
'🔒 [闭环校验] 当前 workMode=$_currentWorkMode 非覆盖模式,'
'outer 应为空、path 不闭合(符合预期)',
);
}
// 完整 JSON:这就是后端 /iot/workRecord/add 收到的 workRecord 内容
debugPrint('📤 [workRecord 完整JSON] ↓↓↓ 后端实际接收内容 ↓↓↓');
try {
printLines(
const JsonEncoder.withIndent(' ').convert(jsonDecode(workRecordJson)),
);
} catch (e) {
debugPrint('📤 [workRecord 完整JSON] 格式化失败($e),输出原始串:');
printLines(workRecordJson);
}
debugPrint('📤 [保存地块-请求详情] ================= END =================');
}
Future<void> _savePlotData(String plotName, String? imgBase64) async {
debugPrint('🔍 [保存地块] ====== _savePlotData 被调用, plotName=$plotName ======');
final loc = AppLocalizations.of(context);
@@ -944,15 +1095,14 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
// 辅助方法:将打点转为 WGS84 格式 {lng, lat}
List<Map<String, double>> _markedPointsToWgs() {
if (_currentRobotMode == RobotMode.robot) {
return _robotModeWgsPoints
.map((p) => {'lng': p.longitude, 'lat': p.latitude})
.toList();
}
return _markedPoints.map((point) {
final List<LatLng> source = _currentRobotMode == RobotMode.robot
? _robotModeWgsPoints
: _markedPoints;
// 🔥 覆盖模式:上传前闭合边界,首尾同点(A B C → A B C A)
// 下位机与后端要求第一个点与最后一个点相同
return _closeBowRing(source).map((point) {
// ⚠️ 地图已切换为 WGS84,直接使用原始坐标
final wgs84 = LatLng(point.latitude, point.longitude);
return {'lng': wgs84.longitude, 'lat': wgs84.latitude};
return {'lng': point.longitude, 'lat': point.latitude};
}).toList();
}
@@ -1036,21 +1186,19 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
);
debugPrint('🔍 [保存地块] 构造请求完成,即将进入try发送...');
// 🔥 发送前打印完整请求详情:URL、请求头、multipart 各 part、
// outer/path 逐点坐标(标注首尾)、闭环校验结论、workRecord 完整 JSON
_logSaveRequestDetail(
request: request,
savePath: savePath,
workRecordJson: workRecordJson,
plotName: plotName,
siteId: siteId,
);
// 7. 发送请求
try {
// debugPrint('📤 [保存地块] ========== 请求参数 BEGIN ==========');
// debugPrint('📤 [保存地块] plotName: $plotName');
// debugPrint('📤 [保存地块] siteId: $siteId');
// debugPrint('📤 [保存地块] workMode: $_currentWorkMode');
// debugPrint('📤 [保存地块] planModel: ${savePath['planModel']}');
// debugPrint('📤 [保存地块] savePath.name: ${savePath['name']}');
// debugPrint('📤 [保存地块] path长度: ${(savePath['path'] as List?)?.length ?? 0}');
// debugPrint('📤 [保存地块] outer长度: ${(savePath['outer'] as List?)?.length ?? 0}');
// debugPrint('📤 [保存地块] 是否有图片文件: ${imgBase64 != null && imgBase64.isNotEmpty}');
// debugPrint('📤 [保存地块] 请求URL: ${HttpApiConsts.baseUrl}/iot/workRecord/add');
// debugPrint('📤 [保存地块] workRecord(完整JSON): $workRecordJson');
// debugPrint('📤 [保存地块] savePath(原始对象): $savePath');
// debugPrint('📤 [保存地块] ========== 请求参数 END ==========');
_showPageToast(
message: loc.translate('route_planning.saving') + "「$plotName」...",
type: ToastType.loading,
@@ -1928,7 +2076,11 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}
LatLng _getDefaultValidCenter() {
return const LatLng(30.279651, 120.154871);
// 优先进场定位到“当前选中场站”的高清图层中心;无有效场站时兜底到苏州地块中心
final center = stationLayerCenter(
sl<SiteCubit>().state.selectedSite?.siteMapName,
);
return center ?? const LatLng(31.083296, 120.840355);
}
Widget _buildMap(
@@ -1990,11 +2142,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
maxZoom: 19,
),
/// GeoServer 苏州无人机高清影像叠加层(WGS84 基准,无偏移)
/// 实际地块很小(约 250m,中心 31.0833/120.8404),需 zoom >= 16 才能看到
/// GeoServer 场站无人机高清影像叠加层(WGS84 基准,无偏移)
/// 按当前选中场站的 siteMapName 动态加载对应图层;需 zoom >= 16 才能看到
/// 当前 WMS 兜底(原生 EPSG:4326);公司补配 GWC EPSG:3857 后切 WMTS
/// 切换位置:geoserver_suzhou_layer.dart 的 kUseWmtsTile 开关
GeoSuzhouOverlay(),
GeoSuzhouOverlay(
siteMapName: sl<SiteCubit>().state.selectedSite?.siteMapName,
),
//if (isDjMapShow) DJITileLayer(stationName: _currentStation.name, mapController: _mapController),
// 绘制path折线(Line模式)
@@ -3776,6 +3930,50 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
return result;
}
/// 覆盖模式下闭合打点集合:把第一个点追加到末尾(A B C → A B C A)
/// 仅覆盖模式(WorkMode.bow)生效;点数不足 3 或首尾已重合时原样返回,避免重复追加
/// ⚠️ 只用于派生数据/出口数据,禁止写入 _markedPoints、_robotModeWgsPoints 源集合
/// 🔥 每个分支都打日志,便于确认“第一个点到底有没有被追加到末尾”
List<LatLng> _closeBowRing(List<LatLng> points) {
if (_currentWorkMode != WorkMode.bow) {
debugPrint(
'🔗 [闭合] ⏭️ 跳过追加:workMode=$_currentWorkMode 不是覆盖模式,'
'点数=${points.length} 原样返回(不闭合)',
);
return List<LatLng>.from(points);
}
if (points.length < 3) {
debugPrint(
'🔗 [闭合] ⏭️ 跳过追加:点数=${points.length} 不足 3 个,无法成环,'
'原样返回(避免造出退化多边形)',
);
return List<LatLng>.from(points);
}
final LatLng first = points.first;
final LatLng last = points.last;
if (first.latitude == last.latitude && first.longitude == last.longitude) {
debugPrint(
'🔗 [闭合] ⏭️ 跳过追加:首尾已经重合'
'(lat=${first.latitude}, lng=${first.longitude}),'
'点数=${points.length} 保持不变(幂等保护,不重复叠加)',
);
return List<LatLng>.from(points);
}
// ✅ 真正执行追加的地方
debugPrint(
'🔗 [闭合] ✅ 已把第一个点追加到末尾!'
'追加的坐标=第[0]个点(lat=${first.latitude}, lng=${first.longitude});'
'它成为新的第[${points.length}]号点(即最后一个点);'
'点数 ${points.length} → ${points.length + 1};'
'原本的第[${points.length - 1}]号点(lat=${last.latitude}, lng=${last.longitude}) 往前让一位',
);
debugPrint(
'🔗 [闭合] ✅ 追加后首尾关系:新[0]=(lat=${first.latitude}, lng=${first.longitude}) '
'== 新[${points.length}]=(lat=${first.latitude}, lng=${first.longitude}) → 完全相同',
);
return [...points, LatLng(first.latitude, first.longitude)];
}
// ========== 抽象:生成路径的核心函数(打点函数) ==========
Future<void> _generatePath({bool showTips = true}) async {
debugPrint(
@@ -3847,7 +4045,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
List<work_area_model.Position> outerPositions = [];
if (_currentRobotMode == RobotMode.robot &&
_robotModeWgsPoints.isNotEmpty) {
outerPositions = _robotModeWgsPoints.map((latLng) {
outerPositions = _closeBowRing(_robotModeWgsPoints).map((latLng) {
return work_area_model.Position(
lat: latLng.latitude,
lon: latLng.longitude,
@@ -3855,7 +4053,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
}).toList();
} else if (_markedPoints.isNotEmpty) {
// robot模式但_robotModeWgsPoints为空时,用_markedPoints(已是WGS84)
outerPositions = _markedPoints.map((latLng) {
outerPositions = _closeBowRing(_markedPoints).map((latLng) {
return work_area_model.Position(
lat: latLng.latitude,
lon: latLng.longitude,
@@ -3988,7 +4186,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
_isWorkAreaCompleted = true;
_currentObstaclePoints.clear();
_robotModeObsWgsPoints.clear();
gcjPathPoints = List.from(_markedPoints);
// 🔥 覆盖模式:闭合边界(A B C → A B C A),保证地图显示与保存截图一致
gcjPathPoints = _closeBowRing(_markedPoints);
typedPathList = [];
});
_saveDataToLocal();
@@ -4276,12 +4475,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
),
),
/// 悬浮"回到苏州"按钮:一键跳转到 GeoServer 苏州地块(zoom 18 才能看清 250m 地块)
/// 悬浮“回到当前场站”按钮:一键跳转到选中场站高清图层(zoom 18 才看得清小地块)
Positioned(
top: 60,
left: 10,
child: GestureDetector(
onTap: () => _mapController.move(kSuzhouCenter, 18),
onTap: () =>
_mapController.move(_getDefaultValidCenter(), 18),
child: Container(
width: 40,
height: 40,
@@ -4298,7 +4498,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
),
alignment: Alignment.center,
child: const Text(
'苏',
'站',
style: TextStyle(
color: Colors.black87,
fontSize: 16,

View File

@@ -338,7 +338,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BindDevicePage(scanResult: matchedDevice!.deviceId),
builder: (_) => BindDevicePage(scanResult: matchedDevice!.name),
),
);
} catch (e) {

View File

@@ -125,6 +125,26 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
/// 🔥 机器人类型中文名关键字(接口 deviceTypeName 可能是纯中文)
static const _robotTypeKeywords = ['除草', '巡检', '清洗', '割草', '机器人'];
/// 🔥 类型 tab 的 code → 别名集合。
/// 筛选条用英文 code(inverter),但接口 deviceTypeName 可能返回中文名(逆变器),
/// 只做 `device.type == code` 的精确比较会让这四个 tab 恒为空,所以两套写法都认。
static const Map<String, Set<String>> _typeAliases = {
'inverter': {'inverter', '逆变器'},
'combiner_box': {'combiner_box', '汇流箱'},
'module': {'module', '组件'},
'monitor': {'monitor', '监控'},
};
/// 设备类型是否命中当前 tab(大小写与首尾空格不敏感)
static bool _matchTypeTab(String deviceType, String selectedType) {
final normalized = deviceType.trim().toLowerCase();
final aliases = _typeAliases[selectedType];
if (aliases != null) {
return aliases.any((alias) => alias.toLowerCase() == normalized);
}
return normalized == selectedType.toLowerCase();
}
/// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别)
static bool _isRobotDevice(String type, String name, String deviceId) {
final haystacks = [
@@ -506,24 +526,15 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
List filteredByType = state.devices;
if (state.selectedType != 'all') {
filteredByType = state.devices.where((device) {
return device.type == state.selectedType;
return _matchTypeTab(device.type, state.selectedType);
}).toList();
} else {
// 🔥 "全部"模式下,排除已由专用组件渲染的类型,避免重复展示
// drone_station 已通过 _buildDroneStationList 渲染
// 机器人通过下面的 _isRobotDevice 通用匹配识别,已由 _buildEmbeddedRobotList 渲染
filteredByType = state.devices.where((device) {
if (device.type == 'drone_station') return false;
final isRobot = _isRobotDevice(
device.type,
device.name,
device.deviceId,
);
debugPrint(
'🔍 [全部-过滤] deviceId=${device.deviceId} type=${device.type} name=${device.name} → isRobot=$isRobot ${isRobot ? '✅排除' : ''}',
);
return !isRobot;
}).toList();
// 🔥 "全部"模式下不渲染通用设备段:
// 机器人段(RobotListBloc → /iot/device/getSiteList)零过滤,已渲染该接口全量设备,
// 通用段数据源是同一个接口,恒为机器人段的子集 → 必然重复渲染。
// 原先靠 _isRobotDevice 字符串猜测来排除,后端 deviceTypeName 缺失时判据即失效。
// 类型 tab(逆变器/汇流箱/组件/监控)走上面的 if 分支,不受影响。
filteredByType = const [];
}
// 🔥 再根据搜索关键词过滤
@@ -550,11 +561,21 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
_buildStatusCard(context, state),
const SizedBox(height: 20.0),
// 🔥 机器人列表(直接复用 RobotListPage 的布局)
if (state.selectedType == 'all') _buildEmbeddedRobotList(context),
// 🔥 无人机机场列表(直接复用 _buildDroneStationList 的布局)
// 传入搜索关键词:通用段移除后,"全部"模式的搜索由机器人段承接
if (state.selectedType == 'all')
_buildDroneStationList(context, embedded: true),
// 🔥 普通设备列表
_buildEmbeddedRobotList(
context,
searchKeyword: state.searchKeyword,
),
// 🔥 无人机机场列表(直接复用 _buildDroneStationList 的布局)
// 传 key:电站切换时强制重建 BlocProvider,否则不会用新 siteId 重新加载
if (state.selectedType == 'all')
_buildDroneStationList(
context,
embedded: true,
key: ValueKey('embedded_drone_station_$_currentSiteId'),
),
// 🔥 普通设备列表(仅类型 tab 生效,"全部"模式下 filteredByType 为空)
...filteredDevices
.map(
(device) => DeviceItemWidget(
@@ -576,7 +597,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
}
/// 🔥 嵌入式机器人列表(直接复用 RobotListPage 的布局)
Widget _buildEmbeddedRobotList(BuildContext context) {
Widget _buildEmbeddedRobotList(
BuildContext context, {
String searchKeyword = '',
}) {
final selectedSite = sl<SiteCubit>().state.selectedSite;
final siteId = selectedSite?.id;
@@ -619,11 +643,25 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
'✅ [EmbeddedRobotList] 加载成功,机器人数量: ${state.robots.length}',
);
// 🔥 搜索联动:按 设备名 / deviceId / 别名 过滤
final keyword = searchKeyword.trim().toLowerCase();
final robots = keyword.isEmpty
? state.robots
: state.robots.where((robot) {
return robot.name.toLowerCase().contains(keyword) ||
robot.id.toLowerCase().contains(keyword) ||
(robot.alias ?? '').toLowerCase().contains(keyword);
}).toList();
if (robots.isEmpty) {
return const SizedBox.shrink();
}
// 🔥 直接复用 RobotListPage 的布局结构(不显示标题)
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...state.robots.map((robot) {
...robots.map((robot) {
debugPrint(
'📍 [EmbeddedRobotList] 机器人 - name: ${robot.name}, id: ${robot.id}',
);
@@ -701,6 +739,8 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
final selectedSite = sl<SiteCubit>().state.selectedSite;
if (selectedSite == null) {
// 🔥 嵌入式分组不输出占位内容,避免"全部"列表中间夹一段提示
if (embedded) return const SizedBox.shrink();
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -762,6 +802,9 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
final stations = state.stations;
if (stations.isEmpty) {
// 🔥 "全部"标签下的嵌入式分组为空时不输出文案,
// 避免列表中间夹一行"该场站暂无无人机机场"
if (embedded) return const SizedBox.shrink();
return Center(
child: Text(
AppLocalizations.of(

View File

@@ -19,6 +19,9 @@ class DeviceItemWidget extends StatelessWidget {
return GestureDetector(
onTap: onTap,
child: Container(
// 🔥 卡片外边距:与 RobotItemCard 保持一致。
// 外层 ListView 只有 bottom padding,缺了这里卡片就会通栏贴边、左右圆角被裁掉。
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: context.appColors.cardBackground,
@@ -81,24 +84,32 @@ class DeviceItemWidget extends StatelessWidget {
IconData icon;
Color color;
// 🔥 同一个 type 字段有两套取值体系:英文 code(inverter)与中文名(逆变器)。
// 类型 tab 用英文 code 过滤,所以到达这里的设备大多是英文 code;
// 两套写法都认,否则全部落到 default 的通用设备图标。
switch (type) {
case '机器人':
case 'robot':
icon = Icons.smart_toy;
color = const Color(0xFF165DFF);
break;
case '逆变器':
case 'inverter':
icon = Icons.power;
color = const Color(0xFF165DFF);
break;
case '汇流箱':
case 'combiner_box':
icon = Icons.electrical_services;
color = const Color(0xFF722ED1);
break;
case '组件':
case 'module':
icon = Icons.solar_power;
color = const Color(0xFF13C2C2);
break;
case '监控':
case 'monitor':
icon = Icons.videocam;
color = const Color(0xFFEB2F96);
break;
@@ -153,7 +164,9 @@ class DeviceItemWidget extends StatelessWidget {
Widget _buildDeviceData(BuildContext context, DeviceEntity device) {
final dataItems = <Widget>[];
if (device.type == 'inverter') {
// 🔥 与 _buildDeviceIcon 同理:英文 code 与中文名都认,
// 否则匹配不上会落到 else 显示 "-- 数据"。
if (device.type == 'inverter' || device.type == '逆变器') {
dataItems.add(
_buildDataItem(
context,
@@ -169,7 +182,7 @@ class DeviceItemWidget extends StatelessWidget {
'${AppLocalizations.of(context).translate('device_list_v2.today_energy')}',
),
);
} else if (device.type == 'combiner_box') {
} else if (device.type == 'combiner_box' || device.type == '汇流箱') {
if (device.current != null) {
dataItems.add(
_buildDataItem(
@@ -189,7 +202,7 @@ class DeviceItemWidget extends StatelessWidget {
),
);
}
} else if (device.type == 'module') {
} else if (device.type == 'module' || device.type == '组件') {
dataItems.add(
_buildDataItem(
context,

View File

@@ -4,6 +4,7 @@ class SiteEntity extends Equatable {
final int id;
final String siteName;
final String? siteCode;
final String? siteMapName; // GeoServer 图层名,形如 workSpace:suzhou
final int orgId;
final double? longitude;
final double? latitude;
@@ -14,6 +15,7 @@ class SiteEntity extends Equatable {
required this.id,
required this.siteName,
this.siteCode,
this.siteMapName,
required this.orgId,
this.longitude,
this.latitude,
@@ -26,6 +28,7 @@ class SiteEntity extends Equatable {
id: json['id'] as int,
siteName: json['name'] as String? ?? json['siteName'] as String? ?? '',
siteCode: json['siteCode'] as String?,
siteMapName: json['siteMapName'] as String?,
orgId: json['orgId'] as int? ?? 0,
longitude: (json['longitude'] as num?)?.toDouble(),
latitude: (json['latitude'] as num?)?.toDouble(),
@@ -39,6 +42,7 @@ class SiteEntity extends Equatable {
'id': id,
'siteName': siteName,
'siteCode': siteCode,
'siteMapName': siteMapName,
'orgId': orgId,
'longitude': longitude,
'latitude': latitude,
@@ -48,5 +52,6 @@ class SiteEntity extends Equatable {
}
@override
List<Object?> get props => [id, siteName, siteCode, orgId, longitude, latitude, address, status];
List<Object?> get props =>
[id, siteName, siteCode, siteMapName, orgId, longitude, latitude, address, status];
}