告警中心的二级详情页面+信息中心的页面+对接机器人接口
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
|
||||
abstract class DroneStationDataSource {
|
||||
Future<List<DroneStationEntity>> getDroneStationList(int siteId);
|
||||
Future<UAVDetailEntity> getUAVDetail(String gatewaySn, String deviceSn);
|
||||
Future<VideoStreamEntity> getVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
required String cameraPosition,
|
||||
String qualityType = 'adaptive',
|
||||
int videoExpire = 7200,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
|
||||
import '../datasources/drone_station_datasource.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
|
||||
class DroneStationDataSourceImpl implements DroneStationDataSource {
|
||||
final Dio dio;
|
||||
|
||||
DroneStationDataSourceImpl(this.dio);
|
||||
|
||||
@override
|
||||
Future<List<DroneStationEntity>> getDroneStationList(int siteId) async {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getSiteUAVList,
|
||||
queryParameters: {'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) => DroneStationEntity.fromJson(item)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UAVDetailEntity> getUAVDetail(
|
||||
String gatewaySn,
|
||||
String deviceSn,
|
||||
) async {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getUAVState,
|
||||
queryParameters: {'sn': gatewaySn, 'droneSn': deviceSn},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '业务异常');
|
||||
}
|
||||
|
||||
return UAVDetailEntity.fromJson(responseData['data']);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VideoStreamEntity> getVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
required String cameraPosition,
|
||||
String qualityType = 'adaptive',
|
||||
int videoExpire = 7200,
|
||||
}) async {
|
||||
final response = await dio.post(
|
||||
HttpApiConsts.changeCamera,
|
||||
data: {
|
||||
'sn': sn,
|
||||
'cameraIndex': cameraIndex,
|
||||
'cameraPosition': cameraPosition,
|
||||
'qualityType': qualityType,
|
||||
'videoExpire': videoExpire,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '业务异常');
|
||||
}
|
||||
|
||||
return VideoStreamEntity.fromJson(responseData['data']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/// 机器人数据模型
|
||||
class RobotDataModel {
|
||||
final String name;
|
||||
final String id;
|
||||
final String type;
|
||||
final String status;
|
||||
final double battery;
|
||||
final String task;
|
||||
|
||||
const RobotDataModel({
|
||||
required this.name,
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.battery,
|
||||
required this.task,
|
||||
});
|
||||
|
||||
/// 从 JSON 创建数据模型
|
||||
factory RobotDataModel.fromJson(Map<String, dynamic> json) {
|
||||
return RobotDataModel(
|
||||
name: json['deviceName'] ?? json['name'] ?? '',
|
||||
id: json['deviceId']?.toString() ?? json['id']?.toString() ?? '',
|
||||
type: json['deviceTypeName'] ?? json['type'] ?? '未知类型',
|
||||
status: _getStatusText(json['status'] ?? 0),
|
||||
battery: (json['battery'] as num?)?.toDouble() ??
|
||||
(json['capacity_percent'] as num?)?.toDouble() ?? 0.0,
|
||||
task: json['task'] ?? json['currentTask'] ?? '待机中',
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为 JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'id': id,
|
||||
'type': type,
|
||||
'status': status,
|
||||
'battery': battery,
|
||||
'task': task,
|
||||
};
|
||||
}
|
||||
|
||||
/// 状态码转换
|
||||
static String _getStatusText(int status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '在线';
|
||||
case 2:
|
||||
return '离线';
|
||||
case 3:
|
||||
return '异常';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../datasources/drone_station_datasource.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
import '../../domain/repositories/drone_station_repository.dart';
|
||||
|
||||
class DroneStationRepositoryImpl implements DroneStationRepository {
|
||||
final DroneStationDataSource dataSource;
|
||||
|
||||
DroneStationRepositoryImpl(this.dataSource);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<DroneStationEntity>>> getDroneStationList(
|
||||
int siteId,
|
||||
) async {
|
||||
try {
|
||||
final stations = await dataSource.getDroneStationList(siteId);
|
||||
return Right(stations);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, UAVDetailEntity>> getUAVDetail(
|
||||
String gatewaySn,
|
||||
String deviceSn,
|
||||
) async {
|
||||
try {
|
||||
final detail = await dataSource.getUAVDetail(gatewaySn, deviceSn);
|
||||
return Right(detail);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, VideoStreamEntity>> getVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
required String cameraPosition,
|
||||
}) async {
|
||||
try {
|
||||
final videoStream = await dataSource.getVideoStream(
|
||||
sn: sn,
|
||||
cameraIndex: cameraIndex,
|
||||
cameraPosition: cameraPosition,
|
||||
);
|
||||
return Right(videoStream);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 摄像头信息
|
||||
class CameraInfo extends Equatable {
|
||||
final String cameraIndex;
|
||||
final List<String>? availableCameraPositions;
|
||||
final String cameraPosition;
|
||||
|
||||
const CameraInfo({
|
||||
required this.cameraIndex,
|
||||
this.availableCameraPositions,
|
||||
required this.cameraPosition,
|
||||
});
|
||||
|
||||
factory CameraInfo.fromJson(Map<String, dynamic> json) {
|
||||
return CameraInfo(
|
||||
cameraIndex: json['camera_index'] ?? '',
|
||||
availableCameraPositions: json['available_camera_positions'] != null
|
||||
? List<String>.from(json['available_camera_positions'])
|
||||
: null,
|
||||
cameraPosition: json['camera_position'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'camera_index': cameraIndex,
|
||||
'available_camera_positions': availableCameraPositions,
|
||||
'camera_position': cameraPosition,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
cameraIndex,
|
||||
availableCameraPositions,
|
||||
cameraPosition,
|
||||
];
|
||||
}
|
||||
|
||||
/// 位置状态
|
||||
class PositionState extends Equatable {
|
||||
final int gpsNumber;
|
||||
final String isFixed;
|
||||
final String quality;
|
||||
final int rtkNumber;
|
||||
|
||||
const PositionState({
|
||||
required this.gpsNumber,
|
||||
required this.isFixed,
|
||||
required this.quality,
|
||||
required this.rtkNumber,
|
||||
});
|
||||
|
||||
factory PositionState.fromJson(Map<String, dynamic> json) {
|
||||
return PositionState(
|
||||
gpsNumber: json['gps_number'] ?? 0,
|
||||
isFixed: json['is_fixed'] ?? '',
|
||||
quality: json['quality'] ?? '',
|
||||
rtkNumber: json['rtk_number'] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'gps_number': gpsNumber,
|
||||
'is_fixed': isFixed,
|
||||
'quality': quality,
|
||||
'rtk_number': rtkNumber,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [gpsNumber, isFixed, quality, rtkNumber];
|
||||
}
|
||||
|
||||
/// UAV详情实体(用于详情页面API返回的数据)
|
||||
class UAVDetailEntity extends Equatable {
|
||||
final String deviceSn;
|
||||
final String gatewaySn;
|
||||
final String callsign;
|
||||
final String droneCallsign;
|
||||
final int onlineStatus;
|
||||
final int droneOnlineStatus;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final double? capacityPercent;
|
||||
final double? windSpeed;
|
||||
final double? height;
|
||||
final double? environmentTemperature;
|
||||
final dynamic networkState;
|
||||
final PositionState? positionState;
|
||||
final double? homeDistance;
|
||||
final double? liveCapacity;
|
||||
final String? rainfall;
|
||||
final List<CameraInfo>? gatewayCameraList;
|
||||
final List<dynamic>? droneCameraList;
|
||||
final int? orgId;
|
||||
final int? siteId;
|
||||
final int? userId;
|
||||
|
||||
const UAVDetailEntity({
|
||||
required this.deviceSn,
|
||||
required this.gatewaySn,
|
||||
required this.callsign,
|
||||
required this.droneCallsign,
|
||||
required this.onlineStatus,
|
||||
required this.droneOnlineStatus,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.capacityPercent,
|
||||
this.windSpeed,
|
||||
this.height,
|
||||
this.environmentTemperature,
|
||||
this.networkState,
|
||||
this.positionState,
|
||||
this.homeDistance,
|
||||
this.liveCapacity,
|
||||
this.rainfall,
|
||||
this.gatewayCameraList,
|
||||
this.droneCameraList,
|
||||
this.orgId,
|
||||
this.siteId,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
factory UAVDetailEntity.fromJson(Map<String, dynamic> json) {
|
||||
return UAVDetailEntity(
|
||||
deviceSn: json['device_sn'] ?? '',
|
||||
gatewaySn: json['gateway_sn'] ?? '',
|
||||
callsign: json['callsign'] ?? '',
|
||||
droneCallsign: json['drone_callsign'] ?? '',
|
||||
onlineStatus: json['onlineStatus'] ?? 0,
|
||||
droneOnlineStatus: json['drone_onlineStatus'] ?? 0,
|
||||
latitude: json['latitude'] != null
|
||||
? (json['latitude'] as num).toDouble()
|
||||
: null,
|
||||
longitude: json['longitude'] != null
|
||||
? (json['longitude'] as num).toDouble()
|
||||
: null,
|
||||
capacityPercent: json['capacity_percent'] != null
|
||||
? (json['capacity_percent'] as num).toDouble()
|
||||
: null,
|
||||
windSpeed: json['wind_speed'] != null
|
||||
? (json['wind_speed'] as num).toDouble()
|
||||
: null,
|
||||
height: json['height'] != null
|
||||
? (json['height'] as num).toDouble()
|
||||
: null,
|
||||
environmentTemperature: json['environment_temperature'] != null
|
||||
? (json['environment_temperature'] as num).toDouble()
|
||||
: null,
|
||||
networkState: json['network_state'],
|
||||
positionState: json['position_state'] != null
|
||||
? PositionState.fromJson(json['position_state'])
|
||||
: null,
|
||||
homeDistance: json['home_distance'] != null
|
||||
? (json['home_distance'] as num).toDouble()
|
||||
: null,
|
||||
liveCapacity: json['live_capacity'] != null
|
||||
? (json['live_capacity'] as num).toDouble()
|
||||
: null,
|
||||
rainfall: json['rainfall']?.toString(),
|
||||
gatewayCameraList: json['gateway_camera_list'] != null
|
||||
? (json['gateway_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
.toList()
|
||||
: null,
|
||||
droneCameraList: json['drone_camera_list'],
|
||||
orgId: json['orgId'],
|
||||
siteId: json['siteId'],
|
||||
userId: json['userId'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'device_sn': deviceSn,
|
||||
'gateway_sn': gatewaySn,
|
||||
'callsign': callsign,
|
||||
'drone_callsign': droneCallsign,
|
||||
'onlineStatus': onlineStatus,
|
||||
'drone_onlineStatus': droneOnlineStatus,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'capacity_percent': capacityPercent,
|
||||
'wind_speed': windSpeed,
|
||||
'height': height,
|
||||
'environment_temperature': environmentTemperature,
|
||||
'network_state': networkState,
|
||||
'position_state': positionState?.toJson(),
|
||||
'home_distance': homeDistance,
|
||||
'live_capacity': liveCapacity,
|
||||
'rainfall': rainfall,
|
||||
'gateway_camera_list': gatewayCameraList?.map((c) => c.toJson()).toList(),
|
||||
'drone_camera_list': droneCameraList,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
'userId': userId,
|
||||
};
|
||||
}
|
||||
|
||||
bool get isOnline => onlineStatus == 1;
|
||||
bool get isDroneOnline => droneOnlineStatus == 1;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
deviceSn,
|
||||
gatewaySn,
|
||||
callsign,
|
||||
droneCallsign,
|
||||
onlineStatus,
|
||||
droneOnlineStatus,
|
||||
latitude,
|
||||
longitude,
|
||||
capacityPercent,
|
||||
windSpeed,
|
||||
height,
|
||||
environmentTemperature,
|
||||
networkState,
|
||||
positionState,
|
||||
homeDistance,
|
||||
liveCapacity,
|
||||
rainfall,
|
||||
gatewayCameraList,
|
||||
droneCameraList,
|
||||
orgId,
|
||||
siteId,
|
||||
userId,
|
||||
];
|
||||
}
|
||||
|
||||
/// 无人机机场实体
|
||||
class DroneStationEntity extends Equatable {
|
||||
final String deviceSn; // 机场设备序列号
|
||||
final String gatewaySn; // 网关序列号
|
||||
final String callsign; // 机场呼号/名称
|
||||
final String droneCallsign; // 无人机呼号
|
||||
final int onlineStatus; // 机场在线状态 (1:在线, 0:离线)
|
||||
final int droneOnlineStatus; // 无人机在线状态
|
||||
final double? latitude; // 纬度
|
||||
final double? longitude; // 经度
|
||||
final double? capacityPercent; // 电量百分比
|
||||
final double? windSpeed; // 风速
|
||||
final double? height; // 高度
|
||||
final double? environmentTemperature; // 环境温度
|
||||
final int? networkState; // 网络状态
|
||||
final int? positionState; // 位置状态
|
||||
final double? homeDistance; // 距离home点距离
|
||||
final double? liveCapacity; // 实时容量
|
||||
final double? rainfall; // 降雨量
|
||||
final List<CameraInfo>? gatewayCameraList; // 网关摄像头列表
|
||||
final List<dynamic>? droneCameraList; // 无人机摄像头列表
|
||||
final int orgId; // 组织ID
|
||||
final int siteId; // 场站ID
|
||||
final int? userId; // 用户ID
|
||||
|
||||
const DroneStationEntity({
|
||||
required this.deviceSn,
|
||||
required this.gatewaySn,
|
||||
required this.callsign,
|
||||
required this.droneCallsign,
|
||||
required this.onlineStatus,
|
||||
required this.droneOnlineStatus,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.capacityPercent,
|
||||
this.windSpeed,
|
||||
this.height,
|
||||
this.environmentTemperature,
|
||||
this.networkState,
|
||||
this.positionState,
|
||||
this.homeDistance,
|
||||
this.liveCapacity,
|
||||
this.rainfall,
|
||||
this.gatewayCameraList,
|
||||
this.droneCameraList,
|
||||
required this.orgId,
|
||||
required this.siteId,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
factory DroneStationEntity.fromJson(Map<String, dynamic> json) {
|
||||
return DroneStationEntity(
|
||||
deviceSn: json['device_sn'] ?? '',
|
||||
gatewaySn: json['gateway_sn'] ?? '',
|
||||
callsign: json['callsign'] ?? '',
|
||||
droneCallsign: json['drone_callsign'] ?? '',
|
||||
onlineStatus: json['onlineStatus'] ?? 0,
|
||||
droneOnlineStatus: json['drone_onlineStatus'] ?? 0,
|
||||
latitude: json['latitude'] != null
|
||||
? (json['latitude'] as num).toDouble()
|
||||
: null,
|
||||
longitude: json['longitude'] != null
|
||||
? (json['longitude'] as num).toDouble()
|
||||
: null,
|
||||
capacityPercent: json['capacity_percent'] != null
|
||||
? (json['capacity_percent'] as num).toDouble()
|
||||
: null,
|
||||
windSpeed: json['wind_speed'] != null
|
||||
? (json['wind_speed'] as num).toDouble()
|
||||
: null,
|
||||
height: json['height'] != null
|
||||
? (json['height'] as num).toDouble()
|
||||
: null,
|
||||
environmentTemperature: json['environment_temperature'] != null
|
||||
? (json['environment_temperature'] as num).toDouble()
|
||||
: null,
|
||||
networkState: json['network_state'],
|
||||
positionState: json['position_state'],
|
||||
homeDistance: json['home_distance'] != null
|
||||
? (json['home_distance'] as num).toDouble()
|
||||
: null,
|
||||
liveCapacity: json['live_capacity'] != null
|
||||
? (json['live_capacity'] as num).toDouble()
|
||||
: null,
|
||||
rainfall: json['rainfall'] != null
|
||||
? (json['rainfall'] as num).toDouble()
|
||||
: null,
|
||||
gatewayCameraList: json['gateway_camera_list'] != null
|
||||
? (json['gateway_camera_list'] as List)
|
||||
.map((item) => CameraInfo.fromJson(item))
|
||||
.toList()
|
||||
: null,
|
||||
droneCameraList: json['drone_camera_list'],
|
||||
orgId: json['orgId'] ?? 0,
|
||||
siteId: json['siteId'] ?? 0,
|
||||
userId: json['userId'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'device_sn': deviceSn,
|
||||
'gateway_sn': gatewaySn,
|
||||
'callsign': callsign,
|
||||
'drone_callsign': droneCallsign,
|
||||
'onlineStatus': onlineStatus,
|
||||
'drone_onlineStatus': droneOnlineStatus,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'capacity_percent': capacityPercent,
|
||||
'wind_speed': windSpeed,
|
||||
'height': height,
|
||||
'environment_temperature': environmentTemperature,
|
||||
'network_state': networkState,
|
||||
'position_state': positionState,
|
||||
'home_distance': homeDistance,
|
||||
'live_capacity': liveCapacity,
|
||||
'rainfall': rainfall,
|
||||
'gateway_camera_list': gatewayCameraList?.map((c) => c.toJson()).toList(),
|
||||
'drone_camera_list': droneCameraList,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
'userId': userId,
|
||||
};
|
||||
}
|
||||
|
||||
/// 是否在线
|
||||
bool get isOnline => onlineStatus == 1;
|
||||
|
||||
/// 无人机是否在线
|
||||
bool get isDroneOnline => droneOnlineStatus == 1;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
deviceSn,
|
||||
gatewaySn,
|
||||
callsign,
|
||||
droneCallsign,
|
||||
onlineStatus,
|
||||
droneOnlineStatus,
|
||||
latitude,
|
||||
longitude,
|
||||
capacityPercent,
|
||||
windSpeed,
|
||||
height,
|
||||
environmentTemperature,
|
||||
networkState,
|
||||
positionState,
|
||||
homeDistance,
|
||||
liveCapacity,
|
||||
rainfall,
|
||||
gatewayCameraList,
|
||||
droneCameraList,
|
||||
orgId,
|
||||
siteId,
|
||||
userId,
|
||||
];
|
||||
}
|
||||
|
||||
/// 视频流实体
|
||||
class VideoStreamEntity extends Equatable {
|
||||
final String sn;
|
||||
final String cameraIndex;
|
||||
final String url;
|
||||
final int expireTs;
|
||||
final String urlType;
|
||||
|
||||
const VideoStreamEntity({
|
||||
required this.sn,
|
||||
required this.cameraIndex,
|
||||
required this.url,
|
||||
required this.expireTs,
|
||||
required this.urlType,
|
||||
});
|
||||
|
||||
factory VideoStreamEntity.fromJson(Map<String, dynamic> json) {
|
||||
return VideoStreamEntity(
|
||||
sn: json['sn'] ?? '',
|
||||
cameraIndex: json['camera_index'] ?? '',
|
||||
url: json['url'] ?? '',
|
||||
expireTs: json['expire_ts'] ?? 0,
|
||||
urlType: json['url_type'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sn, cameraIndex, url, expireTs, urlType];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 视频流实体
|
||||
class VideoStreamEntity extends Equatable {
|
||||
final String sn;
|
||||
final String cameraIndex;
|
||||
final String
|
||||
url; // 火山引擎 RTC 鉴权参数(包含 app_id、room_id、token、user_id、expire_time)
|
||||
final int expireTs;
|
||||
final String urlType; // 标识是 "rtc" 还是其他类型
|
||||
|
||||
const VideoStreamEntity({
|
||||
required this.sn,
|
||||
required this.cameraIndex,
|
||||
required this.url,
|
||||
required this.expireTs,
|
||||
required this.urlType,
|
||||
});
|
||||
|
||||
factory VideoStreamEntity.fromJson(Map<String, dynamic> json) {
|
||||
return VideoStreamEntity(
|
||||
sn: json['sn'] ?? '',
|
||||
cameraIndex: json['camera_index'] ?? '',
|
||||
url: json['url'] ?? '', // 火山引擎 RTC 鉴权参数
|
||||
expireTs: json['expire_ts'] ?? 0,
|
||||
urlType: json['url_type'] ?? 'rtc', // 默认为 rtc 类型
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析火山引擎 RTC 参数
|
||||
/// url 格式示例: "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx&expire_time=xxx"
|
||||
Map<String, String> parseRtcParams() {
|
||||
final params = <String, String>{};
|
||||
final pairs = url.split('&');
|
||||
for (final pair in pairs) {
|
||||
final kv = pair.split('=');
|
||||
if (kv.length == 2) {
|
||||
// 对 URL 编码的字符进行解码(如 %2F -> /, %2B -> +)
|
||||
params[kv[0]] = Uri.decodeComponent(kv[1]);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/// 获取火山引擎 App ID(从 URL 中解析)
|
||||
String get appId => parseRtcParams()['app_id'] ?? '';
|
||||
|
||||
/// 获取房间 ID(从 URL 中解析)
|
||||
String get roomId => parseRtcParams()['room_id'] ?? '';
|
||||
|
||||
/// 获取 Token(从 URL 中解析)
|
||||
String get token => parseRtcParams()['token'] ?? '';
|
||||
|
||||
/// 获取用户 ID(从 URL 中解析)
|
||||
String get userId => parseRtcParams()['user_id'] ?? '';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sn, cameraIndex, url, expireTs, urlType];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/drone_station_entity.dart';
|
||||
|
||||
abstract class DroneStationRepository {
|
||||
Future<Either<Failure, List<DroneStationEntity>>> getDroneStationList(
|
||||
int siteId,
|
||||
);
|
||||
Future<Either<Failure, UAVDetailEntity>> getUAVDetail(
|
||||
String gatewaySn,
|
||||
String deviceSn,
|
||||
);
|
||||
Future<Either<Failure, VideoStreamEntity>> getVideoStream({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
required String cameraPosition,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/drone_station_entity.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class GetDroneStationListUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
GetDroneStationListUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, List<DroneStationEntity>>> call(int siteId) async {
|
||||
return await repository.getDroneStationList(siteId);
|
||||
}
|
||||
}
|
||||
|
||||
class GetUAVDetailUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
GetUAVDetailUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, UAVDetailEntity>> call(String gatewaySn, String deviceSn) async {
|
||||
return await repository.getUAVDetail(gatewaySn, deviceSn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/drone_station_entity.dart';
|
||||
import '../repositories/drone_station_repository.dart';
|
||||
|
||||
class GetVideoStreamUseCase {
|
||||
final DroneStationRepository repository;
|
||||
|
||||
GetVideoStreamUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, VideoStreamEntity>> call({
|
||||
required String sn,
|
||||
required String cameraIndex,
|
||||
required String cameraPosition,
|
||||
}) async {
|
||||
return await repository.getVideoStream(
|
||||
sn: sn,
|
||||
cameraIndex: cameraIndex,
|
||||
cameraPosition: cameraPosition,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/usecases/get_drone_station_list_usecase.dart';
|
||||
import '../../domain/usecases/get_video_stream_usecase.dart';
|
||||
import 'drone_station_event.dart';
|
||||
import 'drone_station_state.dart';
|
||||
|
||||
class DroneStationBloc extends Bloc<DroneStationEvent, DroneStationState> {
|
||||
final GetDroneStationListUseCase getDroneStationListUseCase;
|
||||
final GetUAVDetailUseCase getUAVDetailUseCase;
|
||||
final GetVideoStreamUseCase getVideoStreamUseCase;
|
||||
|
||||
DroneStationBloc(
|
||||
this.getDroneStationListUseCase,
|
||||
this.getUAVDetailUseCase,
|
||||
this.getVideoStreamUseCase,
|
||||
) : super(const DroneStationInitial()) {
|
||||
on<DroneStationLoadData>(_onLoadData);
|
||||
on<DroneStationRefresh>(_onRefresh);
|
||||
on<UAVDetailLoad>(_onUAVDetailLoad);
|
||||
on<VideoStreamLoad>(_onVideoStreamLoad);
|
||||
}
|
||||
|
||||
Future<void> _onLoadData(
|
||||
DroneStationLoadData event,
|
||||
Emitter<DroneStationState> emit,
|
||||
) async {
|
||||
emit(const DroneStationLoading());
|
||||
|
||||
final result = await getDroneStationListUseCase(event.siteId);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DroneStationError(failure.message)),
|
||||
(stations) => emit(DroneStationLoaded(stations)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh(
|
||||
DroneStationRefresh event,
|
||||
Emitter<DroneStationState> emit,
|
||||
) async {
|
||||
if (state is DroneStationLoaded) {
|
||||
final result = await getDroneStationListUseCase(event.siteId);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DroneStationError(failure.message)),
|
||||
(stations) => emit(DroneStationLoaded(stations)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onUAVDetailLoad(
|
||||
UAVDetailLoad event,
|
||||
Emitter<DroneStationState> emit,
|
||||
) async {
|
||||
emit(const UAVDetailLoading());
|
||||
|
||||
final result = await getUAVDetailUseCase(event.gatewaySn, event.deviceSn);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(UAVDetailError(failure.message)),
|
||||
(detail) => emit(UAVDetailLoaded(detail)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onVideoStreamLoad(
|
||||
VideoStreamLoad event,
|
||||
Emitter<DroneStationState> emit,
|
||||
) async {
|
||||
emit(const VideoStreamLoading());
|
||||
|
||||
final result = await getVideoStreamUseCase(
|
||||
sn: event.sn,
|
||||
cameraIndex: event.cameraIndex,
|
||||
cameraPosition: event.cameraPosition,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(VideoStreamError(failure.message)),
|
||||
(videoStream) => emit(VideoStreamLoaded(videoStream)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class DroneStationEvent extends Equatable {
|
||||
const DroneStationEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class DroneStationLoadData extends DroneStationEvent {
|
||||
final int siteId;
|
||||
|
||||
const DroneStationLoadData(this.siteId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [siteId];
|
||||
}
|
||||
|
||||
class DroneStationRefresh extends DroneStationEvent {
|
||||
final int siteId;
|
||||
|
||||
const DroneStationRefresh(this.siteId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [siteId];
|
||||
}
|
||||
|
||||
class UAVDetailLoad extends DroneStationEvent {
|
||||
final String gatewaySn;
|
||||
final String deviceSn;
|
||||
|
||||
const UAVDetailLoad({required this.gatewaySn, required this.deviceSn});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [gatewaySn, deviceSn];
|
||||
}
|
||||
|
||||
class VideoStreamLoad extends DroneStationEvent {
|
||||
final String sn;
|
||||
final String cameraIndex;
|
||||
final String cameraPosition;
|
||||
|
||||
const VideoStreamLoad({
|
||||
required this.sn,
|
||||
required this.cameraIndex,
|
||||
required this.cameraPosition,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sn, cameraIndex, cameraPosition];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/drone_station_entity.dart';
|
||||
|
||||
abstract class DroneStationState extends Equatable {
|
||||
const DroneStationState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class DroneStationInitial extends DroneStationState {
|
||||
const DroneStationInitial();
|
||||
}
|
||||
|
||||
class DroneStationLoading extends DroneStationState {
|
||||
const DroneStationLoading();
|
||||
}
|
||||
|
||||
class DroneStationLoaded extends DroneStationState {
|
||||
final List<DroneStationEntity> stations;
|
||||
|
||||
const DroneStationLoaded(this.stations);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stations];
|
||||
}
|
||||
|
||||
class DroneStationError extends DroneStationState {
|
||||
final String message;
|
||||
|
||||
const DroneStationError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
class UAVDetailLoading extends DroneStationState {
|
||||
const UAVDetailLoading();
|
||||
}
|
||||
|
||||
class UAVDetailLoaded extends DroneStationState {
|
||||
final UAVDetailEntity detail;
|
||||
|
||||
const UAVDetailLoaded(this.detail);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [detail];
|
||||
}
|
||||
|
||||
class UAVDetailError extends DroneStationState {
|
||||
final String message;
|
||||
|
||||
const UAVDetailError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
class VideoStreamLoading extends DroneStationState {
|
||||
const VideoStreamLoading();
|
||||
}
|
||||
|
||||
class VideoStreamLoaded extends DroneStationState {
|
||||
final VideoStreamEntity videoStream;
|
||||
|
||||
const VideoStreamLoaded(this.videoStream);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [videoStream];
|
||||
}
|
||||
|
||||
class VideoStreamError extends DroneStationState {
|
||||
final String message;
|
||||
|
||||
const VideoStreamError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../../core/consts/http_api_consts.dart';
|
||||
import '../../data/models/robot_data_model.dart';
|
||||
import 'robot_list_event.dart';
|
||||
import 'robot_list_state.dart';
|
||||
|
||||
class RobotListBloc extends Bloc<RobotListEvent, RobotListState> {
|
||||
final Dio dio;
|
||||
|
||||
RobotListBloc(this.dio) : super(const RobotListInitial()) {
|
||||
on<RobotListLoadData>(_onLoadData);
|
||||
on<RobotListRefresh>(_onRefresh);
|
||||
on<RobotListChangeType>(_onChangeType);
|
||||
}
|
||||
|
||||
Future<void> _onLoadData(
|
||||
RobotListLoadData event,
|
||||
Emitter<RobotListState> emit,
|
||||
) async {
|
||||
if (event.siteId == null) {
|
||||
emit(const RobotListError('请先选择场站'));
|
||||
return;
|
||||
}
|
||||
|
||||
emit(const RobotListLoading());
|
||||
|
||||
try {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getRobotList,
|
||||
queryParameters: {
|
||||
'siteId': event.siteId,
|
||||
'pageSize': 9999,
|
||||
'pageNum': 1,
|
||||
},
|
||||
);
|
||||
|
||||
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'] ?? [];
|
||||
final robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
|
||||
|
||||
emit(RobotListLoaded(
|
||||
robots: robots,
|
||||
siteId: event.siteId,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(RobotListError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRefresh(
|
||||
RobotListRefresh event,
|
||||
Emitter<RobotListState> emit,
|
||||
) async {
|
||||
if (state is RobotListLoaded) {
|
||||
final currentState = state as RobotListLoaded;
|
||||
|
||||
if (currentState.siteId == null) {
|
||||
emit(const RobotListError('请先选择场站'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getRobotList,
|
||||
queryParameters: {
|
||||
'siteId': currentState.siteId,
|
||||
'pageSize': 9999,
|
||||
'pageNum': 1,
|
||||
},
|
||||
);
|
||||
|
||||
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'] ?? [];
|
||||
final robots = rows.map((item) => RobotDataModel.fromJson(item)).toList();
|
||||
|
||||
emit(currentState.copyWith(robots: robots));
|
||||
} catch (e) {
|
||||
emit(RobotListError(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onChangeType(
|
||||
RobotListChangeType event,
|
||||
Emitter<RobotListState> emit,
|
||||
) {
|
||||
if (state is RobotListLoaded) {
|
||||
final currentState = state as RobotListLoaded;
|
||||
emit(currentState.copyWith(selectedType: event.type));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class RobotListEvent extends Equatable {
|
||||
const RobotListEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class RobotListLoadData extends RobotListEvent {
|
||||
final int? siteId;
|
||||
|
||||
const RobotListLoadData({this.siteId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [siteId];
|
||||
}
|
||||
|
||||
class RobotListRefresh extends RobotListEvent {
|
||||
const RobotListRefresh();
|
||||
}
|
||||
|
||||
class RobotListChangeType extends RobotListEvent {
|
||||
final String? type;
|
||||
|
||||
const RobotListChangeType(this.type);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [type];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../data/models/robot_data_model.dart';
|
||||
|
||||
abstract class RobotListState extends Equatable {
|
||||
const RobotListState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class RobotListInitial extends RobotListState {
|
||||
const RobotListInitial();
|
||||
}
|
||||
|
||||
class RobotListLoading extends RobotListState {
|
||||
const RobotListLoading();
|
||||
}
|
||||
|
||||
class RobotListLoaded extends RobotListState {
|
||||
final List<RobotDataModel> robots;
|
||||
final String? selectedType;
|
||||
final int? siteId;
|
||||
|
||||
const RobotListLoaded({
|
||||
required this.robots,
|
||||
this.selectedType,
|
||||
this.siteId,
|
||||
});
|
||||
|
||||
RobotListLoaded copyWith({
|
||||
List<RobotDataModel>? robots,
|
||||
String? selectedType,
|
||||
int? siteId,
|
||||
}) {
|
||||
return RobotListLoaded(
|
||||
robots: robots ?? this.robots,
|
||||
selectedType: selectedType ?? this.selectedType,
|
||||
siteId: siteId ?? this.siteId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [robots, selectedType, siteId];
|
||||
}
|
||||
|
||||
class RobotListError extends RobotListState {
|
||||
final String message;
|
||||
|
||||
const RobotListError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
// import 'package:volc_engine_rtc/volc_engine_rtc.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
|
||||
class DroneMonitorPage extends StatefulWidget {
|
||||
final String gatewaySn;
|
||||
final String cameraIndex;
|
||||
|
||||
const DroneMonitorPage({
|
||||
super.key,
|
||||
required this.gatewaySn,
|
||||
required this.cameraIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
_DroneMonitorPageState createState() => _DroneMonitorPageState();
|
||||
}
|
||||
|
||||
class _DroneMonitorPageState extends State<DroneMonitorPage> {
|
||||
bool isIndoor = false;
|
||||
late DroneStationBloc _bloc;
|
||||
String? videoUrl;
|
||||
bool isLoading = true;
|
||||
String? errorMessage;
|
||||
|
||||
// RTCEngine? _rtcEngine;
|
||||
// RTCRoom? _rtcRoom;
|
||||
// RTCViewContext? _remoteRenderContext;
|
||||
// final IRTCEngineEventHandler _videoHandler = IRTCEngineEventHandler();
|
||||
// final IRTCRoomEventHandler _roomHandler = IRTCRoomEventHandler();
|
||||
|
||||
String? _appId;
|
||||
String? _roomId;
|
||||
String? _token;
|
||||
String? _userId;
|
||||
bool _isJoined = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = sl<DroneStationBloc>();
|
||||
// _initVideoEventHandler();
|
||||
// _initRoomEventHandler();
|
||||
_loadVideoStream();
|
||||
}
|
||||
|
||||
// void _initVideoEventHandler() {
|
||||
// _videoHandler.onFirstRemoteVideoFrameDecoded =
|
||||
// (String streamId, StreamInfo streamInfo, VideoFrameInfo frameInfo) {
|
||||
// debugPrint('onFirstRemoteVideoFrameDecoded: ${streamInfo.userId}');
|
||||
// if (streamInfo.userId.isNotEmpty && mounted && _roomId != null) {
|
||||
// setState(() {
|
||||
// _remoteRenderContext = RTCViewContext.remoteContext(
|
||||
// roomId: _roomId!,
|
||||
// userId: streamInfo.userId,
|
||||
// streamId: streamId,
|
||||
// );
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
// _videoHandler.onWarning = (WarningCode code) {
|
||||
// debugPrint('warningCode: $code');
|
||||
// };
|
||||
|
||||
// _videoHandler.onError = (ErrorCode code) {
|
||||
// debugPrint('errorCode: $code');
|
||||
// if (mounted) {
|
||||
// setState(() {
|
||||
// errorMessage = '视频错误: $code';
|
||||
// isLoading = false;
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
// }
|
||||
|
||||
// void _initRoomEventHandler() {
|
||||
// _roomHandler.onUserJoined = (UserInfo userInfo) {
|
||||
// debugPrint('onUserJoined: ${userInfo.userId}');
|
||||
// };
|
||||
|
||||
// _roomHandler.onUserLeave = (String uid, int reason) {
|
||||
// debugPrint('onUserLeave: $uid reason: $reason');
|
||||
// };
|
||||
// }
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.close();
|
||||
_leaveRoom();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadVideoStream() {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
});
|
||||
_leaveRoom();
|
||||
_bloc.add(
|
||||
VideoStreamLoad(
|
||||
sn: widget.gatewaySn,
|
||||
cameraIndex: widget.cameraIndex,
|
||||
cameraPosition: isIndoor ? 'indoor' : 'outdoor',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCameraPositionChanged(bool indoor) {
|
||||
setState(() {
|
||||
isIndoor = indoor;
|
||||
});
|
||||
_loadVideoStream();
|
||||
}
|
||||
|
||||
void _parseUrlParams(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url.contains('?') ? 'http://host?$url' : url);
|
||||
final params = uri.queryParameters;
|
||||
|
||||
_appId = params['app_id'];
|
||||
_roomId = params['room_id'];
|
||||
_token = params['token'];
|
||||
_userId =
|
||||
params['user_id'] ?? 'user_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
print(
|
||||
'Parsed RTC params: appId=$_appId, roomId=$_roomId, userId=$_userId',
|
||||
);
|
||||
} catch (e) {
|
||||
print('Failed to parse URL: $e');
|
||||
errorMessage = '解析视频参数失败';
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Future<void> _joinRoom() async {
|
||||
// if (_appId == null || _roomId == null || _token == null) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// try {
|
||||
// _rtcEngine = await RTCEngine.createRTCEngine(
|
||||
// RTCVideoContext(appId: _appId!, eventHandler: _videoHandler),
|
||||
// );
|
||||
|
||||
// if (_rtcEngine == null) {
|
||||
// setState(() {
|
||||
// errorMessage = '创建引擎失败';
|
||||
// isLoading = false;
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// _rtcRoom = await _rtcEngine?.createRTCRoom(_roomId!);
|
||||
// _rtcRoom?.setRTCRoomEventHandler(_roomHandler);
|
||||
|
||||
// final userInfo = UserInfo(userId: _userId!, extraInfo: "");
|
||||
|
||||
// final roomConfig = RoomConfig(
|
||||
// isPublishAudio: false,
|
||||
// isPublishVideo: false,
|
||||
// isAutoSubscribeAudio: true,
|
||||
// isAutoSubscribeVideo: true,
|
||||
// );
|
||||
|
||||
// await _rtcRoom?.joinRoom(
|
||||
// token: _token!,
|
||||
// userInfo: userInfo,
|
||||
// roomConfig: roomConfig,
|
||||
// userVisibility: true,
|
||||
// );
|
||||
|
||||
// setState(() {
|
||||
// _isJoined = true;
|
||||
// });
|
||||
|
||||
// print('Successfully joined room: $_roomId');
|
||||
// } catch (e) {
|
||||
// print('Failed to join room: $e');
|
||||
// setState(() {
|
||||
// errorMessage = '加入房间失败: $e';
|
||||
// isLoading = false;
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
Future<void> _leaveRoom() async {
|
||||
try {
|
||||
// if (_rtcRoom != null) {
|
||||
// await _rtcRoom!.leaveRoom();
|
||||
// _rtcRoom = null;
|
||||
// }
|
||||
// if (_rtcEngine != null) {
|
||||
// _rtcEngine!.destroy();
|
||||
// _rtcEngine = null;
|
||||
// }
|
||||
setState(() {
|
||||
_isJoined = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Failed to leave room: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFF1D2129),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF2A2E34),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text('实时监控', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: BlocConsumer<DroneStationBloc, DroneStationState>(
|
||||
listener: (context, state) async {
|
||||
if (state is VideoStreamLoaded) {
|
||||
videoUrl = state.videoStream.url;
|
||||
_parseUrlParams(videoUrl!);
|
||||
// await _joinRoom();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else if (state is VideoStreamError) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
errorMessage = state.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, size: 64, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadVideoStream,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildVideoView();
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: const Color(0xFF2A2E34),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildBtn(
|
||||
Icons.home,
|
||||
'室内',
|
||||
isIndoor,
|
||||
() => _onCameraPositionChanged(true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildBtn(
|
||||
Icons.sunny,
|
||||
'室外',
|
||||
!isIndoor,
|
||||
() => _onCameraPositionChanged(false),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVideoView() {
|
||||
// if (!_isJoined || _rtcEngine == null || _userId == null) {
|
||||
// return Center(
|
||||
// child: Column(
|
||||
// mainAxisAlignment: MainAxisAlignment.center,
|
||||
// children: [
|
||||
// const Icon(Icons.video_library, size: 80, color: Colors.grey),
|
||||
// const SizedBox(height: 16),
|
||||
// const Text(
|
||||
// '正在连接...',
|
||||
// style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
// ),
|
||||
// const SizedBox(height: 8),
|
||||
// Text(
|
||||
// '房间: $_roomId',
|
||||
// style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.videocam_off, size: 80, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'RTC 功能已禁用',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'火山引擎 RTC 依赖已注释',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBtn(
|
||||
IconData icon,
|
||||
String label,
|
||||
bool active,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? const Color(0xFF165DFF) : const Color(0xFF3A3E44),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white),
|
||||
const SizedBox(height: 8),
|
||||
Text(label, style: const TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '../../domain/entities/site_entity.dart';
|
||||
|
||||
abstract class SiteDataSource {
|
||||
Future<List<SiteEntity>> getSiteList(int orgId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:maibu_satabot_v2/core/consts/http_api_consts.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';
|
||||
|
||||
class SiteDataSourceImpl implements SiteDataSource {
|
||||
final Dio dio;
|
||||
|
||||
SiteDataSourceImpl(this.dio);
|
||||
|
||||
@override
|
||||
Future<List<SiteEntity>> getSiteList(int orgId) async {
|
||||
// 构建查询参数:orgId 为 0 时不传递
|
||||
final queryParams = <String, dynamic>{
|
||||
'pageNum': 1,
|
||||
'pageSize': 9999,
|
||||
};
|
||||
|
||||
if (orgId != 0) {
|
||||
queryParams['orgId'] = orgId;
|
||||
}
|
||||
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getSiteList,
|
||||
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) => SiteEntity.fromJson(item)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../../data/datasources/site_datasource.dart';
|
||||
import '../../domain/entities/site_entity.dart';
|
||||
import '../../domain/repositories/site_repository.dart';
|
||||
|
||||
class SiteRepositoryImpl implements SiteRepository {
|
||||
final SiteDataSource dataSource;
|
||||
|
||||
SiteRepositoryImpl(this.dataSource);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId) async {
|
||||
try {
|
||||
final sites = await dataSource.getSiteList(orgId);
|
||||
return Right(sites);
|
||||
} catch (e) {
|
||||
return Left(Failure(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
52
lib/features/v2/home/domain/entities/site_entity.dart
Normal file
52
lib/features/v2/home/domain/entities/site_entity.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class SiteEntity extends Equatable {
|
||||
final int id;
|
||||
final String siteName;
|
||||
final String? siteCode;
|
||||
final int orgId;
|
||||
final double? longitude;
|
||||
final double? latitude;
|
||||
final String? address;
|
||||
final int status;
|
||||
|
||||
const SiteEntity({
|
||||
required this.id,
|
||||
required this.siteName,
|
||||
this.siteCode,
|
||||
required this.orgId,
|
||||
this.longitude,
|
||||
this.latitude,
|
||||
this.address,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory SiteEntity.fromJson(Map<String, dynamic> json) {
|
||||
return SiteEntity(
|
||||
id: json['id'] as int,
|
||||
siteName: json['siteName'] as String? ?? '',
|
||||
siteCode: json['siteCode'] as String?,
|
||||
orgId: json['orgId'] as int? ?? 0,
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
address: json['address'] as String?,
|
||||
status: json['status'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'siteName': siteName,
|
||||
'siteCode': siteCode,
|
||||
'orgId': orgId,
|
||||
'longitude': longitude,
|
||||
'latitude': latitude,
|
||||
'address': address,
|
||||
'status': status,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, siteName, siteCode, orgId, longitude, latitude, address, status];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/site_entity.dart';
|
||||
|
||||
abstract class SiteRepository {
|
||||
Future<Either<Failure, List<SiteEntity>>> getSiteList(int orgId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
|
||||
import '../../../../../core/error/failure.dart';
|
||||
import '../entities/site_entity.dart';
|
||||
import '../repositories/site_repository.dart';
|
||||
|
||||
class GetSiteListUseCase {
|
||||
final SiteRepository repository;
|
||||
|
||||
GetSiteListUseCase(this.repository);
|
||||
|
||||
// pageNum 和 pageSize 固定,orgId 从登录用户信息中获取
|
||||
Future<Either<Failure, List<SiteEntity>>> call(int orgId) async {
|
||||
return await repository.getSiteList(orgId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/data/datasources/message_center_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/data/models/message_category_model.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
|
||||
/// 消息中心远程数据源实现(模拟数据)
|
||||
class MessageCenterRemoteDataSourceImpl implements MessageCenterRemoteDataSource {
|
||||
@override
|
||||
Future<List<MessageCategoryModel>> getMessageList({MessageType? type}) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final allMessages = [
|
||||
MessageCategoryModel(
|
||||
type: 'alarm',
|
||||
icon: 'notifications',
|
||||
iconColor: '#FF3B30',
|
||||
title: '告警通知',
|
||||
content: '逆变器 INV-001 离网告警',
|
||||
time: '09:24',
|
||||
unreadCount: 12,
|
||||
targetId: 'ALM-001',
|
||||
targetRoute: '/alarm-detail',
|
||||
),
|
||||
MessageCategoryModel(
|
||||
type: 'workorder',
|
||||
icon: 'assignment',
|
||||
iconColor: '#FF9500',
|
||||
title: '工单提醒',
|
||||
content: '工单 #WO-2025051908 已派发',
|
||||
time: '09:18',
|
||||
unreadCount: 3,
|
||||
targetId: 'WO-2025051908',
|
||||
targetRoute: '/workorder-detail',
|
||||
),
|
||||
MessageCategoryModel(
|
||||
type: 'system',
|
||||
icon: 'campaign',
|
||||
iconColor: '#165DFF',
|
||||
title: '系统消息',
|
||||
content: '系统升级维护通知',
|
||||
time: '08:30',
|
||||
unreadCount: 0,
|
||||
targetId: null,
|
||||
targetRoute: null,
|
||||
),
|
||||
MessageCategoryModel(
|
||||
type: 'manager',
|
||||
icon: 'chat',
|
||||
iconColor: '#00B42A',
|
||||
title: '负责人消息',
|
||||
content: '张工:请尽快处理该告警',
|
||||
time: '08:15',
|
||||
unreadCount: 2,
|
||||
targetId: null,
|
||||
targetRoute: null,
|
||||
),
|
||||
MessageCategoryModel(
|
||||
type: 'daily',
|
||||
icon: 'description',
|
||||
iconColor: '#722ED1',
|
||||
title: '日报推送',
|
||||
content: '5月18日光伏电站运行日报',
|
||||
time: '07:30',
|
||||
unreadCount: 0,
|
||||
targetId: null,
|
||||
targetRoute: null,
|
||||
),
|
||||
MessageCategoryModel(
|
||||
type: 'safety',
|
||||
icon: 'security',
|
||||
iconColor: '#165DFF',
|
||||
title: '安全通知',
|
||||
content: '安全策略更新通知',
|
||||
time: '昨日',
|
||||
unreadCount: 0,
|
||||
targetId: null,
|
||||
targetRoute: null,
|
||||
),
|
||||
];
|
||||
|
||||
// 根据类型筛选
|
||||
if (type != null && type != MessageType.all) {
|
||||
return allMessages
|
||||
.where((msg) => msg.type == type.name)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return allMessages;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> markAllAsRead() async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/data/models/message_category_model.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
|
||||
/// 消息中心远程数据源抽象
|
||||
abstract class MessageCenterRemoteDataSource {
|
||||
/// 获取消息列表
|
||||
Future<List<MessageCategoryModel>> getMessageList({MessageType? type});
|
||||
|
||||
/// 一键已读
|
||||
Future<bool> markAllAsRead();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
|
||||
/// 消息分类数据模型
|
||||
class MessageCategoryModel {
|
||||
MessageCategoryModel({
|
||||
required this.type,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.time,
|
||||
this.unreadCount = 0,
|
||||
this.targetId,
|
||||
this.targetRoute,
|
||||
});
|
||||
|
||||
final String type;
|
||||
final String icon;
|
||||
final String iconColor;
|
||||
final String title;
|
||||
final String content;
|
||||
final String time;
|
||||
final int unreadCount;
|
||||
final String? targetId;
|
||||
final String? targetRoute;
|
||||
|
||||
factory MessageCategoryModel.fromJson(Map<String, dynamic> json) {
|
||||
return MessageCategoryModel(
|
||||
type: json['type'] as String,
|
||||
icon: json['icon'] as String,
|
||||
iconColor: json['iconColor'] as String,
|
||||
title: json['title'] as String,
|
||||
content: json['content'] as String,
|
||||
time: json['time'] as String,
|
||||
unreadCount: json['unreadCount'] as int? ?? 0,
|
||||
targetId: json['targetId'] as String?,
|
||||
targetRoute: json['targetRoute'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
MessageCategoryEntity toEntity() {
|
||||
return MessageCategoryEntity(
|
||||
type: _parseMessageType(type),
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
content: content,
|
||||
time: time,
|
||||
unreadCount: unreadCount,
|
||||
targetId: targetId,
|
||||
targetRoute: targetRoute,
|
||||
);
|
||||
}
|
||||
|
||||
MessageType _parseMessageType(String type) {
|
||||
switch (type) {
|
||||
case 'alarm':
|
||||
return MessageType.alarm;
|
||||
case 'workorder':
|
||||
return MessageType.workorder;
|
||||
case 'system':
|
||||
return MessageType.system;
|
||||
case 'manager':
|
||||
return MessageType.manager;
|
||||
case 'daily':
|
||||
return MessageType.daily;
|
||||
case 'safety':
|
||||
return MessageType.safety;
|
||||
default:
|
||||
return MessageType.all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/data/datasources/message_center_remote_datasource.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/repositories/message_center_repository.dart';
|
||||
|
||||
/// 消息中心仓储实现
|
||||
class MessageCenterRepositoryImpl implements MessageCenterRepository {
|
||||
MessageCenterRepositoryImpl(this._remoteDataSource);
|
||||
|
||||
final MessageCenterRemoteDataSource _remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<MessageCategoryEntity>>> getMessageList({
|
||||
MessageType? type,
|
||||
}) async {
|
||||
try {
|
||||
final models = await _remoteDataSource.getMessageList(type: type);
|
||||
return Right(models.map((model) => model.toEntity()).toList());
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('获取消息列表失败:$e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> markAllAsRead() async {
|
||||
try {
|
||||
final success = await _remoteDataSource.markAllAsRead();
|
||||
return Right(success);
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('标记已读失败:$e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 消息类型枚举
|
||||
enum MessageType {
|
||||
all('全部'),
|
||||
alarm('告警'),
|
||||
workorder('工单'),
|
||||
system('系统'),
|
||||
manager('负责人'),
|
||||
daily('日报'),
|
||||
safety('安全');
|
||||
|
||||
const MessageType(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 消息分类实体
|
||||
class MessageCategoryEntity extends Equatable {
|
||||
const MessageCategoryEntity({
|
||||
required this.type,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.time,
|
||||
this.unreadCount = 0,
|
||||
this.targetId,
|
||||
this.targetRoute,
|
||||
});
|
||||
|
||||
/// 消息类型
|
||||
final MessageType type;
|
||||
|
||||
/// 图标名称
|
||||
final String icon;
|
||||
|
||||
/// 图标颜色
|
||||
final String iconColor;
|
||||
|
||||
/// 标题
|
||||
final String title;
|
||||
|
||||
/// 内容摘要
|
||||
final String content;
|
||||
|
||||
/// 时间
|
||||
final String time;
|
||||
|
||||
/// 未读数量
|
||||
final int unreadCount;
|
||||
|
||||
/// 目标ID(用于跳转)
|
||||
final String? targetId;
|
||||
|
||||
/// 目标路由(用于跳转)
|
||||
final String? targetRoute;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
type,
|
||||
icon,
|
||||
iconColor,
|
||||
title,
|
||||
content,
|
||||
time,
|
||||
unreadCount,
|
||||
targetId,
|
||||
targetRoute,
|
||||
];
|
||||
|
||||
MessageCategoryEntity copyWith({
|
||||
MessageType? type,
|
||||
String? icon,
|
||||
String? iconColor,
|
||||
String? title,
|
||||
String? content,
|
||||
String? time,
|
||||
int? unreadCount,
|
||||
String? targetId,
|
||||
String? targetRoute,
|
||||
}) {
|
||||
return MessageCategoryEntity(
|
||||
type: type ?? this.type,
|
||||
icon: icon ?? this.icon,
|
||||
iconColor: iconColor ?? this.iconColor,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
time: time ?? this.time,
|
||||
unreadCount: unreadCount ?? this.unreadCount,
|
||||
targetId: targetId ?? this.targetId,
|
||||
targetRoute: targetRoute ?? this.targetRoute,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
|
||||
/// 消息中心仓储抽象
|
||||
abstract class MessageCenterRepository {
|
||||
/// 获取消息列表
|
||||
Future<Either<Failure, List<MessageCategoryEntity>>> getMessageList({
|
||||
MessageType? type,
|
||||
});
|
||||
|
||||
/// 一键已读
|
||||
Future<Either<Failure, bool>> markAllAsRead();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/repositories/message_center_repository.dart';
|
||||
|
||||
/// 获取消息列表用例
|
||||
class GetMessageListUseCase {
|
||||
GetMessageListUseCase(this._repository);
|
||||
|
||||
final MessageCenterRepository _repository;
|
||||
|
||||
Future<Either<Failure, List<MessageCategoryEntity>>> call({
|
||||
MessageType? type,
|
||||
}) async {
|
||||
return await _repository.getMessageList(type: type);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一键已读用例
|
||||
class MarkAllAsReadUseCase {
|
||||
MarkAllAsReadUseCase(this._repository);
|
||||
|
||||
final MessageCenterRepository _repository;
|
||||
|
||||
Future<Either<Failure, bool>> call() async {
|
||||
return await _repository.markAllAsRead();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/usecases/message_center_usecase.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/presentation/bloc/message_center_state.dart';
|
||||
|
||||
/// 消息中心Cubit
|
||||
class MessageCenterCubit extends Cubit<MessageCenterState> {
|
||||
MessageCenterCubit({
|
||||
required GetMessageListUseCase getMessageListUseCase,
|
||||
required MarkAllAsReadUseCase markAllAsReadUseCase,
|
||||
}) : _getMessageListUseCase = getMessageListUseCase,
|
||||
_markAllAsReadUseCase = markAllAsReadUseCase,
|
||||
super(MessageCenterInitial());
|
||||
|
||||
final GetMessageListUseCase _getMessageListUseCase;
|
||||
final MarkAllAsReadUseCase _markAllAsReadUseCase;
|
||||
|
||||
/// 加载消息列表
|
||||
Future<void> loadMessageList({MessageType? type}) async {
|
||||
emit(MessageCenterLoading());
|
||||
final result = await _getMessageListUseCase(type: type);
|
||||
result.fold(
|
||||
(failure) => emit(MessageCenterError(failure.message)),
|
||||
(messageList) {
|
||||
final totalUnread = messageList.fold<int>(
|
||||
0,
|
||||
(sum, msg) => sum + msg.unreadCount,
|
||||
);
|
||||
emit(MessageCenterLoaded(
|
||||
messageList: messageList,
|
||||
selectedType: type ?? MessageType.all,
|
||||
totalUnread: totalUnread,
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换消息类型筛选
|
||||
Future<void> changeFilter(MessageType type) async {
|
||||
await loadMessageList(type: type);
|
||||
}
|
||||
|
||||
/// 一键已读
|
||||
Future<void> markAllAsRead() async {
|
||||
// 检查当前状态
|
||||
if (state is! MessageCenterLoaded) return;
|
||||
|
||||
emit(const MessageCenterActionInProgress('一键已读'));
|
||||
final result = await _markAllAsReadUseCase();
|
||||
result.fold(
|
||||
(failure) => emit(MessageCenterError(failure.message)),
|
||||
(success) {
|
||||
if (success) {
|
||||
// 将所有消息的未读数清零
|
||||
final currentState = state as MessageCenterLoaded;
|
||||
final updatedList = currentState.messageList
|
||||
.map((msg) => msg.copyWith(unreadCount: 0))
|
||||
.toList();
|
||||
emit(currentState.copyWith(
|
||||
messageList: updatedList,
|
||||
totalUnread: 0,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
|
||||
/// 消息中心状态
|
||||
sealed class MessageCenterState extends Equatable {
|
||||
const MessageCenterState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// 初始状态
|
||||
final class MessageCenterInitial extends MessageCenterState {}
|
||||
|
||||
/// 加载状态
|
||||
final class MessageCenterLoading extends MessageCenterState {}
|
||||
|
||||
/// 加载成功状态
|
||||
final class MessageCenterLoaded extends MessageCenterState {
|
||||
const MessageCenterLoaded({
|
||||
required this.messageList,
|
||||
this.selectedType = MessageType.all,
|
||||
this.totalUnread = 0,
|
||||
});
|
||||
|
||||
final List<MessageCategoryEntity> messageList;
|
||||
final MessageType selectedType;
|
||||
final int totalUnread;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [messageList, selectedType, totalUnread];
|
||||
|
||||
MessageCenterLoaded copyWith({
|
||||
List<MessageCategoryEntity>? messageList,
|
||||
MessageType? selectedType,
|
||||
int? totalUnread,
|
||||
}) {
|
||||
return MessageCenterLoaded(
|
||||
messageList: messageList ?? this.messageList,
|
||||
selectedType: selectedType ?? this.selectedType,
|
||||
totalUnread: totalUnread ?? this.totalUnread,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载失败状态
|
||||
final class MessageCenterError extends MessageCenterState {
|
||||
const MessageCenterError(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// 操作中状态(一键已读)
|
||||
final class MessageCenterActionInProgress extends MessageCenterState {
|
||||
const MessageCenterActionInProgress(this.action);
|
||||
|
||||
final String action;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [action];
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/domain/entities/message_category_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/presentation/bloc/message_center_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/message_center/presentation/bloc/message_center_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
|
||||
/// 消息中心页面
|
||||
class MessageCenterPage extends StatelessWidget {
|
||||
const MessageCenterPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => sl<MessageCenterCubit>()..loadMessageList(),
|
||||
child: const MessageCenterView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageCenterView extends StatelessWidget {
|
||||
const MessageCenterView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<MessageCenterCubit>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AlarmColors.background,
|
||||
body: BlocConsumer<MessageCenterCubit, MessageCenterState>(
|
||||
listener: (context, state) {
|
||||
if (state is MessageCenterActionInProgress) {
|
||||
// 显示加载提示
|
||||
}
|
||||
if (state is MessageCenterError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(context, cubit),
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: _buildContent(context, state),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, MessageCenterCubit cubit) {
|
||||
return Container(
|
||||
height: 44.0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧:下拉筛选菜单
|
||||
_buildFilterDropdown(context, cubit),
|
||||
const Spacer(),
|
||||
// 右侧:一键已读按钮
|
||||
GestureDetector(
|
||||
onTap: () => cubit.markAllAsRead(),
|
||||
child: const Text(
|
||||
'一键已读',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterDropdown(BuildContext context, MessageCenterCubit cubit) {
|
||||
return BlocBuilder<MessageCenterCubit, MessageCenterState>(
|
||||
builder: (context, state) {
|
||||
MessageType selectedType = MessageType.all;
|
||||
if (state is MessageCenterLoaded) {
|
||||
selectedType = state.selectedType;
|
||||
}
|
||||
|
||||
return DropdownButton<MessageType>(
|
||||
value: selectedType,
|
||||
underline: const SizedBox(),
|
||||
icon: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 20,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
items: MessageType.values.map((type) {
|
||||
return DropdownMenuItem<MessageType>(
|
||||
value: type,
|
||||
child: Text(type.label),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
cubit.changeFilter(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, MessageCenterState state) {
|
||||
if (state is MessageCenterLoading) {
|
||||
return _buildSkeletonScreen();
|
||||
}
|
||||
if (state is MessageCenterError) {
|
||||
return _buildErrorState(context, state.message);
|
||||
}
|
||||
if (state is MessageCenterLoaded) {
|
||||
return _buildMessageList(context, state);
|
||||
}
|
||||
if (state is MessageCenterActionInProgress) {
|
||||
return _buildLoadingOverlay(context, state.action);
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
Widget _buildMessageList(BuildContext context, MessageCenterLoaded state) {
|
||||
if (state.messageList.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'暂无消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
itemCount: state.messageList.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final message = state.messageList[index];
|
||||
return _MessageCategoryCard(
|
||||
message: message,
|
||||
onTap: () => _handleMessageTap(context, message),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMessageTap(BuildContext context, MessageCategoryEntity message) {
|
||||
// 根据目标路由跳转
|
||||
if (message.targetRoute != null) {
|
||||
context.push(message.targetRoute!);
|
||||
} else {
|
||||
// 显示消息详情(可以后续扩展)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('查看${message.title}详情')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSkeletonScreen() {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
itemCount: 6,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AlarmColors.background,
|
||||
borderRadius: BorderRadius.circular(AlarmDimensions.borderRadius),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(BuildContext context, String message) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AlarmColors.danger,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<MessageCenterCubit>().loadMessageList();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingOverlay(BuildContext context, String action) {
|
||||
return Stack(
|
||||
children: [
|
||||
BlocBuilder<MessageCenterCubit, MessageCenterState>(
|
||||
builder: (context, state) {
|
||||
if (state is MessageCenterLoaded) {
|
||||
return _buildMessageList(context, state);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'$action中...',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息分类卡片组件
|
||||
class _MessageCategoryCard extends StatelessWidget {
|
||||
const _MessageCategoryCard({
|
||||
required this.message,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final MessageCategoryEntity message;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUnread = message.unreadCount > 0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AlarmColors.background,
|
||||
borderRadius: BorderRadius.circular(AlarmDimensions.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧图标
|
||||
_buildIcon(),
|
||||
const SizedBox(width: 12),
|
||||
// 中间内容
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
message.title,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: isUnread ? FontWeight.bold : FontWeight.w500,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 内容摘要
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 右侧信息
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 未读角标
|
||||
if (isUnread) _buildUnreadBadge(),
|
||||
// 时间
|
||||
Text(
|
||||
message.time,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIcon() {
|
||||
final iconColor = Color(
|
||||
int.parse(message.iconColor.replaceAll('#', '0xFF')),
|
||||
);
|
||||
|
||||
IconData iconData;
|
||||
switch (message.icon) {
|
||||
case 'notifications':
|
||||
iconData = Icons.notifications;
|
||||
break;
|
||||
case 'assignment':
|
||||
iconData = Icons.assignment;
|
||||
break;
|
||||
case 'campaign':
|
||||
iconData = Icons.campaign;
|
||||
break;
|
||||
case 'chat':
|
||||
iconData = Icons.chat_bubble;
|
||||
break;
|
||||
case 'description':
|
||||
iconData = Icons.description;
|
||||
break;
|
||||
case 'security':
|
||||
iconData = Icons.security;
|
||||
break;
|
||||
default:
|
||||
iconData = Icons.notifications;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: iconColor,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnreadBadge() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AlarmColors.danger,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
message.unreadCount.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
279
lib/features/v2/my/presentation/pages/profile_detail_page.dart
Normal file
279
lib/features/v2/my/presentation/pages/profile_detail_page.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_state.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
|
||||
/// 个人信息详情页
|
||||
class ProfileDetailPage extends StatelessWidget {
|
||||
const ProfileDetailPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7FA),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Color(0xFF1D2129)),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: const Text(
|
||||
'个人信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: BlocBuilder<AppUserCubit, AppUserState>(
|
||||
builder: (context, state) {
|
||||
final user = state.user;
|
||||
|
||||
if (user == null) {
|
||||
return const Center(
|
||||
child: Text('未登录'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 头像卡片
|
||||
_buildAvatarCard(user),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 信息列表
|
||||
_buildInfoCard(user),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 操作按钮
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 头像卡片
|
||||
Widget _buildAvatarCard(UserEntity user) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 头像
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: const Color(0xFF165DFF),
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _buildAvatar(user.avatar),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 昵称
|
||||
Text(
|
||||
user.nickname,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1D2129),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 用户名
|
||||
Text(
|
||||
'@${user.username}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头像
|
||||
Widget _buildAvatar(String? avatarUrl) {
|
||||
if (avatarUrl != null && avatarUrl.isNotEmpty) {
|
||||
return Image.network(
|
||||
avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _buildDefaultAvatar();
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildDefaultAvatar();
|
||||
}
|
||||
|
||||
/// 默认头像
|
||||
Widget _buildDefaultAvatar() {
|
||||
return Container(
|
||||
color: const Color(0xFFF5F7FA),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 50,
|
||||
color: Color(0xFF165DFF),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 信息卡片
|
||||
Widget _buildInfoCard(UserEntity user) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x0D000000),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoItem(
|
||||
icon: Icons.person_outline,
|
||||
label: '用户ID',
|
||||
value: user.userId,
|
||||
showDivider: true,
|
||||
),
|
||||
_buildInfoItem(
|
||||
icon: Icons.account_circle_outlined,
|
||||
label: '用户名',
|
||||
value: user.username,
|
||||
showDivider: true,
|
||||
),
|
||||
_buildInfoItem(
|
||||
icon: Icons.badge_outlined,
|
||||
label: '昵称',
|
||||
value: user.nickname,
|
||||
showDivider: user.email != null || user.phone != null,
|
||||
),
|
||||
if (user.email != null)
|
||||
_buildInfoItem(
|
||||
icon: Icons.email_outlined,
|
||||
label: '邮箱',
|
||||
value: user.email!,
|
||||
showDivider: user.phone != null,
|
||||
),
|
||||
if (user.phone != null)
|
||||
_buildInfoItem(
|
||||
icon: Icons.phone_outlined,
|
||||
label: '手机号',
|
||||
value: user.phone!,
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 信息项
|
||||
Widget _buildInfoItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required bool showDivider,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 22, color: const Color(0xFF86909C)),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF86909C),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF1D2129),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: const Color(0xFFF2F3F5),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 操作按钮
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 编辑资料按钮(预留)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: 跳转到编辑资料页面
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('编辑功能开发中...')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
label: const Text(
|
||||
'编辑资料',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF165DFF),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/features/v2/site/presentation/cubit/site_cubit.dart
Normal file
66
lib/features/v2/site/presentation/cubit/site_cubit.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../home/domain/entities/site_entity.dart';
|
||||
|
||||
class SiteState {
|
||||
final List<SiteEntity> sites;
|
||||
final SiteEntity? selectedSite;
|
||||
|
||||
const SiteState({
|
||||
this.sites = const [],
|
||||
this.selectedSite,
|
||||
});
|
||||
|
||||
SiteState copyWith({
|
||||
List<SiteEntity>? sites,
|
||||
SiteEntity? selectedSite,
|
||||
}) {
|
||||
return SiteState(
|
||||
sites: sites ?? this.sites,
|
||||
selectedSite: selectedSite ?? this.selectedSite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SiteCubit extends Cubit<SiteState> {
|
||||
final SharedPreferences sharedPreferences;
|
||||
static const String _selectedSiteIdKey = 'selected_site_id';
|
||||
|
||||
SiteCubit(this.sharedPreferences) : super(const SiteState());
|
||||
|
||||
/// 更新场站列表
|
||||
void updateSites(List<SiteEntity> sites) {
|
||||
// 尝试恢复之前选中的场站
|
||||
final savedSiteId = sharedPreferences.getInt(_selectedSiteIdKey);
|
||||
SiteEntity? selectedSite;
|
||||
|
||||
if (savedSiteId != null && sites.isNotEmpty) {
|
||||
selectedSite = sites.firstWhere(
|
||||
(site) => site.id == savedSiteId,
|
||||
orElse: () => sites.first,
|
||||
);
|
||||
} else if (sites.isNotEmpty) {
|
||||
selectedSite = sites.first;
|
||||
}
|
||||
|
||||
emit(state.copyWith(sites: sites, selectedSite: selectedSite));
|
||||
}
|
||||
|
||||
/// 选择场站(持久化)
|
||||
void selectSite(SiteEntity site) {
|
||||
sharedPreferences.setInt(_selectedSiteIdKey, site.id);
|
||||
emit(state.copyWith(selectedSite: site));
|
||||
}
|
||||
|
||||
/// 清除选中场站
|
||||
void clearSelectedSite() {
|
||||
sharedPreferences.remove(_selectedSiteIdKey);
|
||||
emit(state.copyWith(selectedSite: null));
|
||||
}
|
||||
|
||||
/// 清空所有场站数据(退出登录时调用)
|
||||
void clearAll() {
|
||||
sharedPreferences.remove(_selectedSiteIdKey);
|
||||
emit(const SiteState());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_detail_model.dart';
|
||||
|
||||
/// 告警详情远程数据源抽象
|
||||
abstract class AlarmDetailRemoteDataSource {
|
||||
/// 获取告警详情
|
||||
Future<AlarmDetailModel> getAlarmDetail(String alarmId);
|
||||
|
||||
/// 确认告警
|
||||
Future<bool> confirmAlarm(String alarmId);
|
||||
|
||||
/// AI诊断
|
||||
Future<String> aiDiagnosis(String alarmId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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';
|
||||
|
||||
/// 告警详情远程数据源实现(模拟数据)
|
||||
class AlarmDetailRemoteDataSourceImpl implements AlarmDetailRemoteDataSource {
|
||||
@override
|
||||
Future<AlarmDetailModel> getAlarmDetail(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
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: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> confirmAlarm(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> aiDiagnosis(String alarmId) async {
|
||||
// 模拟网络延迟
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
return 'AI分析结果:\n\n根据历史数据分析,该告警可能是由于以下原因导致:\n1. 电网侧电压波动超过阈值\n2. 逆变器保护机制触发\n3. 并网开关异常断开\n\n建议优先检查并网开关状态和电网电压稳定性。';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_detail_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
|
||||
/// 告警详情数据模型
|
||||
class AlarmDetailModel {
|
||||
AlarmDetailModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.level,
|
||||
required this.deviceInfo,
|
||||
required this.occurTime,
|
||||
required this.alarmStatus,
|
||||
this.recoverTime,
|
||||
required this.duration,
|
||||
required this.affectRange,
|
||||
required this.description,
|
||||
required this.suggestions,
|
||||
required this.historyData,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String level;
|
||||
final String deviceInfo;
|
||||
final String occurTime;
|
||||
final String alarmStatus;
|
||||
final String? recoverTime;
|
||||
final String duration;
|
||||
final String affectRange;
|
||||
final String description;
|
||||
final List<String> suggestions;
|
||||
final HistoryDataModel historyData;
|
||||
|
||||
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() ??
|
||||
[],
|
||||
historyData: HistoryDataModel.fromJson(
|
||||
json['historyData'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AlarmDetailEntity toEntity() {
|
||||
return AlarmDetailEntity(
|
||||
id: id,
|
||||
title: title,
|
||||
level: _parseLevel(level),
|
||||
deviceInfo: deviceInfo,
|
||||
occurTime: occurTime,
|
||||
alarmStatus: alarmStatus,
|
||||
recoverTime: recoverTime,
|
||||
duration: duration,
|
||||
affectRange: affectRange,
|
||||
description: description,
|
||||
suggestions: suggestions,
|
||||
historyData: historyData.toEntity(),
|
||||
);
|
||||
}
|
||||
|
||||
AlarmLevel _parseLevel(String level) {
|
||||
switch (level) {
|
||||
case '严重':
|
||||
return AlarmLevel.danger;
|
||||
case '高危':
|
||||
return AlarmLevel.danger;
|
||||
case '中危':
|
||||
return AlarmLevel.warning;
|
||||
case '低危':
|
||||
return AlarmLevel.low;
|
||||
case '提示':
|
||||
return AlarmLevel.info;
|
||||
default:
|
||||
return AlarmLevel.info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史数据模型
|
||||
class HistoryDataModel {
|
||||
HistoryDataModel({
|
||||
this.power = const [],
|
||||
this.voltage = const [],
|
||||
this.frequency = const [],
|
||||
});
|
||||
|
||||
final List<MetricPointModel> power;
|
||||
final List<MetricPointModel> voltage;
|
||||
final List<MetricPointModel> frequency;
|
||||
|
||||
factory HistoryDataModel.fromJson(Map<String, dynamic> json) {
|
||||
return HistoryDataModel(
|
||||
power: (json['power'] as List<dynamic>?)
|
||||
?.map((e) => MetricPointModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
voltage: (json['voltage'] as List<dynamic>?)
|
||||
?.map((e) => MetricPointModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
frequency: (json['frequency'] as List<dynamic>?)
|
||||
?.map((e) => MetricPointModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
HistoryMetrics toEntity() {
|
||||
return HistoryMetrics(
|
||||
power: power.map((e) => e.toEntity()).toList(),
|
||||
voltage: voltage.map((e) => e.toEntity()).toList(),
|
||||
frequency: frequency.map((e) => e.toEntity()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指标点模型
|
||||
class MetricPointModel {
|
||||
MetricPointModel({
|
||||
required this.time,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String time;
|
||||
final num value;
|
||||
|
||||
factory MetricPointModel.fromJson(Map<String, dynamic> json) {
|
||||
return MetricPointModel(
|
||||
time: json['time'] as String,
|
||||
value: json['value'] as num,
|
||||
);
|
||||
}
|
||||
|
||||
MetricDataPoint toEntity() {
|
||||
return MetricDataPoint(
|
||||
time: time,
|
||||
value: value.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.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/domain/entities/alarm_detail_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart';
|
||||
|
||||
/// 告警详情仓储实现
|
||||
class AlarmDetailRepositoryImpl implements AlarmDetailRepository {
|
||||
AlarmDetailRepositoryImpl(this._remoteDataSource);
|
||||
|
||||
final AlarmDetailRemoteDataSource _remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, AlarmDetailEntity>> getAlarmDetail(String alarmId) async {
|
||||
try {
|
||||
final model = await _remoteDataSource.getAlarmDetail(alarmId);
|
||||
return right(model.toEntity());
|
||||
} catch (e) {
|
||||
return left(ServerFailure('获取告警详情失败: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, bool>> confirmAlarm(String alarmId) async {
|
||||
try {
|
||||
final result = await _remoteDataSource.confirmAlarm(alarmId);
|
||||
return right(result);
|
||||
} catch (e) {
|
||||
return left(ServerFailure('确认告警失败: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, String>> aiDiagnosis(String alarmId) async {
|
||||
try {
|
||||
final result = await _remoteDataSource.aiDiagnosis(alarmId);
|
||||
return right(result);
|
||||
} catch (e) {
|
||||
return left(ServerFailure('AI诊断失败: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
|
||||
/// 指标数据点
|
||||
class MetricDataPoint extends Equatable {
|
||||
const MetricDataPoint({
|
||||
required this.time,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String time;
|
||||
final double value;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [time, value];
|
||||
}
|
||||
|
||||
/// 历史指标数据
|
||||
class HistoryMetrics extends Equatable {
|
||||
const HistoryMetrics({
|
||||
this.power = const [],
|
||||
this.voltage = const [],
|
||||
this.frequency = const [],
|
||||
});
|
||||
|
||||
final List<MetricDataPoint> power;
|
||||
final List<MetricDataPoint> voltage;
|
||||
final List<MetricDataPoint> frequency;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [power, voltage, frequency];
|
||||
}
|
||||
|
||||
/// 告警详情实体
|
||||
class AlarmDetailEntity extends Equatable {
|
||||
const AlarmDetailEntity({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.level,
|
||||
required this.deviceInfo,
|
||||
required this.occurTime,
|
||||
required this.alarmStatus,
|
||||
required this.recoverTime,
|
||||
required this.duration,
|
||||
required this.affectRange,
|
||||
required this.description,
|
||||
required this.suggestions,
|
||||
required this.historyData,
|
||||
});
|
||||
|
||||
/// 告警ID
|
||||
final String id;
|
||||
|
||||
/// 告警标题
|
||||
final String title;
|
||||
|
||||
/// 告警等级
|
||||
final AlarmLevel level;
|
||||
|
||||
/// 设备信息
|
||||
final String deviceInfo;
|
||||
|
||||
/// 发生时间
|
||||
final String occurTime;
|
||||
|
||||
/// 告警状态
|
||||
final String alarmStatus;
|
||||
|
||||
/// 恢复时间
|
||||
final String? recoverTime;
|
||||
|
||||
/// 持续时长
|
||||
final String duration;
|
||||
|
||||
/// 影响范围
|
||||
final String affectRange;
|
||||
|
||||
/// 告警描述
|
||||
final String description;
|
||||
|
||||
/// 处理建议
|
||||
final List<String> suggestions;
|
||||
|
||||
/// 历史指标数据
|
||||
final HistoryMetrics historyData;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
level,
|
||||
deviceInfo,
|
||||
occurTime,
|
||||
alarmStatus,
|
||||
recoverTime,
|
||||
duration,
|
||||
affectRange,
|
||||
description,
|
||||
suggestions,
|
||||
historyData,
|
||||
];
|
||||
|
||||
AlarmDetailEntity copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
AlarmLevel? level,
|
||||
String? deviceInfo,
|
||||
String? occurTime,
|
||||
String? alarmStatus,
|
||||
String? recoverTime,
|
||||
String? duration,
|
||||
String? affectRange,
|
||||
String? description,
|
||||
List<String>? suggestions,
|
||||
HistoryMetrics? historyData,
|
||||
}) {
|
||||
return AlarmDetailEntity(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
level: level ?? this.level,
|
||||
deviceInfo: deviceInfo ?? this.deviceInfo,
|
||||
occurTime: occurTime ?? this.occurTime,
|
||||
alarmStatus: alarmStatus ?? this.alarmStatus,
|
||||
recoverTime: recoverTime ?? this.recoverTime,
|
||||
duration: duration ?? this.duration,
|
||||
affectRange: affectRange ?? this.affectRange,
|
||||
description: description ?? this.description,
|
||||
suggestions: suggestions ?? this.suggestions,
|
||||
historyData: historyData ?? this.historyData,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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_detail_entity.dart';
|
||||
|
||||
/// 告警详情仓储抽象
|
||||
abstract class AlarmDetailRepository {
|
||||
/// 获取告警详情
|
||||
Future<Either<Failure, AlarmDetailEntity>> getAlarmDetail(String alarmId);
|
||||
|
||||
/// 确认告警
|
||||
Future<Either<Failure, bool>> confirmAlarm(String alarmId);
|
||||
|
||||
/// AI诊断
|
||||
Future<Either<Failure, String>> aiDiagnosis(String alarmId);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:maibu_satabot_v2/core/error/failure.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart';
|
||||
|
||||
/// 确认告警用例
|
||||
class ConfirmAlarmUseCase {
|
||||
ConfirmAlarmUseCase(this._repository);
|
||||
|
||||
final AlarmDetailRepository _repository;
|
||||
|
||||
Future<Either<Failure, bool>> call(String alarmId) async {
|
||||
return await _repository.confirmAlarm(alarmId);
|
||||
}
|
||||
}
|
||||
|
||||
/// AI诊断用例
|
||||
class AIDiagnosisUseCase {
|
||||
AIDiagnosisUseCase(this._repository);
|
||||
|
||||
final AlarmDetailRepository _repository;
|
||||
|
||||
Future<Either<Failure, String>> call(String alarmId) async {
|
||||
return await _repository.aiDiagnosis(alarmId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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_detail_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart';
|
||||
|
||||
/// 获取告警详情用例
|
||||
class GetAlarmDetailUseCase {
|
||||
GetAlarmDetailUseCase(this._repository);
|
||||
|
||||
final AlarmDetailRepository _repository;
|
||||
|
||||
Future<Either<Failure, AlarmDetailEntity>> call(String alarmId) async {
|
||||
return await _repository.getAlarmDetail(alarmId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart';
|
||||
|
||||
/// 告警详情 Cubit
|
||||
class AlarmDetailCubit extends Cubit<AlarmDetailState> {
|
||||
AlarmDetailCubit({
|
||||
required GetAlarmDetailUseCase getAlarmDetailUseCase,
|
||||
required ConfirmAlarmUseCase confirmAlarmUseCase,
|
||||
required AIDiagnosisUseCase aiDiagnosisUseCase,
|
||||
}) : _getAlarmDetailUseCase = getAlarmDetailUseCase,
|
||||
_confirmAlarmUseCase = confirmAlarmUseCase,
|
||||
_aiDiagnosisUseCase = aiDiagnosisUseCase,
|
||||
super(AlarmDetailInitial());
|
||||
|
||||
final GetAlarmDetailUseCase _getAlarmDetailUseCase;
|
||||
final ConfirmAlarmUseCase _confirmAlarmUseCase;
|
||||
final AIDiagnosisUseCase _aiDiagnosisUseCase;
|
||||
|
||||
/// 加载告警详情
|
||||
Future<void> loadAlarmDetail(String alarmId) async {
|
||||
emit(AlarmDetailLoading());
|
||||
|
||||
try {
|
||||
final result = await _getAlarmDetailUseCase(alarmId);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(AlarmDetailError(failure.message)),
|
||||
(alarmDetail) => emit(AlarmDetailLoaded(alarmDetail: alarmDetail)),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(AlarmDetailError('加载告警详情失败: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换指标类型
|
||||
void changeMetricType(MetricType metricType) {
|
||||
final currentState = state;
|
||||
if (currentState is AlarmDetailLoaded) {
|
||||
emit(currentState.copyWith(selectedMetric: metricType));
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认告警
|
||||
Future<void> confirmAlarm(String alarmId) async {
|
||||
final currentState = state;
|
||||
if (currentState is! AlarmDetailLoaded) return;
|
||||
|
||||
emit(const AlarmDetailActionInProgress('确认告警'));
|
||||
|
||||
try {
|
||||
final result = await _confirmAlarmUseCase(alarmId);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
emit(AlarmDetailError(failure.message));
|
||||
},
|
||||
(success) {
|
||||
if (success) {
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
isConfirmed: true,
|
||||
alarmDetail: currentState.alarmDetail.copyWith(
|
||||
alarmStatus: '已处理',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
emit(AlarmDetailError('确认告警失败: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// AI诊断
|
||||
Future<void> aiDiagnosis(String alarmId) async {
|
||||
final currentState = state;
|
||||
if (currentState is! AlarmDetailLoaded) return;
|
||||
|
||||
emit(const AlarmDetailActionInProgress('AI诊断'));
|
||||
|
||||
try {
|
||||
final result = await _aiDiagnosisUseCase(alarmId);
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
emit(AlarmDetailError(failure.message));
|
||||
},
|
||||
(diagnosisResult) {
|
||||
emit(currentState.copyWith(aiDiagnosisResult: diagnosisResult));
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
emit(AlarmDetailError('AI诊断失败: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_detail_entity.dart';
|
||||
|
||||
/// 指标类型枚举
|
||||
enum MetricType {
|
||||
power('功率(KW)'),
|
||||
voltage('电压(V)'),
|
||||
frequency('频率(Hz)');
|
||||
|
||||
const MetricType(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 告警详情状态
|
||||
abstract class AlarmDetailState extends Equatable {
|
||||
const AlarmDetailState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// 初始状态
|
||||
class AlarmDetailInitial extends AlarmDetailState {}
|
||||
|
||||
/// 加载状态
|
||||
class AlarmDetailLoading extends AlarmDetailState {}
|
||||
|
||||
/// 加载成功状态
|
||||
class AlarmDetailLoaded extends AlarmDetailState {
|
||||
const AlarmDetailLoaded({
|
||||
required this.alarmDetail,
|
||||
this.selectedMetric = MetricType.power,
|
||||
this.isConfirmed = false,
|
||||
this.aiDiagnosisResult,
|
||||
});
|
||||
|
||||
final AlarmDetailEntity alarmDetail;
|
||||
final MetricType selectedMetric;
|
||||
final bool isConfirmed;
|
||||
final String? aiDiagnosisResult;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [alarmDetail, selectedMetric, isConfirmed, aiDiagnosisResult];
|
||||
|
||||
AlarmDetailLoaded copyWith({
|
||||
AlarmDetailEntity? alarmDetail,
|
||||
MetricType? selectedMetric,
|
||||
bool? isConfirmed,
|
||||
String? aiDiagnosisResult,
|
||||
}) {
|
||||
return AlarmDetailLoaded(
|
||||
alarmDetail: alarmDetail ?? this.alarmDetail,
|
||||
selectedMetric: selectedMetric ?? this.selectedMetric,
|
||||
isConfirmed: isConfirmed ?? this.isConfirmed,
|
||||
aiDiagnosisResult: aiDiagnosisResult ?? this.aiDiagnosisResult,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载失败状态
|
||||
class AlarmDetailError extends AlarmDetailState {
|
||||
const AlarmDetailError(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// 操作进行中状态
|
||||
class AlarmDetailActionInProgress extends AlarmDetailState {
|
||||
const AlarmDetailActionInProgress(this.action);
|
||||
|
||||
final String action;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [action];
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_detail_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/widgets/metric_trend_chart.dart';
|
||||
|
||||
/// 告警详情页面
|
||||
class AlarmDetailPage extends StatefulWidget {
|
||||
final String alarmId;
|
||||
|
||||
const AlarmDetailPage({
|
||||
super.key,
|
||||
required this.alarmId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AlarmDetailPage> createState() => _AlarmDetailPageState();
|
||||
}
|
||||
|
||||
class _AlarmDetailPageState extends State<AlarmDetailPage> {
|
||||
late AlarmDetailCubit _cubit;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = context.read<AlarmDetailCubit>();
|
||||
_cubit.loadAlarmDetail(widget.alarmId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.dark.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7FA),
|
||||
appBar: _buildAppBar(),
|
||||
body: BlocConsumer<AlarmDetailCubit, AlarmDetailState>(
|
||||
listener: (context, state) {
|
||||
// 监听AI诊断结果
|
||||
if (state is AlarmDetailLoaded && state.aiDiagnosisResult != null) {
|
||||
_showAIDiagnosisDialog(state.aiDiagnosisResult!);
|
||||
}
|
||||
|
||||
// 确认告警成功后返回
|
||||
if (state is AlarmDetailLoaded && state.isConfirmed) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('告警已确认'),
|
||||
backgroundColor: AlarmColors.success,
|
||||
),
|
||||
);
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is AlarmDetailLoading || state is AlarmDetailInitial) {
|
||||
return _buildSkeletonScreen();
|
||||
}
|
||||
|
||||
if (state is AlarmDetailError) {
|
||||
return _buildErrorState(state.message);
|
||||
}
|
||||
|
||||
if (state is AlarmDetailLoaded) {
|
||||
return _buildContent(state);
|
||||
}
|
||||
|
||||
if (state is AlarmDetailActionInProgress) {
|
||||
return _buildLoadingOverlay(state.action);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar() {
|
||||
return AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AlarmColors.textPrimary),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'告警详情',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined, color: AlarmColors.textPrimary),
|
||||
onPressed: () {
|
||||
// TODO: 分享功能
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 骨架屏
|
||||
Widget _buildSkeletonScreen() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSkeletonCard(height: 120),
|
||||
const SizedBox(height: 16),
|
||||
_buildSkeletonCard(height: 200),
|
||||
const SizedBox(height: 16),
|
||||
_buildSkeletonCard(height: 150),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonCard({required double height}) {
|
||||
return Container(
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AlarmColors.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 错误状态
|
||||
Widget _buildErrorState(String message) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AlarmColors.textTertiary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _cubit.loadAlarmDetail(widget.alarmId),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AlarmColors.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载中覆盖层
|
||||
Widget _buildLoadingOverlay(String action) {
|
||||
return Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
child: Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: AlarmColors.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'$action...',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 主内容
|
||||
Widget _buildContent(AlarmDetailLoaded state) {
|
||||
final alarm = state.alarmDetail;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 告警头部信息
|
||||
_buildAlarmHeader(alarm),
|
||||
const SizedBox(height: 16),
|
||||
// 告警详情信息列表
|
||||
_buildAlarmInfoList(alarm),
|
||||
const SizedBox(height: 16),
|
||||
// 指标趋势图
|
||||
MetricTrendChart(
|
||||
historyData: alarm.historyData,
|
||||
selectedMetric: state.selectedMetric,
|
||||
onMetricChanged: (metric) => _cubit.changeMetricType(metric),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 处理建议
|
||||
_buildSuggestionsCard(alarm.suggestions),
|
||||
const SizedBox(height: 100), // 为底部按钮留出空间
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部固定按钮
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _buildBottomButtons(state),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 告警头部信息
|
||||
Widget _buildAlarmHeader(AlarmDetailEntity alarm) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [AlarmDimensions.cardShadow],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: AlarmColors.danger,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
alarm.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AlarmColors.danger.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
alarm.level.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AlarmColors.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
alarm.deviceInfo,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
alarm.occurTime,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AlarmColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 告警详情信息列表
|
||||
Widget _buildAlarmInfoList(AlarmDetailEntity alarm) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [AlarmDimensions.cardShadow],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoRow('告警状态', alarm.alarmStatus,
|
||||
valueColor: alarm.alarmStatus == '未处理' ? AlarmColors.danger : AlarmColors.success),
|
||||
_buildDivider(),
|
||||
_buildInfoRow('发生时间', alarm.occurTime),
|
||||
_buildDivider(),
|
||||
_buildInfoRow('恢复时间', alarm.recoverTime ?? '--'),
|
||||
_buildDivider(),
|
||||
_buildInfoRow('持续时长', alarm.duration),
|
||||
_buildDivider(),
|
||||
_buildInfoRow('影响范围', alarm.affectRange),
|
||||
_buildDivider(),
|
||||
_buildInfoRow('告警描述', alarm.description, isLast: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value, {Color? valueColor, bool isLast = false}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: valueColor ?? AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 24,
|
||||
thickness: 0.5,
|
||||
color: Color(0xFFE5E6EB),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理建议卡片
|
||||
Widget _buildSuggestionsCard(List<String> suggestions) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [AlarmDimensions.cardShadow],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'处理建议',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...suggestions.asMap().entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.key + 1}. ',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AlarmColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部按钮
|
||||
Widget _buildBottomButtons(AlarmDetailLoaded state) {
|
||||
final isConfirmed = state.isConfirmed;
|
||||
final isInProgress = _cubit.state is AlarmDetailActionInProgress;
|
||||
final isDisabled = isConfirmed || isInProgress;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// AI诊断按钮
|
||||
Expanded(
|
||||
child: _buildButton(
|
||||
text: 'AI诊断',
|
||||
isPrimary: false,
|
||||
isLoading: isInProgress && _cubit.state is AlarmDetailActionInProgress &&
|
||||
(_cubit.state as AlarmDetailActionInProgress).action == 'AI诊断',
|
||||
isDisabled: isDisabled,
|
||||
onPressed: isDisabled
|
||||
? null
|
||||
: () => _cubit.aiDiagnosis(widget.alarmId),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 确认告警按钮
|
||||
Expanded(
|
||||
child: _buildButton(
|
||||
text: '确认告警',
|
||||
isPrimary: true,
|
||||
isLoading: isInProgress && _cubit.state is AlarmDetailActionInProgress &&
|
||||
(_cubit.state as AlarmDetailActionInProgress).action == '确认告警',
|
||||
isDisabled: isDisabled,
|
||||
onPressed: isDisabled
|
||||
? null
|
||||
: () => _cubit.confirmAlarm(widget.alarmId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButton({
|
||||
required String text,
|
||||
required bool isPrimary,
|
||||
required bool isLoading,
|
||||
required bool isDisabled,
|
||||
VoidCallback? onPressed,
|
||||
}) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading || isDisabled ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isPrimary ? AlarmColors.primary : Colors.white,
|
||||
foregroundColor: isPrimary ? Colors.white : AlarmColors.primary,
|
||||
elevation: 0,
|
||||
side: isPrimary ? null : const BorderSide(color: AlarmColors.primary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: isPrimary ? Colors.white : AlarmColors.primary,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示AI诊断结果弹窗
|
||||
void _showAIDiagnosisDialog(String result) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'AI诊断结果',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Text(
|
||||
result,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/domain/entities/alarm_detail_entity.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/constants/alarm_constants.dart';
|
||||
|
||||
/// 指标趋势图组件
|
||||
class MetricTrendChart extends StatelessWidget {
|
||||
final HistoryMetrics historyData;
|
||||
final MetricType selectedMetric;
|
||||
final Function(MetricType) onMetricChanged;
|
||||
|
||||
const MetricTrendChart({
|
||||
super.key,
|
||||
required this.historyData,
|
||||
required this.selectedMetric,
|
||||
required this.onMetricChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [AlarmDimensions.cardShadow],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'指标趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AlarmColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 指标切换标签
|
||||
_buildMetricTabs(),
|
||||
const SizedBox(height: 16),
|
||||
// 折线图
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: _buildChart(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricTabs() {
|
||||
return Row(
|
||||
children: MetricType.values.map((metric) {
|
||||
final isSelected = metric == selectedMetric;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onMetricChanged(metric),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFE8F3FF) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
metric.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? AlarmColors.primary : AlarmColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChart() {
|
||||
List<MetricDataPoint> dataPoints;
|
||||
double maxY;
|
||||
|
||||
switch (selectedMetric) {
|
||||
case MetricType.power:
|
||||
dataPoints = historyData.power;
|
||||
maxY = 1500;
|
||||
break;
|
||||
case MetricType.voltage:
|
||||
dataPoints = historyData.voltage;
|
||||
maxY = 800;
|
||||
break;
|
||||
case MetricType.frequency:
|
||||
dataPoints = historyData.frequency;
|
||||
maxY = 60;
|
||||
break;
|
||||
}
|
||||
|
||||
if (dataPoints.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: TextStyle(color: AlarmColors.textTertiary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final spots = dataPoints
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => FlSpot(e.key.toDouble(), e.value.value))
|
||||
.toList();
|
||||
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: true,
|
||||
horizontalInterval: maxY / 5,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: const Color(0xFFE5E6EB).withOpacity(0.3),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
getDrawingVerticalLine: (value) {
|
||||
return FlLine(
|
||||
color: const Color(0xFFE5E6EB).withOpacity(0.3),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index >= 0 && index < dataPoints.length) {
|
||||
// 只显示部分标签避免拥挤
|
||||
if (index == 0 || index == dataPoints.length ~/ 2 || index == dataPoints.length - 1) {
|
||||
return Text(
|
||||
dataPoints[index].time,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AlarmColors.textTertiary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: maxY / 5,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
value.toInt().toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AlarmColors.textTertiary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: (dataPoints.length - 1).toDouble(),
|
||||
minY: 0,
|
||||
maxY: maxY,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
color: AlarmColors.primary,
|
||||
barWidth: 2,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 3,
|
||||
color: AlarmColors.danger,
|
||||
strokeWidth: 1,
|
||||
strokeColor: Colors.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AlarmColors.primary.withOpacity(0.2),
|
||||
AlarmColors.primary.withOpacity(0.0),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import flutter_webrtc
|
||||
import geolocator_apple
|
||||
import isar_community_flutter_libs
|
||||
import mobile_scanner
|
||||
import open_file_mac
|
||||
import package_info_plus
|
||||
import sentry_flutter
|
||||
import shared_preferences_foundation
|
||||
@@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
35
start_mock_server.bat
Normal file
35
start_mock_server.bat
Normal file
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo Flutter Patcher Mock Server
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM 检查 dist 目录是否存在
|
||||
if not exist "dist" (
|
||||
echo [错误] dist 目录不存在,正在创建...
|
||||
mkdir dist\version
|
||||
mkdir dist\download
|
||||
mkdir dist\patch
|
||||
echo [完成] 目录创建成功
|
||||
echo.
|
||||
)
|
||||
|
||||
REM 检查 check.json 是否存在
|
||||
if not exist "dist\version\check.json" (
|
||||
echo [警告] dist\version\check.json 不存在
|
||||
echo 请先配置版本检查信息
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [提示] 正在启动 Mock Server...
|
||||
echo [地址] http://127.0.0.1:8080
|
||||
echo [目录] dist/
|
||||
echo.
|
||||
echo 按 Ctrl+C 停止服务器
|
||||
echo.
|
||||
|
||||
dart run flutter_patcher:mock_server --dist dist
|
||||
|
||||
pause
|
||||
Reference in New Issue
Block a user