feat: 生产级 API 错误处理 — 重试拦截器、友好错误提示、网络监测、WebSocket 退避

## 问题
用户看到原始 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 04:01:04 -08:00
parent 1075a6b265
commit 94652857cd
18 changed files with 463 additions and 193 deletions

View File

@ -1,38 +1,139 @@
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'failures.dart'; import 'failures.dart';
class ErrorHandler { class ErrorHandler {
/// Converts any error into a [Failure] with a user-friendly Chinese message.
static Failure handle(dynamic error) { static Failure handle(dynamic error) {
if (error is Failure) return error;
if (error is DioException) { if (error is DioException) {
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) { switch (error.type) {
case DioExceptionType.connectionTimeout: case DioExceptionType.connectionTimeout:
return const TimeoutFailure('连接超时,服务器无响应');
case DioExceptionType.sendTimeout: case DioExceptionType.sendTimeout:
return const TimeoutFailure('发送请求超时,请检查网络');
case DioExceptionType.receiveTimeout: case DioExceptionType.receiveTimeout:
return const NetworkFailure('Connection timeout'); return const TimeoutFailure('等待响应超时,请稍后重试');
case DioExceptionType.connectionError: case DioExceptionType.connectionError:
return const NetworkFailure('No internet connection'); return NetworkFailure(_connectionErrorMessage(error));
case DioExceptionType.badCertificate:
return const NetworkFailure('安全证书验证失败');
case DioExceptionType.badResponse: case DioExceptionType.badResponse:
return _handleBadResponse(error.response); return _handleBadResponse(error.response);
default:
return const NetworkFailure(); case DioExceptionType.cancel:
return const ServerFailure('请求已取消');
case DioExceptionType.unknown:
return NetworkFailure(_unknownErrorMessage(error));
} }
} }
return ServerFailure(error.toString());
}
static Failure _handleBadResponse(Response? response) { 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: case 401:
return const AuthFailure(); return const AuthFailure();
case 403:
return const ServerFailure('没有权限执行此操作', statusCode: 403);
case 404: 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: 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: default:
return ServerFailure( return ServerFailure(
response?.data?['message'] ?? 'Unknown error', serverMsg ?? '服务器错误 ($statusCode)',
statusCode: response?.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;
}
} }

View File

@ -1,6 +1,9 @@
abstract class Failure { abstract class Failure {
final String message; final String message;
const Failure(this.message); const Failure(this.message);
@override
String toString() => message;
} }
class ServerFailure extends Failure { class ServerFailure extends Failure {
@ -9,13 +12,17 @@ class ServerFailure extends Failure {
} }
class NetworkFailure 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 { class CacheFailure extends Failure {
const CacheFailure([super.message = 'Cache read/write failed']); const CacheFailure([super.message = '本地缓存读写失败']);
} }
class AuthFailure extends Failure { class AuthFailure extends Failure {
const AuthFailure([super.message = 'Authentication failed']); const AuthFailure([super.message = '登录已过期,请重新登录']);
} }

View File

@ -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<ConnectivityState> {
final String _apiBaseUrl;
Timer? _timer;
ConnectivityNotifier(this._apiBaseUrl) : super(const ConnectivityState()) {
check();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => check());
}
Future<void> 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<ConnectivityNotifier, ConnectivityState>((ref) {
final config = ref.watch(appConfigProvider);
return ConnectivityNotifier(config.apiBaseUrl);
});
final isOnlineProvider = Provider<bool>((ref) {
return ref.watch(connectivityProvider).isOnline;
});

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import 'retry_interceptor.dart';
import '../../features/auth/data/providers/auth_provider.dart'; import '../../features/auth/data/providers/auth_provider.dart';
import '../../features/auth/data/providers/tenant_provider.dart'; import '../../features/auth/data/providers/tenant_provider.dart';
@ -8,8 +9,9 @@ final dioClientProvider = Provider<Dio>((ref) {
final config = ref.watch(appConfigProvider); final config = ref.watch(appConfigProvider);
final dio = Dio(BaseOptions( final dio = Dio(BaseOptions(
baseUrl: config.apiBaseUrl, baseUrl: config.apiBaseUrl,
connectTimeout: const Duration(seconds: 10), connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@ -55,6 +57,9 @@ final dioClientProvider = Provider<Dio>((ref) {
}, },
)); ));
// Retry interceptor (exponential backoff for transient errors)
dio.interceptors.add(RetryInterceptor(dio: dio));
// Logging interceptor // Logging interceptor
dio.interceptors.add(LogInterceptor( dio.interceptors.add(LogInterceptor(
requestBody: true, requestBody: true,

View File

@ -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;
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
@ -10,18 +11,29 @@ class WebSocketClient {
Timer? _heartbeatTimer; Timer? _heartbeatTimer;
Timer? _reconnectTimer; Timer? _reconnectTimer;
bool _isConnected = false; bool _isConnected = false;
int _reconnectAttempts = 0;
String? _lastPath;
String? _lastToken;
final _messageController = StreamController<Map<String, dynamic>>.broadcast(); final _messageController = StreamController<Map<String, dynamic>>.broadcast();
static const int _maxReconnectAttempts = 10;
static const Duration _baseReconnectDelay = Duration(milliseconds: 1000);
static const Duration _maxReconnectDelay = Duration(seconds: 60);
Stream<Map<String, dynamic>> get messages => _messageController.stream; Stream<Map<String, dynamic>> get messages => _messageController.stream;
bool get isConnected => _isConnected; bool get isConnected => _isConnected;
WebSocketClient({required this.baseUrl}); WebSocketClient({required this.baseUrl});
Future<void> connect(String path, {String? token}) async { Future<void> connect(String path, {String? token}) async {
_lastPath = path;
_lastToken = token;
final uri = Uri.parse('$baseUrl$path'); final uri = Uri.parse('$baseUrl$path');
try { try {
_channel = WebSocketChannel.connect(uri); _channel = WebSocketChannel.connect(uri);
_isConnected = true; _isConnected = true;
_reconnectAttempts = 0;
_channel!.stream.listen( _channel!.stream.listen(
(data) { (data) {
@ -30,18 +42,18 @@ class WebSocketClient {
}, },
onDone: () { onDone: () {
_isConnected = false; _isConnected = false;
_scheduleReconnect(path, token: token); _scheduleReconnect();
}, },
onError: (error) { onError: (error) {
_isConnected = false; _isConnected = false;
_scheduleReconnect(path, token: token); _scheduleReconnect();
}, },
); );
_startHeartbeat(); _startHeartbeat();
} catch (e) { } catch (e) {
_isConnected = false; _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?.cancel();
_reconnectTimer = Timer(
const Duration(seconds: 5), if (_reconnectAttempts >= _maxReconnectAttempts) return;
() => connect(path, token: token),
// 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<void> reconnect() async {
_reconnectAttempts = 0;
if (_lastPath != null) {
await connect(_lastPath!, token: _lastToken);
}
} }
Future<void> disconnect() async { Future<void> disconnect() async {
_heartbeatTimer?.cancel(); _heartbeatTimer?.cancel();
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_isConnected = false; _isConnected = false;
_reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect
await _channel?.sink.close(); await _channel?.sink.close();
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/offline_banner.dart';
import '../../features/auth/data/providers/auth_provider.dart'; import '../../features/auth/data/providers/auth_provider.dart';
import '../../features/auth/presentation/pages/login_page.dart'; import '../../features/auth/presentation/pages/login_page.dart';
import '../../features/dashboard/presentation/pages/dashboard_page.dart'; import '../../features/dashboard/presentation/pages/dashboard_page.dart';
@ -97,7 +98,12 @@ class ScaffoldWithNav extends ConsumerWidget {
final currentIndex = _selectedIndex(context); final currentIndex = _selectedIndex(context);
return Scaffold( return Scaffold(
body: child, body: Column(
children: [
const OfflineBanner(),
Expanded(child: child),
],
),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex, selectedIndex: currentIndex,
destinations: [ destinations: [

View File

@ -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('重试'),
),
],
],
),
),
);
}
}

View File

@ -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,
),
),
),
],
),
);
}
}

View File

@ -5,6 +5,7 @@ import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/date_formatter.dart'; import '../../../../core/utils/date_formatter.dart';
import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/status_badge.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -100,37 +101,9 @@ class _AlertsPageState extends ConsumerState<AlertsPage> {
}, },
loading: () => loading: () =>
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => ErrorView(
child: Padding( error: e,
padding: const EdgeInsets.all(32), onRetry: () => ref.invalidate(alertEventsProvider),
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('重试'),
),
],
),
),
), ),
), ),
), ),

