diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart new file mode 100644 index 00000000..6b8919c7 --- /dev/null +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource.dart @@ -0,0 +1,13 @@ +import '../../domain/entities/drone_station_entity.dart'; + +abstract class DroneStationDataSource { + Future> getDroneStationList(int siteId); + Future getUAVDetail(String gatewaySn, String deviceSn); + Future getVideoStream({ + required String sn, + required String cameraIndex, + required String cameraPosition, + String qualityType = 'adaptive', + int videoExpire = 7200, + }); +} diff --git a/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart new file mode 100644 index 00000000..4266268a --- /dev/null +++ b/lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart @@ -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> 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 rows = responseData['rows'] ?? []; + return rows.map((item) => DroneStationEntity.fromJson(item)).toList(); + } + + @override + Future 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 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']); + } +} diff --git a/lib/features/v2/device_list/data/models/robot_data_model.dart b/lib/features/v2/device_list/data/models/robot_data_model.dart new file mode 100644 index 00000000..d13880fe --- /dev/null +++ b/lib/features/v2/device_list/data/models/robot_data_model.dart @@ -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 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 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 '未知'; + } + } +} diff --git a/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart new file mode 100644 index 00000000..1d404cb3 --- /dev/null +++ b/lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart @@ -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>> getDroneStationList( + int siteId, + ) async { + try { + final stations = await dataSource.getDroneStationList(siteId); + return Right(stations); + } catch (e) { + return Left(Failure(e.toString())); + } + } + + @override + Future> 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> 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())); + } + } +} diff --git a/lib/features/v2/device_list/domain/entities/drone_station_entity.dart b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart new file mode 100644 index 00000000..447d2189 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/drone_station_entity.dart @@ -0,0 +1,420 @@ +import 'package:equatable/equatable.dart'; + +/// 摄像头信息 +class CameraInfo extends Equatable { + final String cameraIndex; + final List? availableCameraPositions; + final String cameraPosition; + + const CameraInfo({ + required this.cameraIndex, + this.availableCameraPositions, + required this.cameraPosition, + }); + + factory CameraInfo.fromJson(Map json) { + return CameraInfo( + cameraIndex: json['camera_index'] ?? '', + availableCameraPositions: json['available_camera_positions'] != null + ? List.from(json['available_camera_positions']) + : null, + cameraPosition: json['camera_position'] ?? '', + ); + } + + Map toJson() { + return { + 'camera_index': cameraIndex, + 'available_camera_positions': availableCameraPositions, + 'camera_position': cameraPosition, + }; + } + + @override + List 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 json) { + return PositionState( + gpsNumber: json['gps_number'] ?? 0, + isFixed: json['is_fixed'] ?? '', + quality: json['quality'] ?? '', + rtkNumber: json['rtk_number'] ?? 0, + ); + } + + Map toJson() { + return { + 'gps_number': gpsNumber, + 'is_fixed': isFixed, + 'quality': quality, + 'rtk_number': rtkNumber, + }; + } + + @override + List 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? gatewayCameraList; + final List? 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 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 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 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? gatewayCameraList; // 网关摄像头列表 + final List? 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 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 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 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 json) { + return VideoStreamEntity( + sn: json['sn'] ?? '', + cameraIndex: json['camera_index'] ?? '', + url: json['url'] ?? '', + expireTs: json['expire_ts'] ?? 0, + urlType: json['url_type'] ?? '', + ); + } + + @override + List get props => [sn, cameraIndex, url, expireTs, urlType]; +} diff --git a/lib/features/v2/device_list/domain/entities/video_stream_entity.dart b/lib/features/v2/device_list/domain/entities/video_stream_entity.dart new file mode 100644 index 00000000..7896add5 --- /dev/null +++ b/lib/features/v2/device_list/domain/entities/video_stream_entity.dart @@ -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 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 parseRtcParams() { + final params = {}; + 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 get props => [sn, cameraIndex, url, expireTs, urlType]; +} diff --git a/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart new file mode 100644 index 00000000..c203153c --- /dev/null +++ b/lib/features/v2/device_list/domain/repositories/drone_station_repository.dart @@ -0,0 +1,18 @@ +import 'package:fpdart/fpdart.dart'; +import '../../../../../core/error/failure.dart'; +import '../entities/drone_station_entity.dart'; + +abstract class DroneStationRepository { + Future>> getDroneStationList( + int siteId, + ); + Future> getUAVDetail( + String gatewaySn, + String deviceSn, + ); + Future> getVideoStream({ + required String sn, + required String cameraIndex, + required String cameraPosition, + }); +} diff --git a/lib/features/v2/device_list/domain/usecases/get_drone_station_list_usecase.dart b/lib/features/v2/device_list/domain/usecases/get_drone_station_list_usecase.dart new file mode 100644 index 00000000..7298fe8c --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/get_drone_station_list_usecase.dart @@ -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>> call(int siteId) async { + return await repository.getDroneStationList(siteId); + } +} + +class GetUAVDetailUseCase { + final DroneStationRepository repository; + + GetUAVDetailUseCase(this.repository); + + Future> call(String gatewaySn, String deviceSn) async { + return await repository.getUAVDetail(gatewaySn, deviceSn); + } +} diff --git a/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart b/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart new file mode 100644 index 00000000..faff104b --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/get_video_stream_usecase.dart @@ -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> call({ + required String sn, + required String cameraIndex, + required String cameraPosition, + }) async { + return await repository.getVideoStream( + sn: sn, + cameraIndex: cameraIndex, + cameraPosition: cameraPosition, + ); + } +} diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart new file mode 100644 index 00000000..0f72821d --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart @@ -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 { + final GetDroneStationListUseCase getDroneStationListUseCase; + final GetUAVDetailUseCase getUAVDetailUseCase; + final GetVideoStreamUseCase getVideoStreamUseCase; + + DroneStationBloc( + this.getDroneStationListUseCase, + this.getUAVDetailUseCase, + this.getVideoStreamUseCase, + ) : super(const DroneStationInitial()) { + on(_onLoadData); + on(_onRefresh); + on(_onUAVDetailLoad); + on(_onVideoStreamLoad); + } + + Future _onLoadData( + DroneStationLoadData event, + Emitter emit, + ) async { + emit(const DroneStationLoading()); + + final result = await getDroneStationListUseCase(event.siteId); + + result.fold( + (failure) => emit(DroneStationError(failure.message)), + (stations) => emit(DroneStationLoaded(stations)), + ); + } + + Future _onRefresh( + DroneStationRefresh event, + Emitter emit, + ) async { + if (state is DroneStationLoaded) { + final result = await getDroneStationListUseCase(event.siteId); + + result.fold( + (failure) => emit(DroneStationError(failure.message)), + (stations) => emit(DroneStationLoaded(stations)), + ); + } + } + + Future _onUAVDetailLoad( + UAVDetailLoad event, + Emitter 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 _onVideoStreamLoad( + VideoStreamLoad event, + Emitter 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)), + ); + } +} diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart new file mode 100644 index 00000000..159c5dc1 --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_event.dart @@ -0,0 +1,51 @@ +import 'package:equatable/equatable.dart'; + +abstract class DroneStationEvent extends Equatable { + const DroneStationEvent(); + + @override + List get props => []; +} + +class DroneStationLoadData extends DroneStationEvent { + final int siteId; + + const DroneStationLoadData(this.siteId); + + @override + List get props => [siteId]; +} + +class DroneStationRefresh extends DroneStationEvent { + final int siteId; + + const DroneStationRefresh(this.siteId); + + @override + List get props => [siteId]; +} + +class UAVDetailLoad extends DroneStationEvent { + final String gatewaySn; + final String deviceSn; + + const UAVDetailLoad({required this.gatewaySn, required this.deviceSn}); + + @override + List 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 get props => [sn, cameraIndex, cameraPosition]; +} diff --git a/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart b/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart new file mode 100644 index 00000000..a70afd2d --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/drone_station_state.dart @@ -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 get props => []; +} + +class DroneStationInitial extends DroneStationState { + const DroneStationInitial(); +} + +class DroneStationLoading extends DroneStationState { + const DroneStationLoading(); +} + +class DroneStationLoaded extends DroneStationState { + final List stations; + + const DroneStationLoaded(this.stations); + + @override + List get props => [stations]; +} + +class DroneStationError extends DroneStationState { + final String message; + + const DroneStationError(this.message); + + @override + List get props => [message]; +} + +class UAVDetailLoading extends DroneStationState { + const UAVDetailLoading(); +} + +class UAVDetailLoaded extends DroneStationState { + final UAVDetailEntity detail; + + const UAVDetailLoaded(this.detail); + + @override + List get props => [detail]; +} + +class UAVDetailError extends DroneStationState { + final String message; + + const UAVDetailError(this.message); + + @override + List get props => [message]; +} + +class VideoStreamLoading extends DroneStationState { + const VideoStreamLoading(); +} + +class VideoStreamLoaded extends DroneStationState { + final VideoStreamEntity videoStream; + + const VideoStreamLoaded(this.videoStream); + + @override + List get props => [videoStream]; +} + +class VideoStreamError extends DroneStationState { + final String message; + + const VideoStreamError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/features/v2/device_list/presentation/bloc/robot_list_bloc.dart b/lib/features/v2/device_list/presentation/bloc/robot_list_bloc.dart new file mode 100644 index 00000000..3f254b68 --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/robot_list_bloc.dart @@ -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 { + final Dio dio; + + RobotListBloc(this.dio) : super(const RobotListInitial()) { + on(_onLoadData); + on(_onRefresh); + on(_onChangeType); + } + + Future _onLoadData( + RobotListLoadData event, + Emitter 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 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 _onRefresh( + RobotListRefresh event, + Emitter 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 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 emit, + ) { + if (state is RobotListLoaded) { + final currentState = state as RobotListLoaded; + emit(currentState.copyWith(selectedType: event.type)); + } + } +} diff --git a/lib/features/v2/device_list/presentation/bloc/robot_list_event.dart b/lib/features/v2/device_list/presentation/bloc/robot_list_event.dart new file mode 100644 index 00000000..603036da --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/robot_list_event.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +abstract class RobotListEvent extends Equatable { + const RobotListEvent(); + + @override + List get props => []; +} + +class RobotListLoadData extends RobotListEvent { + final int? siteId; + + const RobotListLoadData({this.siteId}); + + @override + List get props => [siteId]; +} + +class RobotListRefresh extends RobotListEvent { + const RobotListRefresh(); +} + +class RobotListChangeType extends RobotListEvent { + final String? type; + + const RobotListChangeType(this.type); + + @override + List get props => [type]; +} diff --git a/lib/features/v2/device_list/presentation/bloc/robot_list_state.dart b/lib/features/v2/device_list/presentation/bloc/robot_list_state.dart new file mode 100644 index 00000000..93839cd4 --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/robot_list_state.dart @@ -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 get props => []; +} + +class RobotListInitial extends RobotListState { + const RobotListInitial(); +} + +class RobotListLoading extends RobotListState { + const RobotListLoading(); +} + +class RobotListLoaded extends RobotListState { + final List robots; + final String? selectedType; + final int? siteId; + + const RobotListLoaded({ + required this.robots, + this.selectedType, + this.siteId, + }); + + RobotListLoaded copyWith({ + List? robots, + String? selectedType, + int? siteId, + }) { + return RobotListLoaded( + robots: robots ?? this.robots, + selectedType: selectedType ?? this.selectedType, + siteId: siteId ?? this.siteId, + ); + } + + @override + List get props => [robots, selectedType, siteId]; +} + +class RobotListError extends RobotListState { + final String message; + + const RobotListError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart b/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart new file mode 100644 index 00000000..8d995b8d --- /dev/null +++ b/lib/features/v2/device_list/presentation/pages/drone_monitor_page.dart @@ -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 { + 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(); + // _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 _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 _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( + 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)), + ], + ), + ), + ); + } +} diff --git a/lib/features/v2/home/data/datasources/site_datasource.dart b/lib/features/v2/home/data/datasources/site_datasource.dart new file mode 100644 index 00000000..fedf544c --- /dev/null +++ b/lib/features/v2/home/data/datasources/site_datasource.dart @@ -0,0 +1,5 @@ +import '../../domain/entities/site_entity.dart'; + +abstract class SiteDataSource { + Future> getSiteList(int orgId); +} diff --git a/lib/features/v2/home/data/datasources/site_datasource_impl.dart b/lib/features/v2/home/data/datasources/site_datasource_impl.dart new file mode 100644 index 00000000..06f3e18d --- /dev/null +++ b/lib/features/v2/home/data/datasources/site_datasource_impl.dart @@ -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> getSiteList(int orgId) async { + // 构建查询参数:orgId 为 0 时不传递 + final queryParams = { + '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 rows = responseData['rows'] ?? []; + return rows.map((item) => SiteEntity.fromJson(item)).toList(); + } +} diff --git a/lib/features/v2/home/data/repositories/site_repository_impl.dart b/lib/features/v2/home/data/repositories/site_repository_impl.dart new file mode 100644 index 00000000..64403181 --- /dev/null +++ b/lib/features/v2/home/data/repositories/site_repository_impl.dart @@ -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>> getSiteList(int orgId) async { + try { + final sites = await dataSource.getSiteList(orgId); + return Right(sites); + } catch (e) { + return Left(Failure(e.toString())); + } + } +} diff --git a/lib/features/v2/home/domain/entities/site_entity.dart b/lib/features/v2/home/domain/entities/site_entity.dart new file mode 100644 index 00000000..d43d24f0 --- /dev/null +++ b/lib/features/v2/home/domain/entities/site_entity.dart @@ -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 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 toJson() { + return { + 'id': id, + 'siteName': siteName, + 'siteCode': siteCode, + 'orgId': orgId, + 'longitude': longitude, + 'latitude': latitude, + 'address': address, + 'status': status, + }; + } + + @override + List get props => [id, siteName, siteCode, orgId, longitude, latitude, address, status]; +} diff --git a/lib/features/v2/home/domain/repositories/site_repository.dart b/lib/features/v2/home/domain/repositories/site_repository.dart new file mode 100644 index 00000000..aaf9e97a --- /dev/null +++ b/lib/features/v2/home/domain/repositories/site_repository.dart @@ -0,0 +1,9 @@ + +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../entities/site_entity.dart'; + +abstract class SiteRepository { + Future>> getSiteList(int orgId); +} diff --git a/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart b/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart new file mode 100644 index 00000000..29521b15 --- /dev/null +++ b/lib/features/v2/home/domain/usecases/get_site_list_usecase.dart @@ -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>> call(int orgId) async { + return await repository.getSiteList(orgId); + } +} diff --git a/lib/features/v2/message_center/data/datasources/impl/message_center_remote_datasource_impl.dart b/lib/features/v2/message_center/data/datasources/impl/message_center_remote_datasource_impl.dart new file mode 100644 index 00000000..c576da32 --- /dev/null +++ b/lib/features/v2/message_center/data/datasources/impl/message_center_remote_datasource_impl.dart @@ -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> 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 markAllAsRead() async { + // 模拟网络延迟 + await Future.delayed(const Duration(milliseconds: 500)); + return true; + } +} diff --git a/lib/features/v2/message_center/data/datasources/message_center_remote_datasource.dart b/lib/features/v2/message_center/data/datasources/message_center_remote_datasource.dart new file mode 100644 index 00000000..d66cd9a2 --- /dev/null +++ b/lib/features/v2/message_center/data/datasources/message_center_remote_datasource.dart @@ -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> getMessageList({MessageType? type}); + + /// 一键已读 + Future markAllAsRead(); +} diff --git a/lib/features/v2/message_center/data/models/message_category_model.dart b/lib/features/v2/message_center/data/models/message_category_model.dart new file mode 100644 index 00000000..3f09ce79 --- /dev/null +++ b/lib/features/v2/message_center/data/models/message_category_model.dart @@ -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 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; + } + } +} diff --git a/lib/features/v2/message_center/data/repositories/message_center_repository_impl.dart b/lib/features/v2/message_center/data/repositories/message_center_repository_impl.dart new file mode 100644 index 00000000..c39d9df0 --- /dev/null +++ b/lib/features/v2/message_center/data/repositories/message_center_repository_impl.dart @@ -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>> 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> markAllAsRead() async { + try { + final success = await _remoteDataSource.markAllAsRead(); + return Right(success); + } catch (e) { + return Left(ServerFailure('标记已读失败:$e')); + } + } +} diff --git a/lib/features/v2/message_center/domain/entities/message_category_entity.dart b/lib/features/v2/message_center/domain/entities/message_category_entity.dart new file mode 100644 index 00000000..5422eff5 --- /dev/null +++ b/lib/features/v2/message_center/domain/entities/message_category_entity.dart @@ -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 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, + ); + } +} diff --git a/lib/features/v2/message_center/domain/repositories/message_center_repository.dart b/lib/features/v2/message_center/domain/repositories/message_center_repository.dart new file mode 100644 index 00000000..7c530ba0 --- /dev/null +++ b/lib/features/v2/message_center/domain/repositories/message_center_repository.dart @@ -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>> getMessageList({ + MessageType? type, + }); + + /// 一键已读 + Future> markAllAsRead(); +} diff --git a/lib/features/v2/message_center/domain/usecases/message_center_usecase.dart b/lib/features/v2/message_center/domain/usecases/message_center_usecase.dart new file mode 100644 index 00000000..e39ae7c4 --- /dev/null +++ b/lib/features/v2/message_center/domain/usecases/message_center_usecase.dart @@ -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>> call({ + MessageType? type, + }) async { + return await _repository.getMessageList(type: type); + } +} + +/// 一键已读用例 +class MarkAllAsReadUseCase { + MarkAllAsReadUseCase(this._repository); + + final MessageCenterRepository _repository; + + Future> call() async { + return await _repository.markAllAsRead(); + } +} diff --git a/lib/features/v2/message_center/presentation/bloc/message_center_cubit.dart b/lib/features/v2/message_center/presentation/bloc/message_center_cubit.dart new file mode 100644 index 00000000..9587fe45 --- /dev/null +++ b/lib/features/v2/message_center/presentation/bloc/message_center_cubit.dart @@ -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 { + MessageCenterCubit({ + required GetMessageListUseCase getMessageListUseCase, + required MarkAllAsReadUseCase markAllAsReadUseCase, + }) : _getMessageListUseCase = getMessageListUseCase, + _markAllAsReadUseCase = markAllAsReadUseCase, + super(MessageCenterInitial()); + + final GetMessageListUseCase _getMessageListUseCase; + final MarkAllAsReadUseCase _markAllAsReadUseCase; + + /// 加载消息列表 + Future loadMessageList({MessageType? type}) async { + emit(MessageCenterLoading()); + final result = await _getMessageListUseCase(type: type); + result.fold( + (failure) => emit(MessageCenterError(failure.message)), + (messageList) { + final totalUnread = messageList.fold( + 0, + (sum, msg) => sum + msg.unreadCount, + ); + emit(MessageCenterLoaded( + messageList: messageList, + selectedType: type ?? MessageType.all, + totalUnread: totalUnread, + )); + }, + ); + } + + /// 切换消息类型筛选 + Future changeFilter(MessageType type) async { + await loadMessageList(type: type); + } + + /// 一键已读 + Future 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, + )); + } + }, + ); + } +} diff --git a/lib/features/v2/message_center/presentation/bloc/message_center_state.dart b/lib/features/v2/message_center/presentation/bloc/message_center_state.dart new file mode 100644 index 00000000..8d32c50b --- /dev/null +++ b/lib/features/v2/message_center/presentation/bloc/message_center_state.dart @@ -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 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 messageList; + final MessageType selectedType; + final int totalUnread; + + @override + List get props => [messageList, selectedType, totalUnread]; + + MessageCenterLoaded copyWith({ + List? 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 get props => [message]; +} + +/// 操作中状态(一键已读) +final class MessageCenterActionInProgress extends MessageCenterState { + const MessageCenterActionInProgress(this.action); + + final String action; + + @override + List get props => [action]; +} diff --git a/lib/features/v2/message_center/presentation/pages/message_center_page.dart b/lib/features/v2/message_center/presentation/pages/message_center_page.dart new file mode 100644 index 00000000..37ad9af8 --- /dev/null +++ b/lib/features/v2/message_center/presentation/pages/message_center_page.dart @@ -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()..loadMessageList(), + child: const MessageCenterView(), + ); + } +} + +class MessageCenterView extends StatelessWidget { + const MessageCenterView({super.key}); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + + return Scaffold( + backgroundColor: AlarmColors.background, + body: BlocConsumer( + 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( + builder: (context, state) { + MessageType selectedType = MessageType.all; + if (state is MessageCenterLoaded) { + selectedType = state.selectedType; + } + + return DropdownButton( + 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( + 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().loadMessageList(); + }, + child: const Text('重试'), + ), + ], + ), + ); + } + + Widget _buildLoadingOverlay(BuildContext context, String action) { + return Stack( + children: [ + BlocBuilder( + 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, + ), + ), + ), + ); + } +} diff --git a/lib/features/v2/my/presentation/pages/profile_detail_page.dart b/lib/features/v2/my/presentation/pages/profile_detail_page.dart new file mode 100644 index 00000000..f0ea6610 --- /dev/null +++ b/lib/features/v2/my/presentation/pages/profile_detail_page.dart @@ -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( + 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), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/v2/site/presentation/cubit/site_cubit.dart b/lib/features/v2/site/presentation/cubit/site_cubit.dart new file mode 100644 index 00000000..22ec608f --- /dev/null +++ b/lib/features/v2/site/presentation/cubit/site_cubit.dart @@ -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 sites; + final SiteEntity? selectedSite; + + const SiteState({ + this.sites = const [], + this.selectedSite, + }); + + SiteState copyWith({ + List? sites, + SiteEntity? selectedSite, + }) { + return SiteState( + sites: sites ?? this.sites, + selectedSite: selectedSite ?? this.selectedSite, + ); + } +} + +class SiteCubit extends Cubit { + final SharedPreferences sharedPreferences; + static const String _selectedSiteIdKey = 'selected_site_id'; + + SiteCubit(this.sharedPreferences) : super(const SiteState()); + + /// 更新场站列表 + void updateSites(List 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()); + } +} diff --git a/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart b/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart new file mode 100644 index 00000000..7412103f --- /dev/null +++ b/lib/features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart @@ -0,0 +1,13 @@ +import 'package:maibu_satabot_v2/features/v2/waring_center/data/models/alarm_detail_model.dart'; + +/// 告警详情远程数据源抽象 +abstract class AlarmDetailRemoteDataSource { + /// 获取告警详情 + Future getAlarmDetail(String alarmId); + + /// 确认告警 + Future confirmAlarm(String alarmId); + + /// AI诊断 + Future aiDiagnosis(String alarmId); +} diff --git a/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart b/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart new file mode 100644 index 00000000..ffb40ce3 --- /dev/null +++ b/lib/features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart @@ -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 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 confirmAlarm(String alarmId) async { + // 模拟网络延迟 + await Future.delayed(const Duration(seconds: 1)); + return true; + } + + @override + Future aiDiagnosis(String alarmId) async { + // 模拟网络延迟 + await Future.delayed(const Duration(seconds: 2)); + return 'AI分析结果:\n\n根据历史数据分析,该告警可能是由于以下原因导致:\n1. 电网侧电压波动超过阈值\n2. 逆变器保护机制触发\n3. 并网开关异常断开\n\n建议优先检查并网开关状态和电网电压稳定性。'; + } +} diff --git a/lib/features/v2/waring_center/data/models/alarm_detail_model.dart b/lib/features/v2/waring_center/data/models/alarm_detail_model.dart new file mode 100644 index 00000000..4988f524 --- /dev/null +++ b/lib/features/v2/waring_center/data/models/alarm_detail_model.dart @@ -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 suggestions; + final HistoryDataModel historyData; + + factory AlarmDetailModel.fromJson(Map 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?) + ?.map((e) => e as String) + .toList() ?? + [], + historyData: HistoryDataModel.fromJson( + json['historyData'] as Map, + ), + ); + } + + 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 power; + final List voltage; + final List frequency; + + factory HistoryDataModel.fromJson(Map json) { + return HistoryDataModel( + power: (json['power'] as List?) + ?.map((e) => MetricPointModel.fromJson(e as Map)) + .toList() ?? + [], + voltage: (json['voltage'] as List?) + ?.map((e) => MetricPointModel.fromJson(e as Map)) + .toList() ?? + [], + frequency: (json['frequency'] as List?) + ?.map((e) => MetricPointModel.fromJson(e as Map)) + .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 json) { + return MetricPointModel( + time: json['time'] as String, + value: json['value'] as num, + ); + } + + MetricDataPoint toEntity() { + return MetricDataPoint( + time: time, + value: value.toDouble(), + ); + } +} diff --git a/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart b/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart new file mode 100644 index 00000000..b6451fa4 --- /dev/null +++ b/lib/features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart @@ -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> getAlarmDetail(String alarmId) async { + try { + final model = await _remoteDataSource.getAlarmDetail(alarmId); + return right(model.toEntity()); + } catch (e) { + return left(ServerFailure('获取告警详情失败: $e')); + } + } + + @override + Future> confirmAlarm(String alarmId) async { + try { + final result = await _remoteDataSource.confirmAlarm(alarmId); + return right(result); + } catch (e) { + return left(ServerFailure('确认告警失败: $e')); + } + } + + @override + Future> aiDiagnosis(String alarmId) async { + try { + final result = await _remoteDataSource.aiDiagnosis(alarmId); + return right(result); + } catch (e) { + return left(ServerFailure('AI诊断失败: $e')); + } + } +} diff --git a/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart b/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart new file mode 100644 index 00000000..d10a4b65 --- /dev/null +++ b/lib/features/v2/waring_center/domain/entities/alarm_detail_entity.dart @@ -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 get props => [time, value]; +} + +/// 历史指标数据 +class HistoryMetrics extends Equatable { + const HistoryMetrics({ + this.power = const [], + this.voltage = const [], + this.frequency = const [], + }); + + final List power; + final List voltage; + final List frequency; + + @override + List 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 suggestions; + + /// 历史指标数据 + final HistoryMetrics historyData; + + @override + List 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? 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, + ); + } +} diff --git a/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart b/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart new file mode 100644 index 00000000..041ab7ac --- /dev/null +++ b/lib/features/v2/waring_center/domain/repositories/alarm_detail_repository.dart @@ -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> getAlarmDetail(String alarmId); + + /// 确认告警 + Future> confirmAlarm(String alarmId); + + /// AI诊断 + Future> aiDiagnosis(String alarmId); +} diff --git a/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart b/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart new file mode 100644 index 00000000..1910f6d3 --- /dev/null +++ b/lib/features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart @@ -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> call(String alarmId) async { + return await _repository.confirmAlarm(alarmId); + } +} + +/// AI诊断用例 +class AIDiagnosisUseCase { + AIDiagnosisUseCase(this._repository); + + final AlarmDetailRepository _repository; + + Future> call(String alarmId) async { + return await _repository.aiDiagnosis(alarmId); + } +} diff --git a/lib/features/v2/waring_center/domain/usecases/get_alarm_detail_usecase.dart b/lib/features/v2/waring_center/domain/usecases/get_alarm_detail_usecase.dart new file mode 100644 index 00000000..4723d534 --- /dev/null +++ b/lib/features/v2/waring_center/domain/usecases/get_alarm_detail_usecase.dart @@ -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> call(String alarmId) async { + return await _repository.getAlarmDetail(alarmId); + } +} diff --git a/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart new file mode 100644 index 00000000..4205bae0 --- /dev/null +++ b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart @@ -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 { + 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 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 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 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')); + } + } +} diff --git a/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart new file mode 100644 index 00000000..1a4aa367 --- /dev/null +++ b/lib/features/v2/waring_center/presentation/bloc/alarm_detail_state.dart @@ -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 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 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 get props => [message]; +} + +/// 操作进行中状态 +class AlarmDetailActionInProgress extends AlarmDetailState { + const AlarmDetailActionInProgress(this.action); + + final String action; + + @override + List get props => [action]; +} diff --git a/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart b/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart new file mode 100644 index 00000000..f57ef1a8 --- /dev/null +++ b/lib/features/v2/waring_center/presentation/pages/alarm_detail_page.dart @@ -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 createState() => _AlarmDetailPageState(); +} + +class _AlarmDetailPageState extends State { + late AlarmDetailCubit _cubit; + + @override + void initState() { + super.initState(); + _cubit = context.read(); + _cubit.loadAlarmDetail(widget.alarmId); + } + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: _buildAppBar(), + body: BlocConsumer( + 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 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('关闭'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/v2/waring_center/presentation/widgets/metric_trend_chart.dart b/lib/features/v2/waring_center/presentation/widgets/metric_trend_chart.dart new file mode 100644 index 00000000..f965cad1 --- /dev/null +++ b/lib/features/v2/waring_center/presentation/widgets/metric_trend_chart.dart @@ -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 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6f0e0cce..1a532584 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import flutter_webrtc import geolocator_apple import isar_community_flutter_libs import mobile_scanner +import open_file_mac import package_info_plus import sentry_flutter import shared_preferences_foundation @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/start_mock_server.bat b/start_mock_server.bat new file mode 100644 index 00000000..6c5ba35d --- /dev/null +++ b/start_mock_server.bat @@ -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