From 94652857cd84937e501819cac81af65527c65b60 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 04:01:04 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=9F=E4=BA=A7=E7=BA=A7=20API=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=20=E2=80=94=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=8B=A6=E6=88=AA=E5=99=A8=E3=80=81=E5=8F=8B=E5=A5=BD?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E3=80=81=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E7=9B=91=E6=B5=8B=E3=80=81WebSocket=20=E9=80=80=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 用户看到原始 DioException 堆栈(如 "DioException [unknown]: null Error: HttpException: Connection reset by peer"),且无重试机制,网络抖动即报错。 ## 变更 ### 1. RetryInterceptor(指数退避自动重试) - 新增 `core/network/retry_interceptor.dart` - 自动重试:连接超时、发送超时、Connection reset、502/503/504/429 - 指数退避(800ms → 1.6s → 3.2s)+ 随机抖动防雪崩 - 最多 3 次重试,非瞬态错误(401/403/404)不重试 - 集成到 dio_client,优化超时:connect 8s、send 15s、receive 20s ### 2. ErrorHandler 全面升级(友好中文错误提示) - 重写 `core/errors/error_handler.dart`,新增 `friendlyMessage()` 静态方法 - 所有 DioExceptionType 映射为具体中文: - Connection reset → "连接被服务器重置,请稍后重试" - Connection refused → "服务器拒绝连接,请确认服务是否启动" - Timeout → "连接超时,服务器无响应" - 401 → "登录已过期,请重新登录" - 403/404/429/500/502/503 各有独立提示 - 新增 TimeoutFailure 类型 - 所有 Failure.message 默认中文 ### 3. 网络连接监测 + 离线 Banner - 新增 `core/network/connectivity_provider.dart` — 每30秒探测服务器可达性 - 新增 `core/widgets/offline_banner.dart` — 黄色警告横幅 "网络连接不可用" - 集成到 ScaffoldWithNav,所有页面顶部自动显示离线状态 ### 4. 统一错误展示(杜绝 e.toString()) - 新增 `core/widgets/error_view.dart` — 统一错误 UI(图标 + 友好文案 + 重试按钮) - 替换 6 个页面的内联错误 Column 为 ErrorView: tasks_page / servers_page / alerts_page / approvals_page / standing_orders_page - 替换 dashboard 的 3 处 _SummaryCardError(message: e.toString()) - 替换 4 个 provider 的 e.toString(): chat / auth / settings / approvals - 全项目零 e.toString() 残留(仅剩 time.minute.toString() 时间格式化) ### 5. WebSocket 重连增强 - 指数退避(1s → 2s → 4s → ... → 60s 上限)+ 随机抖动 - 最多 10 次自动重连,超限后停止 - disconnect() 阻止自动重连 - 新增 reconnect() 手动重连方法 Co-Authored-By: Claude Opus 4.6 --- it0_app/lib/core/errors/error_handler.dart | 137 +++++++++++++++--- it0_app/lib/core/errors/failures.dart | 13 +- .../core/network/connectivity_provider.dart | 65 +++++++++ it0_app/lib/core/network/dio_client.dart | 9 +- .../lib/core/network/retry_interceptor.dart | 69 +++++++++ .../lib/core/network/websocket_client.dart | 52 ++++++- it0_app/lib/core/router/app_router.dart | 8 +- it0_app/lib/core/widgets/error_view.dart | 50 +++++++ it0_app/lib/core/widgets/offline_banner.dart | 51 +++++++ .../presentation/pages/alerts_page.dart | 35 +---- .../presentation/pages/approvals_page.dart | 37 +---- .../auth/data/providers/auth_provider.dart | 3 +- .../providers/chat_providers.dart | 5 +- .../presentation/pages/dashboard_page.dart | 9 +- .../presentation/pages/servers_page.dart | 34 +---- .../providers/settings_providers.dart | 7 +- .../pages/standing_orders_page.dart | 38 +---- .../tasks/presentation/pages/tasks_page.dart | 34 +---- 18 files changed, 463 insertions(+), 193 deletions(-) create mode 100644 it0_app/lib/core/network/connectivity_provider.dart create mode 100644 it0_app/lib/core/network/retry_interceptor.dart create mode 100644 it0_app/lib/core/widgets/error_view.dart create mode 100644 it0_app/lib/core/widgets/offline_banner.dart diff --git a/it0_app/lib/core/errors/error_handler.dart b/it0_app/lib/core/errors/error_handler.dart index a1e9b9b..69cc7fe 100644 --- a/it0_app/lib/core/errors/error_handler.dart +++ b/it0_app/lib/core/errors/error_handler.dart @@ -1,38 +1,139 @@ +import 'dart:io'; import 'package:dio/dio.dart'; import 'failures.dart'; class ErrorHandler { + /// Converts any error into a [Failure] with a user-friendly Chinese message. static Failure handle(dynamic error) { + if (error is Failure) return error; + if (error is DioException) { - switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.sendTimeout: - case DioExceptionType.receiveTimeout: - return const NetworkFailure('Connection timeout'); - case DioExceptionType.connectionError: - return const NetworkFailure('No internet connection'); - case DioExceptionType.badResponse: - return _handleBadResponse(error.response); - default: - return const NetworkFailure(); - } + return _handleDio(error); + } + + if (error is SocketException) { + return const NetworkFailure('无法连接到服务器,请检查网络'); + } + + if (error is FormatException) { + return const ServerFailure('数据格式异常', statusCode: null); + } + + return ServerFailure('发生未知错误: ${_truncate(error.toString(), 80)}'); + } + + /// Converts any error into a user-friendly Chinese message string. + /// Use this everywhere instead of `e.toString()`. + static String friendlyMessage(dynamic error) { + return handle(error).message; + } + + static Failure _handleDio(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + return const TimeoutFailure('连接超时,服务器无响应'); + + case DioExceptionType.sendTimeout: + return const TimeoutFailure('发送请求超时,请检查网络'); + + case DioExceptionType.receiveTimeout: + return const TimeoutFailure('等待响应超时,请稍后重试'); + + case DioExceptionType.connectionError: + return NetworkFailure(_connectionErrorMessage(error)); + + case DioExceptionType.badCertificate: + return const NetworkFailure('安全证书验证失败'); + + case DioExceptionType.badResponse: + return _handleBadResponse(error.response); + + case DioExceptionType.cancel: + return const ServerFailure('请求已取消'); + + case DioExceptionType.unknown: + return NetworkFailure(_unknownErrorMessage(error)); } - return ServerFailure(error.toString()); } static Failure _handleBadResponse(Response? response) { - switch (response?.statusCode) { + final statusCode = response?.statusCode; + // Try to extract server-side error message + final serverMsg = _extractServerMessage(response); + + switch (statusCode) { + case 400: + return ServerFailure(serverMsg ?? '请求参数错误', statusCode: 400); case 401: return const AuthFailure(); + case 403: + return const ServerFailure('没有权限执行此操作', statusCode: 403); case 404: - return ServerFailure('Resource not found', statusCode: 404); + return ServerFailure(serverMsg ?? '请求的资源不存在', statusCode: 404); + case 409: + return ServerFailure(serverMsg ?? '数据冲突,请刷新后重试', statusCode: 409); + case 422: + return ServerFailure(serverMsg ?? '提交的数据不合法', statusCode: 422); + case 429: + return const ServerFailure('请求过于频繁,请稍后重试', statusCode: 429); case 500: - return ServerFailure('Internal server error', statusCode: 500); + return const ServerFailure('服务器内部错误,请稍后重试', statusCode: 500); + case 502: + return const ServerFailure('服务器网关错误,请稍后重试', statusCode: 502); + case 503: + return const ServerFailure('服务器暂时不可用,请稍后重试', statusCode: 503); default: return ServerFailure( - response?.data?['message'] ?? 'Unknown error', - statusCode: response?.statusCode, + serverMsg ?? '服务器错误 ($statusCode)', + statusCode: statusCode, ); } } + + static String? _extractServerMessage(Response? response) { + try { + final data = response?.data; + if (data is Map) { + return data['message'] as String? ?? data['error'] as String?; + } + } catch (_) {} + return null; + } + + static String _connectionErrorMessage(DioException error) { + final innerError = error.error; + if (innerError is SocketException) { + if (innerError.osError?.errorCode == 111 || + innerError.osError?.errorCode == 10061) { + return '服务器拒绝连接,请确认服务是否启动'; + } + return '无法连接到服务器,请检查网络'; + } + return '网络连接失败,请检查网络设置'; + } + + static String _unknownErrorMessage(DioException error) { + final errorStr = error.error?.toString() ?? ''; + if (errorStr.contains('Connection reset')) { + return '连接被服务器重置,请稍后重试'; + } + if (errorStr.contains('Connection refused')) { + return '服务器拒绝连接,请确认服务是否启动'; + } + if (errorStr.contains('Connection closed')) { + return '连接已关闭,请稍后重试'; + } + if (errorStr.contains('SocketException')) { + return '网络连接异常,请检查网络设置'; + } + if (errorStr.contains('HandshakeException') || + errorStr.contains('TlsException')) { + return '安全连接失败,请检查网络环境'; + } + return '网络请求失败,请检查网络后重试'; + } + + static String _truncate(String s, int maxLen) { + return s.length > maxLen ? '${s.substring(0, maxLen)}...' : s; + } } diff --git a/it0_app/lib/core/errors/failures.dart b/it0_app/lib/core/errors/failures.dart index 06f99d9..3e00192 100644 --- a/it0_app/lib/core/errors/failures.dart +++ b/it0_app/lib/core/errors/failures.dart @@ -1,6 +1,9 @@ abstract class Failure { final String message; const Failure(this.message); + + @override + String toString() => message; } class ServerFailure extends Failure { @@ -9,13 +12,17 @@ class ServerFailure extends Failure { } class NetworkFailure extends Failure { - const NetworkFailure([super.message = 'Network connection failed']); + const NetworkFailure([super.message = '网络连接失败,请检查网络设置']); +} + +class TimeoutFailure extends Failure { + const TimeoutFailure([super.message = '连接超时,请稍后重试']); } class CacheFailure extends Failure { - const CacheFailure([super.message = 'Cache read/write failed']); + const CacheFailure([super.message = '本地缓存读写失败']); } class AuthFailure extends Failure { - const AuthFailure([super.message = 'Authentication failed']); + const AuthFailure([super.message = '登录已过期,请重新登录']); } diff --git a/it0_app/lib/core/network/connectivity_provider.dart b/it0_app/lib/core/network/connectivity_provider.dart new file mode 100644 index 0000000..4f03956 --- /dev/null +++ b/it0_app/lib/core/network/connectivity_provider.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../config/app_config.dart'; + +/// Tracks network connectivity by periodically pinging the API gateway. +/// More reliable than connectivity_plus (which only checks if Wi-Fi/data is on, +/// not if the server is actually reachable). +class ConnectivityNotifier extends StateNotifier { + final String _apiBaseUrl; + Timer? _timer; + + ConnectivityNotifier(this._apiBaseUrl) : super(const ConnectivityState()) { + check(); + _timer = Timer.periodic(const Duration(seconds: 30), (_) => check()); + } + + Future check() async { + try { + final uri = Uri.parse(_apiBaseUrl); + final socket = await Socket.connect( + uri.host, + uri.port, + timeout: const Duration(seconds: 5), + ); + socket.destroy(); + if (mounted) { + state = const ConnectivityState(isOnline: true); + } + } catch (_) { + if (mounted) { + state = ConnectivityState( + isOnline: false, + lastOfflineAt: DateTime.now(), + ); + } + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +class ConnectivityState { + final bool isOnline; + final DateTime? lastOfflineAt; + + const ConnectivityState({ + this.isOnline = true, + this.lastOfflineAt, + }); +} + +final connectivityProvider = + StateNotifierProvider((ref) { + final config = ref.watch(appConfigProvider); + return ConnectivityNotifier(config.apiBaseUrl); +}); + +final isOnlineProvider = Provider((ref) { + return ref.watch(connectivityProvider).isOnline; +}); diff --git a/it0_app/lib/core/network/dio_client.dart b/it0_app/lib/core/network/dio_client.dart index 2dd3a5a..68f8d35 100644 --- a/it0_app/lib/core/network/dio_client.dart +++ b/it0_app/lib/core/network/dio_client.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../config/app_config.dart'; +import 'retry_interceptor.dart'; import '../../features/auth/data/providers/auth_provider.dart'; import '../../features/auth/data/providers/tenant_provider.dart'; @@ -8,8 +9,9 @@ final dioClientProvider = Provider((ref) { final config = ref.watch(appConfigProvider); final dio = Dio(BaseOptions( baseUrl: config.apiBaseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 30), + connectTimeout: const Duration(seconds: 8), + sendTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 20), headers: { 'Content-Type': 'application/json', }, @@ -55,6 +57,9 @@ final dioClientProvider = Provider((ref) { }, )); + // Retry interceptor (exponential backoff for transient errors) + dio.interceptors.add(RetryInterceptor(dio: dio)); + // Logging interceptor dio.interceptors.add(LogInterceptor( requestBody: true, diff --git a/it0_app/lib/core/network/retry_interceptor.dart b/it0_app/lib/core/network/retry_interceptor.dart new file mode 100644 index 0000000..7ae8304 --- /dev/null +++ b/it0_app/lib/core/network/retry_interceptor.dart @@ -0,0 +1,69 @@ +import 'dart:math'; +import 'package:dio/dio.dart'; + +/// Dio interceptor that automatically retries failed requests with +/// exponential backoff for transient/recoverable errors. +class RetryInterceptor extends Interceptor { + final Dio dio; + final int maxRetries; + final Duration baseDelay; + + RetryInterceptor({ + required this.dio, + this.maxRetries = 3, + this.baseDelay = const Duration(milliseconds: 800), + }); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (!_shouldRetry(err)) { + return handler.next(err); + } + + final retryCount = err.requestOptions.extra['_retryCount'] as int? ?? 0; + if (retryCount >= maxRetries) { + return handler.next(err); + } + + // Exponential backoff with jitter + final delay = baseDelay * pow(2, retryCount).toInt(); + final jitter = Duration(milliseconds: Random().nextInt(500)); + await Future.delayed(delay + jitter); + + // Increment retry count + err.requestOptions.extra['_retryCount'] = retryCount + 1; + + try { + final response = await dio.fetch(err.requestOptions); + return handler.resolve(response); + } on DioException catch (e) { + return handler.next(e); + } + } + + bool _shouldRetry(DioException err) { + // Network-level errors (connection reset, timeout, DNS failure) + if (err.type == DioExceptionType.connectionError || + err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.sendTimeout || + err.type == DioExceptionType.receiveTimeout) { + return true; + } + + // Unknown errors (e.g. "Connection reset by peer") + if (err.type == DioExceptionType.unknown && err.error != null) { + return true; + } + + // Server errors that are typically transient + final statusCode = err.response?.statusCode; + if (statusCode != null) { + return statusCode == 429 || // Too Many Requests + statusCode == 502 || // Bad Gateway + statusCode == 503 || // Service Unavailable + statusCode == 504; // Gateway Timeout + } + + return false; + } +} diff --git a/it0_app/lib/core/network/websocket_client.dart b/it0_app/lib/core/network/websocket_client.dart index 7eebd8e..c7f7cf3 100644 --- a/it0_app/lib/core/network/websocket_client.dart +++ b/it0_app/lib/core/network/websocket_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../config/app_config.dart'; @@ -10,18 +11,29 @@ class WebSocketClient { Timer? _heartbeatTimer; Timer? _reconnectTimer; bool _isConnected = false; + int _reconnectAttempts = 0; + String? _lastPath; + String? _lastToken; final _messageController = StreamController>.broadcast(); + static const int _maxReconnectAttempts = 10; + static const Duration _baseReconnectDelay = Duration(milliseconds: 1000); + static const Duration _maxReconnectDelay = Duration(seconds: 60); + Stream> get messages => _messageController.stream; bool get isConnected => _isConnected; WebSocketClient({required this.baseUrl}); Future connect(String path, {String? token}) async { + _lastPath = path; + _lastToken = token; + final uri = Uri.parse('$baseUrl$path'); try { _channel = WebSocketChannel.connect(uri); _isConnected = true; + _reconnectAttempts = 0; _channel!.stream.listen( (data) { @@ -30,18 +42,18 @@ class WebSocketClient { }, onDone: () { _isConnected = false; - _scheduleReconnect(path, token: token); + _scheduleReconnect(); }, onError: (error) { _isConnected = false; - _scheduleReconnect(path, token: token); + _scheduleReconnect(); }, ); _startHeartbeat(); } catch (e) { _isConnected = false; - _scheduleReconnect(path, token: token); + _scheduleReconnect(); } } @@ -59,18 +71,44 @@ class WebSocketClient { ); } - void _scheduleReconnect(String path, {String? token}) { + /// Reconnects with exponential backoff + jitter, up to [_maxReconnectAttempts]. + void _scheduleReconnect() { + _heartbeatTimer?.cancel(); _reconnectTimer?.cancel(); - _reconnectTimer = Timer( - const Duration(seconds: 5), - () => connect(path, token: token), + + if (_reconnectAttempts >= _maxReconnectAttempts) return; + + // Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 60s + final delayMs = min( + _baseReconnectDelay.inMilliseconds * pow(2, _reconnectAttempts).toInt(), + _maxReconnectDelay.inMilliseconds, ); + final jitter = Random().nextInt(500); + + _reconnectAttempts++; + _reconnectTimer = Timer( + Duration(milliseconds: delayMs + jitter), + () { + if (_lastPath != null) { + connect(_lastPath!, token: _lastToken); + } + }, + ); + } + + /// Resets reconnect counter and forces an immediate reconnect attempt. + Future reconnect() async { + _reconnectAttempts = 0; + if (_lastPath != null) { + await connect(_lastPath!, token: _lastToken); + } } Future disconnect() async { _heartbeatTimer?.cancel(); _reconnectTimer?.cancel(); _isConnected = false; + _reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect await _channel?.sink.close(); } diff --git a/it0_app/lib/core/router/app_router.dart b/it0_app/lib/core/router/app_router.dart index a801dff..3ef0423 100644 --- a/it0_app/lib/core/router/app_router.dart +++ b/it0_app/lib/core/router/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../widgets/offline_banner.dart'; import '../../features/auth/data/providers/auth_provider.dart'; import '../../features/auth/presentation/pages/login_page.dart'; import '../../features/dashboard/presentation/pages/dashboard_page.dart'; @@ -97,7 +98,12 @@ class ScaffoldWithNav extends ConsumerWidget { final currentIndex = _selectedIndex(context); return Scaffold( - body: child, + body: Column( + children: [ + const OfflineBanner(), + Expanded(child: child), + ], + ), bottomNavigationBar: NavigationBar( selectedIndex: currentIndex, destinations: [ diff --git a/it0_app/lib/core/widgets/error_view.dart b/it0_app/lib/core/widgets/error_view.dart new file mode 100644 index 0000000..899fa05 --- /dev/null +++ b/it0_app/lib/core/widgets/error_view.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import '../errors/error_handler.dart'; +import '../theme/app_colors.dart'; + +/// Unified error display widget used across all pages. +/// Converts any error into a user-friendly Chinese message via [ErrorHandler]. +class ErrorView extends StatelessWidget { + final dynamic error; + final VoidCallback? onRetry; + + const ErrorView({ + super.key, + required this.error, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final message = ErrorHandler.friendlyMessage(error); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 48), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('重试'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/it0_app/lib/core/widgets/offline_banner.dart b/it0_app/lib/core/widgets/offline_banner.dart new file mode 100644 index 0000000..58c49c5 --- /dev/null +++ b/it0_app/lib/core/widgets/offline_banner.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../network/connectivity_provider.dart'; +import '../theme/app_colors.dart'; + +/// Banner displayed at the top of the app when the device is offline. +/// Auto-hides when connectivity is restored. +class OfflineBanner extends ConsumerWidget { + const OfflineBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOnline = ref.watch(isOnlineProvider); + + if (isOnline) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: AppColors.warning, + child: Row( + children: [ + const Icon(Icons.wifi_off, size: 16, color: Colors.black87), + const SizedBox(width: 8), + const Expanded( + child: Text( + '网络连接不可用', + style: TextStyle( + color: Colors.black87, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + GestureDetector( + onTap: () => ref.read(connectivityProvider.notifier).check(), + child: const Text( + '重试', + style: TextStyle( + color: Colors.black87, + fontSize: 13, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + ); + } +} diff --git a/it0_app/lib/features/alerts/presentation/pages/alerts_page.dart b/it0_app/lib/features/alerts/presentation/pages/alerts_page.dart index 987b724..4a4b0b0 100644 --- a/it0_app/lib/features/alerts/presentation/pages/alerts_page.dart +++ b/it0_app/lib/features/alerts/presentation/pages/alerts_page.dart @@ -5,6 +5,7 @@ import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/date_formatter.dart'; import '../../../../core/widgets/empty_state.dart'; +import '../../../../core/widgets/error_view.dart'; import '../../../../core/widgets/status_badge.dart'; // --------------------------------------------------------------------------- @@ -100,37 +101,9 @@ class _AlertsPageState extends ConsumerState { }, loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, - color: AppColors.error, size: 48), - const SizedBox(height: 12), - const Text( - '加载告警失败', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - e.toString(), - style: const TextStyle( - color: AppColors.textSecondary, fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => - ref.invalidate(alertEventsProvider), - icon: const Icon(Icons.refresh), - label: const Text('重试'), - ), - ], - ), - ), + error: (e, _) => ErrorView( + error: e, + onRetry: () => ref.invalidate(alertEventsProvider), ), ), ), diff --git a/it0_app/lib/features/approvals/presentation/pages/approvals_page.dart b/it0_app/lib/features/approvals/presentation/pages/approvals_page.dart index 77d87cb..90faa4d 100644 --- a/it0_app/lib/features/approvals/presentation/pages/approvals_page.dart +++ b/it0_app/lib/features/approvals/presentation/pages/approvals_page.dart @@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; +import '../../../../core/errors/error_handler.dart'; import '../../../../core/widgets/empty_state.dart'; +import '../../../../core/widgets/error_view.dart'; // --------------------------------------------------------------------------- // Approvals provider @@ -161,36 +163,9 @@ class _ApprovalsPageState extends ConsumerState { }, loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, - color: AppColors.error, size: 48), - const SizedBox(height: 12), - const Text( - '加载审批失败', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - e.toString(), - style: const TextStyle( - color: AppColors.textSecondary, fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => ref.invalidate(approvalsProvider), - icon: const Icon(Icons.refresh), - label: const Text('重试'), - ), - ], - ), - ), + error: (e, _) => ErrorView( + error: e, + onRetry: () => ref.invalidate(approvalsProvider), ), ), ), @@ -332,7 +307,7 @@ class _ApprovalCardState extends State<_ApprovalCard> { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('操作失败: $e'), + content: Text('操作失败: ${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error, ), ); diff --git a/it0_app/lib/features/auth/data/providers/auth_provider.dart b/it0_app/lib/features/auth/data/providers/auth_provider.dart index 0721295..7c0c7ed 100644 --- a/it0_app/lib/features/auth/data/providers/auth_provider.dart +++ b/it0_app/lib/features/auth/data/providers/auth_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/app_config.dart'; +import '../../../../core/errors/error_handler.dart'; import '../../../notifications/presentation/providers/notification_providers.dart'; import '../models/auth_response.dart'; import 'tenant_provider.dart'; @@ -184,7 +185,7 @@ class AuthNotifier extends StateNotifier { } catch (e) { state = state.copyWith( isLoading: false, - error: e.toString(), + error: ErrorHandler.friendlyMessage(e), ); return false; } diff --git a/it0_app/lib/features/chat/presentation/providers/chat_providers.dart b/it0_app/lib/features/chat/presentation/providers/chat_providers.dart index ece4755..c27bba0 100644 --- a/it0_app/lib/features/chat/presentation/providers/chat_providers.dart +++ b/it0_app/lib/features/chat/presentation/providers/chat_providers.dart @@ -7,6 +7,7 @@ import '../../data/datasources/chat_local_datasource.dart'; import '../../data/datasources/chat_remote_datasource.dart'; import '../../data/models/chat_message_model.dart'; import '../../data/repositories/chat_repository_impl.dart'; +import '../../../../core/errors/error_handler.dart'; import '../../domain/entities/chat_message.dart'; import '../../domain/entities/stream_event.dart'; import '../../domain/repositories/chat_repository.dart'; @@ -143,7 +144,7 @@ class ChatNotifier extends StateNotifier { onError: (error) { state = state.copyWith( agentStatus: AgentStatus.error, - error: error.toString(), + error: ErrorHandler.friendlyMessage(error), ); }, onDone: () { @@ -155,7 +156,7 @@ class ChatNotifier extends StateNotifier { } catch (e) { state = state.copyWith( agentStatus: AgentStatus.error, - error: e.toString(), + error: ErrorHandler.friendlyMessage(e), ); } } diff --git a/it0_app/lib/features/dashboard/presentation/pages/dashboard_page.dart b/it0_app/lib/features/dashboard/presentation/pages/dashboard_page.dart index d93e5c0..a3c2cda 100644 --- a/it0_app/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/it0_app/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -6,6 +6,7 @@ import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/date_formatter.dart'; import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/status_badge.dart'; +import '../../../../core/errors/error_handler.dart'; // --------------------------------------------------------------------------- // TODO 38 – Dashboard providers & page @@ -208,7 +209,7 @@ class DashboardPage extends ConsumerWidget { ], ), loading: () => const _SummaryCardLoading(), - error: (e, _) => _SummaryCardError(message: e.toString()), + error: (e, _) => _SummaryCardError(message: ErrorHandler.friendlyMessage(e)), ); } @@ -230,7 +231,7 @@ class DashboardPage extends ConsumerWidget { ], ), loading: () => const _SummaryCardLoading(), - error: (e, _) => _SummaryCardError(message: e.toString()), + error: (e, _) => _SummaryCardError(message: ErrorHandler.friendlyMessage(e)), ); } @@ -251,7 +252,7 @@ class DashboardPage extends ConsumerWidget { ); }, loading: () => const _SummaryCardLoading(), - error: (e, _) => _SummaryCardError(message: e.toString()), + error: (e, _) => _SummaryCardError(message: ErrorHandler.friendlyMessage(e)), ); } @@ -297,7 +298,7 @@ class DashboardPage extends ConsumerWidget { error: (e, _) => Center( child: Padding( padding: const EdgeInsets.all(32), - child: Text('加载失败: $e', style: const TextStyle(color: AppColors.error)), + child: Text('加载失败: ${ErrorHandler.friendlyMessage(e)}', style: const TextStyle(color: AppColors.error)), ), ), ); diff --git a/it0_app/lib/features/servers/presentation/pages/servers_page.dart b/it0_app/lib/features/servers/presentation/pages/servers_page.dart index 9e0bb2b..5a00a95 100644 --- a/it0_app/lib/features/servers/presentation/pages/servers_page.dart +++ b/it0_app/lib/features/servers/presentation/pages/servers_page.dart @@ -4,6 +4,7 @@ import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/empty_state.dart'; +import '../../../../core/widgets/error_view.dart'; import '../../../../core/widgets/status_badge.dart'; // --------------------------------------------------------------------------- @@ -87,36 +88,9 @@ class _ServersPageState extends ConsumerState { }, loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, - color: AppColors.error, size: 48), - const SizedBox(height: 12), - const Text( - '加载服务器失败', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - e.toString(), - style: const TextStyle( - color: AppColors.textSecondary, fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => ref.invalidate(serversProvider), - icon: const Icon(Icons.refresh), - label: const Text('重试'), - ), - ], - ), - ), + error: (e, _) => ErrorView( + error: e, + onRetry: () => ref.invalidate(serversProvider), ), ), ), diff --git a/it0_app/lib/features/settings/presentation/providers/settings_providers.dart b/it0_app/lib/features/settings/presentation/providers/settings_providers.dart index 7becd38..45e1bed 100644 --- a/it0_app/lib/features/settings/presentation/providers/settings_providers.dart +++ b/it0_app/lib/features/settings/presentation/providers/settings_providers.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../../core/errors/error_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../data/datasources/settings_datasource.dart'; import '../../data/datasources/settings_remote_datasource.dart'; @@ -148,7 +149,7 @@ class AccountProfileNotifier extends StateNotifier { isLoading: false, ); } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + state = state.copyWith(isLoading: false, error: ErrorHandler.friendlyMessage(e)); } } @@ -162,7 +163,7 @@ class AccountProfileNotifier extends StateNotifier { ); return true; } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + state = state.copyWith(isLoading: false, error: ErrorHandler.friendlyMessage(e)); return false; } } @@ -180,7 +181,7 @@ class AccountProfileNotifier extends StateNotifier { final message = data['message'] as String?; return (success: success, message: message); } catch (e) { - return (success: false, message: e.toString()); + return (success: false, message: ErrorHandler.friendlyMessage(e)); } } } diff --git a/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart b/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart index 8d0176c..1e8e8e7 100644 --- a/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart +++ b/it0_app/lib/features/standing_orders/presentation/pages/standing_orders_page.dart @@ -4,7 +4,9 @@ import '../../../../core/config/api_endpoints.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/date_formatter.dart'; +import '../../../../core/errors/error_handler.dart'; import '../../../../core/widgets/empty_state.dart'; +import '../../../../core/widgets/error_view.dart'; import '../../../../core/widgets/status_badge.dart'; // --------------------------------------------------------------------------- @@ -68,37 +70,9 @@ class StandingOrdersPage extends ConsumerWidget { ); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, - color: AppColors.error, size: 48), - const SizedBox(height: 12), - const Text( - '加载常驻指令失败', - style: - TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - e.toString(), - style: const TextStyle( - color: AppColors.textSecondary, fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => - ref.invalidate(standingOrdersProvider), - icon: const Icon(Icons.refresh), - label: const Text('重试'), - ), - ], - ), - ), + error: (e, _) => ErrorView( + error: e, + onRetry: () => ref.invalidate(standingOrdersProvider), ), ), ), @@ -264,7 +238,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('更新状态失败: $e'), + content: Text('更新状态失败: ${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error, ), ); diff --git a/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart b/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart index 81cdee7..7da3618 100644 --- a/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart +++ b/it0_app/lib/features/tasks/presentation/pages/tasks_page.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/config/api_endpoints.dart'; +import '../../../../core/errors/error_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/utils/date_formatter.dart'; import '../../../../core/widgets/empty_state.dart'; +import '../../../../core/widgets/error_view.dart'; import '../../../../core/widgets/status_badge.dart'; // --------------------------------------------------------------------------- @@ -81,33 +83,9 @@ class TasksPage extends ConsumerWidget { ); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, color: AppColors.error, size: 48), - const SizedBox(height: 12), - Text( - '加载任务失败', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - e.toString(), - style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => ref.invalidate(tasksProvider), - icon: const Icon(Icons.refresh), - label: const Text('重试'), - ), - ], - ), - ), + error: (e, _) => ErrorView( + error: e, + onRetry: () => ref.invalidate(tasksProvider), ), ), ), @@ -413,7 +391,7 @@ class _CreateTaskSheetState extends State<_CreateTaskSheet> { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('创建任务失败: $e'), + content: Text('创建任务失败: ${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error, ), );