View File

@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/api_endpoints.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/errors/error_handler.dart';
import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Approvals provider // Approvals provider
@ -161,36 +163,9 @@ class _ApprovalsPageState extends ConsumerState<ApprovalsPage> {
}, },
loading: () => loading: () =>
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => ErrorView(
child: Padding( error: e,
padding: const EdgeInsets.all(32), onRetry: () => ref.invalidate(approvalsProvider),
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('重试'),
),
],
),
),
), ),
), ),
), ),
@ -332,7 +307,7 @@ class _ApprovalCardState extends State<_ApprovalCard> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('操作失败: $e'), content: Text('操作失败: ${ErrorHandler.friendlyMessage(e)}'),
backgroundColor: AppColors.error, backgroundColor: AppColors.error,
), ),
); );

View File

@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/api_endpoints.dart';
import '../../../../core/config/app_config.dart'; import '../../../../core/config/app_config.dart';
import '../../../../core/errors/error_handler.dart';
import '../../../notifications/presentation/providers/notification_providers.dart'; import '../../../notifications/presentation/providers/notification_providers.dart';
import '../models/auth_response.dart'; import '../models/auth_response.dart';
import 'tenant_provider.dart'; import 'tenant_provider.dart';
@ -184,7 +185,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
} catch (e) { } catch (e) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
error: e.toString(), error: ErrorHandler.friendlyMessage(e),
); );
return false; return false;
} }

