一次大的提交
This commit is contained in:
@@ -12,19 +12,22 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
super.avatar,
|
||||
super.email,
|
||||
super.phone,
|
||||
super.roleKey,
|
||||
super.siteId,
|
||||
});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
print(json);
|
||||
return UserModel(
|
||||
userId: json['userId'],
|
||||
username: json['username'],
|
||||
nickname: json['nickName'],
|
||||
token: json['token'],
|
||||
orgId: json['orgId'] ?? 0, // 从登录响应中获取 orgId
|
||||
userId: json['userId']?.toString() ?? '',
|
||||
username: json['username'] ?? '',
|
||||
nickname: json['nickName'] ?? '',
|
||||
token: json['token'] ?? '',
|
||||
orgId: (json['orgId'] as num?)?.toInt() ?? 0,
|
||||
avatar: json['avatar'],
|
||||
email: json['email'],
|
||||
phone: json['phone'],
|
||||
roleKey: json['roleKey'],
|
||||
siteId: (json['siteId'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +41,8 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
'avatar': avatar,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'roleKey': roleKey,
|
||||
'siteId': siteId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +56,8 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
avatar: avatar,
|
||||
email: email,
|
||||
phone: phone,
|
||||
roleKey: roleKey,
|
||||
siteId: siteId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +71,8 @@ class UserModel extends UserEntity implements BaseModel {
|
||||
avatar: entity.avatar,
|
||||
email: entity.email,
|
||||
phone: entity.phone,
|
||||
roleKey: entity.roleKey,
|
||||
siteId: entity.siteId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
|
||||
|
||||
@@ -20,6 +23,11 @@ import '../../../devices/presentation/bloc/devices_cubit.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_event.dart';
|
||||
import '../../../devices/presentation/bloc/devices_state.dart';
|
||||
import '../../../devices/presentation/bloc/device_task_cubit.dart';
|
||||
import '../../../home/presentation/bloc/permission_request_bloc.dart';
|
||||
import '../../../my/presentation/bloc/my_cubit.dart';
|
||||
import '../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../../../core/network/mqtt/domain/interfaces/mqtt_client.dart';
|
||||
import '../../../../features/v2/site/presentation/cubit/site_cubit.dart';
|
||||
import '../../data/datasources/auth_tcp_datasource.dart';
|
||||
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
|
||||
@@ -39,7 +47,7 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
final ILoggerService _logger = GetIt.I<ILoggerService>();
|
||||
|
||||
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
|
||||
|
||||
|
||||
// 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送
|
||||
Completer<bool>? _loginVerificationCompleter;
|
||||
|
||||
@@ -56,11 +64,57 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
) : super(AuthInitial()) {
|
||||
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
|
||||
_listenToAuthResponse();
|
||||
// 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录
|
||||
tcp.onReconnectExhausted = _showReconnectFailedDialog;
|
||||
}
|
||||
|
||||
/// App 启动时检查本地缓存
|
||||
Future<void> appStarted() async {
|
||||
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
|
||||
|
||||
try {
|
||||
final prefs = GetIt.I<SharedPreferences>();
|
||||
|
||||
// Step 1: 先生成新的 session_id,保存旧的
|
||||
final oldSessionId = prefs.getString('current_session_id');
|
||||
final newSessionId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
await prefs.setString('current_session_id', newSessionId);
|
||||
debugPrint('📱 [AUTH] 新会话: $newSessionId,旧会话: $oldSessionId');
|
||||
|
||||
// Step 2: 检查是否从后台被杀
|
||||
final pendingKillLogout = prefs.getBool('pending_kill_logout') ?? false;
|
||||
final savedSessionId = prefs.getString('saved_session_id');
|
||||
|
||||
if (pendingKillLogout && savedSessionId != null && oldSessionId != null) {
|
||||
if (savedSessionId == oldSessionId) {
|
||||
// saved == old_current → App 在后台被杀,从未恢复过
|
||||
logger.logWithLevel('🔄 [AUTH] 检测到 APP 被后台杀死,执行退出登录', level: 'INFO');
|
||||
await prefs.setBool('pending_kill_logout', false);
|
||||
await prefs.remove('saved_session_id');
|
||||
await storage.deleteUser();
|
||||
emit(AuthUnauthenticated());
|
||||
return;
|
||||
} else {
|
||||
// saved != old_current → App 被杀前已恢复过,清除标记
|
||||
logger.logWithLevel(
|
||||
'📱 [AUTH] pending_kill_logout=true 但会话已恢复过,清除标记',
|
||||
level: 'INFO',
|
||||
);
|
||||
await prefs.setBool('pending_kill_logout', false);
|
||||
await prefs.remove('saved_session_id');
|
||||
}
|
||||
} else if (pendingKillLogout) {
|
||||
logger.logWithLevel(
|
||||
'📱 [AUTH] pending_kill_logout=true 但无会话信息,清除标记',
|
||||
level: 'INFO',
|
||||
);
|
||||
await prefs.setBool('pending_kill_logout', false);
|
||||
await prefs.remove('saved_session_id');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.logWithLevel('❌ [AUTH] 检查杀后台标记失败: $e', level: 'ERROR');
|
||||
}
|
||||
|
||||
try {
|
||||
final user = await storage.getUser();
|
||||
logger.logWithLevel(
|
||||
@@ -70,14 +124,21 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
);
|
||||
|
||||
if (user != null) {
|
||||
// 🔥 冷启动时不创建TCP连接,改为用户选择设备时再连接
|
||||
// 避免发送 0x03 触发服务端残留 session 的 have_logged_in
|
||||
|
||||
// 2. 同步全局 App 状态
|
||||
appCubit.setAuth(user);
|
||||
// 3. 进入已登录状态
|
||||
emit(AuthAuthenticated(user));
|
||||
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
|
||||
final isValid = await _verifyToken(user);
|
||||
if (isValid) {
|
||||
logger.logWithLevel('✅ [AUTH] Token 有效,恢复登录状态', level: 'INFO');
|
||||
appCubit.setAuth(user);
|
||||
emit(AuthAuthenticated(user));
|
||||
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
|
||||
} else {
|
||||
logger.logWithLevel('⚠️ [AUTH] Token 已过期,清除本地缓存', level: 'WARN');
|
||||
await storage.deleteUser();
|
||||
emit(AuthUnauthenticated());
|
||||
logger.logWithLevel(
|
||||
'⚠️ [AUTH] 应用启动 - Token 过期,进入未登录状态',
|
||||
level: 'INFO',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
|
||||
emit(AuthUnauthenticated());
|
||||
@@ -88,6 +149,42 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _verifyToken(UserEntity user) async {
|
||||
try {
|
||||
final verifyDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: HttpApiConsts.baseUrl,
|
||||
headers: {'Authorization': 'Bearer ${user.token}'},
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
final response = await verifyDio.get(
|
||||
HttpApiConsts.getUserDevicesList,
|
||||
queryParameters: {'tenantName': user.username},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic> && data['code'] == 401) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} on DioException catch (e) {
|
||||
final statusCode = e.response?.statusCode;
|
||||
debugPrint('>>> [AUTH] Token 验证失败: HTTP $statusCode');
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('>>> [AUTH] Token 验证异常: $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 当 HTTP 登录/注册成功后调用
|
||||
Future<void> loginSuccess(UserEntity user) async {
|
||||
await storage.saveUser(user);
|
||||
@@ -96,33 +193,33 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
try {
|
||||
debugPrint('>>> [AUTH] 登录成功,开始建立TCP连接...');
|
||||
_logger.logWithLevel('>>> [AUTH] 登录成功,开始建立TCP连接...', shouldLog: true);
|
||||
|
||||
|
||||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
||||
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
||||
|
||||
|
||||
// 🔥 关键:等待 2.5 秒,看是否收到 have_logged_in
|
||||
bool isKicked = await _waitForLoginVerification();
|
||||
|
||||
|
||||
if (isKicked) {
|
||||
// 🔥 不能进入 APP,必须清除所有登录信息
|
||||
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息...');
|
||||
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息', shouldLog: true);
|
||||
|
||||
|
||||
// 1. 删除已保存的用户信息
|
||||
await storage.deleteUser();
|
||||
debugPrint('✅ [AUTH] 已删除用户信息');
|
||||
|
||||
|
||||
// 2. 断开 TCP 连接
|
||||
tcp.forceDisconnect();
|
||||
debugPrint('✅ [AUTH] 已断开 TCP 连接');
|
||||
|
||||
|
||||
// 3. 显示 Toast
|
||||
_showLoginFailedToast("账号已在其他设备登录");
|
||||
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页');
|
||||
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页', shouldLog: true);
|
||||
return; // 停留在登录页
|
||||
return; // 停留在登录页
|
||||
}
|
||||
|
||||
|
||||
debugPrint('✅ [AUTH] TCP连接成功,验证通过');
|
||||
_logger.logWithLevel('✅ [AUTH] TCP连接成功,验证通过', shouldLog: true);
|
||||
} catch (e) {
|
||||
@@ -149,6 +246,42 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
|
||||
/// 🔥 Token 过期处理:弹出提示后退出登录
|
||||
Future<void> tokenExpired() async {
|
||||
_showTokenExpiredDialog();
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
logout();
|
||||
});
|
||||
}
|
||||
|
||||
void _showTokenExpiredDialog() {
|
||||
try {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('登录已过期'),
|
||||
content: const Text('账号登录状态已过期,请重新登录'),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('>>> [AUTH] ❌ 显示 Token 过期弹窗失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 新增:显示登录失败 Toast(黑色背景,和登录错误提示一致)
|
||||
void _showLoginFailedToast(String message) {
|
||||
try {
|
||||
@@ -173,7 +306,7 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
try {
|
||||
debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗');
|
||||
_logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true);
|
||||
|
||||
|
||||
// 使用全局 navigatorKey 显示弹窗
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
@@ -190,9 +323,12 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
);
|
||||
} else {
|
||||
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出');
|
||||
_logger.logWithLevel('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出', shouldLog: true);
|
||||
_logger.logWithLevel(
|
||||
'>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出',
|
||||
shouldLog: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 🔥 延迟 2 秒后执行退出,给用户时间看到提示
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
debugPrint('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录');
|
||||
@@ -207,27 +343,142 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 TCP 重连耗尽弹窗:连续4次重连失败后提示用户退出登录
|
||||
void _showReconnectFailedDialog() {
|
||||
try {
|
||||
debugPrint('>>> [AUTH] 📢 重连耗尽,准备显示重连失败提示弹窗');
|
||||
_logger.logWithLevel('>>> [AUTH] 📢 重连耗尽,显示重连失败弹窗', shouldLog: true);
|
||||
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 48, color: Color(0xFFF53F3F)),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'连接异常',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'TCP重连认证无效请重新登陆',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
logout();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('退出登录', style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,直接退出登录');
|
||||
logout();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('>>> [AUTH] ❌ 显示重连失败弹窗失败:$e');
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 新增:清空所有业务状态,防止数据泄露到新账户
|
||||
void _clearAllBusinessState() {
|
||||
try {
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
||||
final siteCubit = GetIt.I<SiteCubit>();
|
||||
// 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等)
|
||||
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
|
||||
remoteControlCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态');
|
||||
|
||||
// 1. 清空设备列表和选中设备
|
||||
// 2. 清空设备列表和选中设备
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
devicesCubit.emit(const DevicesState());
|
||||
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||||
|
||||
// 2. 清空设备实时状态
|
||||
// 3. 清空设备实时状态(图表数据等)
|
||||
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
||||
deviceStatusBloc.add(DeviceStatusReset());
|
||||
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||||
|
||||
// 3. 清空全局选中的场站
|
||||
siteCubit.clearSelectedSite();
|
||||
debugPrint('✅ [AUTH] 已清空 SiteCubit 选中状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 选中状态');
|
||||
// 4. 清空场站所有数据(列表、选中状态、_hasLoadedSites 标记)
|
||||
final siteCubit = GetIt.I<SiteCubit>();
|
||||
siteCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 SiteCubit 所有数据');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 所有数据');
|
||||
|
||||
// 5. 清空设备任务(taskPool、currentTask、currentTaskId 等)
|
||||
final deviceTaskCubit = GetIt.I<DeviceTaskCubit>();
|
||||
deviceTaskCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
|
||||
|
||||
// 6. 清空权限请求弹窗状态
|
||||
final permissionRequestBloc = GetIt.I<PermissionRequestBloc>();
|
||||
permissionRequestBloc.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
|
||||
|
||||
// 7. 清空我的页面数据(昵称等个人信息)
|
||||
final myCubit = GetIt.I<MyCubit>();
|
||||
myCubit.clearAll();
|
||||
debugPrint('✅ [AUTH] 已清空 MyCubit 状态');
|
||||
_logger.logWithLevel('✅ [AUTH] 已清空 MyCubit 状态');
|
||||
|
||||
// 8. 断开 MQTT 连接(避免新用户收到上个用户的实时推送)
|
||||
try {
|
||||
final droneOsdClient = GetIt.I<MqttClient>(instanceName: 'droneOsdClient');
|
||||
if (droneOsdClient.isConnected) {
|
||||
droneOsdClient.disconnect();
|
||||
debugPrint('✅ [AUTH] 已断开 droneOsdClient MQTT');
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
final taskMessageClient = GetIt.I<MqttClient>(instanceName: 'taskMessageClient');
|
||||
if (taskMessageClient.isConnected) {
|
||||
taskMessageClient.disconnect();
|
||||
debugPrint('✅ [AUTH] 已断开 taskMessageClient MQTT');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 9. 清除 SharedPreferences 会话相关 key
|
||||
final prefs = GetIt.I<SharedPreferences>();
|
||||
prefs.remove('current_session_id');
|
||||
prefs.remove('pending_kill_logout');
|
||||
prefs.remove('saved_session_id');
|
||||
debugPrint('✅ [AUTH] 已清除 SharedPreferences 会话 key');
|
||||
|
||||
debugPrint('✅ [AUTH] 所有业务状态已清空');
|
||||
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
|
||||
@@ -240,15 +491,15 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
|
||||
Future<bool> _waitForLoginVerification() async {
|
||||
_loginVerificationCompleter = Completer<bool>();
|
||||
|
||||
|
||||
// 等待 2.5 秒
|
||||
await Future.delayed(const Duration(milliseconds: 2500));
|
||||
|
||||
|
||||
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
|
||||
if (!_loginVerificationCompleter!.isCompleted) {
|
||||
_loginVerificationCompleter!.complete(false);
|
||||
}
|
||||
|
||||
|
||||
return _loginVerificationCompleter!.future;
|
||||
}
|
||||
|
||||
@@ -290,35 +541,46 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
|
||||
if (respond == 'have_logged_in') {
|
||||
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
|
||||
if (_loginVerificationCompleter != null && !_loginVerificationCompleter!.isCompleted) {
|
||||
debugPrint('>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP');
|
||||
if (_loginVerificationCompleter != null &&
|
||||
!_loginVerificationCompleter!.isCompleted) {
|
||||
debugPrint(
|
||||
'>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP',
|
||||
);
|
||||
_logger.logWithLevel(
|
||||
'🛡️ [AUTH] 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入',
|
||||
shouldLog: true,
|
||||
);
|
||||
_loginVerificationCompleter!.complete(false); // 标记为可以进入
|
||||
_loginVerificationCompleter!.complete(false); // 标记为可以进入
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 🔥 已登录阶段策略:检查是否处于安全模式
|
||||
if (_isSafeMode) {
|
||||
print('>>> [AUTH] 🛡️ 安全模式下拦截 have_logged_in 推送,防止控制中断');
|
||||
_logger.logWithLevel(
|
||||
'🛡️ [AUTH] 安全模式下拦截异地登录推送',
|
||||
level: 'WARN',
|
||||
);
|
||||
_logger.logWithLevel('🛡️ [AUTH] 安全模式下拦截异地登录推送', level: 'WARN');
|
||||
return; // 拦截退出逻辑
|
||||
}
|
||||
|
||||
|
||||
print('>>> [AUTH] ⚠️ 已登录状态下收到 TCP 0x12 指令 respond=have_logged_in');
|
||||
debugPrint('>>> [AUTH] ℹ️ 暂时忽略该推送,观察是否影响业务操作...');
|
||||
_logger.logWithLevel(
|
||||
'⚠️ [AUTH] 已登录状态收到 have_logged_in,暂不处理,观察业务影响',
|
||||
'⚠️ [AUTH] 已登录状态收到 have_logged_in,弹出异地登录提示',
|
||||
level: 'WARN',
|
||||
);
|
||||
|
||||
// 如果你希望依然保持严格的安全策略,可以取消下面注释恢复退出逻辑:
|
||||
// _showKickOutDialog();
|
||||
|
||||
// 🔥 关键:检查是否是自身 TCP 重连触发的 have_logged_in
|
||||
// 如果是自身刚发送 0x03 认证包引起的,忽略这次推送
|
||||
if (tcp.isOwnAuthTriggeredKick()) {
|
||||
debugPrint('>>> [AUTH] 🛡️ 检测到是自身认证触发的 have_logged_in,忽略');
|
||||
_logger.logWithLevel(
|
||||
'🛡️ [AUTH] 自身认证触发的 have_logged_in,忽略',
|
||||
level: 'INFO',
|
||||
);
|
||||
tcp.clearAuthTimestamp();
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 弹出"账号被顶下线"提示,2秒后执行退出登录
|
||||
_showKickOutDialog();
|
||||
} else {
|
||||
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
|
||||
}
|
||||
|
||||
@@ -28,15 +28,16 @@ class LoginCubit extends Cubit<LoginState> {
|
||||
// if (user != null) {
|
||||
// devicesCubit.fetchAllDevices(user.username);
|
||||
// }
|
||||
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
|
||||
result.fold((failure) => emit(LoginFailure(failure.message)), (user) async {
|
||||
// 先更新全局用户状态(确保首页能获取到 token)
|
||||
authCubit.appCubit.setAuth(user);
|
||||
// 发出登录成功状态(触发页面跳转)
|
||||
emit(LoginSuccess(user));
|
||||
// 后台异步执行 TCP 连接等初始化操作(不阻塞登录流程)
|
||||
authCubit.loginSuccess(user).catchError((e) {
|
||||
// 🔥 先等待 TCP 连接和认证完成,确保 AuthCubit 变为 AuthAuthenticated
|
||||
// 再触发页面导航,避免 GoRouter 拦截踢回登录页
|
||||
await authCubit.loginSuccess(user).catchError((e) {
|
||||
print('TCP 初始化失败: $e');
|
||||
});
|
||||
// 发出登录成功状态(触发页面跳转)
|
||||
emit(LoginSuccess(user));
|
||||
});
|
||||
} catch (e) {
|
||||
emit(LoginFailure(e.toString()));
|
||||
|
||||
@@ -52,9 +52,11 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
}) async {
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/deviceTaskPool';
|
||||
final response = await dio.get(
|
||||
_logger.logWithLevel('[getDeviceTaskPool] 请求: POST $url');
|
||||
_logger.logWithLevel('[getDeviceTaskPool] 参数: userId=$userId, siteId=$siteId, orgId=$orgId');
|
||||
final response = await dio.post(
|
||||
url,
|
||||
queryParameters: {
|
||||
data: {
|
||||
'userId': userId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
@@ -89,30 +91,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
};
|
||||
_logger.logWithLevel('[cancelTask] 请求: POST $url');
|
||||
_logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}');
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
|
||||
final response = await dio.post(
|
||||
url,
|
||||
data: {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
},
|
||||
);
|
||||
final response = await dio.post(url, data: body);
|
||||
_logger.logWithLevel('[cancelTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] == 200) {
|
||||
_logger.logWithLevel('[cancelTask] 结果: 成功');
|
||||
return data['data'] as bool? ?? false;
|
||||
} else {
|
||||
_logger.logWithLevel('[cancelTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
|
||||
throw Exception(data['msg'] ?? '取消任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 取消任务失败: $e');
|
||||
_logger.logWithLevel('[cancelTask] 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -124,30 +129,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
};
|
||||
_logger.logWithLevel('[pauseTask] 请求: POST $url');
|
||||
_logger.logWithLevel('[pauseTask] 参数: ${jsonEncode(body)}');
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
|
||||
final response = await dio.post(
|
||||
url,
|
||||
data: {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
},
|
||||
);
|
||||
final response = await dio.post(url, data: body);
|
||||
_logger.logWithLevel('[pauseTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] == 200) {
|
||||
_logger.logWithLevel('[pauseTask] 结果: 成功');
|
||||
return data['data'] as bool? ?? false;
|
||||
} else {
|
||||
_logger.logWithLevel('[pauseTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
|
||||
throw Exception(data['msg'] ?? '暂停任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 暂停任务失败: $e');
|
||||
_logger.logWithLevel('[pauseTask] 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -159,30 +167,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
};
|
||||
_logger.logWithLevel('[recoveryTask] 请求: POST $url');
|
||||
_logger.logWithLevel('[recoveryTask] 参数: ${jsonEncode(body)}');
|
||||
try {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
|
||||
final response = await dio.post(
|
||||
url,
|
||||
data: {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
},
|
||||
);
|
||||
final response = await dio.post(url, data: body);
|
||||
_logger.logWithLevel('[recoveryTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] == 200) {
|
||||
_logger.logWithLevel('[recoveryTask] 结果: 成功, data=${jsonEncode(data['data'])}');
|
||||
return data['data'] as Map<String, dynamic>? ?? {};
|
||||
} else {
|
||||
_logger.logWithLevel('[recoveryTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
|
||||
throw Exception(data['msg'] ?? '恢复任务失败');
|
||||
}
|
||||
} else {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.logWithLevel('❌ 恢复任务失败: $e');
|
||||
_logger.logWithLevel('[recoveryTask] 异常: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dar
|
||||
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
|
||||
import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/task_message_repository.dart';
|
||||
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_arrive_entity.dart';
|
||||
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_status_entity.dart';
|
||||
|
||||
import '../../../../core/logging/i_logger_service.dart';
|
||||
import '../../../../core/network/net_message_dispatcher.dart';
|
||||
@@ -27,6 +28,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 保存订阅引用,用于管理生命周期
|
||||
StreamSubscription? _tcpSubscription;
|
||||
StreamSubscription? _mqttArriveSubscription;
|
||||
StreamSubscription? _mqttStatusSubscription;
|
||||
|
||||
// 🔥 节流相关:500ms节流控制0x02数据推送频率
|
||||
Timer? _throttleTimer;
|
||||
@@ -34,6 +36,9 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
RunningStatusEntity? _cachedStatus;
|
||||
GPSEntity? _cachedGps;
|
||||
|
||||
// 🔥 调试计数器:跟踪0x02收包序号,排查断断续续问题
|
||||
int _packetSeq = 0;
|
||||
|
||||
// 🔥 当前监听的设备ID
|
||||
String? _currentDeviceId;
|
||||
|
||||
@@ -46,6 +51,9 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 初始化MQTT到达点监听
|
||||
_initMqttArriveListener();
|
||||
|
||||
// 🔥 初始化MQTT任务状态监听(接收完成推送)
|
||||
_initMqttStatusListener();
|
||||
|
||||
// 保留事件处理(用于手动重置等场景)
|
||||
on<DeviceStatusReset>(_handleReset);
|
||||
on<DeviceStatusLoaded>(_handleDeviceStatusLoaded);
|
||||
@@ -89,23 +97,27 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 关键修复:直接监听 tcpClient.packetStream,不经过 dispatcher 的 filtered stream
|
||||
// 这样即使没有其他监听者,TCP流也不会暂停
|
||||
_tcpSubscription = tcpClient.packetStream
|
||||
.where((p) => p.command == 0x02)
|
||||
.where((p) {
|
||||
final is02 = p.command == 0x02;
|
||||
if (is02) {
|
||||
_packetSeq++;
|
||||
debugPrint('📥 [0x02] #$_packetSeq 收到原始TCP包 | payload长度=${p.payload.length} | ${DateTime.now().toString().substring(11, 19)}');
|
||||
}
|
||||
return is02;
|
||||
})
|
||||
.map((p) {
|
||||
try {
|
||||
final result = utf8.decode(p.payload, allowMalformed: true);
|
||||
// debugPrint('✅ [DeviceStatusBloc] 收到0x02数据: $result');
|
||||
return result;
|
||||
} catch (e) {
|
||||
// debugPrint('❌ [DeviceStatusBloc] 解码失败: $e');
|
||||
debugPrint('❌ [0x02] #$_packetSeq 解码失败: $e, payload前20字节=${p.payload.take(20).toList()}');
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.listen(
|
||||
(message) {
|
||||
//debugPrint('📩 [DeviceStatusBloc] 直接收到0x02数据,长度:${message.length}');
|
||||
|
||||
if (message.isEmpty) {
|
||||
//debugPrint('⚠️ [DeviceStatusBloc] 消息为空,跳过');
|
||||
debugPrint('⚠️ [0x02] #$_packetSeq 解码后为空,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +126,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
final fields = message.trim().split(',');
|
||||
|
||||
if (fields.length < 18) {
|
||||
//debugPrint('⚠️ [DeviceStatusBloc] 字段不足:${fields.length},期望≥18');
|
||||
debugPrint('⚠️ [0x02] #$_packetSeq 字段不足:${fields.length},期望≥18, 原始数据前100字符=${message.substring(0, message.length > 100 ? 100 : message.length)}');
|
||||
// 🔥 错误不节流,立即emit以便UI显示错误
|
||||
if (!isClosed) {
|
||||
emit(DeviceStatusError('字段不足,期望≥18,实际:${fields.length}'));
|
||||
@@ -125,6 +137,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
final status = RunningStatusEntity.fromFields(fields);
|
||||
final gps = GPSEntity(status.latitude, status.longitude);
|
||||
|
||||
debugPrint('✅ [0x02] #$_packetSeq 解析成功 | 字段数=${fields.length} | 电压=${status.voltage}V 电量=${status.battery}% 控制模式=${status.controlMode} | 节流等待${_throttleDuration.inMilliseconds}ms');
|
||||
|
||||
// 🔥 缓存最新数据用于节流发射
|
||||
_cachedStatus = RunningStatusEntity(
|
||||
voltage: status.voltage,
|
||||
@@ -154,13 +168,14 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
);
|
||||
_cachedGps = GPSEntity(status.latitude, status.longitude);
|
||||
|
||||
// 🔥 节流:取消之前的timer,重新计时500ms
|
||||
_throttleTimer?.cancel();
|
||||
_throttleTimer = Timer(_throttleDuration, () {
|
||||
_emitCachedStatus();
|
||||
});
|
||||
// 🔥 节流:如果定时器已在运行,只更新缓存不重置;否则启动新的500ms节流周期
|
||||
if (_throttleTimer == null || !_throttleTimer!.isActive) {
|
||||
_throttleTimer = Timer(_throttleDuration, () {
|
||||
_emitCachedStatus();
|
||||
});
|
||||
}
|
||||
} catch (e, stack) {
|
||||
//debugPrint('❌ [DeviceStatusBloc] 直接解析异常:$e\n$stack');
|
||||
debugPrint('❌ [0x02] #$_packetSeq 解析异常:$e');
|
||||
// _logger.log('❌ [DeviceStatusBloc] 直接解析异常:$e');
|
||||
// 🔥 解析错误不节流,立即emit以便UI显示错误
|
||||
if (!isClosed) {
|
||||
@@ -225,6 +240,60 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
_logger.log('✅ [DeviceStatusBloc] MQTT到达点监听器已建立完成');
|
||||
}
|
||||
|
||||
// 🔥 初始化MQTT任务状态监听(接收 task/+/status 完成推送)
|
||||
void _initMqttStatusListener() {
|
||||
debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
|
||||
_logger.log('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
|
||||
|
||||
_mqttStatusSubscription = _taskMessageRepo.taskStatusStream.listen(
|
||||
(TaskStatusEntity status) {
|
||||
debugPrint(
|
||||
'📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
|
||||
);
|
||||
_logger.log(
|
||||
'📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
|
||||
);
|
||||
|
||||
// 检查设备ID是否匹配当前监听的设备
|
||||
if (_currentDeviceId != null && status.deviceId != _currentDeviceId) {
|
||||
debugPrint(
|
||||
'⚠️ [DeviceStatusBloc] 任务状态设备ID不匹配,跳过 - 当前:$_currentDeviceId, 收到:${status.deviceId}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 状态为 FINISH 表示任务完成
|
||||
if (status.status == 'FINISH') {
|
||||
debugPrint('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
|
||||
_logger.log('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
|
||||
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
devicesCubit.finishWork();
|
||||
|
||||
// 🔥 无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
|
||||
if (!isClosed) {
|
||||
emit(DeviceStatusUpdated(
|
||||
_cachedStatus ?? RunningStatusEntity(),
|
||||
_cachedGps ?? GPSEntity(0.0, 0.0),
|
||||
));
|
||||
debugPrint('📤 [DeviceStatusBloc] 已 emit 完成信号,触发 UI 更新');
|
||||
}
|
||||
|
||||
Future.delayed(Duration(seconds: 1), () {
|
||||
devicesCubit.resetWorkStatus();
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
debugPrint('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
|
||||
_logger.log('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
|
||||
_logger.log('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
|
||||
}
|
||||
|
||||
// 🔥 设置当前监听的设备ID(用于过滤MQTT消息)
|
||||
void setListeningDeviceId(String deviceId) {
|
||||
debugPrint('📱 [DeviceStatusBloc] 设置监听设备ID: $deviceId');
|
||||
@@ -235,7 +304,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 节流发射:500ms到期后发射缓存的最新数据
|
||||
void _emitCachedStatus() {
|
||||
if (_cachedStatus != null && _cachedGps != null && !isClosed) {
|
||||
// debugPrint('📤 [DeviceStatusBloc] 🔥节流发射 - 电压:${_cachedStatus!.voltage}, 电量:${_cachedStatus!.battery}');
|
||||
debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}');
|
||||
emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!));
|
||||
}
|
||||
}
|
||||
@@ -327,6 +396,16 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
|
||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||
devicesCubit.finishWork();
|
||||
|
||||
// 🔥 关键:无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
|
||||
if (!isClosed) {
|
||||
emit(DeviceStatusUpdated(
|
||||
_cachedStatus ?? RunningStatusEntity(),
|
||||
_cachedGps ?? GPSEntity(0.0, 0.0),
|
||||
));
|
||||
debugPrint('📤 [DeviceStatusBloc] 已 emit 停止信号,触发 UI 更新');
|
||||
}
|
||||
|
||||
Future.delayed(Duration(seconds: 1), () {
|
||||
devicesCubit.resetWorkStatus();
|
||||
});
|
||||
@@ -341,6 +420,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
// 🔥 清理MQTT订阅
|
||||
_mqttArriveSubscription?.cancel();
|
||||
_mqttArriveSubscription = null;
|
||||
_mqttStatusSubscription?.cancel();
|
||||
_mqttStatusSubscription = null;
|
||||
|
||||
// 🔥 清理节流timer和缓存
|
||||
_throttleTimer?.cancel();
|
||||
|
||||
@@ -96,7 +96,7 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
taskPool: taskList,
|
||||
currentTask: currentTask,
|
||||
currentTaskId: currentTask?.id,
|
||||
activeTasks: activeTasks, // 🔥 保存所有活跃任务列表
|
||||
activeTasks: activeTasks,
|
||||
));
|
||||
},
|
||||
);
|
||||
@@ -340,4 +340,12 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
));
|
||||
_logger.logWithLevel('🧹 清除当前任务');
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态
|
||||
void clearAll() {
|
||||
if (!isClosed) {
|
||||
emit(const DeviceTaskState());
|
||||
}
|
||||
_logger.logWithLevel('🧹 [DeviceTaskCubit] clearAll - 所有状态已重置');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,13 +665,14 @@ class DevicesCubit extends Cubit<DevicesState> {
|
||||
|
||||
/// 🔥 启动MQTT到达点监听(用于路径规划动画)
|
||||
/// [deviceId] - 目标设备ID,即targetDevice的deviceId
|
||||
Future<void> startListeningMqttArrive({required String deviceId}) async {
|
||||
debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId');
|
||||
_logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId');
|
||||
/// [taskId] - 任务ID,用于订阅 task/{taskId}/status 和 task/{taskId}/arrive
|
||||
Future<void> startListeningMqttArrive({required String deviceId, required int taskId}) async {
|
||||
debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId, taskId: $taskId');
|
||||
_logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId, taskId: $taskId');
|
||||
|
||||
try {
|
||||
// 启动MQTT订阅
|
||||
await _taskMessageRepo.startListening(deviceId: deviceId);
|
||||
await _taskMessageRepo.startListening(deviceId: deviceId, taskId: taskId);
|
||||
|
||||
// 设置DeviceStatusBloc监听的设备ID
|
||||
_deviceStatusBloc.setListeningDeviceId(deviceId);
|
||||
|
||||
@@ -28,6 +28,7 @@ class PermissionRequestBloc
|
||||
// 事件处理
|
||||
on<PermissionRequestReceived>(_handleRequestReceived);
|
||||
on<PermissionDialogDismissed>(_handleDialogDismissed);
|
||||
on<PermissionClearAll>((event, emit) => emit(const PermissionRequestInitial()));
|
||||
}
|
||||
|
||||
// 🔥 通过 NetMessageDispatcher 监听 TCP 0x12 指令,解析权限请求
|
||||
@@ -116,4 +117,11 @@ class PermissionRequestBloc
|
||||
_permissionSubscription?.cancel();
|
||||
_initPermissionListener();
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态
|
||||
void clearAll() {
|
||||
if (!isClosed) {
|
||||
add(const PermissionClearAll());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,8 @@ class PermissionDialogDismissed extends PermissionRequestEvent {
|
||||
@override
|
||||
List<Object?> get props => [agree, deviceId];
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态
|
||||
class PermissionClearAll extends PermissionRequestEvent {
|
||||
const PermissionClearAll();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class _RoutePlanPageState extends State<RoutePlanPage> {
|
||||
final remoteControlState = context.watch<RemoteControlCubit>().state;
|
||||
final targetDevice = remoteControlState.targetDevice;
|
||||
|
||||
debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}');
|
||||
// debugPrint('🔍 [RoutePlanPage] targetDevice: ${targetDevice?.deviceName ?? "null"}');
|
||||
|
||||
// 检查是否有选中的设备
|
||||
if (targetDevice == null) {
|
||||
|
||||
@@ -23,8 +23,8 @@ import '../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_event.dart';
|
||||
import '../../../devices/presentation/bloc/device_status_state.dart';
|
||||
|
||||
// 配置:数据超时时间(5秒)
|
||||
const int DATA_TIMEOUT_SECONDS = 5;
|
||||
// 配置:数据超时时间(15秒)
|
||||
const int DATA_TIMEOUT_SECONDS = 15;
|
||||
|
||||
class RunningStatusPage extends StatefulWidget {
|
||||
const RunningStatusPage({super.key});
|
||||
@@ -483,7 +483,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
// ====================== 图表视图 ======================
|
||||
Widget _buildChartContentView(DeviceStatusState state) {
|
||||
// 修改1:超时/无数据时显示暂无数据,而非loading
|
||||
if (_isDataTimeout || state is DeviceStatusInitial) {
|
||||
if (state is DeviceStatusInitial) {
|
||||
return _noDataWidget();
|
||||
}
|
||||
|
||||
@@ -710,7 +710,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
// ====================== 卡片视图 ======================
|
||||
Widget _buildCardContentView(DeviceStatusState state) {
|
||||
// 修改2:卡片视图同样替换loading为暂无数据
|
||||
if (_isDataTimeout || state is DeviceStatusInitial) {
|
||||
if (state is DeviceStatusInitial) {
|
||||
return _noDataWidget();
|
||||
}
|
||||
|
||||
@@ -904,7 +904,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
String satelliteCnt = _isDataTimeout ? '--' : '--';
|
||||
String headingStatus = _isDataTimeout ? "--" : "--";
|
||||
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
if (state is DeviceStatusUpdated) {
|
||||
headingStatus = state.status.headingStatus == 0 ? AppLocalizations.of(context).translate('running_status.not_initialized') : AppLocalizations.of(context).translate('running_status.initialized');
|
||||
int qualValue = 0;
|
||||
try {
|
||||
@@ -917,9 +917,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
|
||||
// 收到新数据,重置超时计时器和状态
|
||||
_startDataTimeoutTimer();
|
||||
if (_isDataTimeout) {
|
||||
setState(() => _isDataTimeout = false);
|
||||
}
|
||||
_isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState
|
||||
} else if (!_isDataTimeout && state is DeviceStatusError) {
|
||||
qual = '-';
|
||||
satelliteCnt = '-';
|
||||
@@ -1017,7 +1015,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
});
|
||||
}
|
||||
|
||||
if (!_isDataTimeout && state is DeviceStatusUpdated) {
|
||||
if (state is DeviceStatusUpdated) {
|
||||
debugPrint('📈 [UI] 检测到 Updated 状态,准备追加图表数据');
|
||||
// 🔥 如果 initState 已从缓存初始化过,跳过 BlocBuilder 首次触发
|
||||
if (_hasSeededFromCache) {
|
||||
@@ -1028,10 +1026,7 @@ class _RunningStatusPageState extends State<RunningStatusPage> with WidgetsBindi
|
||||
}
|
||||
// 🔥 关键:收到数据后立即重置超时计时器
|
||||
_startDataTimeoutTimer();
|
||||
// 如果之前是超时状态,现在恢复
|
||||
if (_isDataTimeout) {
|
||||
setState(() => _isDataTimeout = false);
|
||||
}
|
||||
_isDataTimeout = false; // 直接赋值,build 阶段禁止调 setState
|
||||
}
|
||||
return Container(margin: const EdgeInsets.all(8), child: _isCardView ? _buildCardContentView(state) : _buildChartContentView(state));
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:maibu_satabot_v2/components/tcp_status_indicator.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart';
|
||||
|
||||
@@ -80,6 +80,7 @@ const String kSavedTPMode = "saved_tp_mode";
|
||||
const String kSavedCurrentRobotMode = "kSavedCurrentRobotMode";
|
||||
const String kSavedIsPanelOpen = "kSavedIsPanelOpen";
|
||||
const String kSavedCurrentWorkMode = "kSavedCurrentWorkMode";
|
||||
const String kSavedDeviceTaskIds = "saved_device_task_ids"; // {deviceId: taskId} Map
|
||||
|
||||
// 保持 PlotData 类不变
|
||||
class PlotData {
|
||||
@@ -246,7 +247,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
});
|
||||
|
||||
// 🔥 关键修复:开始监听路径规划指令应答
|
||||
_setupPathPlanningListener();
|
||||
// 🔥 已禁用:新流程使用 HTTP/MQTT 管理任务,不再需要 TCP 路径规划指令应答监听
|
||||
// 保留 TCP 监听会导致机器正常 TCP 0x01 响应触发 finishWork(),错误地将作业状态重置为 idle
|
||||
// _setupPathPlanningListener();
|
||||
|
||||
// 监听地图移动事件,实时更新连线
|
||||
_mapController.mapEventStream.listen((event) {
|
||||
@@ -309,6 +312,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
await prefs.remove(kSavedSelectedPlot);
|
||||
await prefs.remove(kSavedIsPanelOpen);
|
||||
await prefs.remove(kSavedCurrentWorkMode);
|
||||
await prefs.remove(kSavedDeviceTaskIds); // 🔥 清除 taskId 持久化数据
|
||||
} catch (e) {
|
||||
debugPrint('清空本地数据失败:$e');
|
||||
}
|
||||
@@ -595,11 +599,69 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
} else {
|
||||
prefs.remove(kSavedSelectedPlot);
|
||||
}
|
||||
|
||||
// 🔥 5. 持久化 taskId(按 deviceId 隔离)
|
||||
try {
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
final deviceId = context.read<RemoteControlCubit>().state.targetDevice?.deviceName;
|
||||
if (taskId != null && deviceId != null) {
|
||||
final existingJson = prefs.getString(kSavedDeviceTaskIds);
|
||||
Map<String, dynamic> taskIdMap = {};
|
||||
if (existingJson != null) {
|
||||
taskIdMap = jsonDecode(existingJson) as Map<String, dynamic>;
|
||||
}
|
||||
taskIdMap[deviceId] = taskId;
|
||||
prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
|
||||
debugPrint('💾 [持久化] taskId 已保存: deviceId=$deviceId, taskId=$taskId');
|
||||
} else {
|
||||
debugPrint('💾 [持久化] 跳过: taskId=$taskId, deviceId=$deviceId');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('💾 [持久化] taskId 保存失败: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('保存本地数据失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 从 SharedPreferences 恢复指定设备的 taskId
|
||||
Future<int?> _restoreTaskIdFromLocal(String deviceId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(kSavedDeviceTaskIds);
|
||||
if (json != null) {
|
||||
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
|
||||
final taskId = taskIdMap[deviceId];
|
||||
if (taskId is int) return taskId;
|
||||
if (taskId is String) return int.tryParse(taskId);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('📥 [恢复] taskId 恢复失败: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 🔥 从 SharedPreferences 清除指定设备的 taskId
|
||||
Future<void> _clearTaskIdFromLocal(String deviceId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(kSavedDeviceTaskIds);
|
||||
if (json != null) {
|
||||
final taskIdMap = jsonDecode(json) as Map<String, dynamic>;
|
||||
taskIdMap.remove(deviceId);
|
||||
if (taskIdMap.isEmpty) {
|
||||
await prefs.remove(kSavedDeviceTaskIds);
|
||||
} else {
|
||||
await prefs.setString(kSavedDeviceTaskIds, jsonEncode(taskIdMap));
|
||||
}
|
||||
debugPrint('🧹 [清除] taskId 已从本地移除: deviceId=$deviceId');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('🧹 [清除] taskId 清除失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 新增:计算坐标列表的边界范围 ==========
|
||||
LatLngBounds? calculateBounds(List<LatLng> points) {
|
||||
if (points.isEmpty) return null;
|
||||
@@ -1823,8 +1885,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
_traceManager.upsert(_currentWgsLatLng as PlotPoint, TPAction.UPDATE);
|
||||
tracePoint = _traceManager.getTracePoint();
|
||||
gctracePoint = batchWgs84ToGcj02(tracePoint!);
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
|
||||
_logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]: ${gctracePoint!.length}");
|
||||
// _logger.log("[当前轨迹模式]:${_traceManager.getMode()}"); // 这里必须加 ()
|
||||
|
||||
//_currentLatLng = gcjPoint; // ✅ 状态变量在setState内更新
|
||||
if (_isValidLatLng(gcjPoint.latitude, gcjPoint.longitude)) {
|
||||
@@ -2230,7 +2292,12 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('设备号: ${task.deviceId}'),
|
||||
Text(
|
||||
'设备号: ${task.deviceId}',
|
||||
softWrap: true,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -2323,7 +2390,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
// 🔥 6. 先过滤出活跃任务,让用户选择
|
||||
debugPrint('🔍 [开始作业] 正在查询活跃任务...');
|
||||
debugPrint('══════════ [开始作业] 开始 ══════════');
|
||||
debugPrint('🔍 [开始作业] 步骤0: 查询活跃任务, deviceId=$deviceId');
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
|
||||
@@ -2345,9 +2413,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
taskCubit.selectTask(selectedTask);
|
||||
} else if (activeTasks.length == 1) {
|
||||
selectedTask = activeTasks.first;
|
||||
taskCubit.selectTask(selectedTask); // 🔥 单任务也要存入 cubit
|
||||
debugPrint('✅ [开始作业] 自动选择唯一任务 #${selectedTask.id}');
|
||||
} else {
|
||||
debugPrint('ℹ️ [开始作业] 无活跃任务,将直接创建新任务');
|
||||
// 🔥 无活跃任务:先清除旧的本地 taskId,再创建新任务
|
||||
debugPrint('ℹ️ [开始作业] 无活跃任务,清除旧 taskId 后创建新任务');
|
||||
await _clearTaskIdFromLocal(deviceId);
|
||||
taskCubit.clearCurrentTask();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
@@ -2362,28 +2434,31 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
debugPrint(' ├─ orgId: $orgId');
|
||||
debugPrint(' └─ taskId: ${selectedTask?.id ?? "(无,将新建)"}');
|
||||
|
||||
// 8. 更新UI状态
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
isStartWork = true;
|
||||
_workStatus = WorkStatus.working;
|
||||
_traceManager.reset();
|
||||
tracePoint?.clear();
|
||||
gctracePoint?.clear();
|
||||
});
|
||||
|
||||
// 9. 更新应用状态
|
||||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||||
// 8. 重置轨迹 + 切换到定位模式(关键:防止飞过去的路径被画出来)
|
||||
debugPrint('🔄 [开始作业] 步骤1: 重置轨迹,切换到定位模式');
|
||||
_traceManager.reset();
|
||||
_traceManager.setMode(TPMode.LOCATION);
|
||||
tracePoint?.clear();
|
||||
gctracePoint?.clear();
|
||||
|
||||
// 10. 如果池子里已有活跃任务,直接使用;否则创建新任务
|
||||
debugPrint('🔀 [开始作业] 步骤2: 确认任务来源');
|
||||
if (selectedTask != null) {
|
||||
// 🔥 验证 taskId 是否有效
|
||||
debugPrint('📋 [开始作业] 步骤2: 使用已有任务 #${selectedTask.id}');
|
||||
if (selectedTask.id == null) {
|
||||
debugPrint('⚠️ [开始作业] 活跃任务无有效 taskId,无法控制');
|
||||
_showPageToast(message: "已有任务在其他平台执行,暂不可控制", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
// 🔥 池子里已有任务,直接使用,不需要再创建
|
||||
debugPrint('✅ [开始作业] 使用已有任务 #${selectedTask.id},跳过创建');
|
||||
debugPrint('✅ [开始作业] 步骤2完成: 使用已有任务 #${selectedTask.id},跳过创建');
|
||||
// taskId 已由 selectTask 存入 cubit
|
||||
} else {
|
||||
// 🔥 池子里没有,创建新任务
|
||||
debugPrint('🆕 [开始作业] 池子为空,创建新任务...');
|
||||
debugPrint('🆕 [开始作业] 步骤2a: 池子为空,创建新任务...');
|
||||
try {
|
||||
debugPrint('📤 [开始作业] 调用 createDeviceTask API, deviceId=$deviceId, routeId=$routeId');
|
||||
final result = await sl<CreateDeviceTaskUseCase>().execute(
|
||||
deviceId: deviceId,
|
||||
routeId: routeId,
|
||||
@@ -2392,50 +2467,80 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
);
|
||||
|
||||
var needRequery = false;
|
||||
var failed = false;
|
||||
result.fold(
|
||||
(failure) {
|
||||
(failure) {
|
||||
final failMsg = failure.toString();
|
||||
if (failMsg.contains('存在任务') || failMsg.contains('already exists')) {
|
||||
needRequery = true;
|
||||
return;
|
||||
}
|
||||
failed = true;
|
||||
debugPrint('[开始作业] 创建失败: $failMsg');
|
||||
_showPageToast(message: '作业启动失败', type: ToastType.error);
|
||||
taskCubit.clearCurrentTask();
|
||||
return;
|
||||
debugPrint('⚠️ [开始作业] createDeviceTask API 失败: $failMsg');
|
||||
// 🔥 无论什么失败(网络错误/任务已存在等),都尝试重查池子
|
||||
// 网络错误时服务端可能已创建成功,只是响应丢失
|
||||
needRequery = true;
|
||||
},
|
||||
(taskId) {
|
||||
(taskId) {
|
||||
debugPrint('✅ [开始作业] createDeviceTask 成功, taskId=$taskId');
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
},
|
||||
);
|
||||
|
||||
if (failed) return;
|
||||
if (needRequery) {
|
||||
debugPrint('🔁 [开始作业] 步骤2b: API失败,重新查询池子(服务端可能已创建)...');
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final existingTasks = taskCubit.state.activeTasks;
|
||||
if (existingTasks.isNotEmpty) {
|
||||
taskCubit.selectTask(existingTasks.first);
|
||||
debugPrint('✅ [开始作业] 重查成功,使用已有任务 #${existingTasks.first.id}');
|
||||
// 🔥 继续执行后续 UI更新 + MQTT + 保存逻辑
|
||||
} else {
|
||||
debugPrint('[开始作业] needRequery 后仍无活跃任务');
|
||||
_showPageToast(message: '未找到活跃任务', type: ToastType.warn);
|
||||
debugPrint('❌ [开始作业] 步骤2b失败: 重查后仍无活跃任务,终止');
|
||||
_showPageToast(message: '作业启动失败,未找到活跃任务', type: ToastType.error);
|
||||
taskCubit.clearCurrentTask();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ [开始作业] 异常: $e');
|
||||
debugPrint('❌ [开始作业] 步骤2异常: $e');
|
||||
_showPageToast(message: "作业启动异常: $e", type: ToastType.error);
|
||||
taskCubit.clearCurrentTask();
|
||||
// 🔥 不重置 _workStatus,保持按钮可见
|
||||
return;
|
||||
}
|
||||
} // end if/else 任务确认
|
||||
|
||||
// 🔥 安全检查:taskId 必须有效才能更新 UI 为作业中
|
||||
if (taskCubit.state.currentTaskId == null) {
|
||||
debugPrint('❌ [开始作业] 步骤3前检查: taskId 为空,任务未就绪,终止');
|
||||
_showPageToast(message: '作业启动失败,未获取到任务ID', type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 到这里说明任务已就绪(无论是已有还是新建),刷新一次任务池显示最新状态
|
||||
taskCubit.fetchAndFilterTask(deviceId);
|
||||
// 🔥 步骤3: 任务确认成功,更新UI状态
|
||||
debugPrint('🎯 [开始作业] 步骤3: 任务就绪, currentTaskId=${taskCubit.state.currentTaskId}');
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
isStartWork = true;
|
||||
_workStatus = WorkStatus.working;
|
||||
});
|
||||
context.read<DevicesCubit>().updateAppState(AppState.routePlanning);
|
||||
|
||||
// 🔥 步骤4: 刷新任务池(保护 taskId 不被覆盖)
|
||||
debugPrint('🔄 [开始作业] 步骤4: 刷新任务池');
|
||||
final _confirmedTaskId = taskCubit.state.currentTaskId;
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
if (_confirmedTaskId != null && taskCubit.state.currentTaskId == null) {
|
||||
taskCubit.updateCurrentTaskId(_confirmedTaskId);
|
||||
debugPrint('🛡️ [开始作业] taskId 被刷新覆盖,已还原: $_confirmedTaskId');
|
||||
}
|
||||
|
||||
// 🔥 步骤5: 启动MQTT到达点监听
|
||||
debugPrint('📡 [开始作业] 步骤5: 启动MQTT监听, deviceId: $deviceId, taskId: $_confirmedTaskId');
|
||||
try {
|
||||
await context.read<DevicesCubit>().startListeningMqttArrive(deviceId: deviceId, taskId: _confirmedTaskId!);
|
||||
debugPrint('✅ [开始作业] MQTT到达点监听已启动');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [开始作业] MQTT监听启动失败: $e (非致命,继续)');
|
||||
}
|
||||
|
||||
// 🔥 步骤6: 持久化状态
|
||||
debugPrint('💾 [开始作业] 步骤6: 持久化状态, taskId=${taskCubit.state.currentTaskId}');
|
||||
_showPageToast(message: "作业已开始", type: ToastType.success);
|
||||
_saveDataToLocal();
|
||||
|
||||
@@ -2448,9 +2553,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
_traceManager.setMode(TPMode.LOCATION);
|
||||
tracePoint = _traceManager.getTracePoint();
|
||||
gctracePoint = batchWgs84ToGcj02(tracePoint!);
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
|
||||
_logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $tracePoint");
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业: $gctracePoint");
|
||||
// _logger.log("[当前轨迹模式][转换后gctracePoint]开始作业= ${gctracePoint!.length}");
|
||||
|
||||
setState(() {
|
||||
isStopWork = false;
|
||||
@@ -2492,9 +2597,21 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
var taskId = taskCubit.state.currentTaskId;
|
||||
|
||||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||||
if (taskId == null) {
|
||||
_showPageToast(message: "无可用任务", type: ToastType.warn);
|
||||
debugPrint('📥 [暂停作业] taskId 为空,尝试从本地恢复...');
|
||||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
if (taskId != null) {
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
debugPrint('📥 [暂停作业] 从本地恢复 taskId: $taskId');
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId == null) {
|
||||
debugPrint('❌ [暂停作业] taskId 为空,无法操控');
|
||||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2535,11 +2652,22 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
var taskId = taskCubit.state.currentTaskId;
|
||||
debugPrint('[停止作业] taskId: $taskId');
|
||||
|
||||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||||
if (taskId == null) {
|
||||
debugPrint('[停止作业] ❌ taskId 为 null,退出');
|
||||
_showPageToast(message: "无可用任务", type: ToastType.warn);
|
||||
debugPrint('📥 [停止作业] taskId 为空,尝试从本地恢复...');
|
||||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
if (taskId != null) {
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
debugPrint('📥 [停止作业] 从本地恢复 taskId: $taskId');
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId == null) {
|
||||
debugPrint('[停止作业] ❌ taskId 为空,无法操控');
|
||||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2567,6 +2695,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
debugPrint('[停止作业] cancelTask 返回成功');
|
||||
taskCubit.clearCurrentTask(); // 🔥 停止后清除 taskId,释放任务
|
||||
debugPrint('[停止作业] clearCurrentTask 完成');
|
||||
await _clearTaskIdFromLocal(deviceId); // 🔥 同步清除本地持久化
|
||||
|
||||
// 🔥 停止MQTT到达点监听
|
||||
debugPrint('[停止作业] 停止MQTT到达点监听...');
|
||||
await context.read<DevicesCubit>().stopListeningMqttArrive();
|
||||
debugPrint('[停止作业] MQTT到达点监听已停止');
|
||||
|
||||
_showPageToast(message: "作业已停止", type: ToastType.success);
|
||||
debugPrint('[停止作业] HTTP 取消成功,准备发送 TCP 停止指令');
|
||||
|
||||
@@ -2613,9 +2748,21 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
final taskId = taskCubit.state.currentTaskId;
|
||||
var taskId = taskCubit.state.currentTaskId;
|
||||
|
||||
// 🔥 taskId 为 null 时尝试从 SharedPreferences 恢复
|
||||
if (taskId == null) {
|
||||
_showPageToast(message: "无可用任务", type: ToastType.warn);
|
||||
debugPrint('📥 [继续作业] taskId 为空,尝试从本地恢复...');
|
||||
taskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
if (taskId != null) {
|
||||
taskCubit.updateCurrentTaskId(taskId);
|
||||
debugPrint('📥 [继续作业] 从本地恢复 taskId: $taskId');
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId == null) {
|
||||
debugPrint('❌ [继续作业] taskId 为空,无法操控');
|
||||
_showPageToast(message: "任务ID为空,无法操控", type: ToastType.warn);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2660,18 +2807,18 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
List<dynamic> _parseStartWorkListFromPathData(
|
||||
List<Map<String, dynamic>>? pathData,
|
||||
) {
|
||||
debugPrint(
|
||||
'🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '🔍 [_parseSWL] pathData 是否为null: ${pathData == null}, 长度: ${pathData?.length}',
|
||||
// );
|
||||
if (pathData == null || pathData.isEmpty) {
|
||||
debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
|
||||
// debugPrint('❌ [_parseSWL] pathData 为空,返回 []');
|
||||
return [];
|
||||
}
|
||||
|
||||
final firstRecord = pathData.first;
|
||||
debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
|
||||
// debugPrint('🔍 [_parseSWL] firstRecord keys: ${firstRecord.keys}');
|
||||
final nestedJsonRaw = firstRecord['jsonData'];
|
||||
debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
|
||||
// debugPrint('🔍 [_parseSWL] nestedJsonRaw 类型: ${nestedJsonRaw.runtimeType}');
|
||||
|
||||
// 🔥 兼容两种格式:String(需 jsonDecode)和 Map(已解析)
|
||||
Map<String, dynamic> parsedJson;
|
||||
@@ -2679,31 +2826,31 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
try {
|
||||
final decoded = jsonDecode(nestedJsonRaw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
|
||||
// debugPrint('❌ [_parseSWL] jsonDecode 结果不是 Map,返回 []');
|
||||
return [];
|
||||
}
|
||||
parsedJson = decoded;
|
||||
} catch (_) {
|
||||
debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
|
||||
// debugPrint('❌ [_parseSWL] jsonDecode 失败,返回 []');
|
||||
return [];
|
||||
}
|
||||
} else if (nestedJsonRaw is Map<String, dynamic>) {
|
||||
parsedJson = nestedJsonRaw;
|
||||
} else {
|
||||
debugPrint(
|
||||
'❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
|
||||
);
|
||||
// debugPrint(
|
||||
// '❌ [_parseSWL] nestedJsonRaw 类型不支持: ${nestedJsonRaw.runtimeType},返回 []',
|
||||
// );
|
||||
return [];
|
||||
}
|
||||
|
||||
debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
|
||||
// debugPrint('🔍 [_parseSWL] parsedJson keys: ${parsedJson.keys}');
|
||||
|
||||
final planModel = parsedJson['planModel'];
|
||||
// 🔥 安全解析:支持 int 和 String 类型
|
||||
final int planModelValue = int.tryParse(planModel?.toString() ?? '0') ?? 0;
|
||||
debugPrint(
|
||||
'🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '🔍 [_parseSWL] planModel: $planModel, planModelValue: $planModelValue, WorkMode.bow.value: ${WorkMode.bow.value}',
|
||||
// );
|
||||
final isBow = planModelValue == WorkMode.bow.value;
|
||||
|
||||
List<dynamic> pathList = [];
|
||||
@@ -2728,15 +2875,15 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
|
||||
if (isBow) {
|
||||
final result = pathList.isNotEmpty ? pathList : outerList;
|
||||
debugPrint(
|
||||
'✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '✅ [_parseSWL] 弓字模式,pathList: ${pathList.length}, outerList: ${outerList.length}, 最终返回: ${result.length}',
|
||||
// );
|
||||
return result;
|
||||
} else {
|
||||
final result = outerList.isNotEmpty ? outerList : pathList;
|
||||
debugPrint(
|
||||
'✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '✅ [_parseSWL] 自定义模式,outerList: ${outerList.length}, pathList: ${pathList.length}, 最终返回: ${result.length}',
|
||||
// );
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2750,19 +2897,20 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
|
||||
// 🔥 核心修复:从 state.pathData 实时计算 startWorkList
|
||||
// 确保 BlocBuilder 触发时数据已就绪,不再依赖 onTap 的异步赋值时序
|
||||
debugPrint(
|
||||
'🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '🔍 [_buildWorkPanel] BlocBuilder 触发, state.pathData 是否为空: ${state.pathData?.isEmpty ?? true}, startWorkList 当前长度: ${startWorkList.length}',
|
||||
// );
|
||||
final parsedList = _parseStartWorkListFromPathData(state.pathData);
|
||||
if (parsedList.isNotEmpty) {
|
||||
startWorkList = parsedList; // 同步到类字段,供 _saveDataToLocal 等方法使用
|
||||
debugPrint(
|
||||
'✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '✅ [_buildWorkPanel] startWorkList 已更新,新长度: ${startWorkList.length}',
|
||||
// );
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
|
||||
);
|
||||
// debugPrint(
|
||||
// '⚠️ [_buildWorkPanel] parsedList 为空,startWorkList 保持: ${startWorkList.length}',
|
||||
// );
|
||||
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
@@ -2953,37 +3101,34 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'设备: ${currentTask.deviceId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
Text(
|
||||
'设备: ${currentTask.deviceId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
softWrap: true,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
4,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -3672,6 +3817,13 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
DeviceStatusUpdated? updatedState;
|
||||
//有停止信号
|
||||
if (isFinishWork) {
|
||||
// 🔥 清除 taskId 持久化数据(任务已完成)
|
||||
final finishDeviceId = context.read<RemoteControlCubit>().state.targetDevice?.deviceName;
|
||||
if (finishDeviceId != null) {
|
||||
sl<DeviceTaskCubit>().clearCurrentTask();
|
||||
_clearTaskIdFromLocal(finishDeviceId);
|
||||
debugPrint('🏁 [完成] 任务已到达终点,清除 taskId: deviceId=$finishDeviceId');
|
||||
}
|
||||
// 🔥 关键:用微任务延迟执行状态更新,避开构建阶段
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
@@ -3685,7 +3837,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
tracePoint?.clear();
|
||||
gctracePoint?.clear();
|
||||
});
|
||||
_showPageToast(message: "作业已停止", type: ToastType.error);
|
||||
_showPageToast(message: "作业已完成", type: ToastType.success);
|
||||
|
||||
// 延迟重置轨迹管理器
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
|
||||
@@ -349,7 +349,7 @@ class _MachineDetailsPageState extends State<MachineDetailsPage> {
|
||||
streamUrl: _videoStreamUrl,
|
||||
showLeftPip: false, // 不显示悬浮小窗
|
||||
showRightPip: false,
|
||||
isFrontMain: _currentViewIndex == 0, // 根据当前视角决定主画面
|
||||
mainViewAlignment: _viewConfigs[_currentViewIndex]['alignment'] as Alignment, // 🔥 根据视角切换画面
|
||||
)
|
||||
: Container(
|
||||
color: Colors.black87,
|
||||
|
||||
@@ -45,4 +45,11 @@ class MyCubit extends Cubit<MyState> {
|
||||
emit(state.copyWith(isLoading: true, errorMessage: ''));
|
||||
// 解绑逻辑(如需保留,需补充 UnbindDeviceUsecase 依赖注入)
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态(昵称等个人信息)
|
||||
void clearAll() {
|
||||
if (!isClosed) {
|
||||
emit(const MyState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,4 +1070,28 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||
debugPrint('🗑️ [RemoteControl] 缓存已清空 - voltage, battery, controlMode, ping');
|
||||
}
|
||||
|
||||
/// 🔥 退出登录时清空所有状态(比 _clearAllCache 更彻底,重置全部 state 字段)
|
||||
void clearAll() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
|
||||
_cacheVoltage = null;
|
||||
_cacheBattery = null;
|
||||
_cacheCtrlMode = null;
|
||||
_cachePing = null;
|
||||
_lastUiUpdateTime = null;
|
||||
_lastStatusPushTime = null;
|
||||
_currentOriginX = 0;
|
||||
_currentOriginY = 0;
|
||||
|
||||
if (!isClosed) {
|
||||
emit(RemoteControlState(
|
||||
controlEntity: MachineControlStatusEntity(),
|
||||
runningStatusModel: RunningStatusModel(),
|
||||
));
|
||||
}
|
||||
|
||||
debugPrint('🗑️ [RemoteControl] clearAll - 所有状态已重置为初始值');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -119,8 +119,6 @@ class _RightJoystickAreaState extends State<RightJoystickArea> {
|
||||
}
|
||||
|
||||
void _triggerVibration() {
|
||||
Vibration.hasVibrator().then((has) {
|
||||
if (has ?? false) Vibration.vibrate(duration: 12);
|
||||
});
|
||||
Vibration.vibrate(duration: 12);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,14 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
Widget _buildQuadrantView({required Alignment alignment}) {
|
||||
if (_renderer.srcObject == null) return Container(color: Colors.black);
|
||||
|
||||
// 🔥 俯视(center):显示完整视频帧,不做2倍裁剪
|
||||
if (alignment == Alignment.center) {
|
||||
return RepaintBoundary(
|
||||
child: RTCVideoView(_renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false),
|
||||
);
|
||||
}
|
||||
|
||||
// 前/后/左/右:2倍放大后裁剪对应象限
|
||||
return RepaintBoundary(
|
||||
child: ClipRect(
|
||||
child: FractionallySizedBox(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
|
||||
abstract class BindDeviceDatasource {
|
||||
Future<List<OrgEntity>> getOrgList();
|
||||
Future<List<SiteEntity>> getSitesByOrgId(int orgId);
|
||||
Future<List<UserSimpleEntity>> getUsersBySiteId(int siteId);
|
||||
Future<bool> isDeviceAtSite({required String deviceId, required int siteId});
|
||||
Future<void> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
|
||||
import '../../../domain/entities/bind_device_entities.dart';
|
||||
import '../bind_device_datasource.dart';
|
||||
|
||||
class BindDeviceDatasourceImpl implements BindDeviceDatasource {
|
||||
final Dio _dio;
|
||||
|
||||
BindDeviceDatasourceImpl(this._dio);
|
||||
|
||||
Future<String?> _getToken() async {
|
||||
final token = sl<AppUserCubit>().state.user?.token;
|
||||
if (token != null) return token;
|
||||
final user = await sl<UserStorage>().getUser();
|
||||
return user?.token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OrgEntity>> getOrgList() async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.orgList,
|
||||
queryParameters: {'pageNum': 1, 'pageSize': 1000},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '获取组织列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows
|
||||
.map((e) => OrgEntity.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SiteEntity>> getSitesByOrgId(int orgId) async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.siteListByOrgId,
|
||||
queryParameters: {'id': orgId},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '获取场站列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows
|
||||
.map((e) => SiteEntity.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UserSimpleEntity>> getUsersBySiteId(int siteId) async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.userListBySiteId,
|
||||
queryParameters: {'siteId': siteId, 'pageNum': 1, 'pageSize': 1000},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '获取人员列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows
|
||||
.map((e) => UserSimpleEntity.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isDeviceAtSite({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.getSiteDeviceList,
|
||||
queryParameters: {'siteId': siteId, 'pageSize': 9999, 'pageNum': 1},
|
||||
options: Options(
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) return false;
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) return false;
|
||||
|
||||
final List<dynamic> rows = data['rows'] ?? data['data'] ?? [];
|
||||
return rows.any((e) => e['deviceId']?.toString() == deviceId);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.bindDevice,
|
||||
data: {
|
||||
'deviceIds': deviceIds,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
'userId': userId,
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = response.data;
|
||||
if (data['code'] != 200) {
|
||||
throw Exception(data['msg'] ?? '绑定设备失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
import '../../domain/repositories/bind_device_repository.dart';
|
||||
import '../datasources/bind_device_datasource.dart';
|
||||
|
||||
class BindDeviceRepositoryImpl implements BindDeviceRepository {
|
||||
final BindDeviceDatasource _datasource;
|
||||
|
||||
BindDeviceRepositoryImpl(this._datasource);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<OrgEntity>>> getOrgList() async {
|
||||
try {
|
||||
final result = await _datasource.getOrgList();
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<SiteEntity>>> getSitesByOrgId(int orgId) async {
|
||||
try {
|
||||
final result = await _datasource.getSitesByOrgId(orgId);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<UserSimpleEntity>>> getUsersBySiteId(
|
||||
int siteId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _datasource.getUsersBySiteId(siteId);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> isDeviceAtSite({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _datasource.isDeviceAtSite(
|
||||
deviceId: deviceId,
|
||||
siteId: siteId,
|
||||
);
|
||||
return Right(result);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
}) async {
|
||||
try {
|
||||
await _datasource.bindDevice(
|
||||
deviceIds: deviceIds,
|
||||
orgId: orgId,
|
||||
siteId: siteId,
|
||||
userId: userId,
|
||||
);
|
||||
return const Right(null);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
class OrgEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const OrgEntity({required this.id, required this.name});
|
||||
|
||||
static int _parseId(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory OrgEntity.fromJson(Map<String, dynamic> json) {
|
||||
return OrgEntity(
|
||||
id: _parseId(json['id']),
|
||||
name: json['name'] ?? json['orgName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SiteEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const SiteEntity({required this.id, required this.name});
|
||||
|
||||
static int _parseId(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory SiteEntity.fromJson(Map<String, dynamic> json) {
|
||||
return SiteEntity(
|
||||
id: _parseId(json['id']),
|
||||
name: json['name'] ?? json['siteName'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserSimpleEntity {
|
||||
final int id;
|
||||
final String name;
|
||||
|
||||
const UserSimpleEntity({required this.id, required this.name});
|
||||
|
||||
static int _parseId(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
factory UserSimpleEntity.fromJson(Map<String, dynamic> json) {
|
||||
return UserSimpleEntity(
|
||||
id: _parseId(json['userId'] ?? json['id']),
|
||||
name: json['nickName'] ?? json['name'] ?? json['username'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ class UAVDetailEntity extends Equatable {
|
||||
final double? homeDistance;
|
||||
final double? liveCapacity;
|
||||
final String? rainfall;
|
||||
final String? airConditionerStatus;
|
||||
final String? hangarStatus;
|
||||
final List<CameraInfo>? gatewayCameraList;
|
||||
final List<CameraInfo>? droneCameraList;
|
||||
final int? orgId;
|
||||
@@ -117,6 +119,8 @@ class UAVDetailEntity extends Equatable {
|
||||
this.homeDistance,
|
||||
this.liveCapacity,
|
||||
this.rainfall,
|
||||
this.airConditionerStatus,
|
||||
this.hangarStatus,
|
||||
this.gatewayCameraList,
|
||||
this.droneCameraList,
|
||||
this.orgId,
|
||||
@@ -166,6 +170,8 @@ class UAVDetailEntity extends Equatable {
|
||||
? (json['live_capacity'] as num).toDouble()
|
||||
: null,
|
||||
rainfall: json['rainfall']?.toString(),
|
||||
airConditionerStatus: json['air_conditioner_status'] ?? '',
|
||||
hangarStatus: json['hangar_status'] ?? '',
|
||||
gatewayCameraList: json['gateway_camera_list'] != null
|
||||
? (json['gateway_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
@@ -203,6 +209,8 @@ class UAVDetailEntity extends Equatable {
|
||||
'home_distance': homeDistance,
|
||||
'live_capacity': liveCapacity,
|
||||
'rainfall': rainfall,
|
||||
'air_conditioner_status': airConditionerStatus,
|
||||
'hangar_status': hangarStatus,
|
||||
'gateway_camera_list': gatewayCameraList?.map((c) => c.toJson()).toList(),
|
||||
'drone_camera_list': droneCameraList,
|
||||
'orgId': orgId,
|
||||
@@ -233,6 +241,8 @@ class UAVDetailEntity extends Equatable {
|
||||
homeDistance,
|
||||
liveCapacity,
|
||||
rainfall,
|
||||
airConditionerStatus,
|
||||
hangarStatus,
|
||||
gatewayCameraList,
|
||||
droneCameraList,
|
||||
orgId,
|
||||
@@ -260,6 +270,8 @@ class DroneStationEntity extends Equatable {
|
||||
final double? homeDistance; // 距离home点距离
|
||||
final double? liveCapacity; // 实时容量
|
||||
final double? rainfall; // 降雨量
|
||||
final String? airConditionerStatus; // 机场空调状态
|
||||
final String? hangarStatus; // 机库状态
|
||||
final List<CameraInfo>? gatewayCameraList; // 网关摄像头列表
|
||||
final List<dynamic>? droneCameraList; // 无人机摄像头列表
|
||||
final int orgId; // 组织ID
|
||||
@@ -284,6 +296,8 @@ class DroneStationEntity extends Equatable {
|
||||
this.homeDistance,
|
||||
this.liveCapacity,
|
||||
this.rainfall,
|
||||
this.airConditionerStatus,
|
||||
this.hangarStatus,
|
||||
this.gatewayCameraList,
|
||||
this.droneCameraList,
|
||||
required this.orgId,
|
||||
@@ -328,6 +342,8 @@ class DroneStationEntity extends Equatable {
|
||||
rainfall: json['rainfall'] != null
|
||||
? (json['rainfall'] as num).toDouble()
|
||||
: null,
|
||||
airConditionerStatus: json['air_conditioner_status'] ?? '',
|
||||
hangarStatus: json['hangar_status'] ?? '',
|
||||
gatewayCameraList: json['gateway_camera_list'] != null
|
||||
? (json['gateway_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
@@ -398,6 +414,8 @@ class DroneStationEntity extends Equatable {
|
||||
homeDistance,
|
||||
liveCapacity,
|
||||
rainfall,
|
||||
airConditionerStatus,
|
||||
hangarStatus,
|
||||
gatewayCameraList,
|
||||
droneCameraList,
|
||||
orgId,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../../core/error/failure.dart';
|
||||
import '../entities/bind_device_entities.dart';
|
||||
|
||||
abstract class BindDeviceRepository {
|
||||
Future<Either<Failure, List<OrgEntity>>> getOrgList();
|
||||
Future<Either<Failure, List<SiteEntity>>> getSitesByOrgId(int orgId);
|
||||
Future<Either<Failure, List<UserSimpleEntity>>> getUsersBySiteId(int siteId);
|
||||
Future<Either<Failure, bool>> isDeviceAtSite({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
});
|
||||
Future<Either<Failure, void>> bindDevice({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
|
||||
import '../entities/bind_device_entities.dart';
|
||||
import '../repositories/bind_device_repository.dart';
|
||||
|
||||
class GetOrgListUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
GetOrgListUseCase(this._repository);
|
||||
Future<Either<Failure, List<OrgEntity>>> call() => _repository.getOrgList();
|
||||
}
|
||||
|
||||
class GetSitesByOrgUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
GetSitesByOrgUseCase(this._repository);
|
||||
Future<Either<Failure, List<SiteEntity>>> call(int orgId) =>
|
||||
_repository.getSitesByOrgId(orgId);
|
||||
}
|
||||
|
||||
class GetUsersBySiteUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
GetUsersBySiteUseCase(this._repository);
|
||||
Future<Either<Failure, List<UserSimpleEntity>>> call(int siteId) =>
|
||||
_repository.getUsersBySiteId(siteId);
|
||||
}
|
||||
|
||||
class BindDeviceV2UseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
BindDeviceV2UseCase(this._repository);
|
||||
Future<Either<Failure, void>> call({
|
||||
required List<String> deviceIds,
|
||||
required int orgId,
|
||||
required int siteId,
|
||||
required int userId,
|
||||
}) => _repository.bindDevice(
|
||||
deviceIds: deviceIds,
|
||||
orgId: orgId,
|
||||
siteId: siteId,
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
|
||||
class IsDeviceAtSiteUseCase {
|
||||
final BindDeviceRepository _repository;
|
||||
IsDeviceAtSiteUseCase(this._repository);
|
||||
Future<Either<Failure, bool>> call({
|
||||
required String deviceId,
|
||||
required int siteId,
|
||||
}) => _repository.isDeviceAtSite(deviceId: deviceId, siteId: siteId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class PauseFlightTaskUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
PauseFlightTaskUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, Map<String, dynamic>>> execute({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
return await repository.pauseFlightTask(deviceSn: deviceSn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class ReturnHomeUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
ReturnHomeUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, Map<String, dynamic>>> execute({
|
||||
required String deviceSn,
|
||||
}) async {
|
||||
return await repository.returnHome(deviceSn: deviceSn);
|
||||
}
|
||||
}
|
||||
@@ -43,27 +43,37 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
|
||||
DeviceStatusRefresh event,
|
||||
Emitter<DeviceStatusState> emit,
|
||||
) async {
|
||||
if (state is DeviceStatusLoaded) {
|
||||
final currentState = state as DeviceStatusLoaded;
|
||||
// 🔥 切换电站时,即便当前是 Loading/Initial 也需要重新拉取
|
||||
final currentSiteId = (state is DeviceStatusLoaded)
|
||||
? (state as DeviceStatusLoaded).siteId
|
||||
: null;
|
||||
final targetSiteId = event.siteId ?? currentSiteId;
|
||||
final currentSelectedType = (state is DeviceStatusLoaded)
|
||||
? (state as DeviceStatusLoaded).selectedType
|
||||
: 'all';
|
||||
|
||||
try {
|
||||
final response = await getDeviceStatusDataUseCase.execute(
|
||||
siteId: currentState.siteId,
|
||||
typeFilter: currentState.selectedType == 'all'
|
||||
? null
|
||||
: currentState.selectedType,
|
||||
);
|
||||
// 切换电站时显示 Loading,让用户感知到正在刷新
|
||||
if (event.siteId != null) {
|
||||
emit(const DeviceStatusLoading());
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(
|
||||
deviceStatus: response.status,
|
||||
devices: response.devices,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(DeviceStatusError(
|
||||
message: ErrorHandler.getErrorMessage(e),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
));
|
||||
}
|
||||
try {
|
||||
final response = await getDeviceStatusDataUseCase.execute(
|
||||
siteId: targetSiteId,
|
||||
typeFilter: currentSelectedType == 'all' ? null : currentSelectedType,
|
||||
);
|
||||
|
||||
emit(DeviceStatusLoaded(
|
||||
deviceStatus: response.status,
|
||||
devices: response.devices,
|
||||
siteId: targetSiteId,
|
||||
selectedType: currentSelectedType,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(DeviceStatusError(
|
||||
message: ErrorHandler.getErrorMessage(e),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ class DeviceStatusLoadData extends DeviceStatusEvent {
|
||||
}
|
||||
|
||||
class DeviceStatusRefresh extends DeviceStatusEvent {
|
||||
const DeviceStatusRefresh();
|
||||
/// 可选:切换电站时传入新 siteId 重新请求;不传则用当前 state 中的 siteId
|
||||
final int? siteId;
|
||||
|
||||
const DeviceStatusRefresh({this.siteId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [siteId];
|
||||
}
|
||||
|
||||
class DeviceStatusChangeType extends DeviceStatusEvent {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
import '../../domain/usecases/bind_device_usecases.dart';
|
||||
import 'bind_device_state.dart';
|
||||
|
||||
class BindDeviceCubit extends Cubit<BindDeviceState> {
|
||||
final GetOrgListUseCase _getOrgListUseCase;
|
||||
final GetSitesByOrgUseCase _getSitesByOrgUseCase;
|
||||
final GetUsersBySiteUseCase _getUsersBySiteUseCase;
|
||||
final BindDeviceV2UseCase _bindDeviceUseCase;
|
||||
final IsDeviceAtSiteUseCase _isDeviceAtSiteUseCase;
|
||||
final AppUserCubit _appUserCubit;
|
||||
|
||||
BindDeviceCubit(
|
||||
this._getOrgListUseCase,
|
||||
this._getSitesByOrgUseCase,
|
||||
this._getUsersBySiteUseCase,
|
||||
this._bindDeviceUseCase,
|
||||
this._isDeviceAtSiteUseCase,
|
||||
this._appUserCubit,
|
||||
) : super(const BindDeviceState());
|
||||
|
||||
UserEntity? get _currentUser => _appUserCubit.state.user;
|
||||
String get _roleKey => _currentUser?.roleKey ?? 'user';
|
||||
|
||||
Future<void> init() async {
|
||||
final user = _currentUser;
|
||||
if (user == null) return;
|
||||
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
switch (_roleKey) {
|
||||
case 'admin':
|
||||
await _loadAllOrgs();
|
||||
break;
|
||||
case 'manager':
|
||||
await _loadForManager(user);
|
||||
break;
|
||||
case 'siteManager':
|
||||
await _loadForSiteManager(user);
|
||||
break;
|
||||
case 'user':
|
||||
await _loadForUser(user);
|
||||
break;
|
||||
default:
|
||||
await _loadForUser(user);
|
||||
}
|
||||
|
||||
emit(state.copyWith(isLoading: false));
|
||||
}
|
||||
|
||||
Future<void> _loadAllOrgs() async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) => emit(state.copyWith(orgList: orgs)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForManager(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) async {
|
||||
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
|
||||
await _loadSitesByOrg(user.orgId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForSiteManager(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) async {
|
||||
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
|
||||
await _loadSitesAndUsers(user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadForUser(UserEntity user) async {
|
||||
final result = await _getOrgListUseCase();
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(orgs) async {
|
||||
emit(state.copyWith(orgList: orgs, selectedOrgId: user.orgId));
|
||||
await _loadSitesAndUsers(user);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSitesByOrg(int orgId) async {
|
||||
final result = await _getSitesByOrgUseCase(orgId);
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(sites) => emit(state.copyWith(siteList: sites)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadSitesAndUsers(UserEntity user) async {
|
||||
final sitesResult = await _getSitesByOrgUseCase(user.orgId);
|
||||
sitesResult.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(sites) async {
|
||||
emit(state.copyWith(siteList: sites, selectedSiteId: user.siteId));
|
||||
if (user.siteId != null) {
|
||||
await _loadUsers(user);
|
||||
} else {
|
||||
final currentUser = UserSimpleEntity(
|
||||
id: int.tryParse(user.userId) ?? 0,
|
||||
name: user.nickname,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: [currentUser],
|
||||
selectedUserId: currentUser.id,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadUsers(UserEntity user) async {
|
||||
final result = await _getUsersBySiteUseCase(user.siteId!);
|
||||
final currentUser = UserSimpleEntity(
|
||||
id: int.tryParse(user.userId) ?? 0,
|
||||
name: user.nickname,
|
||||
);
|
||||
result.fold(
|
||||
(failure) =>
|
||||
emit(state.copyWith(errorMessage: failure.message, isLoading: false)),
|
||||
(users) {
|
||||
final allUsers = <UserSimpleEntity>[];
|
||||
final seenIds = <int>{};
|
||||
for (final u in users) {
|
||||
if (u.id != currentUser.id && !seenIds.contains(u.id)) {
|
||||
allUsers.add(u);
|
||||
seenIds.add(u.id);
|
||||
}
|
||||
}
|
||||
if (!seenIds.contains(currentUser.id)) {
|
||||
allUsers.add(currentUser);
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
userList: allUsers,
|
||||
selectedUserId: currentUser.id,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onOrgChanged(int? orgId) async {
|
||||
if (orgId == null) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedOrgId: orgId,
|
||||
selectedSiteId: null,
|
||||
selectedUserId: null,
|
||||
siteList: [],
|
||||
userList: [],
|
||||
),
|
||||
);
|
||||
|
||||
final result = await _getSitesByOrgUseCase(orgId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(sites) => emit(state.copyWith(siteList: sites)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onSiteChanged(int? siteId) async {
|
||||
if (siteId == null) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedSiteId: siteId,
|
||||
selectedUserId: null,
|
||||
userList: [],
|
||||
),
|
||||
);
|
||||
|
||||
final result = await _getUsersBySiteUseCase(siteId);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(errorMessage: failure.message)),
|
||||
(users) => emit(state.copyWith(userList: users)),
|
||||
);
|
||||
}
|
||||
|
||||
void onUserChanged(int? userId) {
|
||||
emit(state.copyWith(selectedUserId: userId));
|
||||
}
|
||||
|
||||
Future<bool> submitBind(String deviceId) async {
|
||||
if (state.selectedOrgId == null ||
|
||||
state.selectedSiteId == null ||
|
||||
state.selectedUserId == null) {
|
||||
emit(state.copyWith(errorMessage: '请选择完整的绑定信息'));
|
||||
return false;
|
||||
}
|
||||
|
||||
emit(state.copyWith(isSubmitting: true, errorMessage: null));
|
||||
|
||||
final existResult = await _isDeviceAtSiteUseCase(
|
||||
deviceId: deviceId,
|
||||
siteId: state.selectedSiteId!,
|
||||
);
|
||||
|
||||
final isAtSite = existResult.fold((failure) => false, (exists) => exists);
|
||||
|
||||
if (isAtSite) {
|
||||
emit(state.copyWith(isSubmitting: false, errorMessage: '该设备已在当前场站,无需绑定'));
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await _bindDeviceUseCase(
|
||||
deviceIds: [deviceId],
|
||||
orgId: state.selectedOrgId!,
|
||||
siteId: state.selectedSiteId!,
|
||||
userId: state.selectedUserId!,
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
emit(
|
||||
state.copyWith(isSubmitting: false, errorMessage: failure.message),
|
||||
);
|
||||
return false;
|
||||
},
|
||||
(_) {
|
||||
emit(state.copyWith(isSubmitting: false, isSuccess: true));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool get isOrgEnabled => _roleKey == 'admin';
|
||||
bool get isSiteEnabled => _roleKey == 'admin' || _roleKey == 'manager';
|
||||
bool get isUserEnabled =>
|
||||
_roleKey == 'admin' || _roleKey == 'manager' || _roleKey == 'siteManager';
|
||||
|
||||
String get roleKey => _roleKey;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/bind_device_entities.dart';
|
||||
|
||||
class BindDeviceState extends Equatable {
|
||||
final List<OrgEntity> orgList;
|
||||
final List<SiteEntity> siteList;
|
||||
final List<UserSimpleEntity> userList;
|
||||
final int? selectedOrgId;
|
||||
final int? selectedSiteId;
|
||||
final int? selectedUserId;
|
||||
final bool isLoading;
|
||||
final bool isSubmitting;
|
||||
final String? errorMessage;
|
||||
final bool isSuccess;
|
||||
|
||||
const BindDeviceState({
|
||||
this.orgList = const [],
|
||||
this.siteList = const [],
|
||||
this.userList = const [],
|
||||
this.selectedOrgId,
|
||||
this.selectedSiteId,
|
||||
this.selectedUserId,
|
||||
this.isLoading = false,
|
||||
this.isSubmitting = false,
|
||||
this.errorMessage,
|
||||
this.isSuccess = false,
|
||||
});
|
||||
|
||||
BindDeviceState copyWith({
|
||||
List<OrgEntity>? orgList,
|
||||
List<SiteEntity>? siteList,
|
||||
List<UserSimpleEntity>? userList,
|
||||
int? selectedOrgId,
|
||||
int? selectedSiteId,
|
||||
int? selectedUserId,
|
||||
bool? isLoading,
|
||||
bool? isSubmitting,
|
||||
String? errorMessage,
|
||||
bool? isSuccess,
|
||||
}) {
|
||||
return BindDeviceState(
|
||||
orgList: orgList ?? this.orgList,
|
||||
siteList: siteList ?? this.siteList,
|
||||
userList: userList ?? this.userList,
|
||||
selectedOrgId: selectedOrgId ?? this.selectedOrgId,
|
||||
selectedSiteId: selectedSiteId ?? this.selectedSiteId,
|
||||
selectedUserId: selectedUserId ?? this.selectedUserId,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||
errorMessage: errorMessage,
|
||||
isSuccess: isSuccess ?? this.isSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
orgList,
|
||||
siteList,
|
||||
userList,
|
||||
selectedOrgId,
|
||||
selectedSiteId,
|
||||
selectedUserId,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
errorMessage,
|
||||
isSuccess,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
|
||||
import '../cubit/bind_device_cubit.dart';
|
||||
import '../cubit/bind_device_state.dart';
|
||||
|
||||
class BindDevicePage extends StatefulWidget {
|
||||
final String scanResult;
|
||||
|
||||
const BindDevicePage({super.key, required this.scanResult});
|
||||
|
||||
@override
|
||||
State<BindDevicePage> createState() => _BindDevicePageState();
|
||||
}
|
||||
|
||||
class _BindDevicePageState extends State<BindDevicePage> {
|
||||
late final BindDeviceCubit _cubit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<BindDeviceCubit>();
|
||||
_cubit.init();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => _cubit,
|
||||
child: BlocListener<BindDeviceCubit, BindDeviceState>(
|
||||
listener: (context, state) {
|
||||
if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
|
||||
}
|
||||
if (state.isSuccess) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('绑定成功')));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFF1D2129),
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'绑定智能装备',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: BlocBuilder<BindDeviceCubit, BindDeviceState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRequiredField(
|
||||
label: '已选装备 ID',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE5E6EB),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.scanResult,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildRequiredField(
|
||||
label: '所属组织',
|
||||
child: _buildOrgDropdown(state),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildField(
|
||||
label: '所属场站',
|
||||
child: _buildSiteDropdown(state),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildField(
|
||||
label: '负责人',
|
||||
child: _buildUserDropdown(state),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: state.isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFE5E6EB),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.isSubmitting
|
||||
? null
|
||||
: _handleSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: state.isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'确定',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOrgDropdown(BindDeviceState state) {
|
||||
final enabled = _cubit.isOrgEnabled;
|
||||
final selectedName = state.selectedOrgId != null
|
||||
? state.orgList
|
||||
.where((org) => org.id == state.selectedOrgId)
|
||||
.fold<String?>(null, (_, org) => org.name)
|
||||
: null;
|
||||
|
||||
if (!enabled && selectedName != null) {
|
||||
return _buildReadonlyField(selectedName);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: state.selectedOrgId,
|
||||
decoration: _dropdownDecoration(enabled),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
|
||||
),
|
||||
items: state.orgList.map((org) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: org.id,
|
||||
child: Text(
|
||||
org.name,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: enabled
|
||||
? (v) {
|
||||
if (v != null) _cubit.onOrgChanged(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSiteDropdown(BindDeviceState state) {
|
||||
final enabled = _cubit.isSiteEnabled && state.selectedOrgId != null;
|
||||
final selectedName = state.selectedSiteId != null
|
||||
? state.siteList
|
||||
.where((site) => site.id == state.selectedSiteId)
|
||||
.fold<String?>(null, (_, site) => site.name)
|
||||
: null;
|
||||
|
||||
if (!enabled && selectedName != null) {
|
||||
return _buildReadonlyField(selectedName);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: state.selectedSiteId,
|
||||
decoration: _dropdownDecoration(enabled),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
|
||||
),
|
||||
items: state.siteList.map((site) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: site.id,
|
||||
child: Text(
|
||||
site.name,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: enabled
|
||||
? (v) {
|
||||
if (v != null) _cubit.onSiteChanged(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserDropdown(BindDeviceState state) {
|
||||
final enabled = _cubit.isUserEnabled && state.selectedSiteId != null;
|
||||
final selectedName = state.selectedUserId != null
|
||||
? state.userList
|
||||
.where((user) => user.id == state.selectedUserId)
|
||||
.fold<String?>(null, (_, user) => user.name)
|
||||
: null;
|
||||
|
||||
if (!enabled && selectedName != null) {
|
||||
return _buildReadonlyField(selectedName);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? Colors.white : const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: state.selectedUserId,
|
||||
decoration: _dropdownDecoration(enabled),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: enabled ? const Color(0xFF86909C) : const Color(0xFFC9CDD4),
|
||||
),
|
||||
items: state.userList.map((user) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: user.id,
|
||||
child: Text(
|
||||
user.name,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: enabled
|
||||
? (v) {
|
||||
_cubit.onUserChanged(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadonlyField(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.lock, size: 16, color: Color(0xFFC9CDD4)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _dropdownDecoration(bool enabled) {
|
||||
return InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: enabled ? '请选择' : '请选择',
|
||||
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequiredField({required String label, required Widget child}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: '* ',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFF53F3F),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1D2129),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({required String label, required Widget child}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1D2129),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit() {
|
||||
_cubit.submitBind(widget.scanResult);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../core/managers/drone_task_state_manager.dart';
|
||||
|
||||
class CreateTaskPage extends StatefulWidget {
|
||||
final String sn;
|
||||
@@ -430,6 +431,9 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
final taskUuid = jsonData['data']['task_uuid'];
|
||||
print('✅ [CreateTask] 任务创建成功, task_uuid: $taskUuid');
|
||||
|
||||
// 🔥 任务下发成功,开始监测无人机实时推送与视频流
|
||||
droneTaskStateManager.markTaskIssued();
|
||||
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -570,8 +574,8 @@ class _CreateTaskPageState extends State<CreateTaskPage> {
|
||||
wayline['waylineUuid'] ??
|
||||
wayline['id'] ??
|
||||
'';
|
||||
// print('🔍 [CreateTask] 航线数据: $wayline');
|
||||
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
|
||||
// print('🔍 [CreateTask] 航线数据: $wayline');
|
||||
// print('🔍 [CreateTask] 解析的UUID: $waylineUuid');
|
||||
return ListTile(
|
||||
title: Text(waylineName),
|
||||
subtitle: waylineUuid.isNotEmpty
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/app/app_user_cubit.dart';
|
||||
import '../../../../../core/bluetooth/ble_manager.dart';
|
||||
import '../../../../../components/tcp_status_indicator.dart';
|
||||
import '../../../../../components/device_status_modal.dart';
|
||||
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
|
||||
import '../../../site/presentation/widgets/site_selector_widget.dart';
|
||||
import '../bloc/device_status_bloc.dart' as DeviceListBloc;
|
||||
import '../bloc/device_status_event.dart' as DeviceListEvent;
|
||||
import '../bloc/device_status_state.dart' as DeviceListState;
|
||||
@@ -20,11 +23,16 @@ import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入
|
||||
import '../widgets/device_item_widget.dart';
|
||||
import '../widgets/drone_station_item_card.dart';
|
||||
import '../widgets/robot_item_card.dart'; // 🔥 添加 RobotItemCard 导入
|
||||
import '../widgets/bluetooth_scan_modal.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart'; // 🔥 添加 DroneStationEntity 导入
|
||||
import 'robot_list_page.dart';
|
||||
import 'robot_control_page.dart';
|
||||
import 'drone_station_detail_page.dart';
|
||||
import 'qr_scanner_page.dart';
|
||||
import 'bind_device_page.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../../../devices/domain/entities/device_entity.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// 设备状态页面 - 使用 BLoC 模式
|
||||
class DeviceStatusPage extends StatelessWidget {
|
||||
@@ -45,9 +53,70 @@ class DeviceStatusPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 设备状态视图
|
||||
class DeviceStatusView extends StatelessWidget {
|
||||
class DeviceStatusView extends StatefulWidget {
|
||||
const DeviceStatusView({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceStatusView> createState() => _DeviceStatusViewState();
|
||||
}
|
||||
|
||||
class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
/// 🔥 监听电站切换:只要电站发生变化,就根据当前选中的标签栏自动刷新对应接口
|
||||
StreamSubscription<SiteState>? _siteSub;
|
||||
|
||||
/// 当前缓存的 siteId,用于判断是否真的发生了变化
|
||||
int? _currentSiteId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentSiteId = sl<SiteCubit>().state.selectedSite?.id;
|
||||
_siteSub = sl<SiteCubit>().stream.listen((siteState) {
|
||||
if (!mounted) return;
|
||||
final newSiteId = siteState.selectedSite?.id;
|
||||
// 只有 siteId 真正变化时才刷新(避免无意义的重复请求)
|
||||
if (newSiteId != _currentSiteId) {
|
||||
_currentSiteId = newSiteId;
|
||||
_refreshCurrentTabForSiteChange(newSiteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_siteSub?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 🔥 电站切换后,根据当前选中的标签栏自动刷新对应接口
|
||||
void _refreshCurrentTabForSiteChange(int? newSiteId) {
|
||||
final bloc = context.read<DeviceListBloc.DeviceStatusBloc>();
|
||||
final state = bloc.state;
|
||||
// 取出当前选中的标签类型;若 BLoC 还未加载完成则默认 'all'
|
||||
final selectedType = (state is DeviceListState.DeviceStatusLoaded)
|
||||
? state.selectedType
|
||||
: 'all';
|
||||
|
||||
debugPrint(
|
||||
'🔄 [DeviceStatus] 电站切换 → siteId=$newSiteId, 当前标签=$selectedType, 自动刷新',
|
||||
);
|
||||
|
||||
// 1. 主设备列表(all / inverter / combiner_box / module / monitor 共用 DeviceStatusBloc)
|
||||
// 直接派发 Refresh 事件并带上新 siteId
|
||||
bloc.add(DeviceListEvent.DeviceStatusRefresh(siteId: newSiteId));
|
||||
|
||||
// 2. robot / drone_station 由各自 BlocProvider 创建,通过 setState + ValueKey 重建子树
|
||||
// 使其用新 siteId 重新加载数据
|
||||
if (selectedType == 'robot' ||
|
||||
selectedType == 'drone_station' ||
|
||||
selectedType == 'all') {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 机器人设备名称前缀,用于从通用设备列表中过滤掉机器人
|
||||
static const _robotPrefixes = [
|
||||
'RCHETD-CN',
|
||||
@@ -109,31 +178,63 @@ class DeviceStatusView extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).translate('device_list_v2.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
const Flexible(child: SiteSelectorWidget(compact: true)),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 1, height: 20, color: const Color(0xFFE5E6EB)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.title'),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'TCP',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
),
|
||||
child: TcpStatusIndicator(
|
||||
size: 12,
|
||||
onTap: () => _showDeviceStatusModal(context),
|
||||
// 🔥 注释掉原有的 TCP 指示灯
|
||||
// const Text(
|
||||
// 'TCP',
|
||||
// style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
// ),
|
||||
// const SizedBox(width: 4),
|
||||
// Container(
|
||||
// padding: const EdgeInsets.all(4),
|
||||
// decoration: BoxDecoration(
|
||||
// shape: BoxShape.circle,
|
||||
// color: Colors.blue.withOpacity(0.1),
|
||||
// ),
|
||||
// child: TcpStatusIndicator(
|
||||
// size: 12,
|
||||
// onTap: () => _showDeviceStatusModal(context),
|
||||
// ),
|
||||
// ),
|
||||
GestureDetector(
|
||||
onTap: () => _showAddMenu(context),
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: const Color(0xFFE5E6EB),
|
||||
width: 1,
|
||||
),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Color(0xFF4E5969),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -146,56 +247,107 @@ class DeviceStatusView extends StatelessWidget {
|
||||
Widget _buildSearchBar(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8, 16.0, 8),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.search_hint'),
|
||||
hintStyle: const TextStyle(color: Color(0xFF86909C), fontSize: 14),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF86909C),
|
||||
size: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.search_hint'),
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFF86909C),
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF86909C),
|
||||
size: 24,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusSearch(
|
||||
_searchController.text,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF2F3F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFE5E6EB),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFE5E6EB),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
onSubmitted: (value) {
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusSearch(value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
suffixIcon: TextButton(
|
||||
onPressed: () {
|
||||
// TODO: 触发搜索
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (context) => const BluetoothScanModal(),
|
||||
).whenComplete(() {
|
||||
// 弹窗关闭时立即停止扫描(无论以什么方式关闭)
|
||||
BleManager.instance.stopScan();
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('device_list_v2.search'),
|
||||
style: const TextStyle(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFF165DFF).withOpacity(0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth,
|
||||
color: Color(0xFF165DFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF2F3F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E6EB), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.0),
|
||||
borderSide: const BorderSide(color: Color(0xFF165DFF), width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
onSubmitted: (value) {
|
||||
// 🔥 点击键盘确定键时触发搜索
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusSearch(value),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -290,14 +442,20 @@ class DeviceStatusView extends StatelessWidget {
|
||||
BuildContext context,
|
||||
DeviceListState.DeviceStatusState state,
|
||||
) {
|
||||
// 🔥 用当前 siteId 作为 Key,电站切换时强制重建子树(重建对应 BlocProvider)
|
||||
final siteId = _currentSiteId;
|
||||
|
||||
if (state is DeviceListState.DeviceStatusLoaded &&
|
||||
state.selectedType == 'robot') {
|
||||
return const RobotListPage();
|
||||
return RobotListPage(key: ValueKey('robot_$siteId'));
|
||||
}
|
||||
|
||||
if (state is DeviceListState.DeviceStatusLoaded &&
|
||||
state.selectedType == 'drone_station') {
|
||||
return _buildDroneStationList(context);
|
||||
return _buildDroneStationList(
|
||||
context,
|
||||
key: ValueKey('drone_station_$siteId'),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildDeviceList(context, state);
|
||||
@@ -398,6 +556,8 @@ class DeviceStatusView extends StatelessWidget {
|
||||
debugPrint('🔍 [EmbeddedRobotList] 开始加载机器人数据, siteId: $siteId');
|
||||
|
||||
return BlocProvider(
|
||||
// 🔥 电站切换时通过 key 变化强制重建 BlocProvider,重新用新 siteId 加载
|
||||
key: ValueKey('embedded_robot_$siteId'),
|
||||
create: (_) =>
|
||||
sl<RobotListBloc>()..add(RobotListLoadData(siteId: siteId)),
|
||||
child: BlocConsumer<RobotListBloc, RobotListState>(
|
||||
@@ -441,16 +601,49 @@ class DeviceStatusView extends StatelessWidget {
|
||||
return RobotItemCard(
|
||||
name: robot.name,
|
||||
id: robot.id,
|
||||
alias: robot.alias,
|
||||
type: robot.type,
|
||||
status: robot.status,
|
||||
battery: robot.battery,
|
||||
task: robot.task,
|
||||
onTap: () {
|
||||
onTap: () async {
|
||||
debugPrint(
|
||||
'🔴🔴🔴 [全部-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}',
|
||||
);
|
||||
|
||||
// 1. 将当前机器人设置为全局待控制设备
|
||||
final device = DeviceEntity(
|
||||
deviceName: robot.name,
|
||||
productId: -1,
|
||||
productName: robot.type,
|
||||
tenantId: 0,
|
||||
tenantName: '',
|
||||
status: robot.status == '在线' ? 1 : 0,
|
||||
onlineStatus: robot.status == '在线' ? 1 : 0,
|
||||
);
|
||||
|
||||
final remoteCubit = GetIt.I<RemoteControlCubit>();
|
||||
remoteCubit.setTargetDevice(device);
|
||||
debugPrint(
|
||||
'✅ [全部-EmbeddedRobotList] setTargetDevice 已调用',
|
||||
);
|
||||
|
||||
// 2. 跳转到机器人控制页面
|
||||
final robotMap = {
|
||||
'name': robot.name,
|
||||
'id': robot.id,
|
||||
'alias': robot.alias,
|
||||
'type': robot.type,
|
||||
'status': robot.status,
|
||||
'battery': robot.battery,
|
||||
'task': robot.task,
|
||||
};
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
RobotControlPage(robot: robot.toJson()),
|
||||
RobotControlPage(robot: robotMap),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -470,7 +663,11 @@ class DeviceStatusView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDroneStationList(BuildContext context, {bool embedded = false}) {
|
||||
Widget _buildDroneStationList(
|
||||
BuildContext context, {
|
||||
bool embedded = false,
|
||||
Key? key,
|
||||
}) {
|
||||
// 从全局 SiteCubit 获取选中的场站 ID
|
||||
final selectedSite = sl<SiteCubit>().state.selectedSite;
|
||||
|
||||
@@ -493,6 +690,7 @@ class DeviceStatusView extends StatelessWidget {
|
||||
}
|
||||
|
||||
return BlocProvider(
|
||||
key: key,
|
||||
create: (_) =>
|
||||
sl<DroneStationBloc>()..add(DroneStationLoadData(selectedSite.id)),
|
||||
child: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
@@ -692,6 +890,96 @@ class DeviceStatusView extends StatelessWidget {
|
||||
}
|
||||
|
||||
// 显示设备状态模态框 - 从底部滑出
|
||||
/// 显示添加菜单(扫一扫、添加设备)
|
||||
void _showAddMenu(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black38,
|
||||
builder: (ctx) => Stack(
|
||||
children: [
|
||||
// 点击遮罩关闭
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(ctx),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 50,
|
||||
right: 16,
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black26,
|
||||
child: SizedBox(
|
||||
width: 150,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildMenuOption(
|
||||
ctx,
|
||||
icon: Icons.qr_code_scanner,
|
||||
label: '扫一扫',
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const QrScannerPage(),
|
||||
),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BindDevicePage(scanResult: result),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 1, color: Color(0xFFF2F3F5)),
|
||||
_buildMenuOption(
|
||||
ctx,
|
||||
icon: Icons.add_circle_outline,
|
||||
label: '添加设备',
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
// TODO: 添加设备功能
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuOption(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFF4E5969), size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF1D2129)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeviceStatusModal(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -794,9 +1082,11 @@ class DeviceStatusView extends StatelessWidget {
|
||||
if (isRobot) {
|
||||
// 跳转到机器人控制页面
|
||||
debugPrint('🚀 [DeviceStatusPage] 跳转到机器人控制页面');
|
||||
final deviceAlias = device.deviceAlias ?? '';
|
||||
final robotMap = {
|
||||
'name': deviceName,
|
||||
'id': deviceId,
|
||||
'alias': deviceAlias,
|
||||
'type': deviceType,
|
||||
'status': device.status ?? '在线',
|
||||
'battery': 100.0, // DeviceEntity 没有 battery 字段,使用默认值
|
||||
|
||||
@@ -30,7 +30,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
FlightTaskDetailEntity? _detailTask; // 详情数据
|
||||
bool _isLoading = false;
|
||||
final Dio _dio = Dio();
|
||||
|
||||
|
||||
// 🔥 任务状态管理
|
||||
bool _isPaused = false; // 是否已暂停(用于切换暂停/恢复按钮)
|
||||
bool _isReturning = false; // 是否正在返航中
|
||||
@@ -220,9 +220,7 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
print('🔍 [DroneMissionControl] 开始暂停任务, deviceSn: ${_detailTask!.sn}');
|
||||
|
||||
final useCase = GetIt.I<PauseFlightTaskUseCase>();
|
||||
final result = await useCase.execute(
|
||||
deviceSn: _detailTask!.sn,
|
||||
);
|
||||
final result = await useCase.execute(deviceSn: _detailTask!.sn);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
@@ -737,7 +735,9 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
child: ElevatedButton(
|
||||
onPressed: _isPaused ? _resumeTask : _pauseTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _isPaused ? const Color(0xFF165DFF) : const Color(0xFFFF7D00),
|
||||
backgroundColor: _isPaused
|
||||
? const Color(0xFF165DFF)
|
||||
: const Color(0xFFFF7D00),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -747,7 +747,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
child: Text(
|
||||
_isPaused ? '恢复任务' : '暂停任务',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -756,8 +759,14 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
child: OutlinedButton(
|
||||
onPressed: _isReturning ? null : _returnHome,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _isReturning ? const Color(0xFF86909C) : const Color(0xFF4E5969),
|
||||
side: BorderSide(color: _isReturning ? const Color(0xFFE5E6EB) : const Color(0xFFC9CDD4)),
|
||||
foregroundColor: _isReturning
|
||||
? const Color(0xFF86909C)
|
||||
: const Color(0xFF4E5969),
|
||||
side: BorderSide(
|
||||
color: _isReturning
|
||||
? const Color(0xFFE5E6EB)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -765,7 +774,10 @@ class _DroneMissionControlPageState extends State<DroneMissionControlPage> {
|
||||
),
|
||||
child: Text(
|
||||
_isReturning ? '已在返航' : '返航降落',
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/managers/drone_task_state_manager.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
import '../widgets/drone_station_osd_card.dart'; // 🔥 添加机场 OSD 卡片
|
||||
import '../widgets/drone_osd_card.dart'; // 🔥 添加无人机 OSD 卡片
|
||||
import '../widgets/drone_station_osd_card.dart';
|
||||
import '../widgets/drone_osd_card.dart';
|
||||
import 'drone_video_control_page.dart';
|
||||
import 'drone_mission_control_page.dart';
|
||||
import 'drone_monitor_page.dart';
|
||||
@@ -26,16 +28,17 @@ class DroneStationDetailPage extends StatefulWidget {
|
||||
class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
late DroneStationBloc _bloc;
|
||||
|
||||
// 无人机详情数据
|
||||
UAVDetailEntity? _detail;
|
||||
String? _droneSn;
|
||||
|
||||
// 无人机状态轮询计时器
|
||||
Timer? _droneStatusPollingTimer;
|
||||
|
||||
// 🔥 标记是否已经初始化过(用于判断是否从其他页面返回)
|
||||
bool _hasInitialized = false;
|
||||
|
||||
late DroneOsdDataSource _osdDataSource;
|
||||
StreamSubscription<DroneOsdEntity>? _stationOsdSubscription;
|
||||
final ValueNotifier<Map<String, dynamic>> _stationHostData =
|
||||
ValueNotifier<Map<String, dynamic>>({});
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -46,20 +49,78 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
// 🔥 标记已初始化
|
||||
_hasInitialized = true;
|
||||
|
||||
// 启动无人机状态轮询(每5秒刷新一次)
|
||||
// 🔥 已禁用自动轮询,改为手动下拉刷新
|
||||
// _startDroneStatusPolling();
|
||||
_osdDataSource = sl<DroneOsdDataSource>();
|
||||
_startStationOsdListening();
|
||||
|
||||
_hasInitialized = true;
|
||||
}
|
||||
|
||||
void _startStationOsdListening() {
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: '',
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
|
||||
_stationOsdSubscription = _osdDataSource.stationOsdStream.listen((osd) {
|
||||
if (!mounted) return;
|
||||
_parseAndUpdateHostData(osd);
|
||||
});
|
||||
}
|
||||
|
||||
void _parseAndUpdateHostData(DroneOsdEntity osd) {
|
||||
final data = osd.rawData;
|
||||
final hostData = data['data'] is Map ? (data['data'] as Map)['host'] : null;
|
||||
if (hostData == null || hostData is! Map) return;
|
||||
|
||||
final Map<String, dynamic> parsed = {};
|
||||
|
||||
parsed['environment_temperature'] =
|
||||
(hostData['environment_temperature'] as num?)?.toDouble();
|
||||
parsed['humidity'] = (hostData['humidity'] as num?)?.toDouble();
|
||||
parsed['wind_speed'] = (hostData['wind_speed'] as num?)?.toDouble();
|
||||
parsed['rainfall'] = hostData['rainfall']?.toString();
|
||||
parsed['cover_state'] = hostData['cover_state']?.toString();
|
||||
parsed['drone_in_dock'] = hostData['drone_in_dock']?.toString();
|
||||
parsed['temperature'] = (hostData['temperature'] as num?)?.toDouble();
|
||||
parsed['putter_state'] = hostData['putter_state']?.toString();
|
||||
parsed['supplement_light_state'] = hostData['supplement_light_state']
|
||||
?.toString();
|
||||
parsed['alarm_state'] = hostData['alarm_state']?.toString();
|
||||
parsed['emergency_stop_state'] = hostData['emergency_stop_state']
|
||||
?.toString();
|
||||
parsed['silent_mode'] = hostData['silent_mode']?.toString();
|
||||
parsed['mode_code'] = hostData['mode_code']?.toString();
|
||||
parsed['heading'] = (hostData['heading'] as num?)?.toDouble();
|
||||
parsed['height'] = (hostData['height'] as num?)?.toDouble();
|
||||
parsed['latitude'] = (hostData['latitude'] as num?)?.toDouble();
|
||||
parsed['longitude'] = (hostData['longitude'] as num?)?.toDouble();
|
||||
parsed['home_position_is_valid'] = hostData['home_position_is_valid']
|
||||
?.toString();
|
||||
parsed['battery_store_mode'] = hostData['battery_store_mode']?.toString();
|
||||
parsed['first_power_on'] = hostData['first_power_on']?.toString();
|
||||
parsed['drone_charge_state'] = hostData['drone_charge_state'];
|
||||
parsed['air_conditioner'] = hostData['air_conditioner'];
|
||||
parsed['network_state'] = hostData['network_state'];
|
||||
parsed['position_state'] = hostData['position_state'];
|
||||
parsed['storage'] = hostData['storage'];
|
||||
parsed['sub_device'] = hostData['sub_device'];
|
||||
parsed['alternate_land_point'] = hostData['alternate_land_point'];
|
||||
|
||||
if (hostData['air_conditioner'] is Map) {
|
||||
final ac = hostData['air_conditioner'] as Map;
|
||||
parsed['air_conditioner_state'] = ac['air_conditioner_state']?.toString();
|
||||
parsed['air_conditioner_switch_time'] = ac['switch_time']?.toString();
|
||||
}
|
||||
|
||||
_stationHostData.value = Map<String, dynamic>.from(parsed);
|
||||
}
|
||||
|
||||
/// 🔥 页面重新激活时调用(从其他页面返回时)
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
|
||||
// 🔥 只有在已经初始化后才执行刷新(避免首次加载时重复刷新)
|
||||
if (_hasInitialized && _bloc.state is UAVDetailLoaded) {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 从其他页面返回,刷新数据');
|
||||
@@ -75,7 +136,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
/// 🔥 刷新数据(无人机详情 + OSD数据会自动通过MQTT更新)
|
||||
void _refreshData() {
|
||||
if (!mounted) return;
|
||||
|
||||
|
||||
debugPrint('📡 [DroneStationDetailPage] 刷新无人机详情数据');
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
@@ -87,9 +148,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stationOsdSubscription?.cancel();
|
||||
_osdDataSource.stopListening();
|
||||
_stationHostData.dispose();
|
||||
_bloc.close();
|
||||
// 🔥 已禁用自动轮询,无需停止
|
||||
// _droneStatusPollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -216,27 +278,41 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
}
|
||||
|
||||
Widget _buildContent(UAVDetailEntity detail) {
|
||||
_detail = detail; // 保存详情数据供其他方法使用
|
||||
_droneSn = detail.deviceSn; // 保存无人机序列号
|
||||
_detail = detail;
|
||||
_droneSn = detail.deviceSn;
|
||||
|
||||
if (_droneSn != null && _droneSn!.isNotEmpty) {
|
||||
_osdDataSource.startListening(
|
||||
deviceSn: _droneSn!,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
color: const Color(0xFF165DFF),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAirportStatusCard(detail),
|
||||
ValueListenableBuilder<Map<String, dynamic>>(
|
||||
valueListenable: _stationHostData,
|
||||
builder: (context, hostData, _) {
|
||||
return _buildAirportStatusCard(detail, hostData);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 添加机场 OSD 实时数据卡片
|
||||
DroneStationOsdCard(
|
||||
stationOsdStream: _osdDataSource.stationOsdStream,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isOnline: detail.isOnline,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 添加无人机 OSD 实时数据卡片(无人机在线时显示)
|
||||
DroneOsdCard(
|
||||
droneOsdStream: _osdDataSource.droneOsdStream,
|
||||
deviceSn: widget.station.deviceSn,
|
||||
gatewaySn: widget.station.gatewaySn,
|
||||
isDroneOnline: detail.isDroneOnline,
|
||||
onRefresh: _refreshData,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildMonitorCard(),
|
||||
@@ -253,10 +329,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
/// 处理下拉刷新
|
||||
Future<void> _handleRefresh() async {
|
||||
debugPrint('🔄 [DroneStationDetailPage] 开始下拉刷新');
|
||||
|
||||
|
||||
// 🔥 创建一个 Completer 来等待 Bloc 状态更新
|
||||
final completer = Completer<void>();
|
||||
|
||||
|
||||
// 监听 Bloc 状态变化
|
||||
final subscription = _bloc.stream.listen((state) {
|
||||
if (state is UAVDetailLoaded || state is UAVDetailError) {
|
||||
@@ -265,7 +341,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 重新加载无人机详情
|
||||
_bloc.add(
|
||||
UAVDetailLoad(
|
||||
@@ -273,7 +349,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
deviceSn: widget.station.deviceSn,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
// 🔥 等待数据加载完成(最多等待5秒)
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
@@ -281,10 +357,10 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
debugPrint('⚠️ [DroneStationDetailPage] 下拉刷新超时');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// 取消订阅
|
||||
subscription.cancel();
|
||||
|
||||
|
||||
debugPrint('✅ [DroneStationDetailPage] 下拉刷新完成');
|
||||
}
|
||||
|
||||
@@ -343,7 +419,90 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAirportStatusCard(UAVDetailEntity detail) {
|
||||
Widget _buildAirportStatusCard(
|
||||
UAVDetailEntity detail,
|
||||
Map<String, dynamic> hostData,
|
||||
) {
|
||||
final envTemp = hostData['environment_temperature'];
|
||||
final humidity = hostData['humidity'];
|
||||
final windSpeed = hostData['wind_speed'];
|
||||
final rainfall = hostData['rainfall'];
|
||||
final coverState = hostData['cover_state'];
|
||||
final droneInDock = hostData['drone_in_dock'];
|
||||
final acState = hostData['air_conditioner_state'];
|
||||
final alarmState = hostData['alarm_state'];
|
||||
|
||||
String envTempStr = '未知';
|
||||
if (envTemp != null) {
|
||||
envTempStr = '${envTemp.toStringAsFixed(1)}°C';
|
||||
} else if (detail.environmentTemperature != null) {
|
||||
envTempStr = '${detail.environmentTemperature}°C';
|
||||
}
|
||||
|
||||
String humidityStr = '未知';
|
||||
if (humidity != null) {
|
||||
humidityStr = '${humidity.toStringAsFixed(0)}%';
|
||||
}
|
||||
|
||||
String windStr = '未知';
|
||||
if (windSpeed != null) {
|
||||
windStr = '${windSpeed.toStringAsFixed(1)} m/s';
|
||||
} else if (detail.windSpeed != null) {
|
||||
windStr = '${detail.windSpeed} m/s';
|
||||
}
|
||||
|
||||
String rainfallStr = '未知';
|
||||
if (rainfall != null) {
|
||||
rainfallStr = _formatRainfall(rainfall);
|
||||
} else if (detail.rainfall != null) {
|
||||
rainfallStr = _formatRainfall(detail.rainfall);
|
||||
}
|
||||
|
||||
String coverStr = '未知';
|
||||
Color coverColor = const Color(0xFF86909C);
|
||||
if (coverState != null) {
|
||||
final state = int.tryParse(coverState) ?? -1;
|
||||
if (state == 1) {
|
||||
coverStr = '开启';
|
||||
coverColor = const Color(0xFFFF7D00);
|
||||
} else if (state == 0) {
|
||||
coverStr = '关闭';
|
||||
coverColor = const Color(0xFF00B42A);
|
||||
}
|
||||
}
|
||||
|
||||
String droneDockStr = '未知';
|
||||
Color droneDockColor = const Color(0xFF86909C);
|
||||
if (droneInDock != null) {
|
||||
final state = int.tryParse(droneInDock) ?? -1;
|
||||
if (state == 1) {
|
||||
droneDockStr = '在库内';
|
||||
droneDockColor = const Color(0xFF00B42A);
|
||||
} else if (state == 0) {
|
||||
droneDockStr = '出库';
|
||||
droneDockColor = const Color(0xFFFF7D00);
|
||||
}
|
||||
}
|
||||
|
||||
String acStr = '未知';
|
||||
Color acColor = const Color(0xFF86909C);
|
||||
if (acState != null) {
|
||||
final state = int.tryParse(acState) ?? -1;
|
||||
if (state == 1) {
|
||||
acStr = '开启';
|
||||
acColor = const Color(0xFF00B42A);
|
||||
} else if (state == 0) {
|
||||
acStr = '关闭';
|
||||
acColor = const Color(0xFF86909C);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasAlarm = false;
|
||||
if (alarmState != null) {
|
||||
final state = int.tryParse(alarmState) ?? 0;
|
||||
hasAlarm = state != 0;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -371,6 +530,37 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (hasAlarm)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF53F3F).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
size: 14,
|
||||
color: Color(0xFFF53F3F),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'告警',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFF53F3F),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -392,9 +582,7 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, color: Color(0xFFF2F3F5)),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow(
|
||||
'机场名称',
|
||||
detail.callsign.isNotEmpty ? detail.callsign : '未知',
|
||||
@@ -413,17 +601,13 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
? '${detail.capacityPercent}%'
|
||||
: '未知',
|
||||
),
|
||||
_buildInfoRow(
|
||||
'环境温度',
|
||||
detail.environmentTemperature != null
|
||||
? '${detail.environmentTemperature}°C'
|
||||
: '未知',
|
||||
),
|
||||
_buildInfoRow(
|
||||
'风速',
|
||||
detail.windSpeed != null ? '${detail.windSpeed} m/s' : '未知',
|
||||
),
|
||||
_buildInfoRow('降雨量', _formatRainfall(detail.rainfall)),
|
||||
_buildInfoRowColor('环境温度', envTempStr, const Color(0xFF165DFF)),
|
||||
_buildInfoRowColor('湿度', humidityStr, const Color(0xFF00B42A)),
|
||||
_buildInfoRowColor('风速', windStr, const Color(0xFF722ED1)),
|
||||
_buildInfoRowColor('降雨量', rainfallStr, const Color(0xFF00B42A)),
|
||||
_buildInfoRowColor('机库舱门', coverStr, coverColor),
|
||||
_buildInfoRowColor('无人机位置', droneDockStr, droneDockColor),
|
||||
_buildInfoRowColor('空调状态', acStr, acColor),
|
||||
_buildInfoRow('网络状态', detail.networkState?.toString() ?? '未知'),
|
||||
_buildPositionStateRow('位置状态', detail.positionState),
|
||||
],
|
||||
@@ -431,6 +615,41 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRowColor(String label, String value, Color valueColor) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
':',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFC0C4CC)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: valueColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPositionStateRow(String label, PositionState? positionState) {
|
||||
String value = '未知';
|
||||
if (positionState != null) {
|
||||
|
||||
@@ -229,6 +229,18 @@ class _DroneStationStatusPageState extends State<DroneStationStatusPage> {
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow('舱门状态', '关闭', status: '正常'),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow(
|
||||
'机场空调',
|
||||
_uavDetail?.airConditionerStatus ?? '未开启',
|
||||
status: (_uavDetail?.airConditionerStatus == '开启') ? '正常' : '关闭',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRow(
|
||||
'机库状态',
|
||||
_uavDetail?.hangarStatus ?? '关闭',
|
||||
status: (_uavDetail?.hangarStatus == '开启') ? '正常' : '关闭',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatusRowWithProgress('充电状态', '充电中', '85%'),
|
||||
const SizedBox(height: 12),
|
||||
_buildWeatherRow(),
|
||||
@@ -319,8 +331,6 @@ class _DroneStationStatusPageState extends State<DroneStationStatusPage> {
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDroneBatteryRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:volc_engine_rtc/volc_engine_rtc.dart' as volc;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/managers/drone_task_state_manager.dart';
|
||||
import '../../../../../core/network/dio_client.dart';
|
||||
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
|
||||
import '../../../../../core/network/mqtt/domain/entities/drone_osd_entity.dart';
|
||||
import '../../domain/entities/uav_video_stream_entity.dart';
|
||||
@@ -47,6 +52,11 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
List<CameraInfo> _effectiveCameraList = []; // 实际使用的摄像头列表
|
||||
List<CameraInfo>? _backupCameraList; // 备选摄像头列表(网关摄像头)
|
||||
|
||||
// 暂停状态
|
||||
bool _isPaused = false;
|
||||
// 任务下发后等待视频流(用于显示“无人机已启动 视频获取中”toast)
|
||||
bool _isWaitingForVideo = false;
|
||||
|
||||
// 火山引擎 RTC
|
||||
volc.RTCEngine? _rtcEngine;
|
||||
volc.RTCRoom? _rtcRoom;
|
||||
@@ -64,6 +74,9 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
List<LatLng> _trajectoryPoints = [];
|
||||
LatLng? _currentPosition;
|
||||
double? _currentHeading;
|
||||
|
||||
/// 从全局管理器恢复的轨迹点(避免退出后丢失)
|
||||
bool _hasRestoredTrajectory = false;
|
||||
StreamSubscription<DroneOsdEntity>? _osdSubscription;
|
||||
DroneOsdDataSource? _droneOsdDataSource;
|
||||
|
||||
@@ -72,6 +85,23 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
|
||||
// 🔥 初始化"等待视频流"状态(任务下发后进入此页面时显示 toast)
|
||||
_isWaitingForVideo = droneTaskStateManager.isWaitingForVideo.value;
|
||||
droneTaskStateManager.isWaitingForVideo.addListener(_onVideoWaitingChanged);
|
||||
|
||||
// 🔥 从全局管理器恢复历史轨迹(退出视频页后重新进入时不丢失)
|
||||
final savedPoints = droneTaskStateManager.trajectoryPoints.value;
|
||||
if (savedPoints.isNotEmpty) {
|
||||
_trajectoryPoints = savedPoints
|
||||
.map((p) => LatLng(p.latitude, p.longitude))
|
||||
.toList();
|
||||
_currentPosition = _trajectoryPoints.last;
|
||||
_hasRestoredTrajectory = true;
|
||||
debugPrint(
|
||||
'🗺️ [DroneVideoControlPage] 恢复历史轨迹: ${_trajectoryPoints.length} 个点',
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 初始化 MQTT OSD 数据源
|
||||
_droneOsdDataSource = sl<DroneOsdDataSource>();
|
||||
_startOsdListening();
|
||||
@@ -123,11 +153,101 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
// 默认加载广角镜头
|
||||
if (_currentCamera != null) {
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
} else {
|
||||
// 摄像头列表为空(任务下发后无人机刚上线,详情接口尚未返回摄像头数据)
|
||||
// 自动获取无人机详情,拿到摄像头列表
|
||||
_fetchDroneDetailAndLoadCamera();
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动获取无人机详情,拿到摄像头列表
|
||||
/// 任务下发后无人机刚上线,传入的 cameraList 可能为空
|
||||
Future<void> _fetchDroneDetailAndLoadCamera() async {
|
||||
debugPrint('🔍 [DroneVideoControlPage] 摄像头列表为空,自动获取无人机详情');
|
||||
try {
|
||||
final response = await Dio().get(
|
||||
HttpApiConsts.getUAVDetail,
|
||||
queryParameters: {
|
||||
'gatewaySn': widget.gatewaySn,
|
||||
'deviceSn': widget.droneSn,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final Map<String, dynamic> jsonData = (response.data is String)
|
||||
? json.decode(response.data)
|
||||
: Map<String, dynamic>.from(response.data);
|
||||
|
||||
if (jsonData['code'] == 0 || jsonData['code'] == 200) {
|
||||
final detailData = jsonData['data'];
|
||||
final Map<String, dynamic> detailMap = (detailData is Map)
|
||||
? Map<String, dynamic>.from(detailData)
|
||||
: <String, dynamic>{};
|
||||
|
||||
final droneDetail = UAVDetailEntity.fromJson(detailMap);
|
||||
final cameras = droneDetail.droneCameraList ?? [];
|
||||
|
||||
debugPrint('✅ [DroneVideoControlPage] 获取到摄像头列表: ${cameras.length}');
|
||||
|
||||
if (cameras.isNotEmpty && mounted) {
|
||||
setState(() {
|
||||
_effectiveCameraList = cameras;
|
||||
_currentCamera = cameras.first;
|
||||
_useBackupSource = false;
|
||||
});
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无人机摄像头仍然为空,尝试网关摄像头
|
||||
if (mounted) {
|
||||
_tryGatewayCameraFallback();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [DroneVideoControlPage] 获取无人机详情失败: $e');
|
||||
if (mounted) {
|
||||
_tryGatewayCameraFallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试使用网关摄像头作为备选
|
||||
void _tryGatewayCameraFallback() {
|
||||
if (widget.gatewayCameraList != null &&
|
||||
widget.gatewayCameraList!.isNotEmpty &&
|
||||
_currentCamera == null) {
|
||||
debugPrint('⚠️ [DroneVideoControlPage] 使用网关摄像头作为备选');
|
||||
setState(() {
|
||||
_effectiveCameraList = widget.gatewayCameraList!;
|
||||
_currentCamera = _effectiveCameraList.first;
|
||||
_useBackupSource = true;
|
||||
});
|
||||
_loadVideoStream(UavLensType.wide);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = '没有可用的摄像头';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onVideoWaitingChanged() {
|
||||
if (!mounted) return;
|
||||
final waiting = droneTaskStateManager.isWaitingForVideo.value;
|
||||
if (waiting != _isWaitingForVideo) {
|
||||
setState(() {
|
||||
_isWaitingForVideo = waiting;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
droneTaskStateManager.isWaitingForVideo.removeListener(
|
||||
_onVideoWaitingChanged,
|
||||
);
|
||||
_osdSubscription?.cancel();
|
||||
_droneOsdDataSource?.dispose();
|
||||
_mapController?.dispose();
|
||||
@@ -253,13 +373,16 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
gatewaySn: widget.gatewaySn,
|
||||
);
|
||||
|
||||
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen((osdData) {
|
||||
if (!mounted) return;
|
||||
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
|
||||
_handleOsdUpdate(osdData);
|
||||
}, onError: (error) {
|
||||
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
|
||||
});
|
||||
_osdSubscription = _droneOsdDataSource!.droneOsdStream.listen(
|
||||
(osdData) {
|
||||
if (!mounted) return;
|
||||
debugPrint('📡 [DroneVideoControlPage] 收到 droneOsdStream 事件');
|
||||
_handleOsdUpdate(osdData);
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('❌ [DroneVideoControlPage] OSD 监听错误: $error');
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('✅ [DroneVideoControlPage] OSD 监听已启动');
|
||||
}
|
||||
@@ -268,103 +391,110 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
void _handleOsdUpdate(DroneOsdEntity osdData) {
|
||||
// 从 rawData 中提取位置信息
|
||||
final rawData = osdData.rawData;
|
||||
|
||||
|
||||
// 🔥 尝试从嵌套结构中获取经纬度
|
||||
double? lat;
|
||||
double? lng;
|
||||
double? heading;
|
||||
|
||||
|
||||
// 路径1: rawData['data']['host']['99-0-0']['measure_target_latitude'] (无人机)
|
||||
if (rawData['data'] is Map &&
|
||||
(rawData['data'] as Map)['host'] is Map) {
|
||||
if (rawData['data'] is Map && (rawData['data'] as Map)['host'] is Map) {
|
||||
final host = (rawData['data'] as Map)['host'] as Map;
|
||||
|
||||
|
||||
// 尝试从 99-0-0 载荷获取(无人机)
|
||||
if (host.containsKey('99-0-0') && host['99-0-0'] is Map) {
|
||||
final payload = host['99-0-0'] as Map;
|
||||
lat = (payload['measure_target_latitude'] as num?)?.toDouble();
|
||||
lng = (payload['measure_target_longitude'] as num?)?.toDouble();
|
||||
debugPrint('✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng');
|
||||
debugPrint(
|
||||
'✅ [DroneVideoControlPage] 从 99-0-0 获取位置: lat=$lat, lng=$lng',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 如果 99-0-0 中没有,尝试从 host 直接获取(机场)
|
||||
if (lat == null || lng == null) {
|
||||
lat = (host['latitude'] as num?)?.toDouble();
|
||||
lng = (host['longitude'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) {
|
||||
debugPrint('✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng');
|
||||
debugPrint(
|
||||
'✅ [DroneVideoControlPage] 从 host 获取位置: lat=$lat, lng=$lng',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取航向角
|
||||
heading = (host['attitude_head'] as num?)?.toDouble() ??
|
||||
(host['heading'] as num?)?.toDouble();
|
||||
heading =
|
||||
(host['attitude_head'] as num?)?.toDouble() ??
|
||||
(host['heading'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
|
||||
// 兼容旧格式:直接从 rawData 获取
|
||||
if (lat == null || lng == null) {
|
||||
lat = lat ?? (rawData['latitude'] as num?)?.toDouble() ??
|
||||
(rawData['lat'] as num?)?.toDouble();
|
||||
lng = lng ?? (rawData['longitude'] as num?)?.toDouble() ??
|
||||
(rawData['lng'] as num?)?.toDouble() ??
|
||||
(rawData['lon'] as num?)?.toDouble();
|
||||
heading = heading ?? (rawData['heading'] as num?)?.toDouble() ??
|
||||
(rawData['attitudeHeading'] as num?)?.toDouble();
|
||||
lat =
|
||||
lat ??
|
||||
(rawData['latitude'] as num?)?.toDouble() ??
|
||||
(rawData['lat'] as num?)?.toDouble();
|
||||
lng =
|
||||
lng ??
|
||||
(rawData['longitude'] as num?)?.toDouble() ??
|
||||
(rawData['lng'] as num?)?.toDouble() ??
|
||||
(rawData['lon'] as num?)?.toDouble();
|
||||
heading =
|
||||
heading ??
|
||||
(rawData['heading'] as num?)?.toDouble() ??
|
||||
(rawData['attitudeHeading'] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
|
||||
debugPrint('🛰️ [DroneVideoControlPage] 收到 OSD 数据');
|
||||
debugPrint(' lat=$lat, lng=$lng, heading=$heading');
|
||||
debugPrint(' 当前轨迹点数: ${_trajectoryPoints.length}');
|
||||
debugPrint(' 当前位置: $_currentPosition');
|
||||
|
||||
|
||||
// 验证位置有效性
|
||||
if (lat != null && lng != null && lat.abs() <= 90 && lng.abs() <= 180) {
|
||||
final newPos = LatLng(lat, lng);
|
||||
|
||||
|
||||
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
|
||||
bool shouldAddPoint = false;
|
||||
if (_trajectoryPoints.isEmpty) {
|
||||
shouldAddPoint = true;
|
||||
} else {
|
||||
final distance = _calculateDistance(
|
||||
_trajectoryPoints.last.latitude,
|
||||
_trajectoryPoints.last.longitude,
|
||||
lat,
|
||||
lng,
|
||||
);
|
||||
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
|
||||
shouldAddPoint = distance > 0.5;
|
||||
}
|
||||
|
||||
if (shouldAddPoint) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
// 性能优化:只保留最近 1000 个点
|
||||
if (_trajectoryPoints.length > 1000) {
|
||||
_trajectoryPoints.removeAt(0);
|
||||
}
|
||||
// 🔥 同步到全局管理器(退出页面后不丢失)
|
||||
droneTaskStateManager.addTrajectoryPoint(
|
||||
DroneTrajectoryPoint(latitude: lat, longitude: lng, heading: heading),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
// 更新当前位置(驱动飞机 Marker)
|
||||
_currentPosition = newPos;
|
||||
_currentHeading = heading;
|
||||
|
||||
// 轨迹"拉烟"逻辑:距离过滤(防止 GPS 抖动导致轨迹像乱麻)
|
||||
if (_trajectoryPoints.isEmpty) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
debugPrint('✅ [DroneVideoControlPage] 添加第一个轨迹点: $newPos');
|
||||
|
||||
// 🔥 重要:第一个点添加后,等待 UI 构建完成再移动地图
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapController != null && mounted) {
|
||||
_mapController!.move(newPos, 18); // 缩放到 18 级
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 首次定位到: $newPos');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
final distance = _calculateDistance(
|
||||
_trajectoryPoints.last.latitude,
|
||||
_trajectoryPoints.last.longitude,
|
||||
lat!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
|
||||
lng!, // ✅ 空安全:已经通过 if 检查,使用 ! 断言
|
||||
);
|
||||
debugPrint(' 📏 距离上一个点: ${distance.toStringAsFixed(2)} 米');
|
||||
// 只有移动超过 0.5 米才画线,否则认为是原地漂移
|
||||
if (distance > 0.5) {
|
||||
_trajectoryPoints.add(newPos);
|
||||
debugPrint('✅ [DroneVideoControlPage] 添加新轨迹点,当前总数: ${_trajectoryPoints.length}');
|
||||
// 性能优化:只保留最近 1000 个点
|
||||
if (_trajectoryPoints.length > 1000) {
|
||||
_trajectoryPoints.removeAt(0);
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [DroneVideoControlPage] 距离不足0.5米(${distance.toStringAsFixed(2)}m),跳过此点');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 地图跟随:后续点也移动地图(保持飞机在视野中)
|
||||
|
||||
// 地图跟随:保持飞机在视野中
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_mapController != null && mounted) {
|
||||
_mapController!.move(newPos, _mapController!.camera.zoom);
|
||||
debugPrint('🗺️ [DroneVideoControlPage] 地图已移动到: $newPos');
|
||||
if (_trajectoryPoints.length == 1) {
|
||||
_mapController!.move(newPos, 18); // 首次定位缩放到 18 级
|
||||
} else {
|
||||
_mapController!.move(newPos, _mapController!.camera.zoom);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -373,12 +503,20 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
}
|
||||
|
||||
/// 🔥 计算两点之间的距离(米)
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
double _calculateDistance(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const p = 0.017453292519943295; // Math.PI / 180
|
||||
final a = 0.5 -
|
||||
final a =
|
||||
0.5 -
|
||||
cos((lat2 - lat1) * p) / 2 +
|
||||
cos(lat1 * p) * cos(lat2 * p) * (1 - cos((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * asin(sqrt(a)) * 1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
|
||||
return 12742 *
|
||||
asin(sqrt(a)) *
|
||||
1000; // 2 * R * asin(sqrt(a)) * 1000 (R = 6371km)
|
||||
}
|
||||
|
||||
// 初始化火山引擎事件处理器
|
||||
@@ -416,6 +554,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
_isLoading = false;
|
||||
});
|
||||
// 收到视频流,隐藏“无人机已启动 视频获取中”toast
|
||||
droneTaskStateManager.markVideoReceived();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -664,58 +804,111 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
});
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) {
|
||||
if (state is UavVideoStreamLoaded) {
|
||||
setState(() {
|
||||
_videoStream = state.videoStream;
|
||||
});
|
||||
debugPrint('=== 视频流加载成功 ===');
|
||||
debugPrint('URL Type: ${state.videoStream.urlType}');
|
||||
|
||||
if (state.videoStream.urlType == 'volc') {
|
||||
_destroyRtcEngine();
|
||||
_initRtcEngine(state.videoStream);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = '不支持的 URL 类型: ${state.videoStream.urlType}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = '暂无视频';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildVideoPlayer(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
_buildTrajectoryMap(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlightData(),
|
||||
const SizedBox(height: 12),
|
||||
_buildAIResults(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 摇杆控制(单独一行)
|
||||
_buildJoystickControl(),
|
||||
const SizedBox(height: 16),
|
||||
_buildBottomToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (state.videoStream.urlType == 'volc') {
|
||||
_destroyRtcEngine();
|
||||
_initRtcEngine(state.videoStream);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage =
|
||||
'不支持的 URL 类型: ${state.videoStream.urlType}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else if (state is UavVideoStreamError) {
|
||||
setState(() {
|
||||
_errorMessage = '暂无视频';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildVideoPlayer(),
|
||||
const SizedBox(height: 12),
|
||||
// 🔥 实时轨迹地图(放在视频和飞行数据之间)
|
||||
_buildTrajectoryMap(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlightData(),
|
||||
const SizedBox(height: 12),
|
||||
_buildAIResults(),
|
||||
const SizedBox(height: 12),
|
||||
// 抓拍/录像/变焦/补光灯(挪到滚动区内)
|
||||
_buildBottomToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 🔥 固定底部栏:方向控制 + 暂停 + 返航
|
||||
_buildFixedBottomBar(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_isWaitingForVideo)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(child: _buildVideoWaitingToast()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 任务下发后等待视频流时的居中提示
|
||||
Widget _buildVideoWaitingToast() {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE61D2129),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x29000000),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'无人机已启动 视频获取中',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1073,7 +1266,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
debugPrint(' currentPosition: $_currentPosition');
|
||||
debugPrint(' currentHeading: $_currentHeading');
|
||||
debugPrint(' trajectoryPoints.length: ${_trajectoryPoints.length}');
|
||||
|
||||
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
@@ -1087,9 +1280,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
// 🔥 地图
|
||||
FlutterMap(
|
||||
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
|
||||
mapController: _mapController ??= MapController(), // ✅ 懒加载初始化
|
||||
options: MapOptions(
|
||||
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialCenter:
|
||||
_currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
@@ -1098,7 +1292,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
userAgentPackageName: 'com.example.app',
|
||||
),
|
||||
@@ -1178,7 +1373,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -1206,81 +1404,138 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 摇杆控制(单独一行)
|
||||
Widget _buildJoystickControl() {
|
||||
/// 🔥 固定底部栏:暂停 + 返航(不随页面滚动)
|
||||
Widget _buildFixedBottomBar() {
|
||||
return Container(
|
||||
height: 200,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC9CDD4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
// 暂停/恢复按钮
|
||||
_buildActionBtn(
|
||||
label: _isPaused ? '恢复' : '暂停',
|
||||
icon: _isPaused ? Icons.play_arrow : Icons.pause,
|
||||
color: _isPaused
|
||||
? const Color(0xFF00B42A)
|
||||
: const Color(0xFFFF7D00),
|
||||
onTap: () {
|
||||
if (_isPaused) {
|
||||
_sendFlightCommand('flighttask_recovery');
|
||||
} else {
|
||||
_sendFlightCommand('flighttask_pause');
|
||||
}
|
||||
setState(() => _isPaused = !_isPaused);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_left,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_right,
|
||||
size: 32,
|
||||
color: Color(0xFF4E5969),
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
// 返航按钮
|
||||
_buildActionBtn(
|
||||
label: '返航',
|
||||
icon: Icons.flight_land,
|
||||
color: const Color(0xFF165DFF),
|
||||
onTap: () {
|
||||
_sendFlightCommand('return_home');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 发送飞行指令
|
||||
Future<void> _sendFlightCommand(String command) async {
|
||||
try {
|
||||
final dio = DioClient.create();
|
||||
final response = await dio.post(
|
||||
'http://1.95.137.212:8081/iot/UAV/flightTaskCommand',
|
||||
data: {'command': command, 'deviceSn': widget.droneSn},
|
||||
);
|
||||
|
||||
debugPrint('✅ 飞行指令发送成功: $command, deviceSn: ${widget.droneSn}');
|
||||
debugPrint('响应: ${response.data}');
|
||||
|
||||
String message;
|
||||
switch (command) {
|
||||
case 'flighttask_pause':
|
||||
message = '已发送暂停指令';
|
||||
break;
|
||||
case 'flighttask_recovery':
|
||||
message = '已发送恢复指令';
|
||||
break;
|
||||
case 'return_home':
|
||||
message = '命令下达成功';
|
||||
break;
|
||||
default:
|
||||
message = '指令发送成功';
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ 飞行指令发送失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('指令发送失败'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActionBtn({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapAndJoystick() {
|
||||
return Row(
|
||||
children: [
|
||||
@@ -1300,7 +1555,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialCenter:
|
||||
_currentPosition ?? const LatLng(39.9042, 116.4074),
|
||||
initialZoom: 18,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
@@ -1309,7 +1565,8 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
children: [
|
||||
// 高德地图瓦片(最底层)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
urlTemplate:
|
||||
'https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}',
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
userAgentPackageName: 'com.example.app',
|
||||
),
|
||||
@@ -1357,7 +1614,10 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -1389,14 +1649,20 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'轨迹点: ${_trajectoryPoints.length}',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
class QrScannerPage extends StatefulWidget {
|
||||
const QrScannerPage({super.key});
|
||||
|
||||
@override
|
||||
State<QrScannerPage> createState() => _QrScannerPageState();
|
||||
}
|
||||
|
||||
class _QrScannerPageState extends State<QrScannerPage> {
|
||||
final MobileScannerController _controller = MobileScannerController();
|
||||
bool _isScanned = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (_isScanned) return;
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
if (barcodes.isNotEmpty) {
|
||||
final String? code = barcodes.first.rawValue;
|
||||
if (code != null && code.isNotEmpty) {
|
||||
_isScanned = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context, code);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
MobileScanner(controller: _controller, onDetect: _onDetect),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'扫一扫',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 250,
|
||||
height: 250,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
right: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
left: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
right: BorderSide(
|
||||
color: Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'将二维码放入框内,即可自动扫描',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
icon: Icons.flash_on,
|
||||
label: '手电筒',
|
||||
onTap: () async {
|
||||
await _controller.toggleTorch();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../../../../core/bluetooth/ble_manager.dart';
|
||||
import '../pages/ble_device_detail_page.dart';
|
||||
|
||||
class BluetoothScanModal extends StatefulWidget {
|
||||
const BluetoothScanModal({super.key});
|
||||
|
||||
@override
|
||||
State<BluetoothScanModal> createState() => _BluetoothScanModalState();
|
||||
}
|
||||
|
||||
class _BluetoothScanModalState extends State<BluetoothScanModal> {
|
||||
BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
|
||||
List<ScanResult> _scanResults = [];
|
||||
bool _isConnecting = false;
|
||||
BluetoothDevice? _connectingDevice;
|
||||
BluetoothDevice? _connectedDevice;
|
||||
int _connectingCountdown = 15;
|
||||
Timer? _connectingTimer;
|
||||
bool _isStartingScan = false;
|
||||
|
||||
StreamSubscription<BluetoothAdapterState>? _stateSubscription;
|
||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||
StreamSubscription<BluetoothDevice?>? _connectionSubscription;
|
||||
StreamSubscription<BluetoothDevice?>? _connectingSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initBluetooth();
|
||||
}
|
||||
|
||||
Future<void> _initBluetooth() async {
|
||||
// 先设置扫描结果监听,确保 startScan 前订阅已就绪
|
||||
_scanSubscription = BleManager.instance.scanResults.listen((results) {
|
||||
if (mounted) {
|
||||
final mgrConnected = BleManager.instance.connectedDevice;
|
||||
setState(() {
|
||||
_connectedDevice = mgrConnected;
|
||||
_scanResults = _sortResults(results);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 先设置连接状态监听
|
||||
_connectionSubscription = BleManager.instance.connectionStream.listen((
|
||||
device,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connectedDevice = device;
|
||||
_isConnecting = false;
|
||||
if (device != null) {
|
||||
_scanResults = _sortResults(_scanResults);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 监听连接中状态(跨页面同步:详情页连接时列表页也能看到"连接中")
|
||||
_connectingSubscription = BleManager.instance.connectingStream.listen((
|
||||
device,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (device != null) {
|
||||
_isConnecting = true;
|
||||
_connectingDevice = device;
|
||||
} else {
|
||||
_isConnecting = false;
|
||||
_connectingDevice = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 初始检查蓝牙状态并启动扫描(只调用一次)
|
||||
final currentState = await BleManager.instance.checkBluetooth();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_adapterState = currentState
|
||||
? BluetoothAdapterState.on
|
||||
: BluetoothAdapterState.off;
|
||||
});
|
||||
if (currentState) {
|
||||
_startScan();
|
||||
}
|
||||
}
|
||||
|
||||
// 监听后续蓝牙开关变化(用户操作)
|
||||
_stateSubscription = BleManager.instance.adapterState.listen((state) {
|
||||
if (mounted) {
|
||||
setState(() => _adapterState = state);
|
||||
if (state == BluetoothAdapterState.on) {
|
||||
_startScan();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
if (_isStartingScan) return;
|
||||
_isStartingScan = true;
|
||||
try {
|
||||
await BleManager.instance.requestPermissions();
|
||||
if (!mounted) return;
|
||||
await BleManager.instance.startScan(continuous: true);
|
||||
} finally {
|
||||
_isStartingScan = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshScan() async {
|
||||
setState(() {
|
||||
_scanResults = [];
|
||||
});
|
||||
await BleManager.instance.refreshScan();
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
Future<void> _openBluetooth() async {
|
||||
await BleManager.instance.openBluetooth();
|
||||
}
|
||||
|
||||
/// 排序:已连接设备排第一,有名称设备优先
|
||||
List<ScanResult> _sortResults(List<ScanResult> results) {
|
||||
final mgrConnected = BleManager.instance.connectedDevice;
|
||||
final sorted = List<ScanResult>.from(results);
|
||||
sorted.sort((a, b) {
|
||||
// 第一级:已连接排最前
|
||||
final aConnected = a.device.remoteId == mgrConnected?.remoteId;
|
||||
final bConnected = b.device.remoteId == mgrConnected?.remoteId;
|
||||
if (aConnected && !bConnected) return -1;
|
||||
if (!aConnected && bConnected) return 1;
|
||||
// 第二级:有名称的排前面
|
||||
final aHasName = _hasDeviceName(a);
|
||||
final bHasName = _hasDeviceName(b);
|
||||
if (aHasName && !bHasName) return -1;
|
||||
if (!aHasName && bHasName) return 1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// 判断设备是否有可读名称(非 MAC 地址)
|
||||
bool _hasDeviceName(ScanResult r) {
|
||||
final advData = r.advertisementData;
|
||||
return advData.advName.isNotEmpty ||
|
||||
r.device.platformName.isNotEmpty ||
|
||||
(advData.localName?.isNotEmpty == true) ||
|
||||
r.device.advName.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 从详情页返回时同步连接状态(包括连接中和已连接)
|
||||
void _syncConnectedDevice() {
|
||||
final mgrConnected = BleManager.instance.connectedDevice;
|
||||
final mgrConnecting = BleManager.instance.connectingDevice;
|
||||
if (mgrConnected?.remoteId != _connectedDevice?.remoteId ||
|
||||
mgrConnecting?.remoteId != _connectingDevice?.remoteId) {
|
||||
setState(() {
|
||||
_connectedDevice = mgrConnected;
|
||||
_connectingDevice = mgrConnecting;
|
||||
_isConnecting = mgrConnecting != null;
|
||||
_scanResults = _sortResults(_scanResults);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectDevice(ScanResult result) async {
|
||||
// 如果已连接其他设备,弹出提示
|
||||
final currentConnected = BleManager.instance.connectedDevice;
|
||||
if (currentConnected != null &&
|
||||
currentConnected.remoteId != result.device.remoteId) {
|
||||
final connName = currentConnected.platformName.isNotEmpty
|
||||
? currentConnected.platformName
|
||||
: '${currentConnected.remoteId}';
|
||||
if (!mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('已有设备连接'),
|
||||
content: Text('当前已连接设备:$connName\n\n请先断开当前设备后再连接新设备。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_connectingDevice = result.device;
|
||||
_connectingCountdown = 15;
|
||||
});
|
||||
|
||||
// 启动倒计时
|
||||
_connectingTimer?.cancel();
|
||||
_connectingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (_connectingCountdown > 0) {
|
||||
_connectingCountdown--;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
final error = await BleManager.instance.connect(result.device);
|
||||
|
||||
// 取消倒计时
|
||||
_connectingTimer?.cancel();
|
||||
_connectingTimer = null;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_connectingDevice = null;
|
||||
if (error == null) {
|
||||
_connectedDevice = result.device;
|
||||
}
|
||||
});
|
||||
|
||||
if (error == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'已连接 ${result.device.platformName.isEmpty ? result.device.remoteId : result.device.platformName}',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (error != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectDevice() async {
|
||||
await BleManager.instance.disconnect();
|
||||
if (mounted) {
|
||||
setState(() => _connectedDevice = null);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isBluetoothOn => _adapterState == BluetoothAdapterState.on;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSubscription?.cancel();
|
||||
_scanSubscription?.cancel();
|
||||
_connectionSubscription?.cancel();
|
||||
_connectingSubscription?.cancel();
|
||||
_connectingTimer?.cancel();
|
||||
unawaited(BleManager.instance.stopScan());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
// 弹窗关闭时立即停止扫描,不等待 dispose() 动画延迟
|
||||
BleManager.instance.stopScan();
|
||||
},
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
_buildStatusCard(),
|
||||
if (_connectedDevice != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildConnectedDeviceCard(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_buildDeviceList(),
|
||||
const SizedBox(height: 12),
|
||||
_buildTipBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1677FF),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'蓝牙设备',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_connectedDevice != null
|
||||
? '当前已连接: ${_connectedDevice!.platformName.isEmpty ? _connectedDevice!.remoteId : _connectedDevice!.platformName}'
|
||||
: _isBluetoothOn
|
||||
? '正在扫描周边设备...'
|
||||
: '请先打开蓝牙',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.white, size: 22),
|
||||
onPressed: _isBluetoothOn ? _refreshScan : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () {
|
||||
BleManager.instance.stopScan();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard() {
|
||||
final isOn = _isBluetoothOn;
|
||||
final hasConnected = _connectedDevice != null;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isOn ? const Color(0xFF165DFF) : const Color(0xFF86909C),
|
||||
),
|
||||
child: const Icon(Icons.bluetooth, color: Colors.white, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOn ? '蓝牙已开启' : '蓝牙未打开',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasConnected
|
||||
? '已连接设备'
|
||||
: isOn
|
||||
? '正在扫描周边设备'
|
||||
: '请打开蓝牙以扫描附近设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
isOn
|
||||
? Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF00B42A),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: _openBluetooth,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF165DFF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'去打开',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceList() {
|
||||
// 过滤掉已连接设备(它已在状态卡片下方单独显示)
|
||||
final otherResults = _connectedDevice != null
|
||||
? _scanResults
|
||||
.where((r) => r.device.remoteId != _connectedDevice!.remoteId)
|
||||
.toList()
|
||||
: _scanResults;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'附近设备',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!_isBluetoothOn)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
size: 48,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'蓝牙未开启,无法扫描设备',
|
||||
style: TextStyle(color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (otherResults.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'正在扫描附近设备...',
|
||||
style: TextStyle(color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (int i = 0; i < otherResults.length; i++) ...[
|
||||
_buildDeviceItem(otherResults[i]),
|
||||
if (i < otherResults.length - 1) const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 已连接设备卡片(始终显示在状态卡片下方,不依赖扫描结果)
|
||||
Widget _buildConnectedDeviceCard() {
|
||||
final device = _connectedDevice!;
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: (device.advName.isNotEmpty ? device.advName : '${device.remoteId}');
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(device: device),
|
||||
),
|
||||
).then((_) => _syncConnectedDevice());
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0FFF4),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF00B42A), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00B42A).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_connected,
|
||||
color: Color(0xFF00B42A),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00B42A),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${device.remoteId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _disconnectDevice,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF00B42A),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'断开',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceItem(ScanResult result) {
|
||||
final device = result.device;
|
||||
final advData = result.advertisementData;
|
||||
// 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的
|
||||
final name = advData.advName.isNotEmpty
|
||||
? advData.advName
|
||||
: (device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: (advData.localName?.isNotEmpty == true
|
||||
? advData.localName!
|
||||
: (device.advName.isNotEmpty
|
||||
? device.advName
|
||||
: '${device.remoteId}')));
|
||||
final isConnected = _connectedDevice?.remoteId == device.remoteId;
|
||||
final isConnecting =
|
||||
_connectingDevice?.remoteId == device.remoteId && _isConnecting;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
BleDeviceDetailPage(device: device, scanResult: result),
|
||||
),
|
||||
).then((_) => _syncConnectedDevice());
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF165DFF).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.devices,
|
||||
color: Color(0xFF165DFF),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${device.remoteId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (isConnected) {
|
||||
_disconnectDevice();
|
||||
} else if (!isConnecting) {
|
||||
_connectDevice(result);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isConnected
|
||||
? const Color(0xFF00B42A)
|
||||
: const Color(0xFFFF7D00),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: isConnecting
|
||||
? Text(
|
||||
'${_connectingCountdown}s',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
isConnected ? '已连接' : '连接',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTipBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Color(0xFFFF7D00), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'请选择现场本机设备,避免连接无关设备',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFFF7D00)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,11 @@ import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_state.dart'; // 🔥 导入 AppUserState
|
||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart';
|
||||
import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart';
|
||||
import '../../../device_run_param/presentation/pages/robot_param_settings_page.dart';
|
||||
|
||||
/// 机器人顶部信息卡片
|
||||
class RobotHeaderCard extends StatefulWidget {
|
||||
class RobotHeaderCard extends StatefulWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
const RobotHeaderCard({super.key, required this.robot});
|
||||
@@ -58,20 +60,26 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.robot['name'] as String,
|
||||
(widget.robot['alias'] as String?)?.isNotEmpty == true
|
||||
? widget.robot['alias'] as String
|
||||
: '暂无别名',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ID: ${widget.robot['id']}',
|
||||
widget.robot['name'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -115,7 +123,23 @@ class _RobotHeaderCardState extends State<RobotHeaderCard> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 设置图标
|
||||
const Icon(Icons.settings, size: 20, color: Color(0xFF86909C)),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RobotParamSettingsPage(
|
||||
robot: widget.robot,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.settings,
|
||||
size: 20,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../home/presentation/pages/running_status_page.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 机器人状态栏
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../../components/device_status_modal.dart';
|
||||
import '../../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../../devices/presentation/bloc/device_status_state.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
|
||||
/// 机器人状态栏 - 实时显示设备推送的运行状态
|
||||
class RobotStatusBar extends StatelessWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
@@ -11,67 +18,127 @@ class RobotStatusBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const RunningStatusPage(),
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
builder: (BuildContext ctx) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: GetIt.I<DeviceStatusBloc>()),
|
||||
BlocProvider.value(value: GetIt.I<RemoteControlCubit>()),
|
||||
],
|
||||
child: const DeviceStatusModal(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
child: StreamBuilder<DeviceStatusState>(
|
||||
stream: GetIt.I<DeviceStatusBloc>().stream,
|
||||
initialData: GetIt.I<DeviceStatusBloc>().state,
|
||||
builder: (context, snapshot) {
|
||||
final state = snapshot.data;
|
||||
|
||||
String speed = '--';
|
||||
String mode = '--';
|
||||
String battery = '--';
|
||||
String signal = '--';
|
||||
Color signalColor = const Color(0xFF86909C);
|
||||
Color batteryColor = const Color(0xFF86909C);
|
||||
|
||||
if (state is DeviceStatusUpdated) {
|
||||
final s = state.status;
|
||||
speed = s.leftMeasureSpeed.toStringAsFixed(0);
|
||||
mode = s.controlMode == '3' ? '远程' : '本地';
|
||||
battery = s.battery.isNotEmpty ? s.battery : '--';
|
||||
|
||||
final qual = s.qual;
|
||||
if (qual >= 4) {
|
||||
signal = '强';
|
||||
signalColor = const Color(0xFF00B42A);
|
||||
} else if (qual >= 2) {
|
||||
signal = '中';
|
||||
signalColor = const Color(0xFFFF7D00);
|
||||
} else if (qual >= 1) {
|
||||
signal = '弱';
|
||||
signalColor = const Color(0xFFF53F3F);
|
||||
} else {
|
||||
signal = '无';
|
||||
signalColor = const Color(0xFF86909C);
|
||||
}
|
||||
|
||||
final batValue = int.tryParse(battery) ?? 0;
|
||||
if (batValue > 50) {
|
||||
batteryColor = const Color(0xFF00B42A);
|
||||
} else if (batValue > 20) {
|
||||
batteryColor = const Color(0xFFFF7D00);
|
||||
} else if (batValue > 0) {
|
||||
batteryColor = const Color(0xFFF53F3F);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildStatusItem(
|
||||
label: '速度',
|
||||
value: '1.2',
|
||||
unit: 'm/s',
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildStatusItem(
|
||||
label: '转速',
|
||||
value: speed,
|
||||
unit: 'rpm',
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '模式',
|
||||
value: mode,
|
||||
unit: '',
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '电量',
|
||||
value: battery,
|
||||
unit: '%',
|
||||
valueColor: batteryColor,
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '信号',
|
||||
value: signal,
|
||||
unit: '',
|
||||
valueColor: signalColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '里程',
|
||||
value: '2.36',
|
||||
unit: 'km',
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '电量',
|
||||
value: '82',
|
||||
unit: '%',
|
||||
valueColor: const Color(0xFF00B42A),
|
||||
),
|
||||
Container(width: 1, height: 40, color: const Color(0xFFE5E6EB)),
|
||||
_buildStatusItem(
|
||||
label: '信号',
|
||||
value: '强',
|
||||
unit: '',
|
||||
valueColor: const Color(0xFF00B42A),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -88,27 +155,31 @@ class RobotStatusBar extends StatelessWidget {
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor ?? const Color(0xFF1D2129),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: valueColor ?? const Color(0xFF1D2129),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty)
|
||||
Text(
|
||||
unit,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
color: valueColor ?? const Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../models/device_run_param_model.dart';
|
||||
|
||||
abstract class DeviceRunParamRemoteDataSource {
|
||||
Future<Either<Failure, DeviceRunParamModel>> getByDeviceId(String deviceId);
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
import '../models/device_run_param_model.dart';
|
||||
import 'device_run_param_remote_datasource.dart';
|
||||
|
||||
class DeviceRunParamRemoteDataSourceImpl
|
||||
implements DeviceRunParamRemoteDataSource {
|
||||
DeviceRunParamRemoteDataSourceImpl(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DeviceRunParamModel>> getByDeviceId(
|
||||
String deviceId,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.deviceRunParamSelect,
|
||||
queryParameters: {'deviceId': deviceId},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final code = body['code'];
|
||||
if (code != null && code.toString() == '200') {
|
||||
final data = body['data'] as Map<String, dynamic>? ?? {};
|
||||
return right(DeviceRunParamModel.fromJson(data));
|
||||
} else {
|
||||
return left(Failure(body['msg'] ?? '获取设备运行参数失败'));
|
||||
}
|
||||
} else {
|
||||
return left(Failure('HTTP错误: ${response.statusCode}'));
|
||||
}
|
||||
} catch (e) {
|
||||
return left(Failure('获取设备运行参数异常: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.deviceRunParamSave,
|
||||
data: params,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final code = body['code'];
|
||||
if (code != null && code.toString() == '200') {
|
||||
return right(true);
|
||||
} else {
|
||||
return left(Failure(body['msg'] ?? '保存设备运行参数失败'));
|
||||
}
|
||||
} else {
|
||||
return left(Failure('HTTP错误: ${response.statusCode}'));
|
||||
}
|
||||
} catch (e) {
|
||||
return left(Failure('保存设备运行参数异常: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
|
||||
class DeviceRunParamModel extends DeviceRunParamEntity {
|
||||
DeviceRunParamModel({
|
||||
required super.id,
|
||||
required super.deviceId,
|
||||
required super.siteId,
|
||||
required super.orgId,
|
||||
required super.runSpeed,
|
||||
required super.leftForwardGain,
|
||||
required super.leftBackwardGain,
|
||||
required super.rightForwardGain,
|
||||
required super.rightBackwardGain,
|
||||
required super.rawData,
|
||||
});
|
||||
|
||||
factory DeviceRunParamModel.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceRunParamModel(
|
||||
id: (json['id'] as dynamic)?.toInt() ?? 0,
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
siteId: (json['siteId'] as dynamic)?.toInt() ?? 0,
|
||||
orgId: (json['orgId'] as dynamic)?.toInt() ?? 0,
|
||||
runSpeed: (json['runSpeed'] as dynamic)?.toDouble() ?? 0.0,
|
||||
leftForwardGain:
|
||||
(json['leftForwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
leftBackwardGain:
|
||||
(json['leftBackwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
rightForwardGain:
|
||||
(json['rightForwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
rightBackwardGain:
|
||||
(json['rightBackwardGain'] as dynamic)?.toDouble() ?? 0.0,
|
||||
rawData: Map<String, dynamic>.from(json),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'deviceId': deviceId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
'runSpeed': runSpeed,
|
||||
'leftForwardGain': leftForwardGain,
|
||||
'leftBackwardGain': leftBackwardGain,
|
||||
'rightForwardGain': rightForwardGain,
|
||||
'rightBackwardGain': rightBackwardGain,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
import '../../domain/repositories/device_run_param_repository.dart';
|
||||
import '../datasources/device_run_param_remote_datasource.dart';
|
||||
|
||||
class DeviceRunParamRepositoryImpl implements DeviceRunParamRepository {
|
||||
DeviceRunParamRepositoryImpl({required this.remoteDataSource});
|
||||
|
||||
final DeviceRunParamRemoteDataSource remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DeviceRunParamEntity>> getByDeviceId(
|
||||
String deviceId,
|
||||
) async {
|
||||
final result = await remoteDataSource.getByDeviceId(deviceId);
|
||||
return result.fold(
|
||||
(failure) => left(failure),
|
||||
(model) => right(model),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params) async {
|
||||
return await remoteDataSource.save(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/// 设备运行参数实体
|
||||
class DeviceRunParamEntity {
|
||||
final int id;
|
||||
final String deviceId;
|
||||
final int siteId;
|
||||
final int orgId;
|
||||
final double runSpeed;
|
||||
final double leftForwardGain;
|
||||
final double leftBackwardGain;
|
||||
final double rightForwardGain;
|
||||
final double rightBackwardGain;
|
||||
/// API 返回的全部原始字段,用于页面展示
|
||||
final Map<String, dynamic> rawData;
|
||||
|
||||
DeviceRunParamEntity({
|
||||
required this.id,
|
||||
required this.deviceId,
|
||||
required this.siteId,
|
||||
required this.orgId,
|
||||
required this.runSpeed,
|
||||
required this.leftForwardGain,
|
||||
required this.leftBackwardGain,
|
||||
required this.rightForwardGain,
|
||||
required this.rightBackwardGain,
|
||||
required this.rawData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../entities/device_run_param_entity.dart';
|
||||
|
||||
abstract class DeviceRunParamRepository {
|
||||
Future<Either<Failure, DeviceRunParamEntity>> getByDeviceId(String deviceId);
|
||||
Future<Either<Failure, bool>> save(Map<String, dynamic> params);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../entities/device_run_param_entity.dart';
|
||||
import '../repositories/device_run_param_repository.dart';
|
||||
|
||||
class GetDeviceRunParamUseCase {
|
||||
final DeviceRunParamRepository repository;
|
||||
|
||||
GetDeviceRunParamUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, DeviceRunParamEntity>> execute(String deviceId) async {
|
||||
return await repository.getByDeviceId(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
class SaveDeviceRunParamUseCase {
|
||||
final DeviceRunParamRepository repository;
|
||||
|
||||
SaveDeviceRunParamUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, bool>> execute(Map<String, dynamic> params) async {
|
||||
return await repository.save(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../../core/app/app_user_cubit.dart';
|
||||
import '../../../site/presentation/cubit/site_cubit.dart';
|
||||
import '../../domain/entities/device_run_param_entity.dart';
|
||||
import '../../domain/usecases/device_run_param_usecases.dart';
|
||||
import '../../../../../core/services/device_permission_service.dart';
|
||||
|
||||
class RobotParamSettingsPage extends StatefulWidget {
|
||||
final Map<String, dynamic> robot;
|
||||
|
||||
const RobotParamSettingsPage({super.key, required this.robot});
|
||||
|
||||
@override
|
||||
State<RobotParamSettingsPage> createState() => _RobotParamSettingsPageState();
|
||||
}
|
||||
|
||||
class _RobotParamSettingsPageState extends State<RobotParamSettingsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
String? _errorMessage;
|
||||
DeviceRunParamEntity? _param;
|
||||
|
||||
// 表单控制器(仅可编辑的5个字段)
|
||||
late TextEditingController _runSpeedController;
|
||||
late TextEditingController _leftForwardGainController;
|
||||
late TextEditingController _leftBackwardGainController;
|
||||
late TextEditingController _rightForwardGainController;
|
||||
late TextEditingController _rightBackwardGainController;
|
||||
|
||||
String get _deviceId => widget.robot['name'] as String? ?? '';
|
||||
|
||||
// ============ 字段定义 ============
|
||||
static const _editableKeys = {
|
||||
'runSpeed',
|
||||
'leftForwardGain',
|
||||
'leftBackwardGain',
|
||||
'rightForwardGain',
|
||||
'rightBackwardGain',
|
||||
};
|
||||
|
||||
static const _skipKeys = {
|
||||
'createBy', 'createTime', 'updateBy', 'updateTime', 'delFlag', 'remark',
|
||||
};
|
||||
|
||||
static const _fieldLabels = {
|
||||
'id': 'ID',
|
||||
'deviceId': '设备ID',
|
||||
'siteId': '场站ID',
|
||||
'orgId': '组织ID',
|
||||
'runSpeed': '运行速度',
|
||||
'header1': 'Header1',
|
||||
'header2': 'Header2',
|
||||
'cmd': 'CMD',
|
||||
'chipUidSign': '芯片UID标记',
|
||||
'chipUid': '芯片UID',
|
||||
'remoteConfig': '远程配置',
|
||||
'knifeMotorMode': '刀盘电机模式',
|
||||
'walkMotorMode': '行走电机模式',
|
||||
'leftMotorReverse': '左电机反转',
|
||||
'rightMotorReverse': '右电机反转',
|
||||
'swapChannel': '交换通道',
|
||||
'use4G': '使用4G',
|
||||
'forwardSpeedLimit': '前进限速',
|
||||
'turnSpeedLimit': '转弯限速',
|
||||
'knifePolarity': '刀盘极性',
|
||||
'fanPolarity': '风扇极性',
|
||||
'throttlePolarity': '油门极性',
|
||||
'liftProtectTime': '升降保护时间',
|
||||
'dualRtk': '双RTK',
|
||||
'knifeChannel': '刀盘通道',
|
||||
'fanChannel': '风扇通道',
|
||||
'throttleChannel': '油门通道',
|
||||
'remoteType': '遥控类型',
|
||||
'relayBoard': '继电器板',
|
||||
'liftChannel': '升降通道',
|
||||
'chassisChannel': '底盘通道',
|
||||
'armChannel': '机械臂通道',
|
||||
'fuelPumpChannel': '燃油泵通道',
|
||||
'wifiName': 'WiFi名称',
|
||||
'wifiPassword': 'WiFi密码',
|
||||
'batteryType': '电池类型',
|
||||
'driveType': '驱动类型',
|
||||
'gearRatio': '齿轮比',
|
||||
'robotLength': '机器人长度',
|
||||
'robotWidth': '机器人宽度',
|
||||
'robotHeight': '机器人高度',
|
||||
'knifeWidth': '刀盘宽度',
|
||||
'tyreSize': '轮胎尺寸',
|
||||
'leftForwardGain': '左轮前进',
|
||||
'leftBackwardGain': '左轮后退',
|
||||
'rightForwardGain': '右轮前进',
|
||||
'rightBackwardGain': '右轮后退',
|
||||
'firmwareVersion': '固件版本',
|
||||
'crc16': 'CRC16',
|
||||
'tail1': '尾部1',
|
||||
'tail2': '尾部2',
|
||||
};
|
||||
|
||||
// ============ 生命周期 ============
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_initControllers();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _initControllers() {
|
||||
_runSpeedController = TextEditingController();
|
||||
_leftForwardGainController = TextEditingController();
|
||||
_leftBackwardGainController = TextEditingController();
|
||||
_rightForwardGainController = TextEditingController();
|
||||
_rightBackwardGainController = TextEditingController();
|
||||
}
|
||||
|
||||
void _fillControllers(DeviceRunParamEntity param) {
|
||||
_runSpeedController.text = param.runSpeed.toStringAsFixed(0);
|
||||
_leftForwardGainController.text = param.leftForwardGain.toString();
|
||||
_leftBackwardGainController.text = param.leftBackwardGain.toString();
|
||||
_rightForwardGainController.text = param.rightForwardGain.toString();
|
||||
_rightBackwardGainController.text = param.rightBackwardGain.toString();
|
||||
}
|
||||
|
||||
TextEditingController? _controllerForKey(String key) {
|
||||
switch (key) {
|
||||
case 'runSpeed': return _runSpeedController;
|
||||
case 'leftForwardGain': return _leftForwardGainController;
|
||||
case 'leftBackwardGain': return _leftBackwardGainController;
|
||||
case 'rightForwardGain': return _rightForwardGainController;
|
||||
case 'rightBackwardGain': return _rightBackwardGainController;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _showReadonlyTip() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('暂不支持修改'),
|
||||
duration: Duration(seconds: 1),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.only(bottom: 80, left: 80, right: 80),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_runSpeedController.dispose();
|
||||
_leftForwardGainController.dispose();
|
||||
_leftBackwardGainController.dispose();
|
||||
_rightForwardGainController.dispose();
|
||||
_rightBackwardGainController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ============ 数据加载 ============
|
||||
Future<void> _loadData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
final useCase = GetIt.I<GetDeviceRunParamUseCase>();
|
||||
final result = await useCase.execute(_deviceId);
|
||||
|
||||
if (!mounted) return;
|
||||
result.fold(
|
||||
(failure) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = failure.message;
|
||||
});
|
||||
},
|
||||
(param) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_param = param;
|
||||
});
|
||||
_fillControllers(param);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 保存 ============
|
||||
Future<void> _handleSave() async {
|
||||
if (_isSaving || _param == null) return;
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
// 🔐 前置权限校验:只有 code=200 && data=true 才允许保存
|
||||
final permissionService = GetIt.I<DevicePermissionService>();
|
||||
final hasPermission = await permissionService.checkPermission(_deviceId);
|
||||
|
||||
if (!mounted) return;
|
||||
if (!hasPermission) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('权限校验未通过,无法保存', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限通过提示
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('权限通过'),
|
||||
],
|
||||
),
|
||||
content: const Text('您有权限操作此设备'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final siteId = GetIt.I<SiteCubit>().state.selectedSite?.id ?? 0;
|
||||
final orgId = GetIt.I<AppUserCubit>().state.user?.orgId ?? 0;
|
||||
|
||||
final useCase = GetIt.I<SaveDeviceRunParamUseCase>();
|
||||
final params = <String, dynamic>{
|
||||
'id': _param!.id,
|
||||
'deviceId': _deviceId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
'runSpeed': int.tryParse(_runSpeedController.text) ?? _param!.runSpeed,
|
||||
'leftForwardGain':
|
||||
double.tryParse(_leftForwardGainController.text) ??
|
||||
_param!.leftForwardGain,
|
||||
'leftBackwardGain':
|
||||
double.tryParse(_leftBackwardGainController.text) ??
|
||||
_param!.leftBackwardGain,
|
||||
'rightForwardGain':
|
||||
double.tryParse(_rightForwardGainController.text) ??
|
||||
_param!.rightForwardGain,
|
||||
'rightBackwardGain':
|
||||
double.tryParse(_rightBackwardGainController.text) ??
|
||||
_param!.rightBackwardGain,
|
||||
};
|
||||
|
||||
final result = await useCase.execute(params);
|
||||
|
||||
if (!mounted) return;
|
||||
result.fold(
|
||||
(failure) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('保存失败: ${failure.message}', isError: true);
|
||||
},
|
||||
(success) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('保存成功');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
_showSnackBar('保存异常: $e', isError: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red : Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ UI ============
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0.5,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'参数设置',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_deviceId,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(44),
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorColor: const Color(0xFF165DFF),
|
||||
indicatorWeight: 2,
|
||||
labelColor: const Color(0xFF165DFF),
|
||||
unselectedLabelColor: const Color(0xFF86909C),
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: '参数设置'),
|
||||
Tab(text: '增益参数设置'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildParamTab(),
|
||||
_buildGainParamTab(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 参数设置 Tab ============
|
||||
Widget _buildParamTab() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Color(0xFF86909C)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadData,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final raw = _param!.rawData;
|
||||
final keys = raw.keys
|
||||
.where((k) => !_skipKeys.contains(k))
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(keys.length, (i) {
|
||||
final key = keys[i];
|
||||
final label = _fieldLabels[key] ?? key;
|
||||
final isEditable = _editableKeys.contains(key);
|
||||
final isLast = i == keys.length - 1;
|
||||
final value = raw[key];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildFieldRow(key, label, value, isEditable),
|
||||
if (!isLast) _buildDivider(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 增益参数 Tab ============
|
||||
Widget _buildGainParamTab() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.construction, size: 48, color: Color(0xFFC9CDD4)),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'暂未开放',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 单行字段 ============
|
||||
Widget _buildFieldRow(
|
||||
String key,
|
||||
String label,
|
||||
dynamic value,
|
||||
bool editable,
|
||||
) {
|
||||
final controller = editable ? _controllerForKey(key) : null;
|
||||
final displayValue = value?.toString() ?? '-';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF4E5969),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: editable
|
||||
? TextField(
|
||||
controller: controller,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
|
||||
],
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)),
|
||||
decoration: _inputDecoration(),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: _showReadonlyTip,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F3F5),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFE5E6EB)),
|
||||
),
|
||||
child: Text(
|
||||
displayValue,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration() {
|
||||
return const InputDecoration(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
filled: true,
|
||||
fillColor: Color(0xFFF7F8FA),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFFE5E6EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
borderSide: BorderSide(color: Color(0xFF165DFF)),
|
||||
),
|
||||
isDense: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(height: 1, color: Color(0xFFF2F3F5));
|
||||
}
|
||||
|
||||
// ============ 底部保存按钮 ============
|
||||
Widget _buildBottomBar() {
|
||||
final bottom = MediaQuery.of(context).padding.bottom;
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + bottom),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading || _isSaving ? null : _handleSave,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'保存',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import '../../domain/entities/site_entity.dart';
|
||||
|
||||
abstract class SiteDataSource {
|
||||
Future<List<SiteEntity>> getSiteList(int orgId);
|
||||
Future<List<SiteEntity>> getSiteList(String userId);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/data/datasources/site_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
|
||||
|
||||
@@ -14,42 +15,38 @@ class SiteDataSourceImpl implements SiteDataSource {
|
||||
SiteDataSourceImpl(this.dio, this._userStorage, this._appUserCubit);
|
||||
|
||||
@override
|
||||
Future<List<SiteEntity>> getSiteList(int orgId) async {
|
||||
Future<List<SiteEntity>> getSiteList(String userId) async {
|
||||
print('🔍 [SiteDataSource] 开始获取 Token...');
|
||||
print('🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}');
|
||||
|
||||
// 优先从全局状态获取 Token(更快更可靠)
|
||||
print(
|
||||
'🔍 [SiteDataSource] AppUserCubit 当前用户: ${_appUserCubit.state.user?.username}',
|
||||
);
|
||||
|
||||
var token = _appUserCubit.state.user?.token;
|
||||
|
||||
print('🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
|
||||
|
||||
// 如果全局状态没有,再从本地存储获取
|
||||
|
||||
print(
|
||||
'🔍 [SiteDataSource] 从 AppUserCubit 获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
|
||||
);
|
||||
|
||||
if (token == null) {
|
||||
print('⚠️ [SiteDataSource] AppUserCubit 没有 Token,尝试从本地存储获取...');
|
||||
final user = await _userStorage.getUser();
|
||||
token = user?.token;
|
||||
print('🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
|
||||
print(
|
||||
'🔍 [SiteDataSource] 从本地存储获取的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
|
||||
);
|
||||
}
|
||||
|
||||
print('🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}');
|
||||
|
||||
// 构建查询参数:orgId 为 0 时不传递
|
||||
final queryParams = <String, dynamic>{
|
||||
'pageNum': 1,
|
||||
'pageSize': 9999,
|
||||
};
|
||||
|
||||
if (orgId != 0) {
|
||||
queryParams['orgId'] = orgId;
|
||||
}
|
||||
|
||||
print(
|
||||
'🔑 [SiteDataSource] 获取到的 Token: ${token != null ? "${token.substring(0, 20)}..." : "null"}',
|
||||
);
|
||||
|
||||
final queryParams = <String, dynamic>{'userId': userId};
|
||||
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getSiteList,
|
||||
queryParameters: queryParams,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Authorization': token != null ? 'Bearer $token' : '',
|
||||
},
|
||||
headers: {'Authorization': token != null ? 'Bearer $token' : ''},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -59,17 +56,27 @@ class SiteDataSourceImpl implements SiteDataSource {
|
||||
|
||||
final responseData = response.data;
|
||||
|
||||
if (responseData['code'] == 401 || responseData['code'] == 403) {
|
||||
print(
|
||||
'🚨 [SiteDataSource] 收到认证错误码 ${responseData['code']},触发 Token 过期处理',
|
||||
);
|
||||
try {
|
||||
GetIt.I<AuthCubit>().tokenExpired();
|
||||
} catch (e) {}
|
||||
throw Exception('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '业务异常');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = responseData['rows'] ?? [];
|
||||
|
||||
final List<dynamic> rows = responseData['data'] ?? [];
|
||||
|
||||
print('📤 [SiteDataSource] 接口返回原始数据:');
|
||||
for (var i = 0; i < rows.length && i < 3; i++) {
|
||||
print(' 场站$i: ${rows[i]}');
|
||||
}
|
||||
|
||||
|
||||
return rows.map((item) => SiteEntity.fromJson(item)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ class SiteRepositoryImpl implements SiteRepository {
|
||||
SiteRepositoryImpl(this.dataSource);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId) async {
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(String userId) async {
|
||||
try {
|
||||
final sites = await dataSource.getSiteList(orgId);
|
||||
final sites = await dataSource.getSiteList(userId);
|
||||
return Right(sites);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
|
||||
@@ -5,5 +5,5 @@ import '../../../../../core/error/failure.dart';
|
||||
import '../entities/site_entity.dart';
|
||||
|
||||
abstract class SiteRepository {
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId);
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(String userId);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ class GetSiteListUseCase {
|
||||
|
||||
GetSiteListUseCase(this.repository);
|
||||
|
||||
// pageNum 和 pageSize 固定,orgId 从登录用户信息中获取
|
||||
Future<Either<Failure, List<SiteEntity>>> call(int orgId) async {
|
||||
return await repository.getSiteList(orgId);
|
||||
// userId 从登录用户信息中获取
|
||||
Future<Either<Failure, List<SiteEntity>>> call(String userId) async {
|
||||
return await repository.getSiteList(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
final AppUserCubit appUserCubit;
|
||||
final SiteCubit siteCubit;
|
||||
|
||||
HomeV2Bloc(this.getHomeDataUseCase, this.getSiteListUseCase, this.appUserCubit, this.siteCubit) : super(const HomeV2Initial()) {
|
||||
HomeV2Bloc(
|
||||
this.getHomeDataUseCase,
|
||||
this.getSiteListUseCase,
|
||||
this.appUserCubit,
|
||||
this.siteCubit,
|
||||
) : super(const HomeV2Initial()) {
|
||||
on<HomeV2LoadData>(_onLoadData);
|
||||
on<HomeV2Refresh>(_onRefresh);
|
||||
on<HomeV2ToggleTrendType>(_onToggleTrendType);
|
||||
@@ -28,26 +33,25 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
|
||||
final user = appUserCubit.state.user;
|
||||
if (user == null) {
|
||||
emit(const HomeV2Error(
|
||||
message: '用户未登录',
|
||||
shouldShowError: true,
|
||||
));
|
||||
emit(const HomeV2Error(message: '用户未登录', shouldShowError: true));
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行加载首页数据和场站列表
|
||||
final homeResult = await getHomeDataUseCase(const NoParams());
|
||||
final siteResult = await getSiteListUseCase(user.orgId); // 使用用户的 orgId
|
||||
final siteResult = await getSiteListUseCase(user.userId); // 使用用户的 userId
|
||||
|
||||
homeResult.fold(
|
||||
(failure) => emit(HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
)),
|
||||
(failure) => emit(
|
||||
HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
),
|
||||
),
|
||||
(homeData) {
|
||||
List<SiteEntity> sites = [];
|
||||
SiteEntity? selectedSite;
|
||||
|
||||
|
||||
siteResult.fold(
|
||||
(failure) {
|
||||
print('加载场站列表失败: ${failure.message}');
|
||||
@@ -56,7 +60,7 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
sites = siteList;
|
||||
// 从全局 SiteCubit 获取之前选中的场站
|
||||
final savedSelectedSite = siteCubit.state.selectedSite;
|
||||
|
||||
|
||||
// 尝试找到之前选中的场站
|
||||
if (savedSelectedSite != null && siteList.isNotEmpty) {
|
||||
selectedSite = siteList.firstWhere(
|
||||
@@ -67,19 +71,21 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
// 没有选中过,默认选中第一个
|
||||
selectedSite = siteList.isNotEmpty ? siteList.first : null;
|
||||
}
|
||||
|
||||
|
||||
// 更新全局 SiteCubit 的选中状态
|
||||
if (selectedSite != null) {
|
||||
siteCubit.selectSite(selectedSite!);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
emit(HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
));
|
||||
|
||||
emit(
|
||||
HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -90,29 +96,28 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
) async {
|
||||
if (state is HomeV2Loaded) {
|
||||
final currentState = state as HomeV2Loaded;
|
||||
|
||||
|
||||
final user = appUserCubit.state.user;
|
||||
if (user == null) {
|
||||
emit(const HomeV2Error(
|
||||
message: '用户未登录',
|
||||
shouldShowError: true,
|
||||
));
|
||||
emit(const HomeV2Error(message: '用户未登录', shouldShowError: true));
|
||||
return;
|
||||
}
|
||||
|
||||
// 并行刷新首页数据和电站列表
|
||||
final homeResult = await getHomeDataUseCase(const NoParams());
|
||||
final siteResult = await getSiteListUseCase(user.orgId);
|
||||
final siteResult = await getSiteListUseCase(user.userId);
|
||||
|
||||
homeResult.fold(
|
||||
(failure) => emit(HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
)),
|
||||
(failure) => emit(
|
||||
HomeV2Error(
|
||||
message: ErrorHandler.getErrorMessage(failure.message),
|
||||
shouldShowError: true, // 🔥 标记需要显示弹窗
|
||||
),
|
||||
),
|
||||
(homeData) {
|
||||
List<SiteEntity> sites = currentState.sites;
|
||||
SiteEntity? selectedSite = currentState.selectedSite;
|
||||
|
||||
|
||||
siteResult.fold(
|
||||
(failure) {
|
||||
print('刷新场站列表失败: ${failure.message}');
|
||||
@@ -129,20 +134,22 @@ class HomeV2Bloc extends Bloc<HomeV2Event, HomeV2State> {
|
||||
} else if (siteList.isNotEmpty) {
|
||||
selectedSite = siteList.first;
|
||||
}
|
||||
|
||||
|
||||
// 更新全局 SiteCubit 的选中状态
|
||||
if (selectedSite != null) {
|
||||
siteCubit.selectSite(selectedSite!);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
emit(HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
trendType: currentState.trendType,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
));
|
||||
|
||||
emit(
|
||||
HomeV2Loaded(
|
||||
homeData: homeData,
|
||||
trendType: currentState.trendType,
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_bloc
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_event.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/bloc/home_v2_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/site/presentation/widgets/site_selector_widget.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_card.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/stats_grid.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/work_order_card.dart';
|
||||
@@ -60,9 +61,16 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.orange),
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(state.message, style: const TextStyle(fontSize: 14, color: Colors.grey)),
|
||||
Text(
|
||||
state.message,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _bloc.add(const HomeV2LoadData()),
|
||||
@@ -91,47 +99,8 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: _showPlantSelector,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: StreamBuilder<SiteState>(
|
||||
stream: sl<SiteCubit>().stream,
|
||||
builder: (context, snapshot) {
|
||||
final siteState =
|
||||
snapshot.data ??
|
||||
sl<SiteCubit>().state;
|
||||
return Text(
|
||||
siteState.selectedSite?.siteName ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate(
|
||||
'home_v2.select_site',
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 20,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: SiteSelectorWidget()),
|
||||
const SizedBox(width: 12),
|
||||
TcpStatusIndicator(
|
||||
onTap: () => _showDeviceStatusModal(context),
|
||||
),
|
||||
@@ -252,7 +221,9 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
}
|
||||
|
||||
// 🔥 Initial/Loading 状态显示加载指示器
|
||||
return const Center(child: CircularProgressIndicator(color: Color(0xFF165DFF)));
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF165DFF)),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,179 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../../core/error/failure.dart';
|
||||
import '../../../../../../core/app/app_user_cubit.dart';
|
||||
import '../models/report_model.dart';
|
||||
import 'report_remote_datasource.dart';
|
||||
|
||||
/// 上报远程数据源实现(模拟接口请求)
|
||||
/// 上报远程数据源实现
|
||||
class ReportRemoteDataSourceImpl implements ReportRemoteDataSource {
|
||||
@override
|
||||
Future<Either<Failure, bool>> submitReport(ReportModel report) async {
|
||||
// 模拟网络请求延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
print('========================================');
|
||||
print('[上报工单] 开始提交');
|
||||
print('[上报工单] 请求URL: ${HttpApiConsts.workOrderAdd}');
|
||||
print('[上报工单] 请求方法: POST (multipart/form-data)');
|
||||
try {
|
||||
// 1. 构造 workOrder JSON(直接映射 IOTWorkOrder 实体)
|
||||
print('[上报工单] 入参: siteId=${report.siteId}, siteName=${report.siteName}, reportType=${report.reportType}');
|
||||
print('[上报工单] 入参: deviceId=${report.deviceId}, deviceName=${report.deviceName}');
|
||||
print('[上报工单] 入参: description=${report.description}, problemLevel=${report.problemLevel}');
|
||||
print('[上报工单] 入参: mediaUrls数量=${report.mediaUrls?.length ?? 0}');
|
||||
final workOrder = <String, dynamic>{
|
||||
'siteId': report.siteId,
|
||||
'siteName': report.siteName ?? '',
|
||||
'sourceType': 6, // 6=人工创建
|
||||
'orderType': report.reportType ?? '',
|
||||
'deviceId': report.deviceId ?? '',
|
||||
'deviceName': report.deviceName ?? '',
|
||||
'taskDescription': report.description ?? '',
|
||||
'priorityLevel': report.problemLevel ?? 'INFO',
|
||||
'orderTitle': '${report.reportType ?? '上报'} - ${report.deviceName ?? ''}',
|
||||
'orderStatus': 1, // 1=待处理
|
||||
'imgUrl': <String>[],
|
||||
'videoUrl': <String>[],
|
||||
};
|
||||
final workOrderJson = jsonEncode(workOrder);
|
||||
print('[上报工单] workOrder JSON: $workOrderJson');
|
||||
|
||||
// 模拟成功返回
|
||||
return right(true);
|
||||
// 2. 分离图片和视频
|
||||
final imagePaths = <String>[];
|
||||
final videoPaths = <String>[];
|
||||
final imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic'];
|
||||
final videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', '3gp'];
|
||||
|
||||
// 模拟失败情况(取消注释以测试错误处理)
|
||||
// return left(ServerFailure('提交失败,请重试'));
|
||||
if (report.mediaUrls != null) {
|
||||
for (final path in report.mediaUrls!) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
if (videoExts.contains(ext)) {
|
||||
videoPaths.add(path);
|
||||
print('[上报工单] 识别为视频: $path');
|
||||
} else {
|
||||
imagePaths.add(path);
|
||||
print('[上报工单] 识别为图片: $path');
|
||||
}
|
||||
}
|
||||
}
|
||||
print('[上报工单] 图片: ${imagePaths.length}个, 视频: ${videoPaths.length}个');
|
||||
|
||||
// 3. 创建 multipart 请求
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse(HttpApiConsts.workOrderAdd),
|
||||
);
|
||||
|
||||
// 4. 添加 Authorization 认证头
|
||||
final userToken = GetIt.I<AppUserCubit>().state.user?.token;
|
||||
if (userToken != null) {
|
||||
request.headers['Authorization'] = 'Bearer $userToken';
|
||||
print('[上报工单] Authorization token: ${userToken.substring(0, userToken.length > 20 ? 20 : userToken.length)}...');
|
||||
} else {
|
||||
print('[上报工单] WARNING: Token为空');
|
||||
}
|
||||
|
||||
// 5. 处理图片文件(后端要求 file 字段必须存在)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final firstImage = imagePaths.first;
|
||||
final file = File(firstImage);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
print('[上报工单] 上传图片: $firstImage, 大小: ${bytes.length} bytes');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: firstImage.split('/').last,
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
} else {
|
||||
print('[上报工单] WARNING: 图片文件不存在: $firstImage');
|
||||
}
|
||||
} else {
|
||||
print('[上报工单] 无图片,使用空文件占位');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
<int>[],
|
||||
filename: 'empty.jpg',
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
}
|
||||
|
||||
// 6. 处理视频文件(后端要求 video 字段必须存在)
|
||||
if (videoPaths.isNotEmpty) {
|
||||
final firstVideo = videoPaths.first;
|
||||
final file = File(firstVideo);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
print('[上报工单] 上传视频: $firstVideo, 大小: ${bytes.length} bytes');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
bytes,
|
||||
filename: firstVideo.split('/').last,
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
} else {
|
||||
print('[上报工单] WARNING: 视频文件不存在: $firstVideo');
|
||||
}
|
||||
} else {
|
||||
print('[上报工单] 无视频,使用空文件占位');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
<int>[],
|
||||
filename: 'empty.mp4',
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
}
|
||||
|
||||
// 7. 添加 workOrder JSON(对应前端 new Blob,filename 为空字符串)
|
||||
print('[上报工单] workOrder JSON长度: ${workOrderJson.length} chars');
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'workOrder',
|
||||
utf8.encode(workOrderJson),
|
||||
filename: '',
|
||||
contentType: http.MediaType('application', 'json'),
|
||||
));
|
||||
|
||||
print('[上报工单] 请求字段: ${request.fields.keys.toList()}');
|
||||
print('[上报工单] 文件字段: ${request.files.map((f) => f.field).toList()}');
|
||||
print('[上报工单] 开始发送请求...');
|
||||
|
||||
// 8. 发送请求
|
||||
final http.StreamedResponse response = await request.send();
|
||||
final String responseBody = await response.stream.bytesToString();
|
||||
|
||||
print('[上报工单] 响应状态码: ${response.statusCode}');
|
||||
print('[上报工单] 响应头: ${response.headers}');
|
||||
print('[上报工单] 响应体: $responseBody');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final respJson = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final code = respJson['code'];
|
||||
print('[上报工单] 业务code: $code');
|
||||
if (code != null && code.toString() == '200') {
|
||||
print('[上报工单] 提交成功');
|
||||
print('========================================');
|
||||
return right(true);
|
||||
} else {
|
||||
final msg = respJson['msg'] ?? respJson['message'] ?? '提交失败';
|
||||
print('[上报工单] 业务失败: $msg');
|
||||
print('========================================');
|
||||
return left(Failure(msg.toString()));
|
||||
}
|
||||
} else {
|
||||
print('[上报工单] HTTP错误: ${response.statusCode}');
|
||||
print('========================================');
|
||||
return left(Failure('服务器错误: HTTP ${response.statusCode}, 响应: $responseBody'));
|
||||
}
|
||||
} catch (e) {
|
||||
print('[上报工单] 异常: $e');
|
||||
print('[上报工单] 异常类型: ${e.runtimeType}');
|
||||
print('========================================');
|
||||
return left(Failure('提交失败: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import '../../domain/entities/report_entity.dart';
|
||||
class ReportModel {
|
||||
final String? location;
|
||||
final String? reportType;
|
||||
final int? siteId;
|
||||
final String? siteName;
|
||||
final String? deviceId;
|
||||
final String? deviceName;
|
||||
final String? description;
|
||||
final List<String>? mediaUrls;
|
||||
final String? problemLevel;
|
||||
@@ -12,7 +15,10 @@ class ReportModel {
|
||||
ReportModel({
|
||||
this.location,
|
||||
this.reportType,
|
||||
this.siteId,
|
||||
this.siteName,
|
||||
this.deviceId,
|
||||
this.deviceName,
|
||||
this.description,
|
||||
this.mediaUrls,
|
||||
this.problemLevel,
|
||||
@@ -23,7 +29,10 @@ class ReportModel {
|
||||
return ReportModel(
|
||||
location: json['location'] as String?,
|
||||
reportType: json['reportType'] as String?,
|
||||
siteId: json['siteId'] as int?,
|
||||
siteName: json['siteName'] as String?,
|
||||
deviceId: json['deviceId'] as String?,
|
||||
deviceName: json['deviceName'] as String?,
|
||||
description: json['description'] as String?,
|
||||
mediaUrls: (json['mediaUrls'] as List<dynamic>?)?.cast<String>(),
|
||||
problemLevel: json['problemLevel'] as String?,
|
||||
@@ -35,7 +44,10 @@ class ReportModel {
|
||||
return {
|
||||
'location': location,
|
||||
'reportType': reportType,
|
||||
'siteId': siteId,
|
||||
'siteName': siteName,
|
||||
'deviceId': deviceId,
|
||||
'deviceName': deviceName,
|
||||
'description': description,
|
||||
'mediaUrls': mediaUrls,
|
||||
'problemLevel': problemLevel,
|
||||
@@ -47,7 +59,10 @@ class ReportModel {
|
||||
return ReportEntity(
|
||||
location: location,
|
||||
reportType: reportType,
|
||||
siteId: siteId,
|
||||
siteName: siteName,
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
description: description,
|
||||
mediaUrls: mediaUrls,
|
||||
problemLevel: problemLevel,
|
||||
@@ -59,7 +74,10 @@ class ReportModel {
|
||||
return ReportModel(
|
||||
location: entity.location,
|
||||
reportType: entity.reportType,
|
||||
siteId: entity.siteId,
|
||||
siteName: entity.siteName,
|
||||
deviceId: entity.deviceId,
|
||||
deviceName: entity.deviceName,
|
||||
description: entity.description,
|
||||
mediaUrls: entity.mediaUrls,
|
||||
problemLevel: entity.problemLevel,
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../data/datasources/report_remote_datasource.dart';
|
||||
import '../data/datasources/report_remote_datasource_impl.dart';
|
||||
import '../data/repositories/report_repository_impl.dart';
|
||||
import '../domain/repositories/report_repository.dart';
|
||||
import '../domain/usecases/submit_report_usecase.dart';
|
||||
import '../presentation/cubit/report_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
|
||||
/// 上报模块依赖注入
|
||||
class ReportDependencyInjector {
|
||||
/// 创建 ReportCubit 实例
|
||||
static ReportCubit createReportCubit() {
|
||||
final sl = GetIt.I;
|
||||
|
||||
// 创建数据源
|
||||
final ReportRemoteDataSource remoteDataSource = ReportRemoteDataSourceImpl();
|
||||
final ReportRemoteDataSource remoteDataSource =
|
||||
ReportRemoteDataSourceImpl();
|
||||
|
||||
// 创建仓储
|
||||
final ReportRepository repository = ReportRepositoryImpl(
|
||||
@@ -18,9 +25,16 @@ class ReportDependencyInjector {
|
||||
);
|
||||
|
||||
// 创建用例
|
||||
final SubmitReportUseCase submitReportUseCase = SubmitReportUseCase(repository);
|
||||
final SubmitReportUseCase submitReportUseCase = SubmitReportUseCase(
|
||||
repository,
|
||||
);
|
||||
|
||||
// 创建 Cubit
|
||||
return ReportCubit(submitReportUseCase: submitReportUseCase);
|
||||
// 创建 Cubit(注入 Dio、AppUserCubit、UserStorage)
|
||||
return ReportCubit(
|
||||
submitReportUseCase: submitReportUseCase,
|
||||
dio: sl<Dio>(),
|
||||
appUserCubit: sl<AppUserCubit>(),
|
||||
userStorage: sl<UserStorage>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
class ReportEntity {
|
||||
final String? location;
|
||||
final String? reportType;
|
||||
final int? siteId;
|
||||
final String? siteName;
|
||||
final String? deviceId;
|
||||
final String? deviceName;
|
||||
final String? description;
|
||||
final List<String>? mediaUrls;
|
||||
final String? problemLevel;
|
||||
@@ -10,7 +13,10 @@ class ReportEntity {
|
||||
const ReportEntity({
|
||||
this.location,
|
||||
this.reportType,
|
||||
this.siteId,
|
||||
this.siteName,
|
||||
this.deviceId,
|
||||
this.deviceName,
|
||||
this.description,
|
||||
this.mediaUrls,
|
||||
this.problemLevel,
|
||||
@@ -19,7 +25,10 @@ class ReportEntity {
|
||||
ReportEntity copyWith({
|
||||
String? location,
|
||||
String? reportType,
|
||||
int? siteId,
|
||||
String? siteName,
|
||||
String? deviceId,
|
||||
String? deviceName,
|
||||
String? description,
|
||||
List<String>? mediaUrls,
|
||||
String? problemLevel,
|
||||
@@ -27,7 +36,10 @@ class ReportEntity {
|
||||
return ReportEntity(
|
||||
location: location ?? this.location,
|
||||
reportType: reportType ?? this.reportType,
|
||||
siteId: siteId ?? this.siteId,
|
||||
siteName: siteName ?? this.siteName,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
description: description ?? this.description,
|
||||
mediaUrls: mediaUrls ?? this.mediaUrls,
|
||||
problemLevel: problemLevel ?? this.problemLevel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局颜色常量
|
||||
class AppColors {
|
||||
@@ -27,26 +27,32 @@ class AppDimensions {
|
||||
|
||||
/// 上报类型枚举
|
||||
enum ReportType {
|
||||
deviceFault('report.device_fault', Icons.notifications_active),
|
||||
patrolRecord('report.patrol_record', Icons.description),
|
||||
hiddenDanger('report.hidden_danger', Icons.shield),
|
||||
defectReport('report.defect_report', Icons.build);
|
||||
mowerError('MOWER_ERROR', '割草机故障', Icons.notifications_active),
|
||||
uavError('UAV_ERROR', '无人机故障', Icons.flight),
|
||||
inspectionTask('INSPECTION_TASK', '巡检', Icons.search),
|
||||
componentDefect('COMPONENT_DEFECT', '光伏组件故障', Icons.power),
|
||||
cleanTask('CLEAN_TASK', '清洗任务', Icons.cleaning_services),
|
||||
maintainTask('MAINTAIN_TASK', '维护任务', Icons.handyman),
|
||||
repairTask('REPAIR_TASK', '维修任务', Icons.build),
|
||||
other('OTHER', '其他', Icons.more_horiz);
|
||||
|
||||
final String labelKey;
|
||||
final String orderType;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
const ReportType(this.labelKey, this.icon);
|
||||
const ReportType(this.orderType, this.label, this.icon);
|
||||
}
|
||||
|
||||
/// 问题等级枚举
|
||||
enum ProblemLevel {
|
||||
normal('report.normal', Color(0xFF86909C), Color(0xFFE5E6EB)),
|
||||
important('report.important', Color(0xFFFF7D00), Color(0xFFE5E6EB)),
|
||||
urgent('report.urgent', Color(0xFFF53F3F), Color(0xFFF53F3F));
|
||||
info('INFO', '信息', Color(0xFF165DFF), Color(0xFF165DFF)),
|
||||
warning('WARNING', '警告', Color(0xFFFF7D00), Color(0xFFFF7D00)),
|
||||
error('ERROR', '错误', Color(0xFFF53F3F), Color(0xFFF53F3F));
|
||||
|
||||
final String labelKey;
|
||||
final String level;
|
||||
final String label;
|
||||
final Color textColor;
|
||||
final Color borderColor;
|
||||
|
||||
const ProblemLevel(this.labelKey, this.textColor, this.borderColor);
|
||||
const ProblemLevel(this.level, this.label, this.textColor, this.borderColor);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,174 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/entities/report_entity.dart';
|
||||
import '../../domain/usecases/submit_report_usecase.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
import '../states/report_state.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
import '../../../home/domain/usecases/get_site_list_usecase.dart';
|
||||
import '../../../home/domain/repositories/site_repository.dart';
|
||||
import '../../../home/data/repositories/site_repository_impl.dart';
|
||||
import '../../../home/data/datasources/site_datasource_impl.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import '../../../device_list/data/models/robot_data_model.dart';
|
||||
import '../../../device_list/domain/entities/drone_station_entity.dart';
|
||||
|
||||
/// 上报页面 Cubit
|
||||
class ReportCubit extends Cubit<ReportState> {
|
||||
final SubmitReportUseCase submitReportUseCase;
|
||||
final Dio dio;
|
||||
final AppUserCubit appUserCubit;
|
||||
final UserStorage userStorage;
|
||||
|
||||
ReportCubit({required this.submitReportUseCase})
|
||||
: super(ReportFormState(report: ReportEntity()));
|
||||
ReportCubit({
|
||||
required this.submitReportUseCase,
|
||||
required this.dio,
|
||||
required this.appUserCubit,
|
||||
required this.userStorage,
|
||||
}) : super(ReportFormState(report: ReportEntity())) {
|
||||
_loadSiteList();
|
||||
}
|
||||
|
||||
Future<void> _loadSiteList() async {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(currentState.copyWith(sites: []));
|
||||
|
||||
try {
|
||||
final siteDataSource = SiteDataSourceImpl(
|
||||
dio,
|
||||
userStorage,
|
||||
appUserCubit,
|
||||
);
|
||||
final siteRepository = SiteRepositoryImpl(siteDataSource);
|
||||
final getSiteListUseCase = GetSiteListUseCase(siteRepository);
|
||||
|
||||
final user = appUserCubit.state.user;
|
||||
if (user == null) {
|
||||
emit(currentState.copyWith(sites: []));
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await getSiteListUseCase(user.userId);
|
||||
result.fold(
|
||||
(failure) {
|
||||
emit(currentState.copyWith(sites: []));
|
||||
},
|
||||
(sites) {
|
||||
SiteEntity? selectedSite;
|
||||
if (sites.isNotEmpty) {
|
||||
selectedSite = sites.first;
|
||||
}
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
sites: sites,
|
||||
selectedSite: selectedSite,
|
||||
location: selectedSite?.siteName ?? '请选择场站',
|
||||
report: currentState.report.copyWith(
|
||||
siteId: selectedSite?.id,
|
||||
siteName: selectedSite?.siteName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
emit(currentState.copyWith(sites: []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadDeviceList(int siteId) async {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(ReportDevicesLoading());
|
||||
|
||||
try {
|
||||
final robotResponse = await dio.get(
|
||||
HttpApiConsts.getRobotList,
|
||||
queryParameters: {'siteId': siteId, 'pageSize': 9999, 'pageNum': 1},
|
||||
);
|
||||
|
||||
final droneResponse = await dio.get(
|
||||
HttpApiConsts.getSiteUAVList,
|
||||
queryParameters: {'siteId': siteId},
|
||||
);
|
||||
|
||||
List<RobotDataModel> robots = [];
|
||||
List<DroneStationEntity> drones = [];
|
||||
|
||||
if (robotResponse.statusCode == 200 &&
|
||||
robotResponse.data['code'] == 200) {
|
||||
final List<dynamic> rows = robotResponse.data['rows'] ?? [];
|
||||
robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
if (droneResponse.statusCode == 200 &&
|
||||
droneResponse.data['code'] == 200) {
|
||||
final List<dynamic> rows = droneResponse.data['rows'] ?? [];
|
||||
drones = rows
|
||||
.map((item) => DroneStationEntity.fromJson(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(robots: robots, drones: drones));
|
||||
} catch (e) {
|
||||
emit(currentState.copyWith(robots: [], drones: []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择上报类型
|
||||
void selectReportType(ReportType type) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedReportType: type,
|
||||
report: currentState.report.copyWith(reportType: type.labelKey),
|
||||
report: currentState.report.copyWith(reportType: type.orderType),
|
||||
selectedDevice: null,
|
||||
selectedDeviceId: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择设备
|
||||
void selectDevice(String device) {
|
||||
void selectSite(SiteEntity site) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedDevice: device,
|
||||
report: currentState.report.copyWith(deviceId: device),
|
||||
selectedSite: site,
|
||||
location: site.siteName,
|
||||
report: currentState.report.copyWith(
|
||||
siteId: site.id,
|
||||
siteName: site.siteName,
|
||||
),
|
||||
selectedDevice: null,
|
||||
selectedDeviceId: null,
|
||||
robots: [],
|
||||
drones: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void selectDevice(String deviceName, String deviceId) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedDevice: deviceName,
|
||||
selectedDeviceId: deviceId,
|
||||
report: currentState.report.copyWith(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新位置
|
||||
void updateLocation(String location) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -50,7 +181,6 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新问题描述
|
||||
void updateDescription(String description) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -62,20 +192,18 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择问题等级
|
||||
void selectProblemLevel(ProblemLevel level) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
selectedProblemLevel: level,
|
||||
report: currentState.report.copyWith(problemLevel: level.labelKey),
|
||||
report: currentState.report.copyWith(problemLevel: level.level),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加媒体文件
|
||||
void addMediaFile(String filePath) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -90,7 +218,6 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除媒体文件
|
||||
void removeMediaFile(int index) {
|
||||
if (state is ReportFormState) {
|
||||
final currentState = state as ReportFormState;
|
||||
@@ -105,43 +232,68 @@ class ReportCubit extends Cubit<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交上报
|
||||
Future<void> submitReport() async {
|
||||
if (state is! ReportFormState) return;
|
||||
|
||||
final currentState = state as ReportFormState;
|
||||
|
||||
// 验证必填项
|
||||
if (currentState.selectedSite == null) {
|
||||
emit(currentState.copyWith(errorMessage: '请选择场站'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedReportType == null) {
|
||||
emit(currentState.copyWith(errorMessage: '请选择上报类型'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedDevice == null ||
|
||||
currentState.selectedDevice!.isEmpty) {
|
||||
emit(const ReportFailure('请选择设备'));
|
||||
emit(currentState.copyWith(errorMessage: '请选择设备'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.report.description == null ||
|
||||
currentState.report.description!.isEmpty) {
|
||||
emit(const ReportFailure('请填写问题描述'));
|
||||
emit(currentState.copyWith(errorMessage: '请填写问题描述'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentState.selectedProblemLevel == null) {
|
||||
emit(const ReportFailure('请选择问题等级'));
|
||||
emit(currentState.copyWith(errorMessage: '请选择问题等级'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 开始提交
|
||||
emit(ReportSubmitting());
|
||||
|
||||
final result = await submitReportUseCase.execute(currentState.report);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(ReportFailure(failure.message)),
|
||||
(success) => emit(ReportSuccess()),
|
||||
(failure) => emit(currentState.copyWith(errorMessage: failure.message)),
|
||||
(success) {
|
||||
// 提交成功:清空表单,保留场站列表
|
||||
emit(currentState.copyWith(
|
||||
report: ReportEntity(),
|
||||
selectedReportType: null,
|
||||
selectedProblemLevel: null,
|
||||
selectedDevice: null,
|
||||
selectedDeviceId: null,
|
||||
mediaFiles: [],
|
||||
successMessage: '提交成功',
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 重置表单
|
||||
void clearMessages() {
|
||||
if (state is ReportFormState) {
|
||||
final s = state as ReportFormState;
|
||||
emit(s.copyWith(errorMessage: null, successMessage: null));
|
||||
}
|
||||
}
|
||||
|
||||
void resetForm() {
|
||||
emit(ReportFormState(report: ReportEntity()));
|
||||
_loadSiteList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../cubit/report_cubit.dart';
|
||||
import '../states/report_state.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
@@ -14,7 +13,6 @@ import '../widgets/level_selector.dart';
|
||||
import 'media_preview_page.dart';
|
||||
import '../../di/report_di.dart';
|
||||
|
||||
/// 现场上报主页面
|
||||
class ReportPage extends StatelessWidget {
|
||||
const ReportPage({super.key});
|
||||
|
||||
@@ -38,14 +36,30 @@ class _ReportPageContent extends StatelessWidget {
|
||||
backgroundColor: AppColors.cardBackground,
|
||||
body: BlocConsumer<ReportCubit, ReportState>(
|
||||
listener: (context, state) {
|
||||
if (state is ReportSuccess) {
|
||||
_showSuccessDialog(context);
|
||||
} else if (state is ReportFailure) {
|
||||
_showErrorDialog(context, state.message);
|
||||
if (state is ReportFormState) {
|
||||
if (state.successMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.successMessage!),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
context.read<ReportCubit>().clearMessages();
|
||||
} else if (state.errorMessage != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage!),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
context.read<ReportCubit>().clearMessages();
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is ReportSubmitting) {
|
||||
if (state is ReportSubmitting || state is ReportDevicesLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
@@ -55,7 +69,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
final cubit = context.read<ReportCubit>();
|
||||
return Column(
|
||||
children: [
|
||||
_buildAppBar(context, state.location, cubit),
|
||||
_buildAppBar(context, state, cubit),
|
||||
Expanded(child: _buildFormContent(context, state)),
|
||||
],
|
||||
);
|
||||
@@ -68,10 +82,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建导航栏
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
BuildContext context,
|
||||
String location,
|
||||
ReportFormState state,
|
||||
ReportCubit cubit,
|
||||
) {
|
||||
return PreferredSize(
|
||||
@@ -86,13 +99,12 @@ class _ReportPageContent extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 第一行:标题 + 提交按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).translate('report.title'),
|
||||
style: const TextStyle(
|
||||
const Text(
|
||||
'现场上报',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -102,9 +114,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
onTap: () {
|
||||
context.read<ReportCubit>().submitReport();
|
||||
},
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('report.submit'),
|
||||
style: const TextStyle(
|
||||
child: const Text(
|
||||
'提交',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -114,10 +126,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 第二行:位置信息(可点击)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_showLocationPicker(context, cubit);
|
||||
_showSitePicker(context, cubit, state);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -129,7 +140,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
location,
|
||||
state.location,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -156,7 +167,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建表单内容
|
||||
Widget _buildFormContent(BuildContext context, ReportFormState state) {
|
||||
final cubit = context.read<ReportCubit>();
|
||||
|
||||
@@ -168,30 +178,23 @@ class _ReportPageContent extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 上报类型选择
|
||||
ReportTypeSelector(
|
||||
selectedType: state.selectedReportType,
|
||||
onSelected: (type) => cubit.selectReportType(type),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 设备选择
|
||||
DeviceSelector(
|
||||
selectedDevice: state.selectedDevice,
|
||||
onTap: () {
|
||||
_showDevicePicker(context, cubit);
|
||||
_showDevicePicker(context, cubit, state);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 问题描述
|
||||
DescriptionInput(
|
||||
description: state.report.description,
|
||||
onChanged: (value) => cubit.updateDescription(value),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 媒体上传
|
||||
MediaUploader(
|
||||
mediaFiles: state.mediaFiles,
|
||||
onCameraTap: () {
|
||||
@@ -209,8 +212,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
onRemove: (index) => cubit.removeMediaFile(index),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.moduleSpacing),
|
||||
|
||||
// 问题等级选择
|
||||
LevelSelector(
|
||||
selectedLevel: state.selectedProblemLevel,
|
||||
onSelected: (level) => cubit.selectProblemLevel(level),
|
||||
@@ -221,40 +222,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建位置信息行
|
||||
Widget _buildLocationRow(BuildContext context, String location) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.horizontalPadding,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.borderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x0D000000),
|
||||
blurRadius: AppDimensions.cardShadowBlur,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on, color: AppColors.primary, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 选择图片
|
||||
Future<void> _pickImage(ReportCubit cubit) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
@@ -273,7 +240,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择视频
|
||||
Future<void> _pickVideo(ReportCubit cubit) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
@@ -290,7 +256,6 @@ class _ReportPageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览媒体文件
|
||||
void _previewMedia(BuildContext context, List<String> mediaFiles, int index) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -301,11 +266,9 @@ class _ReportPageContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 从相册选择
|
||||
Future<void> _pickFromGallery(BuildContext context, ReportCubit cubit) async {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
try {
|
||||
// 显示选择图片或视频的选项
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
@@ -314,9 +277,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: Text(
|
||||
AppLocalizations.of(context).translate('report.select_image'),
|
||||
),
|
||||
title: const Text('选择图片'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final XFile? image = await picker.pickImage(
|
||||
@@ -332,9 +293,7 @@ class _ReportPageContent extends StatelessWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.video_library),
|
||||
title: Text(
|
||||
AppLocalizations.of(context).translate('report.select_video'),
|
||||
),
|
||||
title: const Text('选择视频'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final XFile? video = await picker.pickVideo(
|
||||
@@ -354,123 +313,246 @@ class _ReportPageContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示位置选择器
|
||||
void _showLocationPicker(BuildContext context, ReportCubit cubit) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
final locations = [
|
||||
loc.translate('report.main_building_a'),
|
||||
loc.translate('report.main_building_b'),
|
||||
loc.translate('report.boiler_room'),
|
||||
loc.translate('report.turbine_room'),
|
||||
loc.translate('report.control_room'),
|
||||
loc.translate('report.power_distribution_room'),
|
||||
];
|
||||
void _showSitePicker(
|
||||
BuildContext context,
|
||||
ReportCubit cubit,
|
||||
ReportFormState currentState,
|
||||
) {
|
||||
if (currentState.sites.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('暂无场站数据')));
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.select_location')),
|
||||
content: SingleChildScrollView(
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: locations.map((location) {
|
||||
return ListTile(
|
||||
title: Text(location),
|
||||
onTap: () {
|
||||
cubit.updateLocation(location);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
children: [
|
||||
const Text(
|
||||
'选择场站',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: currentState.sites.length,
|
||||
itemBuilder: (context, index) {
|
||||
final site = currentState.sites[index];
|
||||
final isSelected = currentState.selectedSite?.id == site.id;
|
||||
|
||||
return ListTile(
|
||||
title: Text(site.siteName),
|
||||
subtitle:
|
||||
site.siteCode != null && site.siteCode!.isNotEmpty
|
||||
? Text('站点编码: ${site.siteCode}')
|
||||
: null,
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: Color(0xFF165DFF))
|
||||
: null,
|
||||
onTap: () {
|
||||
cubit.selectSite(site);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDevicePicker(
|
||||
BuildContext context,
|
||||
ReportCubit cubit,
|
||||
ReportFormState state,
|
||||
) {
|
||||
final selectedType = state.selectedReportType;
|
||||
final selectedSite = state.selectedSite;
|
||||
|
||||
if (selectedSite == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先选择场站')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请先选择上报类型')));
|
||||
return;
|
||||
}
|
||||
|
||||
bool isRobotType = selectedType == ReportType.mowerError;
|
||||
bool isDroneType = selectedType == ReportType.uavError;
|
||||
|
||||
if (!isRobotType && !isDroneType) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('选择设备'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('设备一'),
|
||||
onTap: () {
|
||||
cubit.selectDevice('设备一', 'device_1');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('设备二'),
|
||||
onTap: () {
|
||||
cubit.selectDevice('设备二', 'device_2');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('设备三'),
|
||||
onTap: () {
|
||||
cubit.selectDevice('设备三', 'device_3');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(loc.translate('report.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
/// 显示设备选择器
|
||||
void _showDevicePicker(BuildContext context, ReportCubit cubit) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
final devices = [
|
||||
loc.translate('report.boiler_1'),
|
||||
loc.translate('report.boiler_2'),
|
||||
loc.translate('report.turbine_1'),
|
||||
loc.translate('report.generator_1'),
|
||||
loc.translate('report.transformer_1'),
|
||||
loc.translate('report.water_pump_1'),
|
||||
];
|
||||
Future<void> loadAndShowDevices() async {
|
||||
await cubit.loadDeviceList(selectedSite.id);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.select_device')),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: devices.map((device) {
|
||||
return ListTile(
|
||||
title: Text(device),
|
||||
onTap: () {
|
||||
cubit.selectDevice(device);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(loc.translate('report.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final newState = cubit.state;
|
||||
if (newState is! ReportFormState) return;
|
||||
|
||||
/// 显示成功对话框
|
||||
void _showSuccessDialog(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.submit_success')),
|
||||
content: Text(loc.translate('report.submit_success_message')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context); // 返回上一页
|
||||
},
|
||||
child: Text(loc.translate('report.confirm')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
List<Widget> deviceList = [];
|
||||
|
||||
/// 显示错误对话框
|
||||
void _showErrorDialog(BuildContext context, String message) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(loc.translate('report.submit_failed')),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(loc.translate('report.confirm')),
|
||||
if (isRobotType) {
|
||||
if (newState.robots.isEmpty) {
|
||||
deviceList = [
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'暂无机器人数据',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
deviceList = newState.robots
|
||||
.map(
|
||||
(robot) => ListTile(
|
||||
title: Text(robot.name),
|
||||
subtitle: robot.alias != null && robot.alias!.isNotEmpty
|
||||
? Text(robot.alias!)
|
||||
: null,
|
||||
trailing: Text(robot.status),
|
||||
onTap: () {
|
||||
cubit.selectDevice(robot.name, robot.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
} else if (isDroneType) {
|
||||
if (newState.drones.isEmpty) {
|
||||
deviceList = [
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'暂无无人机数据',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
deviceList = newState.drones
|
||||
.map(
|
||||
(drone) => ListTile(
|
||||
title: Text(drone.callsign),
|
||||
subtitle: Text(drone.deviceSn),
|
||||
onTap: () {
|
||||
cubit.selectDevice(drone.callsign, drone.deviceSn);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isRobotType ? '选择机器人' : '选择无人机',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: ListView(shrinkWrap: true, children: deviceList),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
loadAndShowDevices();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/report_entity.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
import '../../../device_list/data/models/robot_data_model.dart';
|
||||
import '../../../device_list/domain/entities/drone_station_entity.dart';
|
||||
|
||||
/// 上报页面状态抽象类
|
||||
abstract class ReportState extends Equatable {
|
||||
@@ -32,6 +35,11 @@ class ReportFailure extends ReportState {
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// 设备列表加载状态
|
||||
class ReportDevicesLoading extends ReportState {
|
||||
const ReportDevicesLoading();
|
||||
}
|
||||
|
||||
/// 表单数据状态
|
||||
class ReportFormState extends ReportState {
|
||||
final ReportEntity report;
|
||||
@@ -39,7 +47,14 @@ class ReportFormState extends ReportState {
|
||||
final ProblemLevel? selectedProblemLevel;
|
||||
final String location;
|
||||
final String? selectedDevice;
|
||||
final String? selectedDeviceId;
|
||||
final List<String> mediaFiles;
|
||||
final List<SiteEntity> sites;
|
||||
final SiteEntity? selectedSite;
|
||||
final List<RobotDataModel> robots;
|
||||
final List<DroneStationEntity> drones;
|
||||
final String? errorMessage;
|
||||
final String? successMessage;
|
||||
|
||||
const ReportFormState({
|
||||
required this.report,
|
||||
@@ -47,7 +62,14 @@ class ReportFormState extends ReportState {
|
||||
this.selectedProblemLevel,
|
||||
this.location = '江苏省苏州市吴中区',
|
||||
this.selectedDevice,
|
||||
this.selectedDeviceId,
|
||||
this.mediaFiles = const [],
|
||||
this.sites = const [],
|
||||
this.selectedSite,
|
||||
this.robots = const [],
|
||||
this.drones = const [],
|
||||
this.errorMessage,
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
ReportFormState copyWith({
|
||||
@@ -56,7 +78,14 @@ class ReportFormState extends ReportState {
|
||||
ProblemLevel? selectedProblemLevel,
|
||||
String? location,
|
||||
String? selectedDevice,
|
||||
String? selectedDeviceId,
|
||||
List<String>? mediaFiles,
|
||||
List<SiteEntity>? sites,
|
||||
SiteEntity? selectedSite,
|
||||
List<RobotDataModel>? robots,
|
||||
List<DroneStationEntity>? drones,
|
||||
String? errorMessage,
|
||||
String? successMessage,
|
||||
}) {
|
||||
return ReportFormState(
|
||||
report: report ?? this.report,
|
||||
@@ -64,7 +93,14 @@ class ReportFormState extends ReportState {
|
||||
selectedProblemLevel: selectedProblemLevel ?? this.selectedProblemLevel,
|
||||
location: location ?? this.location,
|
||||
selectedDevice: selectedDevice ?? this.selectedDevice,
|
||||
selectedDeviceId: selectedDeviceId ?? this.selectedDeviceId,
|
||||
mediaFiles: mediaFiles ?? this.mediaFiles,
|
||||
sites: sites ?? this.sites,
|
||||
selectedSite: selectedSite ?? this.selectedSite,
|
||||
robots: robots ?? this.robots,
|
||||
drones: drones ?? this.drones,
|
||||
errorMessage: errorMessage,
|
||||
successMessage: successMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +111,13 @@ class ReportFormState extends ReportState {
|
||||
selectedProblemLevel,
|
||||
location,
|
||||
selectedDevice,
|
||||
selectedDeviceId,
|
||||
mediaFiles,
|
||||
sites,
|
||||
selectedSite,
|
||||
robots,
|
||||
drones,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,27 +40,35 @@ class DeviceSelector extends StatelessWidget {
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedDevice ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('report.please_select_device'),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: selectedDevice != null
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textHint,
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
selectedDevice ??
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).translate('report.please_select_device'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: selectedDevice != null
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textHint,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
|
||||
/// 问题等级选择器
|
||||
class LevelSelector extends StatelessWidget {
|
||||
final ProblemLevel? selectedLevel;
|
||||
final Function(ProblemLevel) onSelected;
|
||||
@@ -15,7 +13,6 @@ class LevelSelector extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.horizontalPadding,
|
||||
@@ -35,9 +32,9 @@ class LevelSelector extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
loc.translate('report.problem_level'),
|
||||
style: const TextStyle(
|
||||
const Text(
|
||||
'问题等级',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -54,8 +51,8 @@ class LevelSelector extends StatelessWidget {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: AppDimensions.buttonHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected && level == ProblemLevel.urgent
|
||||
? AppColors.danger.withOpacity(0.1)
|
||||
color: isSelected
|
||||
? level.textColor.withOpacity(0.1)
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
@@ -69,7 +66,7 @@ class LevelSelector extends StatelessWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
loc.translate(level.labelKey),
|
||||
level.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isSelected
|
||||
|
||||
@@ -53,127 +53,131 @@ class MediaUploader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// 拍照按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.camera_alt,
|
||||
label: loc.translate('report.take_photo'),
|
||||
onTap: onCameraTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 录像按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.videocam,
|
||||
label: loc.translate('report.record_video'),
|
||||
onTap: onVideoTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 已上传的媒体预览
|
||||
...mediaFiles.asMap().entries.map((entry) {
|
||||
final String filePath = entry.value;
|
||||
final bool isNetworkImage = filePath.startsWith('http');
|
||||
final bool isVideo = _isVideoFile(filePath);
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Row(
|
||||
children: [
|
||||
// 拍照按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.camera_alt,
|
||||
label: loc.translate('report.take_photo'),
|
||||
onTap: onCameraTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 录像按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.videocam,
|
||||
label: loc.translate('report.record_video'),
|
||||
onTap: onVideoTap,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 已上传的媒体预览
|
||||
...mediaFiles.asMap().entries.map((entry) {
|
||||
final String filePath = entry.value;
|
||||
final bool isNetworkImage = filePath.startsWith('http');
|
||||
final bool isVideo = _isVideoFile(filePath);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onTap?.call(entry.key),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
color: AppColors.cardBackground,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
child: isVideo
|
||||
? _buildVideoThumbnail(filePath)
|
||||
: (isNetworkImage
|
||||
? Image.network(
|
||||
filePath,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.file(
|
||||
File(filePath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => onRemove(entry.key),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
return GestureDetector(
|
||||
onTap: () => onTap?.call(entry.key),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
color: AppColors.cardBackground,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
child: isVideo
|
||||
? _buildVideoThumbnail(filePath)
|
||||
: (isNetworkImage
|
||||
? Image.network(
|
||||
filePath,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Image.file(
|
||||
File(filePath),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildPlaceholderIcon(
|
||||
Icons.broken_image,
|
||||
);
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 视频播放图标
|
||||
if (isVideo)
|
||||
Positioned(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
alignment: Alignment.center,
|
||||
top: 4,
|
||||
right: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => onRemove(entry.key),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black38,
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// 添加按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.add,
|
||||
label: '',
|
||||
onTap: onAddFromGallery,
|
||||
isAddButton: true,
|
||||
),
|
||||
],
|
||||
// 视频播放图标
|
||||
if (isVideo)
|
||||
Positioned(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black38,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
// 添加按钮
|
||||
_buildUploadButton(
|
||||
icon: Icons.add,
|
||||
label: '',
|
||||
onTap: onAddFromGallery,
|
||||
isAddButton: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import '../constants/report_constants.dart';
|
||||
|
||||
/// 上报类型选择器
|
||||
class ReportTypeSelector extends StatelessWidget {
|
||||
final ReportType? selectedType;
|
||||
final Function(ReportType) onSelected;
|
||||
@@ -19,22 +17,24 @@ class ReportTypeSelector extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.horizontalPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: ReportType.values.map((type) {
|
||||
final isSelected = selectedType == type;
|
||||
return Flexible(
|
||||
flex: 1,
|
||||
child: GestureDetector(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Row(
|
||||
children: ReportType.values.map((type) {
|
||||
final isSelected = selectedType == type;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelected(type),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
color: isSelected
|
||||
? AppColors.primary.withOpacity(0.1)
|
||||
: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimensions.borderRadiusSmall,
|
||||
),
|
||||
@@ -60,13 +60,13 @@ class ReportTypeSelector extends StatelessWidget {
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
: AppColors.textHint,
|
||||
size: 26,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
AppLocalizations.of(context).translate(type.labelKey),
|
||||
type.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
color: isSelected
|
||||
? AppColors.primary
|
||||
@@ -82,9 +82,9 @@ class ReportTypeSelector extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,29 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../../../core/network/tcp/tcp_client.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
import '../../../home/domain/usecases/get_site_list_usecase.dart';
|
||||
import '../../../../../core/app/app_user_cubit.dart';
|
||||
|
||||
class SiteState {
|
||||
final List<SiteEntity> sites;
|
||||
final SiteEntity? selectedSite;
|
||||
final bool isLoading;
|
||||
|
||||
const SiteState({
|
||||
this.sites = const [],
|
||||
this.selectedSite,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
SiteState copyWith({
|
||||
List<SiteEntity>? sites,
|
||||
SiteEntity? selectedSite,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return SiteState(
|
||||
sites: sites ?? this.sites,
|
||||
selectedSite: selectedSite ?? this.selectedSite,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,9 +34,47 @@ class SiteState {
|
||||
class SiteCubit extends Cubit<SiteState> {
|
||||
final SharedPreferences sharedPreferences;
|
||||
static const String _selectedSiteIdKey = 'selected_site_id';
|
||||
bool _hasLoadedSites = false;
|
||||
|
||||
SiteCubit(this.sharedPreferences) : super(const SiteState());
|
||||
|
||||
/// 加载场站列表(首次调用时请求接口,之后不重复请求)
|
||||
Future<void> loadSites() async {
|
||||
if (_hasLoadedSites) return;
|
||||
_hasLoadedSites = true;
|
||||
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
final userId = GetIt.I<AppUserCubit>().state.user?.userId;
|
||||
if (userId == null) {
|
||||
emit(state.copyWith(isLoading: false));
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await GetIt.I<GetSiteListUseCase>().call(userId);
|
||||
result.fold(
|
||||
(failure) {
|
||||
debugPrint('❌ [SiteCubit] 加载场站列表失败: ${failure.message}');
|
||||
emit(state.copyWith(isLoading: false));
|
||||
},
|
||||
(sites) {
|
||||
updateSites(sites);
|
||||
emit(state.copyWith(isLoading: false));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SiteCubit] 加载场站列表异常: $e');
|
||||
emit(state.copyWith(isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制重新加载场站列表
|
||||
Future<void> reloadSites() async {
|
||||
_hasLoadedSites = false;
|
||||
await loadSites();
|
||||
}
|
||||
|
||||
/// 更新场站列表
|
||||
void updateSites(List<SiteEntity> sites) {
|
||||
// 尝试恢复之前选中的场站
|
||||
@@ -52,8 +96,10 @@ class SiteCubit extends Cubit<SiteState> {
|
||||
/// 选择场站(持久化)
|
||||
void selectSite(SiteEntity site) {
|
||||
debugPrint('🏭 [SiteCubit] ========== 切换场站 ==========');
|
||||
debugPrint('🏭 [SiteCubit] 从 ${state.selectedSite?.siteName ?? "无"} 切换到 ${site.siteName}');
|
||||
|
||||
debugPrint(
|
||||
'🏭 [SiteCubit] 从 ${state.selectedSite?.siteName ?? "无"} 切换到 ${site.siteName}',
|
||||
);
|
||||
|
||||
// 🔥 关键修复:只有切换到不同场站时才断开TCP
|
||||
if (state.selectedSite?.id != site.id) {
|
||||
final tcpClient = GetIt.I<TcpClient>();
|
||||
@@ -67,7 +113,7 @@ class SiteCubit extends Cubit<SiteState> {
|
||||
} else {
|
||||
debugPrint('✅ [SiteCubit] 相同场站,保持TCP连接状态');
|
||||
}
|
||||
|
||||
|
||||
sharedPreferences.setInt(_selectedSiteIdKey, site.id);
|
||||
emit(state.copyWith(selectedSite: site));
|
||||
debugPrint('✅ [SiteCubit] 场站切换完成');
|
||||
@@ -81,6 +127,7 @@ class SiteCubit extends Cubit<SiteState> {
|
||||
|
||||
/// 清空所有场站数据(退出登录时调用)
|
||||
void clearAll() {
|
||||
_hasLoadedSites = false;
|
||||
sharedPreferences.remove(_selectedSiteIdKey);
|
||||
emit(const SiteState());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/domain/entities/site_entity.dart';
|
||||
|
||||
class SiteSelectorWidget extends StatefulWidget {
|
||||
final bool compact;
|
||||
final bool enabled;
|
||||
final void Function(SiteEntity site)? onSiteSelected;
|
||||
|
||||
const SiteSelectorWidget({
|
||||
super.key,
|
||||
this.compact = false,
|
||||
this.enabled = true,
|
||||
this.onSiteSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SiteSelectorWidget> createState() => _SiteSelectorWidgetState();
|
||||
}
|
||||
|
||||
class _SiteSelectorWidgetState extends State<SiteSelectorWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
GetIt.I<SiteCubit>().loadSites();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<SiteState>(
|
||||
stream: GetIt.I<SiteCubit>().stream,
|
||||
builder: (context, snapshot) {
|
||||
final siteState = snapshot.data ?? GetIt.I<SiteCubit>().state;
|
||||
final selectedSite = siteState.selectedSite;
|
||||
final sites = siteState.sites;
|
||||
|
||||
if (widget.compact) {
|
||||
return _buildCompact(context, selectedSite, sites);
|
||||
}
|
||||
return _buildNormal(context, selectedSite, sites);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 紧凑模式(用于空间有限的场景)
|
||||
Widget _buildCompact(
|
||||
BuildContext context,
|
||||
SiteEntity? selectedSite,
|
||||
List<SiteEntity> sites,
|
||||
) {
|
||||
const iconSize = 16.0;
|
||||
const spacing = 2.0;
|
||||
final text = selectedSite?.siteName ?? '请选择场站';
|
||||
|
||||
return InkWell(
|
||||
onTap: widget.enabled ? () => _showSitePicker(context, sites) : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxTextWidth = constraints.maxWidth - iconSize - spacing;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: spacing),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: iconSize,
|
||||
color: widget.enabled
|
||||
? const Color(0xFF4E5969)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 普通模式
|
||||
Widget _buildNormal(
|
||||
BuildContext context,
|
||||
SiteEntity? selectedSite,
|
||||
List<SiteEntity> sites,
|
||||
) {
|
||||
const iconSize = 18.0;
|
||||
const spacing = 3.0;
|
||||
final text = selectedSite?.siteName ?? '请选择场站';
|
||||
|
||||
return InkWell(
|
||||
onTap: widget.enabled ? () => _showSitePicker(context, sites) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxTextWidth = constraints.maxWidth - iconSize - spacing;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxTextWidth > 0 ? maxTextWidth : 0,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: spacing),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: iconSize,
|
||||
color: widget.enabled
|
||||
? const Color(0xFF4E5969)
|
||||
: const Color(0xFFC9CDD4),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出场站选择底部弹窗
|
||||
void _showSitePicker(BuildContext context, List<SiteEntity> sites) {
|
||||
if (sites.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('暂无场站数据')));
|
||||
return;
|
||||
}
|
||||
|
||||
final siteCubit = GetIt.I<SiteCubit>();
|
||||
final currentSiteId = siteCubit.state.selectedSite?.id;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
const Text(
|
||||
'选择场站',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 22,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 8),
|
||||
// 场站列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: sites.length,
|
||||
itemBuilder: (context, index) {
|
||||
final site = sites[index];
|
||||
final isSelected = currentSiteId == site.id;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFFE8F3FF)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 2,
|
||||
),
|
||||
title: Text(
|
||||
site.siteName,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected
|
||||
? const Color(0xFF165DFF)
|
||||
: const Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 20,
|
||||
color: Color(0xFF165DFF),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
siteCubit.selectSite(site);
|
||||
widget.onSiteSelected?.call(site);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ abstract class AlarmDetailRemoteDataSource {
|
||||
/// 获取告警详情
|
||||
Future<AlarmDetailModel> getAlarmDetail(String alarmId);
|
||||
|
||||
/// 确认告警
|
||||
Future<bool> confirmAlarm(String alarmId);
|
||||
/// 确认告警(处理中)
|
||||
Future<Map<String, dynamic>> confirmAlarm(String alarmId);
|
||||
|
||||
/// 处理告警(已关闭)
|
||||
Future<Map<String, dynamic>> handleAlarm(Map<String, dynamic> handleData);
|
||||
|
||||
/// AI诊断
|
||||
Future<String> aiDiagnosis(String alarmId);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import '../../domain/entities/alarm_dispatch_entity.dart';
|
||||
|
||||
/// 告警派发远程数据源抽象
|
||||
abstract class AlarmDispatchRemoteDataSource {
|
||||
Future<Either<Failure, bool>> dispatchWorkOrder(AlarmDispatchEntity entity);
|
||||
}
|
||||
@@ -2,9 +2,17 @@ import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_mod
|
||||
|
||||
/// 告警远程数据源抽象
|
||||
abstract class AlarmRemoteDataSource {
|
||||
/// 获取告警列表
|
||||
Future<List<AlarmModel>> getAlarmList();
|
||||
/// 获取告警列表(支持分页)
|
||||
Future<List<AlarmModel>> getAlarmList({
|
||||
int? siteId,
|
||||
int? configId,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
});
|
||||
|
||||
/// 获取告警统计
|
||||
Future<AlarmCountModel> getAlarmCount();
|
||||
Future<AlarmCountModel> getAlarmCount({int? siteId});
|
||||
|
||||
/// 获取告警工单配置列表
|
||||
Future<List<AlarmOrderConfigModel>> getAlarmOrderConfigList(int siteId);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,118 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_detail_model.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_handle_model.dart';
|
||||
|
||||
/// 告警详情远程数据源实现(模拟数据)
|
||||
/// 告警详情远程数据源实现
|
||||
class AlarmDetailRemoteDataSourceImpl implements AlarmDetailRemoteDataSource {
|
||||
AlarmDetailRemoteDataSourceImpl(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<AlarmDetailModel> getAlarmDetail(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final uri = '${HttpApiConsts.alarmDetail}/$alarmId';
|
||||
debugPrint('=== 告警详情请求 ===');
|
||||
debugPrint('uri: `$uri`');
|
||||
|
||||
return AlarmDetailModel(
|
||||
id: alarmId,
|
||||
title: '逆变器离网告警',
|
||||
level: '严重',
|
||||
deviceInfo: '光伏区A / 逆变器 INV-001',
|
||||
occurTime: '2025-05-19T09:24:30',
|
||||
alarmStatus: '未处理',
|
||||
recoverTime: null,
|
||||
duration: '36分钟',
|
||||
affectRange: '光伏区A(2.6 MW)',
|
||||
description: '逆变器与电网失去连接,功率输出为0。',
|
||||
suggestions: [
|
||||
'检查逆变器侧并网开关状态',
|
||||
'检查电网电压及频率是否异常',
|
||||
],
|
||||
historyData: HistoryDataModel(
|
||||
power: [
|
||||
MetricPointModel(time: '08:24', value: 1050),
|
||||
MetricPointModel(time: '08:54', value: 980),
|
||||
MetricPointModel(time: '09:04', value: 1020),
|
||||
MetricPointModel(time: '09:14', value: 990),
|
||||
MetricPointModel(time: '09:19', value: 1010),
|
||||
MetricPointModel(time: '09:24', value: 100),
|
||||
MetricPointModel(time: '09:24:30', value: 0),
|
||||
],
|
||||
voltage: [],
|
||||
frequency: [],
|
||||
),
|
||||
final response = await _dio.get(uri);
|
||||
|
||||
debugPrint('statusCode: ${response.statusCode}');
|
||||
debugPrint('statusMessage: ${response.statusMessage}');
|
||||
debugPrint('Response Text: ${response.data}');
|
||||
debugPrint('=== 告警详情响应结束 ===');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '获取告警详情失败');
|
||||
}
|
||||
|
||||
return AlarmDetailModel.fromJson(
|
||||
responseData['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> confirmAlarm(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return true;
|
||||
Future<Map<String, dynamic>> confirmAlarm(String alarmId) async {
|
||||
final requestData = AlarmHandleModel(alarmId: alarmId, handleStatus: 2);
|
||||
|
||||
debugPrint('=== 确认告警请求 ===');
|
||||
debugPrint('uri: ${HttpApiConsts.alarmHandle}');
|
||||
debugPrint('requestData: ${requestData.toJson()}');
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.alarmHandle,
|
||||
data: requestData.toJson(),
|
||||
);
|
||||
|
||||
debugPrint('statusCode: ${response.statusCode}');
|
||||
debugPrint('statusMessage: ${response.statusMessage}');
|
||||
debugPrint('Response Data: ${response.data}');
|
||||
debugPrint('=== 确认告警响应结束 ===');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '确认告警失败');
|
||||
}
|
||||
|
||||
return responseData as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> handleAlarm(
|
||||
Map<String, dynamic> handleData,
|
||||
) async {
|
||||
debugPrint('=== 处理告警请求 ===');
|
||||
debugPrint('uri: ${HttpApiConsts.alarmHandle}');
|
||||
debugPrint('requestData: $handleData');
|
||||
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.alarmHandle,
|
||||
data: handleData,
|
||||
);
|
||||
|
||||
debugPrint('statusCode: ${response.statusCode}');
|
||||
debugPrint('statusMessage: ${response.statusMessage}');
|
||||
debugPrint('Response Data: ${response.data}');
|
||||
debugPrint('=== 处理告警响应结束 ===');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '处理告警失败');
|
||||
}
|
||||
|
||||
return responseData as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> aiDiagnosis(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
return 'AI分析结果:\n\n根据历史数据分析,该告警可能是由于以下原因导致:\n1. 电网侧电压波动超过阈值\n2. 逆变器保护机制触发\n3. 并网开关异常断开\n\n建议优先检查并网开关状态和电网电压稳定性。';
|
||||
final response = await _dio.get(
|
||||
'${HttpApiConsts.alarmDetail}/$alarmId/aiDiagnosis',
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? 'AI诊断失败');
|
||||
}
|
||||
|
||||
return responseData['data'] as String? ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../../../core/error/failure.dart';
|
||||
import '../../../../../../core/app/app_user_cubit.dart';
|
||||
import '../../../domain/entities/alarm_dispatch_entity.dart';
|
||||
import '../alarm_dispatch_remote_datasource.dart';
|
||||
|
||||
/// 告警派发远程数据源实现
|
||||
class AlarmDispatchRemoteDataSourceImpl
|
||||
implements AlarmDispatchRemoteDataSource {
|
||||
@override
|
||||
Future<Either<Failure, bool>> dispatchWorkOrder(
|
||||
AlarmDispatchEntity entity,
|
||||
) async {
|
||||
print('========================================');
|
||||
print('[告警派发工单] 开始提交');
|
||||
print('[告警派发工单] 请求URL: ${HttpApiConsts.workOrderAdd}');
|
||||
|
||||
try {
|
||||
// 格式化计划时间
|
||||
String formatDateTime(DateTime? dt) {
|
||||
if (dt == null) return '';
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:'
|
||||
'${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// 构造 workOrder JSON
|
||||
final workOrder = <String, dynamic>{
|
||||
'siteId': entity.siteId,
|
||||
'siteName': entity.siteName ?? '',
|
||||
'sourceType': 7, // 7=告警关联
|
||||
'orderType': entity.orderType ?? '',
|
||||
'deviceId': entity.deviceId ?? '',
|
||||
'deviceName': entity.deviceName ?? '',
|
||||
'taskDescription': entity.description ?? '',
|
||||
'priorityLevel': entity.problemLevel ?? 'INFO',
|
||||
'orderTitle': '${entity.orderType ?? '告警派发'} - ${entity.deviceName ?? ''}',
|
||||
'orderStatus': 1, // 1=待处理
|
||||
'imgUrl': <String>[],
|
||||
'videoUrl': <String>[],
|
||||
'alarmId': entity.alarmId,
|
||||
'alarmNo': entity.alarmNo ?? '',
|
||||
'planStartTime': formatDateTime(entity.planStartTime),
|
||||
'planEndTime': formatDateTime(entity.planEndTime),
|
||||
};
|
||||
final workOrderJson = jsonEncode(workOrder);
|
||||
print('[告警派发工单] workOrder JSON: $workOrderJson');
|
||||
|
||||
// 分离图片和视频
|
||||
final imagePaths = <String>[];
|
||||
final videoPaths = <String>[];
|
||||
const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'wmv', 'flv', '3gp'];
|
||||
|
||||
if (entity.mediaUrls != null) {
|
||||
for (final path in entity.mediaUrls!) {
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
if (videoExts.contains(ext)) {
|
||||
videoPaths.add(path);
|
||||
} else {
|
||||
imagePaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
print('[告警派发工单] 图片: ${imagePaths.length}个, 视频: ${videoPaths.length}个');
|
||||
|
||||
// 创建 multipart 请求
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse(HttpApiConsts.workOrderAdd),
|
||||
);
|
||||
|
||||
// Authorization
|
||||
final userToken = GetIt.I<AppUserCubit>().state.user?.token;
|
||||
if (userToken != null) {
|
||||
request.headers['Authorization'] = 'Bearer $userToken';
|
||||
}
|
||||
|
||||
// 图片文件
|
||||
if (imagePaths.isNotEmpty) {
|
||||
final firstImage = imagePaths.first;
|
||||
final file = File(firstImage);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: firstImage.split('/').last,
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
<int>[],
|
||||
filename: 'empty.jpg',
|
||||
contentType: http.MediaType('image', 'jpeg'),
|
||||
));
|
||||
}
|
||||
|
||||
// 视频文件
|
||||
if (videoPaths.isNotEmpty) {
|
||||
final firstVideo = videoPaths.first;
|
||||
final file = File(firstVideo);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
bytes,
|
||||
filename: firstVideo.split('/').last,
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'video',
|
||||
<int>[],
|
||||
filename: 'empty.mp4',
|
||||
contentType: http.MediaType('video', 'mp4'),
|
||||
));
|
||||
}
|
||||
|
||||
// workOrder JSON
|
||||
request.files.add(http.MultipartFile.fromBytes(
|
||||
'workOrder',
|
||||
utf8.encode(workOrderJson),
|
||||
filename: '',
|
||||
contentType: http.MediaType('application', 'json'),
|
||||
));
|
||||
|
||||
print('[告警派发工单] 开始发送请求...');
|
||||
|
||||
final http.StreamedResponse response = await request.send();
|
||||
final String responseBody = await response.stream.bytesToString();
|
||||
|
||||
print('[告警派发工单] 响应状态码: ${response.statusCode}');
|
||||
print('[告警派发工单] 响应体: $responseBody');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final respJson = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
final code = respJson['code'];
|
||||
if (code != null && code.toString() == '200') {
|
||||
print('[告警派发工单] 提交成功');
|
||||
print('========================================');
|
||||
return right(true);
|
||||
} else {
|
||||
final msg = respJson['msg'] ?? respJson['message'] ?? '提交失败';
|
||||
print('[告警派发工单] 业务失败: $msg');
|
||||
print('========================================');
|
||||
return left(Failure(msg.toString()));
|
||||
}
|
||||
} else {
|
||||
print('[告警派发工单] HTTP错误: ${response.statusCode}');
|
||||
print('========================================');
|
||||
return left(
|
||||
Failure('服务器错误: HTTP ${response.statusCode}'),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[告警派发工单] 异常: $e');
|
||||
print('========================================');
|
||||
return left(Failure('提交失败: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,56 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_model.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
|
||||
/// 告警远程数据源实现(模拟数据)
|
||||
/// 告警远程数据源实现
|
||||
class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource {
|
||||
@override
|
||||
Future<List<AlarmModel>> getAlarmList() async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
AlarmRemoteDataSourceImpl(this._dio);
|
||||
|
||||
return [
|
||||
AlarmModel(
|
||||
id: '1',
|
||||
title: '逆变器INV-001通讯异常',
|
||||
area: 'A区',
|
||||
device: 'INV-001',
|
||||
time: '05-21 10:23',
|
||||
level: AlarmLevel.danger,
|
||||
status: AlarmStatus.unconfirmed,
|
||||
aiDiagnosis: 'INV-001通讯异常可能由网络波动或采集器故障引起,建议检查网络连接。',
|
||||
),
|
||||
AlarmModel(
|
||||
id: '2',
|
||||
title: '汇流箱SCB-003过温告警',
|
||||
area: 'B区',
|
||||
device: 'SCB-003',
|
||||
time: '05-21 09:45',
|
||||
level: AlarmLevel.warning,
|
||||
status: AlarmStatus.unconfirmed,
|
||||
),
|
||||
AlarmModel(
|
||||
id: '3',
|
||||
title: '组件串STR-024功率异常',
|
||||
area: 'C区',
|
||||
device: 'STR-024',
|
||||
time: '05-21 08:32',
|
||||
level: AlarmLevel.low,
|
||||
status: AlarmStatus.confirmed,
|
||||
),
|
||||
AlarmModel(
|
||||
id: '4',
|
||||
title: '环境辐照度传感器离线',
|
||||
area: 'D区',
|
||||
device: 'SEN-001',
|
||||
time: '05-21 07:15',
|
||||
level: AlarmLevel.info,
|
||||
status: AlarmStatus.confirmed,
|
||||
),
|
||||
];
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<List<AlarmModel>> getAlarmList({
|
||||
int? siteId,
|
||||
int? configId,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (siteId != null) queryParams['siteId'] = siteId;
|
||||
if (configId != null) queryParams['configId'] = configId;
|
||||
if (page != null) queryParams['page'] = page;
|
||||
if (pageSize != null) queryParams['pageSize'] = pageSize;
|
||||
|
||||
debugPrint('=== 获取告警列表请求 ===');
|
||||
debugPrint('uri: ${HttpApiConsts.alarmList}');
|
||||
debugPrint('queryParams: $queryParams');
|
||||
|
||||
final response = await _dio.get(
|
||||
HttpApiConsts.alarmList,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '获取告警列表失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = responseData['rows'] ?? [];
|
||||
return rows
|
||||
.map((item) => AlarmModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AlarmCountModel> getAlarmCount() async {
|
||||
Future<AlarmCountModel> getAlarmCount({int? siteId}) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
@@ -62,4 +61,31 @@ class AlarmRemoteDataSourceImpl implements AlarmRemoteDataSource {
|
||||
todayNew: 12,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AlarmOrderConfigModel>> getAlarmOrderConfigList(
|
||||
int siteId,
|
||||
) async {
|
||||
final response = await _dio.post(
|
||||
HttpApiConsts.alarmOrderConfigList,
|
||||
data: {'siteId': siteId},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '获取告警工单配置失败');
|
||||
}
|
||||
|
||||
final List<dynamic> rows = responseData['rows'] ?? [];
|
||||
return rows
|
||||
.map(
|
||||
(item) =>
|
||||
AlarmOrderConfigModel.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user