一次大的提交

This commit is contained in:
2026-08-07 08:49:29 +08:00
parent bf2fc49c19
commit db70665ced
181 changed files with 23060 additions and 2804 deletions

View File

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

View File

@@ -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",不处理');
}

View File

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

View File

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

View File

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

View File

@@ -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 - 所有状态已重置');
}
}

View File

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

View File

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

View File

@@ -32,3 +32,8 @@ class PermissionDialogDismissed extends PermissionRequestEvent {
@override
List<Object?> get props => [agree, deviceId];
}
/// 🔥 退出登录时清空所有状态
class PermissionClearAll extends PermissionRequestEvent {
const PermissionClearAll();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,4 +45,11 @@ class MyCubit extends Cubit<MyState> {
emit(state.copyWith(isLoading: true, errorMessage: ''));
// 解绑逻辑(如需保留,需补充 UnbindDeviceUsecase 依赖注入)
}
/// 🔥 退出登录时清空所有状态(昵称等个人信息)
void clearAll() {
if (!isClosed) {
emit(const MyState());
}
}
}

View File

@@ -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 - 所有状态已重置为初始值');
}
}

View File

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

View File

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

View File

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

View File

@@ -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'] ?? '绑定设备失败');
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, // 🔥 标记需要显示弹窗
));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 字段,使用默认值

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,24 @@ class AlarmDetailModel {
required this.description,
required this.suggestions,
required this.historyData,
this.alarmNo,
this.alarmType,
this.deviceId,
this.deviceType,
this.createTime,
this.updateTime,
this.handleUserId,
this.handleUserName,
this.handleTime,
this.handleRemark,
this.rootCause,
this.handleSuggestion,
this.siteId,
this.orgId,
this.notifySms,
this.notifyTelegram,
this.notifyEmail,
this.handleResult,
});
final String id;
@@ -30,29 +48,99 @@ class AlarmDetailModel {
final String description;
final List<String> suggestions;
final HistoryDataModel historyData;
final String? alarmNo;
final int? alarmType;
final String? deviceId;
final String? deviceType;
final String? createTime;
final String? updateTime;
final String? handleUserId;
final String? handleUserName;
final String? handleTime;
final String? handleRemark;
final String? rootCause;
final String? handleSuggestion;
final int? siteId;
final int? orgId;
final int? notifySms;
final int? notifyTelegram;
final int? notifyEmail;
final String? handleResult;
factory AlarmDetailModel.fromJson(Map<String, dynamic> json) {
return AlarmDetailModel(
id: json['id'] as String,
title: json['title'] as String,
level: json['level'] as String,
deviceInfo: json['deviceInfo'] as String,
occurTime: json['occurTime'] as String,
alarmStatus: json['alarmStatus'] as String,
recoverTime: json['recoverTime'] as String?,
duration: json['duration'] as String,
affectRange: json['affectRange'] as String,
description: json['description'] as String,
suggestions: (json['suggestions'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
id: json['id'].toString(),
title: json['alarmTitle']?.toString() ?? '',
level: json['alarmLevel']?.toString() ?? '',
deviceInfo:
(json['deviceName']?.toString() ??
json['deviceId']?.toString() ??
'') +
(json['deviceType'] != null ? ' (${json['deviceType']})' : ''),
occurTime: json['alarmTime']?.toString() ?? '',
alarmStatus: _parseStatus(json['handleStatus']),
recoverTime: json['recoverTime']?.toString(),
duration: json['duration']?.toString() ?? '',
affectRange: json['siteId'] != null ? '站点${json['siteId']}' : '',
description: json['alarmContent']?.toString() ?? '',
suggestions: _parseSuggestions(json['handleSuggestion']),
historyData: HistoryDataModel.fromJson(
json['historyData'] as Map<String, dynamic>,
json['historyData'] as Map<String, dynamic>? ?? {},
),
alarmNo: json['alarmNo']?.toString(),
alarmType: json['alarmType'] is int ? json['alarmType'] as int : null,
deviceId: json['deviceId']?.toString(),
deviceType: json['deviceType']?.toString(),
createTime: json['createTime']?.toString(),
updateTime: json['updateTime']?.toString(),
handleUserId: json['handleUserId']?.toString(),
handleUserName: json['handleUserName']?.toString(),
handleTime: json['handleTime']?.toString(),
handleRemark: json['handleRemark']?.toString(),
rootCause: json['rootCause']?.toString(),
handleSuggestion: json['handleSuggestion']?.toString(),
siteId: json['siteId'] is int ? json['siteId'] as int : null,
orgId: json['orgId'] is int ? json['orgId'] as int : null,
notifySms: json['notifySms'] is int ? json['notifySms'] as int : null,
notifyTelegram: json['notifyTelegram'] is int
? json['notifyTelegram'] as int
: null,
notifyEmail: json['notifyEmail'] is int
? json['notifyEmail'] as int
: null,
handleResult: json['handleResult']?.toString(),
);
}
static String _parseStatus(dynamic status) {
if (status is int) {
switch (status) {
case 1:
return '待处理';
case 2:
return '已关闭';
case 3:
return '已关闭';
case 4:
return '已忽略';
default:
return '待处理';
}
}
return status as String? ?? '待处理';
}
static List<String> _parseSuggestions(dynamic suggestion) {
if (suggestion == null) return [];
if (suggestion is String) {
return suggestion.split('\n').where((s) => s.trim().isNotEmpty).toList();
}
if (suggestion is List) {
return suggestion.map((e) => e.toString()).toList();
}
return [];
}
AlarmDetailEntity toEntity() {
return AlarmDetailEntity(
id: id,
@@ -67,23 +155,45 @@ class AlarmDetailModel {
description: description,
suggestions: suggestions,
historyData: historyData.toEntity(),
alarmNo: alarmNo,
alarmType: alarmType,
deviceId: deviceId,
deviceType: deviceType,
createTime: createTime,
updateTime: updateTime,
handleUserId: handleUserId,
handleUserName: handleUserName,
handleTime: handleTime,
handleRemark: handleRemark,
rootCause: rootCause,
handleSuggestion: handleSuggestion,
siteId: siteId,
orgId: orgId,
notifySms: notifySms,
notifyTelegram: notifyTelegram,
notifyEmail: notifyEmail,
handleResult: handleResult,
);
}
AlarmLevel _parseLevel(String level) {
switch (level) {
final lowerLevel = level.toLowerCase();
switch (lowerLevel) {
case 'danger':
case '严重':
return AlarmLevel.danger;
case '高危':
return AlarmLevel.danger;
case 'warning':
case '中危':
return AlarmLevel.warning;
case 'low':
case '低危':
return AlarmLevel.low;
case 'info':
case '提示':
return AlarmLevel.info;
default:
return AlarmLevel.info;
return AlarmLevel.warning;
}
}
}
@@ -102,15 +212,18 @@ class HistoryDataModel {
factory HistoryDataModel.fromJson(Map<String, dynamic> json) {
return HistoryDataModel(
power: (json['power'] as List<dynamic>?)
power:
(json['power'] as List<dynamic>?)
?.map((e) => MetricPointModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
voltage: (json['voltage'] as List<dynamic>?)
voltage:
(json['voltage'] as List<dynamic>?)
?.map((e) => MetricPointModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
frequency: (json['frequency'] as List<dynamic>?)
frequency:
(json['frequency'] as List<dynamic>?)
?.map((e) => MetricPointModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
@@ -128,10 +241,7 @@ class HistoryDataModel {
/// 指标点模型
class MetricPointModel {
MetricPointModel({
required this.time,
required this.value,
});
MetricPointModel({required this.time, required this.value});
final String time;
final num value;
@@ -144,9 +254,6 @@ class MetricPointModel {
}
MetricDataPoint toEntity() {
return MetricDataPoint(
time: time,
value: value.toDouble(),
);
return MetricDataPoint(time: time, value: value.toDouble());
}
}

View File

@@ -0,0 +1,25 @@
class AlarmHandleModel {
AlarmHandleModel({
required this.alarmId,
required this.handleStatus,
this.handleRemark,
this.rootCause,
this.handleResult,
});
final String alarmId;
final int handleStatus;
final String? handleRemark;
final String? rootCause;
final String? handleResult;
Map<String, dynamic> toJson() {
return {
'alarmId': alarmId,
'handleStatus': handleStatus,
'handleRemark': handleRemark,
'rootCause': rootCause,
'handleResult': handleResult,
};
}
}

View File

@@ -27,14 +27,15 @@ class AlarmModel {
/// 从 JSON 创建
factory AlarmModel.fromJson(Map<String, dynamic> json) {
return AlarmModel(
id: json['id'] as String,
title: json['title'] as String,
area: json['area'] as String,
device: json['device'] as String,
time: json['time'] as String,
level: _parseLevel(json['level'] as String),
status: _parseStatus(json['status'] as String),
aiDiagnosis: json['aiDiagnosis'] as String?,
id: json['id'].toString(),
title: json['alarmTitle']?.toString() ?? '',
area: json['siteId'] != null ? '站点${json['siteId']}' : '',
device:
json['deviceName']?.toString() ?? json['deviceId']?.toString() ?? '',
time: json['alarmTime']?.toString() ?? '',
level: _parseLevel(json['alarmLevel']?.toString() ?? ''),
status: _parseStatus(json['handleStatus']),
aiDiagnosis: json['aiDiagnosis']?.toString(),
);
}
@@ -81,7 +82,8 @@ class AlarmModel {
}
static AlarmLevel _parseLevel(String level) {
switch (level) {
final lowerLevel = level.toLowerCase();
switch (lowerLevel) {
case 'danger':
return AlarmLevel.danger;
case 'warning':
@@ -91,21 +93,40 @@ class AlarmModel {
case 'info':
return AlarmLevel.info;
default:
return AlarmLevel.info;
return AlarmLevel.warning;
}
}
static AlarmStatus _parseStatus(String status) {
switch (status) {
case 'unconfirmed':
return AlarmStatus.unconfirmed;
case 'confirmed':
return AlarmStatus.confirmed;
case 'recovered':
return AlarmStatus.recovered;
default:
return AlarmStatus.unconfirmed;
static AlarmStatus _parseStatus(dynamic status) {
if (status is int) {
switch (status) {
case 1:
return AlarmStatus.pending;
case 2:
return AlarmStatus.closed;
case 3:
return AlarmStatus.closed;
case 4:
return AlarmStatus.ignored;
default:
return AlarmStatus.pending;
}
}
if (status is String) {
switch (status) {
case 'pending':
return AlarmStatus.pending;
case 'processing':
return AlarmStatus.closed;
case 'closed':
return AlarmStatus.closed;
case 'ignored':
return AlarmStatus.ignored;
default:
return AlarmStatus.pending;
}
}
return AlarmStatus.pending;
}
}
@@ -153,3 +174,31 @@ class AlarmCountModel {
);
}
}
/// 告警工单配置数据模型
class AlarmOrderConfigModel {
AlarmOrderConfigModel({required this.id, required this.name, this.code});
final int id;
final String name;
final String? code;
/// 从 JSON 创建
factory AlarmOrderConfigModel.fromJson(Map<String, dynamic> json) {
return AlarmOrderConfigModel(
id: json['id'] as int,
name: json['name'] as String? ?? '',
code: json['code'] as String?,
);
}
/// 转换为 JSON
Map<String, dynamic> toJson() {
return {'id': id, 'name': name, 'code': code};
}
/// 转换为实体
AlarmOrderConfigEntity toEntity() {
return AlarmOrderConfigEntity(id: id, name: name, code: code);
}
}

View File

@@ -11,7 +11,9 @@ class AlarmDetailRepositoryImpl implements AlarmDetailRepository {
final AlarmDetailRemoteDataSource _remoteDataSource;
@override
Future<Either<Failure, AlarmDetailEntity>> getAlarmDetail(String alarmId) async {
Future<Either<Failure, AlarmDetailEntity>> getAlarmDetail(
String alarmId,
) async {
try {
final model = await _remoteDataSource.getAlarmDetail(alarmId);
return right(model.toEntity());
@@ -21,7 +23,9 @@ class AlarmDetailRepositoryImpl implements AlarmDetailRepository {
}
@override
Future<Either<Failure, bool>> confirmAlarm(String alarmId) async {
Future<Either<Failure, Map<String, dynamic>>> confirmAlarm(
String alarmId,
) async {
try {
final result = await _remoteDataSource.confirmAlarm(alarmId);
return right(result);
@@ -30,6 +34,18 @@ class AlarmDetailRepositoryImpl implements AlarmDetailRepository {
}
}
@override
Future<Either<Failure, Map<String, dynamic>>> handleAlarm(
Map<String, dynamic> handleData,
) async {
try {
final result = await _remoteDataSource.handleAlarm(handleData);
return right(result);
} catch (e) {
return left(ServerFailure('处理告警失败: $e'));
}
}
@override
Future<Either<Failure, String>> aiDiagnosis(String alarmId) async {
try {

View File

@@ -0,0 +1,19 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../../domain/entities/alarm_dispatch_entity.dart';
import '../../domain/usecases/dispatch_workorder_usecase.dart';
import '../datasources/alarm_dispatch_remote_datasource.dart';
/// 告警派发仓储实现
class AlarmDispatchRepositoryImpl implements AlarmDispatchRepository {
final AlarmDispatchRemoteDataSource remoteDataSource;
AlarmDispatchRepositoryImpl({required this.remoteDataSource});
@override
Future<Either<Failure, bool>> dispatchWorkOrder(
AlarmDispatchEntity entity,
) async {
return await remoteDataSource.dispatchWorkOrder(entity);
}
}

View File

@@ -16,9 +16,18 @@ class AlarmRepositoryImpl implements AlarmRepository {
@override
Future<Either<Failure, List<AlarmEntity>>> getAlarmList({
AlarmFilterTab? filter,
int? siteId,
int? configId,
int? page,
int? pageSize,
}) async {
try {
final models = await _remoteDataSource.getAlarmList();
final models = await _remoteDataSource.getAlarmList(
siteId: siteId,
configId: configId,
page: page,
pageSize: pageSize,
);
final entities = models.map((model) => model.toEntity()).toList();
// 根据筛选条件过滤
@@ -34,24 +43,46 @@ class AlarmRepositoryImpl implements AlarmRepository {
}
@override
Future<Either<Failure, AlarmCountEntity>> getAlarmCount() async {
Future<Either<Failure, AlarmCountEntity>> getAlarmCount({int? siteId}) async {
try {
final model = await _remoteDataSource.getAlarmCount();
final model = await _remoteDataSource.getAlarmCount(siteId: siteId);
return right(model.toEntity());
} catch (e) {
return left(core_error.ServerFailure('获取告警统计失败: $e'));
}
}
@override
Future<Either<Failure, List<AlarmOrderConfigEntity>>> getAlarmOrderConfigList(
int siteId,
) async {
try {
final models = await _remoteDataSource.getAlarmOrderConfigList(siteId);
final entities = models.map((model) => model.toEntity()).toList();
return right(entities);
} catch (e) {
return left(core_error.ServerFailure('获取告警工单配置失败: $e'));
}
}
/// 筛选告警
List<AlarmEntity> _filterAlarms(List<AlarmEntity> alarms, AlarmFilterTab filter) {
List<AlarmEntity> _filterAlarms(
List<AlarmEntity> alarms,
AlarmFilterTab filter,
) {
switch (filter) {
case AlarmFilterTab.unprocessed:
return alarms.where((alarm) => alarm.status == AlarmStatus.unconfirmed).toList();
return alarms
.where((alarm) => alarm.status == AlarmStatus.pending)
.toList();
case AlarmFilterTab.confirmed:
return alarms.where((alarm) => alarm.status == AlarmStatus.confirmed).toList();
return alarms
.where((alarm) => alarm.status == AlarmStatus.processing)
.toList();
case AlarmFilterTab.recovered:
return alarms.where((alarm) => alarm.status == AlarmStatus.recovered).toList();
return alarms
.where((alarm) => alarm.status == AlarmStatus.closed)
.toList();
case AlarmFilterTab.all:
return alarms;
}

View File

@@ -1,5 +1,21 @@
import 'package:equatable/equatable.dart';
/// 告警工单配置实体
class AlarmOrderConfigEntity extends Equatable {
const AlarmOrderConfigEntity({
required this.id,
required this.name,
this.code,
});
final int id;
final String name;
final String? code;
@override
List<Object?> get props => [id, name, code];
}
/// 告警统计实体
class AlarmCountEntity extends Equatable {
const AlarmCountEntity({

View File

@@ -46,6 +46,24 @@ class AlarmDetailEntity extends Equatable {
required this.description,
required this.suggestions,
required this.historyData,
this.alarmNo,
this.alarmType,
this.deviceId,
this.deviceType,
this.createTime,
this.updateTime,
this.handleUserId,
this.handleUserName,
this.handleTime,
this.handleRemark,
this.rootCause,
this.handleSuggestion,
this.siteId,
this.orgId,
this.notifySms,
this.notifyTelegram,
this.notifyEmail,
this.handleResult,
});
/// 告警ID
@@ -84,6 +102,60 @@ class AlarmDetailEntity extends Equatable {
/// 历史指标数据
final HistoryMetrics historyData;
/// 告警编号
final String? alarmNo;
/// 告警类型
final int? alarmType;
/// 设备ID
final String? deviceId;
/// 设备类型
final String? deviceType;
/// 创建时间
final String? createTime;
/// 更新时间
final String? updateTime;
/// 处理人ID
final String? handleUserId;
/// 处理人名称
final String? handleUserName;
/// 处理时间
final String? handleTime;
/// 处理备注
final String? handleRemark;
/// 根因
final String? rootCause;
/// 处理建议(原始字符串)
final String? handleSuggestion;
/// 站点ID
final int? siteId;
/// 组织ID
final int? orgId;
/// 是否短信通知
final int? notifySms;
/// 是否电报通知
final int? notifyTelegram;
/// 是否邮件通知
final int? notifyEmail;
/// 处理结果
final String? handleResult;
@override
List<Object?> get props => [
id,
@@ -98,6 +170,24 @@ class AlarmDetailEntity extends Equatable {
description,
suggestions,
historyData,
alarmNo,
alarmType,
deviceId,
deviceType,
createTime,
updateTime,
handleUserId,
handleUserName,
handleTime,
handleRemark,
rootCause,
handleSuggestion,
siteId,
orgId,
notifySms,
notifyTelegram,
notifyEmail,
handleResult,
];
AlarmDetailEntity copyWith({
@@ -113,6 +203,24 @@ class AlarmDetailEntity extends Equatable {
String? description,
List<String>? suggestions,
HistoryMetrics? historyData,
String? alarmNo,
int? alarmType,
String? deviceId,
String? deviceType,
String? createTime,
String? updateTime,
String? handleUserId,
String? handleUserName,
String? handleTime,
String? handleRemark,
String? rootCause,
String? handleSuggestion,
int? siteId,
int? orgId,
int? notifySms,
int? notifyTelegram,
int? notifyEmail,
String? handleResult,
}) {
return AlarmDetailEntity(
id: id ?? this.id,
@@ -127,6 +235,24 @@ class AlarmDetailEntity extends Equatable {
description: description ?? this.description,
suggestions: suggestions ?? this.suggestions,
historyData: historyData ?? this.historyData,
alarmNo: alarmNo ?? this.alarmNo,
alarmType: alarmType ?? this.alarmType,
deviceId: deviceId ?? this.deviceId,
deviceType: deviceType ?? this.deviceType,
createTime: createTime ?? this.createTime,
updateTime: updateTime ?? this.updateTime,
handleUserId: handleUserId ?? this.handleUserId,
handleUserName: handleUserName ?? this.handleUserName,
handleTime: handleTime ?? this.handleTime,
handleRemark: handleRemark ?? this.handleRemark,
rootCause: rootCause ?? this.rootCause,
handleSuggestion: handleSuggestion ?? this.handleSuggestion,
siteId: siteId ?? this.siteId,
orgId: orgId ?? this.orgId,
notifySms: notifySms ?? this.notifySms,
notifyTelegram: notifyTelegram ?? this.notifyTelegram,
notifyEmail: notifyEmail ?? this.notifyEmail,
handleResult: handleResult ?? this.handleResult,
);
}
}

View File

@@ -0,0 +1,89 @@
/// 告警派发工单实体
class AlarmDispatchEntity {
/// 告警ID
final String alarmId;
/// 告警编号
final String? alarmNo;
/// 告警原始设备类型(如 UAV, MOWER),用于映射 orderType
final String? deviceType;
/// 映射后的工单类型(如 UAV_ERROR, MOWER_ERROR)
final String? orderType;
/// 场站ID
final int? siteId;
/// 场站名称
final String? siteName;
/// 设备ID
final String? deviceId;
/// 设备名称
final String? deviceName;
/// 任务描述
final String? description;
/// 问题等级
final String? problemLevel;
/// 媒体文件路径列表
final List<String>? mediaUrls;
/// 计划开始时间
final DateTime? planStartTime;
/// 计划结束时间
final DateTime? planEndTime;
const AlarmDispatchEntity({
required this.alarmId,
this.alarmNo,
this.deviceType,
this.orderType,
this.siteId,
this.siteName,
this.deviceId,
this.deviceName,
this.description,
this.problemLevel,
this.mediaUrls,
this.planStartTime,
this.planEndTime,
});
AlarmDispatchEntity copyWith({
String? alarmId,
String? alarmNo,
String? deviceType,
String? orderType,
int? siteId,
String? siteName,
String? deviceId,
String? deviceName,
String? description,
String? problemLevel,
List<String>? mediaUrls,
DateTime? planStartTime,
DateTime? planEndTime,
}) {
return AlarmDispatchEntity(
alarmId: alarmId ?? this.alarmId,
alarmNo: alarmNo ?? this.alarmNo,
deviceType: deviceType ?? this.deviceType,
orderType: orderType ?? this.orderType,
siteId: siteId ?? this.siteId,
siteName: siteName ?? this.siteName,
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
description: description ?? this.description,
problemLevel: problemLevel ?? this.problemLevel,
mediaUrls: mediaUrls ?? this.mediaUrls,
planStartTime: planStartTime ?? this.planStartTime,
planEndTime: planEndTime ?? this.planEndTime,
);
}
}

View File

@@ -7,8 +7,13 @@ abstract class AlarmDetailRepository {
/// 获取告警详情
Future<Either<Failure, AlarmDetailEntity>> getAlarmDetail(String alarmId);
/// 确认告警
Future<Either<Failure, bool>> confirmAlarm(String alarmId);
/// 确认告警(处理中)
Future<Either<Failure, Map<String, dynamic>>> confirmAlarm(String alarmId);
/// 处理告警(已关闭)
Future<Either<Failure, Map<String, dynamic>>> handleAlarm(
Map<String, dynamic> handleData,
);
/// AI诊断
Future<Either<Failure, String>> aiDiagnosis(String alarmId);

View File

@@ -6,12 +6,21 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constant
/// 告警仓储接口
abstract class AlarmRepository {
/// 获取告警列表
/// 获取告警列表(支持分页)
/// [filter] 筛选条件
Future<Either<Failure, List<AlarmEntity>>> getAlarmList({
AlarmFilterTab? filter,
int? siteId,
int? configId,
int? page,
int? pageSize,
});
/// 获取告警统计
Future<Either<Failure, AlarmCountEntity>> getAlarmCount();
Future<Either<Failure, AlarmCountEntity>> getAlarmCount({int? siteId});
/// 获取告警工单配置列表
Future<Either<Failure, List<AlarmOrderConfigEntity>>> getAlarmOrderConfigList(
int siteId,
);
}

View File

@@ -8,11 +8,24 @@ class ConfirmAlarmUseCase {
final AlarmDetailRepository _repository;
Future<Either<Failure, bool>> call(String alarmId) async {
Future<Either<Failure, Map<String, dynamic>>> call(String alarmId) async {
return await _repository.confirmAlarm(alarmId);
}
}
/// 处理告警用例
class HandleAlarmUseCase {
HandleAlarmUseCase(this._repository);
final AlarmDetailRepository _repository;
Future<Either<Failure, Map<String, dynamic>>> call(
Map<String, dynamic> handleData,
) async {
return await _repository.handleAlarm(handleData);
}
}
/// AI诊断用例
class AIDiagnosisUseCase {
AIDiagnosisUseCase(this._repository);

View File

@@ -0,0 +1,39 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import '../entities/alarm_dispatch_entity.dart';
/// 派发工单用例
abstract class AlarmDispatchRepository {
Future<Either<Failure, bool>> dispatchWorkOrder(
AlarmDispatchEntity entity,
);
}
class DispatchWorkOrderUseCase {
final AlarmDispatchRepository repository;
DispatchWorkOrderUseCase(this.repository);
Future<Either<Failure, bool>> execute(AlarmDispatchEntity entity) async {
if (entity.siteId == null) {
return left(Failure('请选择场站'));
}
if (entity.orderType == null || entity.orderType!.isEmpty) {
return left(Failure('请选择上报类型'));
}
if (entity.deviceId == null || entity.deviceId!.isEmpty) {
return left(Failure('请选择设备'));
}
if (entity.description == null || entity.description!.isEmpty) {
return left(Failure('请填写问题描述'));
}
if (entity.problemLevel == null || entity.problemLevel!.isEmpty) {
return left(Failure('请选择问题等级'));
}
if (entity.planStartTime == null) {
return left(Failure('请选择计划开始时间'));
}
return await repository.dispatchWorkOrder(entity);
}
}

View File

@@ -10,7 +10,7 @@ class GetAlarmCountUseCase {
final AlarmRepository _repository;
/// 执行用例
Future<Either<Failure, AlarmCountEntity>> call() async {
return await _repository.getAlarmCount();
Future<Either<Failure, AlarmCountEntity>> call({int? siteId}) async {
return await _repository.getAlarmCount(siteId: siteId);
}
}

View File

@@ -12,9 +12,21 @@ class GetAlarmListUseCase {
/// 执行用例
/// [filter] 筛选条件
/// [page] 页码
/// [pageSize] 每页条数
Future<Either<Failure, List<AlarmEntity>>> call({
AlarmFilterTab? filter,
int? siteId,
int? configId,
int? page,
int? pageSize,
}) async {
return await _repository.getAlarmList(filter: filter);
return await _repository.getAlarmList(
filter: filter,
siteId: siteId,
configId: configId,
page: page,
pageSize: pageSize,
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:fpdart/fpdart.dart';
import 'package:maibu_satabot_v2/core/error/failure.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_count_entity.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/repositories/alarm_repository.dart';
/// 获取告警工单配置列表用例
class GetAlarmOrderConfigUseCase {
GetAlarmOrderConfigUseCase(this._repository);
final AlarmRepository _repository;
/// 执行用例
Future<Either<Failure, List<AlarmOrderConfigEntity>>> call(int siteId) async {
return await _repository.getAlarmOrderConfigList(siteId);
}
}

View File

@@ -1,70 +1,232 @@
import 'package:flutter_bloc/flutter_bloc.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/waring_center/domain/entities/alarm_count_entity.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_entity.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_state.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
/// 告警中心 Cubit
const int _pageSize = 50;
const int _countPageSize = 500;
class AlarmCubit extends Cubit<AlarmState> {
AlarmCubit({
required GetAlarmListUseCase getAlarmListUseCase,
required GetAlarmCountUseCase getAlarmCountUseCase,
}) : _getAlarmListUseCase = getAlarmListUseCase,
_getAlarmCountUseCase = getAlarmCountUseCase,
super(AlarmInitial());
}) : _getAlarmListUseCase = getAlarmListUseCase,
super(AlarmInitial());
final GetAlarmListUseCase _getAlarmListUseCase;
final GetAlarmCountUseCase _getAlarmCountUseCase;
int _loadRequestId = 0;
int? get _siteId {
try {
return GetIt.I<SiteCubit>().state.selectedSite?.id;
} catch (_) {
return null;
}
}
/// 加载告警数据
Future<void> loadAlarms({AlarmFilterTab? filter}) async {
// 如果不是初始加载,保留旧数据
final isInitialLoad = state is AlarmInitial;
if (isInitialLoad) {
emit(AlarmLoading());
}
try {
// 并行获取告警列表和统计
final alarmsResult = await _getAlarmListUseCase(filter: filter);
final countResult = await _getAlarmCountUseCase();
final siteId = _siteId;
final requestId = ++_loadRequestId;
final alarms = alarmsResult.fold(
try {
final alarmsResult = await _getAlarmListUseCase(
siteId: siteId,
page: 1,
pageSize: _pageSize,
);
final allAlarms = alarmsResult.fold(
(failure) => <AlarmEntity>[],
(alarms) => alarms,
);
final count = countResult.fold(
(failure) => null,
(count) => count,
final selectedFilter = filter ?? AlarmFilterTab.all;
final filteredAlarms = _filterAlarms(allAlarms, selectedFilter);
emit(
AlarmLoaded(
allAlarms: allAlarms,
alarms: filteredAlarms,
count: const AlarmCountEntity(
unprocessed: 0,
processing: 0,
confirmed: 0,
todayNew: 0,
),
selectedFilter: selectedFilter,
currentPage: 1,
hasMore: allAlarms.length >= _pageSize,
isCountLoading: true,
),
);
if (count == null) {
emit(const AlarmError('获取告警统计失败'));
return;
}
emit(AlarmLoaded(
alarms: alarms,
count: count,
selectedFilter: filter ?? AlarmFilterTab.all,
));
_loadAndCalculateCounts(siteId, requestId);
} catch (e) {
emit(AlarmError('加载告警数据失败: $e'));
}
}
/// 切换筛选标签
Future<void> changeFilter(AlarmFilterTab filter) async {
final currentState = state;
if (currentState is AlarmLoaded) {
emit(currentState.copyWith(selectedFilter: filter));
await loadAlarms(filter: filter);
Future<void> _loadAndCalculateCounts(
int? siteId,
int requestId,
) async {
try {
final result = await _getAlarmListUseCase(
siteId: siteId,
page: 1,
pageSize: _countPageSize,
);
if (requestId != _loadRequestId) return;
result.fold(
(failure) {
if (requestId != _loadRequestId) return;
final currentState = state;
if (currentState is AlarmLoaded) {
emit(currentState.copyWith(
isCountLoading: false,
count: const AlarmCountEntity(
unprocessed: 0,
processing: 0,
confirmed: 0,
todayNew: 0,
),
));
}
},
(alarms) {
if (requestId != _loadRequestId) return;
final count = _calculateCounts(alarms);
final currentState = state;
if (currentState is AlarmLoaded) {
emit(currentState.copyWith(
isCountLoading: false,
count: count,
));
}
},
);
} catch (e) {
if (requestId != _loadRequestId) return;
final currentState = state;
if (currentState is AlarmLoaded) {
emit(currentState.copyWith(
isCountLoading: false,
count: const AlarmCountEntity(
unprocessed: 0,
processing: 0,
confirmed: 0,
todayNew: 0,
),
));
}
}
}
AlarmCountEntity _calculateCounts(List<AlarmEntity> alarms) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
int unprocessed = 0;
int confirmed = 0;
int todayNew = 0;
for (final alarm in alarms) {
if (alarm.status == AlarmStatus.pending) {
unprocessed++;
}
if (alarm.status == AlarmStatus.closed) {
confirmed++;
}
try {
final alarmTime = DateTime.parse(alarm.time);
final alarmDate = DateTime(
alarmTime.year,
alarmTime.month,
alarmTime.day,
);
if (alarmDate == today) {
todayNew++;
}
} catch (_) {}
}
return AlarmCountEntity(
unprocessed: unprocessed,
processing: 0,
confirmed: confirmed,
todayNew: todayNew,
);
}
Future<void> loadMore() async {
final currentState = state;
if (currentState is! AlarmLoaded) return;
if (currentState.isLoadingMore || !currentState.hasMore) return;
emit(currentState.copyWith(isLoadingMore: true));
final siteId = _siteId;
final nextPage = currentState.currentPage + 1;
try {
final alarmsResult = await _getAlarmListUseCase(
siteId: siteId,
page: nextPage,
pageSize: _pageSize,
);
final newAlarms = alarmsResult.fold(
(failure) => <AlarmEntity>[],
(alarms) => alarms,
);
final updatedAllAlarms = [
...currentState.allAlarms,
...newAlarms,
];
final filteredAlarms = _filterAlarms(
updatedAllAlarms,
currentState.selectedFilter,
);
emit(
AlarmLoaded(
allAlarms: updatedAllAlarms,
alarms: filteredAlarms,
count: currentState.count,
selectedFilter: currentState.selectedFilter,
currentPage: nextPage,
hasMore: newAlarms.length >= _pageSize,
isLoadingMore: false,
isCountLoading: currentState.isCountLoading,
),
);
} catch (e) {
emit(currentState.copyWith(isLoadingMore: false));
}
}
void changeFilter(AlarmFilterTab filter) {
final currentState = state;
if (currentState is AlarmLoaded) {
final filteredAlarms = _filterAlarms(currentState.allAlarms, filter);
emit(
currentState.copyWith(alarms: filteredAlarms, selectedFilter: filter),
);
}
}
/// 刷新数据
Future<void> refresh() async {
final currentState = state;
if (currentState is AlarmLoaded) {
@@ -73,4 +235,26 @@ class AlarmCubit extends Cubit<AlarmState> {
await loadAlarms();
}
}
}
List<AlarmEntity> _filterAlarms(
List<AlarmEntity> alarms,
AlarmFilterTab filter,
) {
switch (filter) {
case AlarmFilterTab.unprocessed:
return alarms
.where((alarm) => alarm.status == AlarmStatus.pending)
.toList();
case AlarmFilterTab.confirmed:
return alarms
.where((alarm) => alarm.status == AlarmStatus.processing)
.toList();
case AlarmFilterTab.recovered:
return alarms
.where((alarm) => alarm.status == AlarmStatus.closed)
.toList();
case AlarmFilterTab.all:
return alarms;
}
}
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/usecases/get_alarm_detail_usecase.dart';
@@ -8,14 +9,17 @@ class AlarmDetailCubit extends Cubit<AlarmDetailState> {
AlarmDetailCubit({
required GetAlarmDetailUseCase getAlarmDetailUseCase,
required ConfirmAlarmUseCase confirmAlarmUseCase,
required HandleAlarmUseCase handleAlarmUseCase,
required AIDiagnosisUseCase aiDiagnosisUseCase,
}) : _getAlarmDetailUseCase = getAlarmDetailUseCase,
_confirmAlarmUseCase = confirmAlarmUseCase,
_aiDiagnosisUseCase = aiDiagnosisUseCase,
super(AlarmDetailInitial());
}) : _getAlarmDetailUseCase = getAlarmDetailUseCase,
_confirmAlarmUseCase = confirmAlarmUseCase,
_handleAlarmUseCase = handleAlarmUseCase,
_aiDiagnosisUseCase = aiDiagnosisUseCase,
super(AlarmDetailInitial());
final GetAlarmDetailUseCase _getAlarmDetailUseCase;
final ConfirmAlarmUseCase _confirmAlarmUseCase;
final HandleAlarmUseCase _handleAlarmUseCase;
final AIDiagnosisUseCase _aiDiagnosisUseCase;
/// 加载告警详情
@@ -47,7 +51,12 @@ class AlarmDetailCubit extends Cubit<AlarmDetailState> {
final currentState = state;
if (currentState is! AlarmDetailLoaded) return;
emit(const AlarmDetailActionInProgress('确认告警'));
emit(
AlarmDetailActionInProgress(
'确认告警',
alarmDetail: currentState.alarmDetail,
),
);
try {
final result = await _confirmAlarmUseCase(alarmId);
@@ -56,17 +65,16 @@ class AlarmDetailCubit extends Cubit<AlarmDetailState> {
(failure) {
emit(AlarmDetailError(failure.message));
},
(success) {
if (success) {
emit(
currentState.copyWith(
isConfirmed: true,
alarmDetail: currentState.alarmDetail.copyWith(
alarmStatus: '已处理',
),
(responseData) {
debugPrint('确认告警响应数据: $responseData');
emit(
currentState.copyWith(
isConfirmed: true,
alarmDetail: currentState.alarmDetail.copyWith(
alarmStatus: '处理中',
),
);
}
),
);
},
);
} catch (e) {
@@ -79,7 +87,12 @@ class AlarmDetailCubit extends Cubit<AlarmDetailState> {
final currentState = state;
if (currentState is! AlarmDetailLoaded) return;
emit(const AlarmDetailActionInProgress('AI诊断'));
emit(
AlarmDetailActionInProgress(
'AI诊断',
alarmDetail: currentState.alarmDetail,
),
);
try {
final result = await _aiDiagnosisUseCase(alarmId);
@@ -96,4 +109,40 @@ class AlarmDetailCubit extends Cubit<AlarmDetailState> {
emit(AlarmDetailError('AI诊断失败: $e'));
}
}
/// 处理告警(提交处理)
Future<void> handleAlarm(Map<String, dynamic> handleData) async {
final currentState = state;
if (currentState is! AlarmDetailLoaded) return;
emit(
AlarmDetailActionInProgress(
'提交处理',
alarmDetail: currentState.alarmDetail,
),
);
try {
final result = await _handleAlarmUseCase(handleData);
result.fold(
(failure) {
emit(AlarmDetailError(failure.message));
},
(responseData) {
debugPrint('处理告警响应数据: $responseData');
emit(
currentState.copyWith(
alarmDetail: currentState.alarmDetail.copyWith(
alarmStatus: '已关闭',
),
isHandleSuccess: true,
),
);
},
);
} catch (e) {
emit(AlarmDetailError('处理告警失败: $e'));
}
}
}

View File

@@ -32,27 +32,31 @@ class AlarmDetailLoaded extends AlarmDetailState {
this.selectedMetric = MetricType.power,
this.isConfirmed = false,
this.aiDiagnosisResult,
this.isHandleSuccess = false,
});
final AlarmDetailEntity alarmDetail;
final MetricType selectedMetric;
final bool isConfirmed;
final String? aiDiagnosisResult;
final bool isHandleSuccess;
@override
List<Object?> get props => [alarmDetail, selectedMetric, isConfirmed, aiDiagnosisResult];
List<Object?> get props => [alarmDetail, selectedMetric, isConfirmed, aiDiagnosisResult, isHandleSuccess];
AlarmDetailLoaded copyWith({
AlarmDetailEntity? alarmDetail,
MetricType? selectedMetric,
bool? isConfirmed,
String? aiDiagnosisResult,
bool? isHandleSuccess,
}) {
return AlarmDetailLoaded(
alarmDetail: alarmDetail ?? this.alarmDetail,
selectedMetric: selectedMetric ?? this.selectedMetric,
isConfirmed: isConfirmed ?? this.isConfirmed,
aiDiagnosisResult: aiDiagnosisResult ?? this.aiDiagnosisResult,
isHandleSuccess: isHandleSuccess ?? this.isHandleSuccess,
);
}
}
@@ -69,10 +73,14 @@ class AlarmDetailError extends AlarmDetailState {
/// 操作进行中状态
class AlarmDetailActionInProgress extends AlarmDetailState {
const AlarmDetailActionInProgress(this.action);
const AlarmDetailActionInProgress(
this.action, {
this.alarmDetail,
});
final String action;
final AlarmDetailEntity? alarmDetail;
@override
List<Object?> get props => [action];
List<Object?> get props => [action, alarmDetail];
}

View File

@@ -20,27 +20,56 @@ class AlarmLoading extends AlarmState {}
/// 加载成功状态
class AlarmLoaded extends AlarmState {
const AlarmLoaded({
required this.allAlarms,
required this.alarms,
required this.count,
this.selectedFilter = AlarmFilterTab.all,
this.currentPage = 1,
this.hasMore = true,
this.isLoadingMore = false,
this.isCountLoading = false,
});
final List<AlarmEntity> allAlarms;
final List<AlarmEntity> alarms;
final AlarmCountEntity count;
final AlarmFilterTab selectedFilter;
final int currentPage;
final bool hasMore;
final bool isLoadingMore;
final bool isCountLoading;
@override
List<Object?> get props => [alarms, count, selectedFilter];
List<Object?> get props => [
allAlarms,
alarms,
count,
selectedFilter,
currentPage,
hasMore,
isLoadingMore,
isCountLoading,
];
AlarmLoaded copyWith({
List<AlarmEntity>? allAlarms,
List<AlarmEntity>? alarms,
AlarmCountEntity? count,
AlarmFilterTab? selectedFilter,
int? currentPage,
bool? hasMore,
bool? isLoadingMore,
bool? isCountLoading,
}) {
return AlarmLoaded(
allAlarms: allAlarms ?? this.allAlarms,
alarms: alarms ?? this.alarms,
count: count ?? this.count,
selectedFilter: selectedFilter ?? this.selectedFilter,
currentPage: currentPage ?? this.currentPage,
hasMore: hasMore ?? this.hasMore,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
isCountLoading: isCountLoading ?? this.isCountLoading,
);
}
}

Some files were not shown because too many files have changed in this diff Show More