View File

@ -7,6 +7,7 @@ import '../../data/datasources/chat_local_datasource.dart';
import '../../data/datasources/chat_remote_datasource.dart'; import '../../data/datasources/chat_remote_datasource.dart';
import '../../data/models/chat_message_model.dart'; import '../../data/models/chat_message_model.dart';
import '../../data/repositories/chat_repository_impl.dart'; import '../../data/repositories/chat_repository_impl.dart';
import '../../../../core/errors/error_handler.dart';
import '../../domain/entities/chat_message.dart'; import '../../domain/entities/chat_message.dart';
import '../../domain/entities/stream_event.dart'; import '../../domain/entities/stream_event.dart';
import '../../domain/repositories/chat_repository.dart'; import '../../domain/repositories/chat_repository.dart';
@ -143,7 +144,7 @@ class ChatNotifier extends StateNotifier<ChatState> {
onError: (error) { onError: (error) {
state = state.copyWith( state = state.copyWith(
agentStatus: AgentStatus.error, agentStatus: AgentStatus.error,
error: error.toString(), error: ErrorHandler.friendlyMessage(error),
); );
}, },
onDone: () { onDone: () {
@ -155,7 +156,7 @@ class ChatNotifier extends StateNotifier<ChatState> {
} catch (e) { } catch (e) {
state = state.copyWith( state = state.copyWith(
agentStatus: AgentStatus.error, agentStatus: AgentStatus.error,
error: e.toString(), error: ErrorHandler.friendlyMessage(e),
); );
} }
} }

View File

