668 lines
21 KiB
Dart
668 lines
21 KiB
Dart
import 'package:dio/dio.dart';
|
||
|
||
import '../utils/constants.dart';
|
||
import '../utils/credential_storage.dart';
|
||
import '../config/app_config.dart';
|
||
|
||
/// 内存态令牌存储。
|
||
///
|
||
/// token / refreshToken 仅保存在内存中(不引入 shared_preferences,
|
||
/// 避免鸿蒙端插件兼容问题),应用重启后需重新登录。
|
||
class TokenStore {
|
||
TokenStore._();
|
||
|
||
/// 全局单例。
|
||
static final TokenStore instance = TokenStore._();
|
||
|
||
/// 访问令牌。
|
||
String? token;
|
||
|
||
/// 刷新令牌。
|
||
String? refreshToken;
|
||
|
||
/// 保存登录 / 刷新后的令牌对。
|
||
void save({required String token, String? refreshToken}) {
|
||
this.token = token;
|
||
if (refreshToken != null && refreshToken.isNotEmpty) {
|
||
this.refreshToken = refreshToken;
|
||
}
|
||
}
|
||
|
||
/// 清空令牌(登出或会话失效时调用)。
|
||
void clear() {
|
||
token = null;
|
||
refreshToken = null;
|
||
}
|
||
|
||
/// 是否持有访问令牌。
|
||
bool get hasToken => token != null && token!.isNotEmpty;
|
||
}
|
||
|
||
/// 业务异常:后端统一响应 `{code, msg, data}` 中 code 非成功值时抛出。
|
||
class ApiException implements Exception {
|
||
const ApiException(this.code, this.message);
|
||
|
||
/// 业务错误码。
|
||
final int code;
|
||
|
||
/// 错误信息。
|
||
final String message;
|
||
|
||
@override
|
||
String toString() => message;
|
||
}
|
||
|
||
/// 把 DioException / 业务异常转成用户可读的简短文本。
|
||
///
|
||
/// 优先展示后端返回的 msg,其次网络状态,最后兜底提示。
|
||
String formatApiError(Object? error, {String fallback = '请求失败,请稍后重试'}) {
|
||
if (error is ApiException && error.message.isNotEmpty) {
|
||
return error.message;
|
||
}
|
||
if (error is DioException) {
|
||
final inner = error.error;
|
||
if (inner is ApiException && inner.message.isNotEmpty) {
|
||
return inner.message;
|
||
}
|
||
final response = error.response;
|
||
if (response != null && response.statusCode != null) {
|
||
return '服务器响应异常:HTTP ${response.statusCode}';
|
||
}
|
||
switch (error.type) {
|
||
case DioExceptionType.connectionTimeout:
|
||
case DioExceptionType.receiveTimeout:
|
||
case DioExceptionType.sendTimeout:
|
||
return '连接超时,请检查网络';
|
||
case DioExceptionType.connectionError:
|
||
return '网络连接失败,请检查网络';
|
||
case DioExceptionType.cancel:
|
||
return '请求已取消';
|
||
case DioExceptionType.badCertificate:
|
||
return '服务器证书校验失败';
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
final text = error?.toString();
|
||
if (text != null && text.isNotEmpty && text != 'null') {
|
||
return text;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/// 判断异常是否表示登录态失效(token 过期 / 401)。
|
||
///
|
||
/// 后端常以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」而非 HTTP 401,
|
||
/// 拦截器已统一把这类场景包装为 [ApiException](401, '登录已过期,请重新登录')。
|
||
///
|
||
/// UI 层捕获到此类异常时,应跳过普通错误提示、改由路由重定向引导重新登录,
|
||
/// 避免把「登录已过期」之类的底层文案直接暴露给用户(如「查询失败:登录已过期」)。
|
||
bool isUnauthorizedError(Object? error) {
|
||
ApiException? apiEx;
|
||
if (error is ApiException) {
|
||
apiEx = error;
|
||
} else if (error is DioException && error.error is ApiException) {
|
||
apiEx = error.error as ApiException;
|
||
}
|
||
if (apiEx == null) return false;
|
||
return apiEx.code == 401 || apiEx.message.contains('登录已过期');
|
||
}
|
||
|
||
/// HTTP 客户端(Dio 单例)。
|
||
///
|
||
/// - [AppConstants.useMock] = false:挂载 [ApiInterceptor],对接真实「爱视后台」,
|
||
/// 自动附加 Bearer 令牌与防缓存时间戳、解包 `{code,msg,data}`、
|
||
/// 401 时用 refreshToken 刷新并重试一次;
|
||
/// - [AppConstants.useMock] = true:挂载 [MockInterceptor] 本地返回示例数据。
|
||
class ApiClient {
|
||
ApiClient._() {
|
||
_dio = _buildDio();
|
||
}
|
||
|
||
/// 构建 Dio 实例(拦截器配置集中于此,[reconfigure] 复用)。
|
||
///
|
||
/// baseUrl 取自 [AppConfig],支持运行时切换环境后端地址。
|
||
Dio _buildDio() {
|
||
final dio = Dio(
|
||
BaseOptions(
|
||
baseUrl: AppConfig.apiFullBaseUrl,
|
||
connectTimeout: AppConstants.apiTimeout,
|
||
receiveTimeout: AppConstants.apiTimeout,
|
||
headers: {'Content-Type': 'application/json; charset=utf-8'},
|
||
),
|
||
);
|
||
if (AppConstants.useMock) {
|
||
// Mock 在前:直接本地 resolve,后续 ApiInterceptor 仍负责解包信封。
|
||
dio.interceptors.add(MockInterceptor());
|
||
}
|
||
dio.interceptors.add(ApiInterceptor(dio));
|
||
return dio;
|
||
}
|
||
|
||
/// 运行时切换后端地址后重建 Dio 单例(保留拦截器配置)。
|
||
///
|
||
/// 允许重复调用,用于环境切换、语言切换后刷新网络层等场景。
|
||
/// 返回重建结果与可能的错误信息,避免直接抛异常导致 UI 崩溃。
|
||
({bool success, String? error}) reconfigure() {
|
||
try {
|
||
_dio = _buildDio();
|
||
return (success: true, error: null);
|
||
} catch (e) {
|
||
return (success: false, error: e.toString());
|
||
}
|
||
}
|
||
|
||
/// 全局单例。
|
||
static final ApiClient instance = ApiClient._();
|
||
|
||
/// Dio 实例。使用 [late](非 [late final])以支持 [reconfigure] 重复重建。
|
||
late Dio _dio;
|
||
|
||
/// 会话失效回调(401 刷新失败后触发),由认证层注册以清空登录态。
|
||
void Function()? onUnauthorized;
|
||
|
||
/// 获取 Dio 实例。
|
||
Dio get dio => _dio;
|
||
}
|
||
|
||
/// 真实接口拦截器:鉴权头、防缓存时间戳、信封解包、401 刷新重试。
|
||
class ApiInterceptor extends Interceptor {
|
||
ApiInterceptor(this._dio);
|
||
|
||
final Dio _dio;
|
||
|
||
/// 刷新接口路径(避免 401 处理时对自身重试)。
|
||
static const String _refreshPath = '/sys/refresh';
|
||
|
||
/// 标记已重试过的请求,防止循环刷新。
|
||
static const String _retriedFlag = '__retried_after_refresh';
|
||
|
||
@override
|
||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||
// 鉴权:Bearer token(不使用 Cookie)。
|
||
final token = TokenStore.instance.token;
|
||
if (token != null && token.isNotEmpty) {
|
||
options.headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
// 防缓存:仅 GET 在 query 上附加毫秒时间戳 t。
|
||
// 注意:POST/PUT 写入请求不缓存,且绝不能把 t 注入业务 body——
|
||
// 后端(如 CodeIgniter)按全字段 insert 时会因未知列 `t` 报 500,
|
||
// 导致「列表能读、保存失败」。故写入请求保持 body 纯净。
|
||
if (options.method == 'GET') {
|
||
options.queryParameters['t'] =
|
||
DateTime.now().millisecondsSinceEpoch.toString();
|
||
}
|
||
handler.next(options);
|
||
}
|
||
|
||
@override
|
||
Future<void> onResponse(
|
||
Response response,
|
||
ResponseInterceptorHandler handler,
|
||
) async {
|
||
final body = response.data;
|
||
// 非信封格式(如二进制流)直接放行。
|
||
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
|
||
handler.next(response);
|
||
return;
|
||
}
|
||
|
||
final code = _asInt(body['code']);
|
||
final msg = body['msg']?.toString() ?? '请求失败';
|
||
|
||
// 成功码:0 / 200,解包 data。
|
||
if (code == 0 || code == 200) {
|
||
response.data = body['data'];
|
||
handler.next(response);
|
||
return;
|
||
}
|
||
|
||
// 业务 401 或后端以普通业务码返回 token 失效:尝试刷新令牌并重试一次。
|
||
final isTokenExpired = _isSessionExpiredMessage(msg);
|
||
if ((code == 401 || isTokenExpired) &&
|
||
response.requestOptions.path != _refreshPath &&
|
||
response.requestOptions.extra[_retriedFlag] != true) {
|
||
final retried = await _refreshAndRetry(response.requestOptions, handler);
|
||
if (retried) {
|
||
return;
|
||
}
|
||
// 刷新失败:已触发 _notifyUnauthorized() 清空登录态并跳转登录页,
|
||
// 将错误信息统一为更友好的文案,避免把原始 DioException 抛给 UI。
|
||
handler.reject(
|
||
DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
error: ApiException(401, '登录已过期,请重新登录'),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
handler.reject(
|
||
DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
error: ApiException(code, msg),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> onError(
|
||
DioException err,
|
||
ErrorInterceptorHandler handler,
|
||
) async {
|
||
// HTTP 401:同样尝试刷新令牌并重试一次。
|
||
if (err.response?.statusCode == 401 &&
|
||
err.requestOptions.path != _refreshPath &&
|
||
err.requestOptions.extra[_retriedFlag] != true) {
|
||
final retried = await _refreshAndRetry(err.requestOptions, handler);
|
||
if (retried) {
|
||
return;
|
||
}
|
||
// _refreshAndRetry 失败路径内部已调用 _notifyUnauthorized,此处不重复调用。
|
||
handler.next(err);
|
||
return;
|
||
}
|
||
// 已解包出的业务异常直接透传,其余包装网络错误信息。
|
||
if (err.error is ApiException) {
|
||
handler.next(err);
|
||
return;
|
||
}
|
||
handler.next(err);
|
||
}
|
||
|
||
/// 刷新令牌并重试原请求;成功返回 true。
|
||
Future<bool> _refreshAndRetry(
|
||
RequestOptions options,
|
||
dynamic handler,
|
||
) async {
|
||
final refreshed = await _tryRefreshToken();
|
||
if (!refreshed) {
|
||
_notifyUnauthorized();
|
||
return false;
|
||
}
|
||
try {
|
||
options.extra[_retriedFlag] = true;
|
||
// 重新附加新 token。
|
||
final token = TokenStore.instance.token;
|
||
if (token != null) {
|
||
options.headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
final retryResponse = await _dio.fetch<dynamic>(options);
|
||
if (handler is ResponseInterceptorHandler) {
|
||
handler.resolve(retryResponse);
|
||
} else if (handler is ErrorInterceptorHandler) {
|
||
handler.resolve(retryResponse);
|
||
}
|
||
return true;
|
||
} catch (_) {
|
||
_notifyUnauthorized();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 进行中的刷新请求:并发 401 只发起一次 /sys/refresh,其余共享结果。
|
||
Future<bool>? _refreshing;
|
||
|
||
/// 用 refreshToken 换新 token(带并发锁)。
|
||
Future<bool> _tryRefreshToken() {
|
||
return _refreshing ??= _doRefreshToken().whenComplete(() {
|
||
_refreshing = null;
|
||
});
|
||
}
|
||
|
||
/// 实际刷新逻辑。使用独立 Dio 实例,避免进入拦截器循环。
|
||
Future<bool> _doRefreshToken() async {
|
||
final refreshToken = TokenStore.instance.refreshToken;
|
||
if (refreshToken == null || refreshToken.isEmpty) {
|
||
return false;
|
||
}
|
||
try {
|
||
final rawDio = Dio(
|
||
BaseOptions(
|
||
baseUrl: AppConfig.apiFullBaseUrl,
|
||
connectTimeout: AppConstants.apiTimeout,
|
||
receiveTimeout: AppConstants.apiTimeout,
|
||
headers: {'Content-Type': 'application/json; charset=utf-8'},
|
||
),
|
||
);
|
||
final response = await rawDio.post<Map<String, dynamic>>(
|
||
_refreshPath,
|
||
data: {
|
||
'refreshToken': refreshToken,
|
||
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
||
},
|
||
);
|
||
final body = response.data;
|
||
if (body == null) {
|
||
return false;
|
||
}
|
||
final code = _asInt(body['code']);
|
||
if (code != 0 && code != 200) {
|
||
return false;
|
||
}
|
||
final data = body['data'];
|
||
if (data is Map<String, dynamic> && data['token'] != null) {
|
||
final newToken = data['token'].toString();
|
||
final newRefreshToken = data['refreshToken']?.toString();
|
||
TokenStore.instance.save(
|
||
token: newToken,
|
||
refreshToken: newRefreshToken,
|
||
);
|
||
// 刷新后的新令牌必须同步持久化,否则切换语言/进程重启后
|
||
// 会恢复到旧令牌,导致「token 失效」报错。
|
||
await CredentialStorage.instance.saveSession(
|
||
token: newToken,
|
||
refreshToken: newRefreshToken,
|
||
);
|
||
return true;
|
||
}
|
||
return false;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 会话失效:清空令牌并通知认证层(跳转登录页)。
|
||
void _notifyUnauthorized() {
|
||
TokenStore.instance.clear();
|
||
ApiClient.instance.onUnauthorized?.call();
|
||
}
|
||
|
||
/// 判断后端返回的 msg 是否为 token/会话失效类提示。
|
||
///
|
||
/// 爱视后台有时以 HTTP 200 + 业务码 500 返回「token失效,请重新登录」,
|
||
/// 而非 HTTP 401;此处统一识别并走刷新/登出流程。
|
||
static bool _isSessionExpiredMessage(String msg) {
|
||
final lower = msg.toLowerCase();
|
||
if (!lower.contains('token') && !lower.contains('登录')) return false;
|
||
return lower.contains('失效') ||
|
||
lower.contains('过期') ||
|
||
lower.contains('无效') ||
|
||
lower.contains('请重新登录') ||
|
||
lower.contains('未登录');
|
||
}
|
||
|
||
static int _asInt(dynamic value) {
|
||
if (value is int) {
|
||
return value;
|
||
}
|
||
return int.tryParse(value?.toString() ?? '') ?? -1;
|
||
}
|
||
}
|
||
|
||
/// Mock 拦截器(仅 [AppConstants.useMock] = true 时挂载)。
|
||
///
|
||
/// 返回与真实后端一致的 `{code, msg, data}` 信封,由 [ApiInterceptor] 统一解包。
|
||
class MockInterceptor extends Interceptor {
|
||
@override
|
||
void onRequest(
|
||
RequestOptions options,
|
||
RequestInterceptorHandler handler,
|
||
) {
|
||
// 模拟网络延迟。
|
||
Future.delayed(const Duration(milliseconds: 300), () {
|
||
handler.resolve(
|
||
Response(
|
||
requestOptions: options,
|
||
statusCode: 200,
|
||
data: _mockResponse(options),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
Map<String, dynamic> _ok(Object? data) {
|
||
return {'code': 0, 'msg': 'success', 'data': data};
|
||
}
|
||
|
||
Map<String, dynamic> _mockResponse(RequestOptions options) {
|
||
final path = options.path;
|
||
|
||
// 登录:POST /sys/login
|
||
if (path == '/sys/login') {
|
||
final body = options.data;
|
||
if (body is Map &&
|
||
body['username'] == AppConstants.mockAccount &&
|
||
body['password'] == AppConstants.mockPassword) {
|
||
return _ok({
|
||
'token': 'mock-token-20240520',
|
||
'expire': 7200,
|
||
'refreshToken': 'mock-refresh-token',
|
||
'refreshExpire': 604800,
|
||
'orgId': 1,
|
||
'orgType': 1,
|
||
'superAdmin': true,
|
||
'roleNames': ['运维管理员'],
|
||
});
|
||
}
|
||
return {'code': 500, 'msg': '账号或密码错误', 'data': null};
|
||
}
|
||
|
||
// 登出:POST /sys/logout
|
||
if (path == '/sys/logout') {
|
||
return _ok(null);
|
||
}
|
||
|
||
// 验证码图片:GET /captcha.jpg?uuid=xxx
|
||
// mock 模式下返回一个 1x1 透明 PNG 占位图。
|
||
if (path == '/captcha.jpg') {
|
||
return {'code': 0, 'msg': 'success', 'data': _transparentPng};
|
||
}
|
||
|
||
// 当前用户:GET /sys/user/info
|
||
if (path == '/sys/user/info') {
|
||
return _ok({
|
||
'username': AppConstants.mockAccount,
|
||
'realName': '运维管理员',
|
||
'roleNames': ['运维管理员'],
|
||
});
|
||
}
|
||
|
||
// 设备列表:GET /device/info/list
|
||
if (path == '/device/info/list') {
|
||
return _ok({'list': _mockDevices, 'totalCount': _mockDevices.length});
|
||
}
|
||
|
||
// 设备详情:GET /device/info/info/{id}
|
||
final deviceDetailMatch =
|
||
RegExp(r'^/device/info/info/([A-Za-z0-9\-]+)$').firstMatch(path);
|
||
if (deviceDetailMatch != null) {
|
||
final id = deviceDetailMatch.group(1);
|
||
for (final device in _mockDevices) {
|
||
if (device['id'] == id) {
|
||
return _ok(device);
|
||
}
|
||
}
|
||
return {'code': 404, 'msg': '未找到设备:$id', 'data': null};
|
||
}
|
||
|
||
// 设备指令:POST /device/command/sendByMac | sendByOrg
|
||
if (path == '/device/command/sendByMac' ||
|
||
path == '/device/command/sendByOrg') {
|
||
return _ok(null);
|
||
}
|
||
|
||
// 告警列表(仅 mock 模式使用;真实模式由设备列表推导):GET /alerts
|
||
if (path == '/alerts') {
|
||
return _ok(_mockAlerts);
|
||
}
|
||
|
||
return {'code': 404, 'msg': '接口不存在:$path', 'data': null};
|
||
}
|
||
|
||
/// mock 验证码占位图:1x1 透明 PNG(Base64)。
|
||
static const String _transparentPng =
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
||
|
||
/// 示例设备数据(真实 DeviceInfo 结构 + 演示用扩展字段:
|
||
/// ip / location / os / uptimeSeconds / metrics)。
|
||
static final List<Map<String, dynamic>> _mockDevices = [
|
||
{
|
||
'id': 'dev-001',
|
||
'mac': 'AA:BB:CC:00:00:11',
|
||
'name': '核心交换机-SW01',
|
||
'deviceSn': 'SN-SW01-2024',
|
||
'deviceUid': 'UID-0001',
|
||
'status': 'online',
|
||
'groupId': 'g-core',
|
||
'lastSyncTime': '2024-05-20 09:30:00',
|
||
'createTime': '2024-01-15 10:00:00',
|
||
'ip': '10.0.1.11',
|
||
'location': '华南机房-A区',
|
||
'os': 'Huawei VRP V8',
|
||
'uptimeSeconds': 1861272,
|
||
'metrics': {
|
||
'cpuUsage': 0.32,
|
||
'memoryUsage': 0.41,
|
||
'diskUsage': 0.55,
|
||
'networkRxKbps': 1280.5,
|
||
'networkTxKbps': 986.2,
|
||
},
|
||
},
|
||
{
|
||
'id': 'dev-002',
|
||
'mac': 'AA:BB:CC:00:00:21',
|
||
'name': '应用服务器-APP01',
|
||
'deviceSn': 'SN-APP01-2024',
|
||
'deviceUid': 'UID-0002',
|
||
'status': 'warning',
|
||
'groupId': 'g-app',
|
||
'lastSyncTime': '2024-05-20 09:28:40',
|
||
'createTime': '2024-01-16 14:20:00',
|
||
'ip': '10.0.2.21',
|
||
'location': '华南机房-B区',
|
||
'os': 'CentOS Stream 9',
|
||
'uptimeSeconds': 952133,
|
||
'metrics': {
|
||
'cpuUsage': 0.87,
|
||
'memoryUsage': 0.92,
|
||
'diskUsage': 0.63,
|
||
'networkRxKbps': 4520.8,
|
||
'networkTxKbps': 3890.1,
|
||
},
|
||
},
|
||
{
|
||
'id': 'dev-003',
|
||
'mac': 'AA:BB:CC:00:00:31',
|
||
'name': '数据库服务器-DB01',
|
||
'deviceSn': 'SN-DB01-2024',
|
||
'deviceUid': 'UID-0003',
|
||
'status': 'online',
|
||
'groupId': 'g-db',
|
||
'lastSyncTime': '2024-05-20 09:29:55',
|
||
'createTime': '2024-01-18 08:00:00',
|
||
'ip': '10.0.2.31',
|
||
'location': '华南机房-B区',
|
||
'os': 'openEuler 22.03 LTS',
|
||
'uptimeSeconds': 3654820,
|
||
'metrics': {
|
||
'cpuUsage': 0.45,
|
||
'memoryUsage': 0.68,
|
||
'diskUsage': 0.78,
|
||
'networkRxKbps': 2310.4,
|
||
'networkTxKbps': 1980.6,
|
||
},
|
||
},
|
||
{
|
||
'id': 'dev-004',
|
||
'mac': 'AA:BB:CC:00:00:42',
|
||
'name': '存储节点-NAS02',
|
||
'deviceSn': 'SN-NAS02-2024',
|
||
'deviceUid': 'UID-0004',
|
||
'status': 'offline',
|
||
'groupId': 'g-store',
|
||
'lastSyncTime': '2024-05-20 07:58:00',
|
||
'createTime': '2024-02-01 09:00:00',
|
||
'ip': '10.0.3.42',
|
||
'location': '华北机房-C区',
|
||
'os': 'TrueNAS SCALE 23.10',
|
||
'uptimeSeconds': 0,
|
||
'metrics': {
|
||
'cpuUsage': 0.0,
|
||
'memoryUsage': 0.0,
|
||
'diskUsage': 0.0,
|
||
'networkRxKbps': 0.0,
|
||
'networkTxKbps': 0.0,
|
||
},
|
||
},
|
||
{
|
||
'id': 'dev-005',
|
||
'mac': 'AA:BB:CC:00:00:107',
|
||
'name': '边缘网关-EDGE07',
|
||
'deviceSn': 'SN-EDGE07-2024',
|
||
'deviceUid': 'UID-0005',
|
||
'status': 'online',
|
||
'groupId': 'g-edge',
|
||
'lastSyncTime': '2024-05-20 09:31:10',
|
||
'createTime': '2024-03-05 16:40:00',
|
||
'ip': '172.16.8.107',
|
||
'location': '华东边缘节点',
|
||
'os': 'OpenHarmony 4.1',
|
||
'uptimeSeconds': 612345,
|
||
'metrics': {
|
||
'cpuUsage': 0.21,
|
||
'memoryUsage': 0.35,
|
||
'diskUsage': 0.48,
|
||
'networkRxKbps': 320.7,
|
||
'networkTxKbps': 288.3,
|
||
},
|
||
},
|
||
];
|
||
|
||
/// 示例告警数据(仅 mock 模式使用)。
|
||
static final List<Map<String, dynamic>> _mockAlerts = [
|
||
{
|
||
'id': 'alert-001',
|
||
'deviceId': 'dev-002',
|
||
'deviceName': '应用服务器-APP01',
|
||
'title': '内存使用率过高',
|
||
'message': '内存使用率达到 92%,超过阈值 85%,请尽快扩容或排查内存泄漏。',
|
||
'level': 'critical',
|
||
'occurredAt': '2024-05-20T09:12:30.000',
|
||
'acknowledged': false,
|
||
},
|
||
{
|
||
'id': 'alert-002',
|
||
'deviceId': 'dev-002',
|
||
'deviceName': '应用服务器-APP01',
|
||
'title': 'CPU 使用率过高',
|
||
'message': 'CPU 使用率达到 87%,超过阈值 80%。',
|
||
'level': 'warning',
|
||
'occurredAt': '2024-05-20T08:45:10.000',
|
||
'acknowledged': true,
|
||
},
|
||
{
|
||
'id': 'alert-003',
|
||
'deviceId': 'dev-004',
|
||
'deviceName': '存储节点-NAS02',
|
||
'title': '设备离线',
|
||
'message': '存储节点连续 10 分钟无心跳,请检查电源与网络连接。',
|
||
'level': 'critical',
|
||
'occurredAt': '2024-05-20T07:58:00.000',
|
||
'acknowledged': false,
|
||
},
|
||
{
|
||
'id': 'alert-004',
|
||
'deviceId': 'dev-003',
|
||
'deviceName': '数据库服务器-DB01',
|
||
'title': '磁盘空间不足',
|
||
'message': '磁盘使用率达到 78%,接近阈值 80%,建议清理归档日志。',
|
||
'level': 'warning',
|
||
'occurredAt': '2024-05-19T22:30:45.000',
|
||
'acknowledged': true,
|
||
},
|
||
{
|
||
'id': 'alert-005',
|
||
'deviceId': 'dev-001',
|
||
'deviceName': '核心交换机-SW01',
|
||
'title': '端口流量波动',
|
||
'message': '上联端口 GE0/0/1 出现短时流量尖峰,已自动恢复。',
|
||
'level': 'info',
|
||
'occurredAt': '2024-05-19T16:05:22.000',
|
||
'acknowledged': true,
|
||
},
|
||
];
|
||
}
|