ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

View File

@@ -0,0 +1,667 @@
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,
},
];
}