@ -6,6 +6,7 @@ import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/date_formatter.dart'; import '../../../../core/utils/date_formatter.dart';
import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/status_badge.dart';
import '../../../../core/errors/error_handler.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TODO 38 Dashboard providers & page // TODO 38 Dashboard providers & page
@ -208,7 +209,7 @@ class DashboardPage extends ConsumerWidget {
], ],
), ),
loading: () => const _SummaryCardLoading(), 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(), 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(), 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( error: (e, _) => Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), 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)),
), ),
), ),
); );

View File

@ -4,6 +4,7 @@ import '../../../../core/config/api_endpoints.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/status_badge.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -87,36 +88,9 @@ class _ServersPageState extends ConsumerState<ServersPage> {
}, },
loading: () => loading: () =>
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => ErrorView(
child: Padding( error: e,
padding: const EdgeInsets.all(32), onRetry: () => ref.invalidate(serversProvider),
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('重试'),
),
],
),
),
), ),
), ),
), ),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../core/errors/error_handler.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../data/datasources/settings_datasource.dart'; import '../../data/datasources/settings_datasource.dart';
import '../../data/datasources/settings_remote_datasource.dart'; import '../../data/datasources/settings_remote_datasource.dart';
@ -148,7 +149,7 @@ class AccountProfileNotifier extends StateNotifier<AccountProfile> {
isLoading: false, isLoading: false,
); );
} catch (e) { } 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<AccountProfile> {
); );
return true; return true;
} catch (e) { } catch (e) {
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: ErrorHandler.friendlyMessage(e));
return false; return false;
} }
} }
@ -180,7 +181,7 @@ class AccountProfileNotifier extends StateNotifier<AccountProfile> {
final message = data['message'] as String?; final message = data['message'] as String?;
return (success: success, message: message); return (success: success, message: message);
} catch (e) { } catch (e) {
return (success: false, message: e.toString()); return (success: false, message: ErrorHandler.friendlyMessage(e));
} }
} }
} }

View File

@ -4,7 +4,9 @@ import '../../../../core/config/api_endpoints.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/date_formatter.dart'; import '../../../../core/utils/date_formatter.dart';
import '../../../../core/errors/error_handler.dart';
import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/status_badge.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -68,37 +70,9 @@ class StandingOrdersPage extends ConsumerWidget {
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => ErrorView(
child: Padding( error: e,
padding: const EdgeInsets.all(32), onRetry: () => ref.invalidate(standingOrdersProvider),
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('重试'),
),
],
),
),
), ),
), ),
), ),
@ -264,7 +238,7 @@ class _StandingOrderCardState extends ConsumerState<_StandingOrderCard> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('更新状态失败: $e'), content: Text('更新状态失败: ${ErrorHandler.friendlyMessage(e)}'),
backgroundColor: AppColors.error, backgroundColor: AppColors.error,
), ),
); );

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/config/api_endpoints.dart'; import '../../../../core/config/api_endpoints.dart';
import '../../../../core/errors/error_handler.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_colors.dart';
import '../../../../core/utils/date_formatter.dart'; import '../../../../core/utils/date_formatter.dart';
import '../../../../core/widgets/empty_state.dart'; import '../../../../core/widgets/empty_state.dart';
import '../../../../core/widgets/error_view.dart';
import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/status_badge.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -81,33 +83,9 @@ class TasksPage extends ConsumerWidget {
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => ErrorView(
child: Padding( error: e,
padding: const EdgeInsets.all(32), onRetry: () => ref.invalidate(tasksProvider),
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('重试'),
),
],
),
),
), ),
), ),
), ),
@ -413,7 +391,7 @@ class _CreateTaskSheetState extends State<_CreateTaskSheet> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('创建任务失败: $e'), content: Text('创建任务失败: ${ErrorHandler.friendlyMessage(e)}'),
backgroundColor: AppColors.error, backgroundColor: AppColors.error,
), ),
); );