告警中心的二级详情页面+信息中心的页面+对接机器人接口
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 geolocator_apple
|
||||||
import isar_community_flutter_libs
|
import isar_community_flutter_libs
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
|
import open_file_mac
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import sentry_flutter
|
import sentry_flutter
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin"))
|
IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin"))
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
|
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
|
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
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