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:
parent
1075a6b265
commit
94652857cd
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '登录已过期,请重新登录']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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<Dio>((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<Dio>((ref) {
|
|||
},
|
||||
));
|
||||
|
||||
// Retry interceptor (exponential backoff for transient errors)
|
||||
dio.interceptors.add(RetryInterceptor(dio: dio));
|
||||
|
||||
// Logging interceptor
|
||||
dio.interceptors.add(LogInterceptor(
|
||||
requestBody: true,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
WebSocketClient({required this.baseUrl});
|
||||
|
||||
Future<void> 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<void> reconnect() async {
|
||||
_reconnectAttempts = 0;
|
||||
if (_lastPath != null) {
|
||||
await connect(_lastPath!, token: _lastToken);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_heartbeatTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_isConnected = false;
|
||||
_reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect
|
||||
await _channel?.sink.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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('重试'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AlertsPage> {
|
|||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<ApprovalsPage> {
|
|||
},
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<AuthState> {
|
|||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
error: ErrorHandler.friendlyMessage(e),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ChatState> {
|
|||
onError: (error) {
|
||||
state = state.copyWith(
|
||||
agentStatus: AgentStatus.error,
|
||||
error: error.toString(),
|
||||
error: ErrorHandler.friendlyMessage(error),
|
||||
);
|
||||
},
|
||||
onDone: () {
|
||||
|
|
@ -155,7 +156,7 @@ class ChatNotifier extends StateNotifier<ChatState> {
|
|||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
agentStatus: AgentStatus.error,
|
||||
error: e.toString(),
|
||||
error: ErrorHandler.friendlyMessage(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ServersPage> {
|
|||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<AccountProfile> {
|
|||
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<AccountProfile> {
|
|||
);
|
||||
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<AccountProfile> {
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue