From 018180de8c423978863aad4428c71f9fd801c556 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 16 Jun 2026 14:42:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E7=BD=91=E7=BB=9C=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E9=87=8D=E8=AF=95=20+=20=E7=A6=BB=E7=BA=BF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=8F=AF=E6=84=9F=E7=9F=A5=E9=87=8D=E8=AF=95=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复①请求级重试:新增 RetryInterceptor,网络层失败重试 3 次间隔递增 1s→2s→4s;GET 全重试,写操作仅在连接未建立时重试以防重复提交。 connectTimeout 5s→8s。 修复②连通性:启动首检改退避重试 4s→8s→12s,根治跨境冷启动假离线; 新增检测中状态 + retry();登录页/全局离线提示加 NetworkRetryButton, 重试中转圈、结果 SnackBar 反馈。 121 个测试通过,analyze 无 error。todo #52。 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/api/api_client.dart | 22 ++- client/lib/core/api/retry_interceptor.dart | 63 +++++++ .../lib/providers/connectivity_provider.dart | 73 ++++++-- client/lib/screens/auth/login_screen.dart | 3 + client/lib/screens/shell/app_shell.dart | 16 +- client/lib/widgets/network_retry_button.dart | 65 +++++++ todo/todo.html | 160 ++++-------------- todo/todo.json | 17 +- 8 files changed, 269 insertions(+), 150 deletions(-) create mode 100644 client/lib/core/api/retry_interceptor.dart create mode 100644 client/lib/widgets/network_retry_button.dart diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index f7f1b74..a9b76f6 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -5,13 +5,20 @@ import '../auth/auth_state.dart'; import '../config/app_config.dart'; import '../errors/error_reporter.dart'; import '../../providers/connectivity_provider.dart'; +import 'retry_interceptor.dart'; /// Public Dio instance for unauthenticated calls (login / refresh) -final _publicDio = Dio(BaseOptions( - baseUrl: AppConfig.apiBaseUrl, - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 15), -)); +final _publicDio = _buildPublicDio(); + +Dio _buildPublicDio() { + final dio = Dio(BaseOptions( + baseUrl: AppConfig.apiBaseUrl, + connectTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 15), + )); + dio.interceptors.add(RetryInterceptor(dio)); + return dio; +} final apiClientProvider = Provider((ref) { // 只监听登录/登出,不监听 token 内容变化。 @@ -52,13 +59,16 @@ class ApiClient { }) { _dio = Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, - connectTimeout: const Duration(seconds: 5), + connectTimeout: const Duration(seconds: 8), receiveTimeout: const Duration(seconds: 15), headers: { if (token != null) 'Authorization': 'Bearer $token', }, )); + // 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑) + _dio.interceptors.add(RetryInterceptor(_dio)); + // 网络错误 + 401 拦截器 _dio.interceptors.add( InterceptorsWrapper( diff --git a/client/lib/core/api/retry_interceptor.dart b/client/lib/core/api/retry_interceptor.dart new file mode 100644 index 0000000..48d5794 --- /dev/null +++ b/client/lib/core/api/retry_interceptor.dart @@ -0,0 +1,63 @@ +import 'package:dio/dio.dart'; + +/// 网络层错误自动重试拦截器。 +/// +/// 仅对「网络层」失败(连接超时 / 连接失败 / 读写超时)重试,**不**对带响应的 +/// HTTP 错误(4xx/5xx 业务错误)重试。重试 3 次,间隔递增(1s → 2s → 4s)。 +/// +/// 安全策略(避免重复入库/出库等副作用): +/// - GET:幂等,所有网络错误都重试。 +/// - POST/PUT/PATCH/DELETE:仅在「连接尚未建立」(connectTimeout / connectionError, +/// 服务器还没收到请求)时重试;receiveTimeout/sendTimeout 时服务器可能已处理, +/// 不重试,避免重复提交。 +class RetryInterceptor extends Interceptor { + final Dio dio; + final List delays; + + RetryInterceptor( + this.dio, { + this.delays = const [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 4), + ], + }); + + static const _attemptKey = 'retry_attempt'; + + bool _retriable(DioException e) { + final method = (e.requestOptions.method).toUpperCase(); + final isIdempotent = method == 'GET' || method == 'HEAD'; + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.connectionError: + // 连接未建立 → 服务器未收到 → 任何方法都可安全重试 + return true; + case DioExceptionType.receiveTimeout: + case DioExceptionType.sendTimeout: + // 服务器可能已处理 → 仅幂等方法重试 + return isIdempotent; + default: + return false; + } + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + final attempt = (err.requestOptions.extra[_attemptKey] as int?) ?? 0; + + if (_retriable(err) && attempt < delays.length) { + await Future.delayed(delays[attempt]); + err.requestOptions.extra[_attemptKey] = attempt + 1; + try { + final resp = await dio.fetch(err.requestOptions); + return handler.resolve(resp); + } on DioException catch (e) { + return handler.next(e); + } catch (_) { + return handler.next(err); + } + } + return handler.next(err); + } +} diff --git a/client/lib/providers/connectivity_provider.dart b/client/lib/providers/connectivity_provider.dart index e4dfa1c..3dab0e4 100644 --- a/client/lib/providers/connectivity_provider.dart +++ b/client/lib/providers/connectivity_provider.dart @@ -7,34 +7,55 @@ import '../core/config/app_config.dart'; /// 数据 provider 通过 watch 此值实现网络恢复后自动刷新。 final networkRecoveryCountProvider = StateProvider((ref) => 0); +/// 是否正在进行一次「带重试的连通性检测」(供 UI 显示「重试中…」转圈)。 +final connectivityCheckingProvider = StateProvider((ref) => false); + final connectivityProvider = StateNotifierProvider((ref) { return ConnectivityNotifier( onRecovered: () { ref.read(networkRecoveryCountProvider.notifier).update((s) => s + 1); }, + onCheckingChanged: (checking) { + ref.read(connectivityCheckingProvider.notifier).state = checking; + }, ); }); class ConnectivityNotifier extends StateNotifier { final void Function()? onRecovered; + final void Function(bool checking)? onCheckingChanged; - ConnectivityNotifier({this.onRecovered, bool skipInit = false}) : super(true) { + ConnectivityNotifier({ + this.onRecovered, + this.onCheckingChanged, + bool skipInit = false, + }) : super(true) { if (!skipInit) { - _check(); + // 启动首检走「带重试」:跨境冷启动 DNS+TLS 握手较慢,单次 4s 易误判离线, + // 退避重试 4s→8s→12s 可显著降低首屏假离线。 + _check(withRetry: true); _startOnlineTimer(); } } Timer? _timer; - // 独立轻量 Dio:短超时,无拦截器 + // 独立轻量 Dio:无拦截器。连接预算用 Future.timeout 逐次控制, + // 故 BaseOptions 超时放宽到 15s 作为兜底。 final _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 3), - receiveTimeout: const Duration(seconds: 3), + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), )); - /// 在线时:每 30 秒检测一次 + /// 每次尝试的超时预算,逐次拉长(越来越长)。 + static const _budgets = [ + Duration(seconds: 4), + Duration(seconds: 8), + Duration(seconds: 12), + ]; + + /// 在线时:每 30 秒轻量检测一次(单次,不重试) void _startOnlineTimer() { _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 30), (_) => _check()); @@ -46,25 +67,57 @@ class ConnectivityNotifier extends StateNotifier { _timer = Timer.periodic(const Duration(minutes: 1), (_) => _check()); } - /// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时) + /// 立即触发一次检测(供外部调用,如 API 请求失败 / 启动时)。单次,不重试。 Future forceCheck() => _check(); - Future _check() async { + /// 用户手动重试:带退避重试,并对外广播「检测中」状态,返回最终是否在线。 + Future retry() async { + onCheckingChanged?.call(true); try { - await _dio.get(AppConfig.healthUrl); + return await _check(withRetry: true); + } finally { + onCheckingChanged?.call(false); + } + } + + /// 单次 ping /health,[budget] 为本次超时预算。 + Future _ping(Duration budget) async { + try { + await _dio.get(AppConfig.healthUrl).timeout(budget); + return true; + } catch (_) { + return false; + } + } + + /// 连通性检测。[withRetry]=true 时按 [_budgets] 退避重试,间隔递增。 + Future _check({bool withRetry = false}) async { + final budgets = withRetry ? _budgets : const [Duration(seconds: 4)]; + var ok = false; + for (var i = 0; i < budgets.length; i++) { + ok = await _ping(budgets[i]); + if (ok) break; + // 最后一次失败后不再等待;中间失败按递增间隔退避(0.6s, 1.2s…) + if (i < budgets.length - 1) { + await Future.delayed(Duration(milliseconds: 600 * (i + 1))); + } + } + + if (ok) { if (!state) { // 离线 → 在线:切回高频检测,广播恢复事件 state = true; onRecovered?.call(); _startOnlineTimer(); } - } catch (_) { + } else { if (state) { // 在线 → 离线:切换为低频嗅探 state = false; _startOfflineTimer(); } } + return ok; } @override diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index a3e9255..e87e5ba 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -11,6 +11,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/storage/login_history.dart'; import '../../providers/connectivity_provider.dart'; import '../../repositories/auth_repository.dart'; +import '../../widgets/network_retry_button.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -426,6 +427,8 @@ class _LoginScreenState extends ConsumerState { fontSize: 13), ), ), + NetworkRetryButton( + foreground: Color(0xFF5D4037)), ], ), ), diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index a109c02..2c4aaa2 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -14,6 +14,7 @@ import '../../providers/shop_provider.dart'; import '../../providers/update_provider.dart'; import '../../core/update/app_updater.dart'; import '../../providers/license_provider.dart'; +import '../../widgets/network_retry_button.dart'; class AppShell extends ConsumerStatefulWidget { final Widget child; @@ -382,13 +383,16 @@ class _AppShellState extends ConsumerState { Icon(Icons.wifi_off, size: 16, color: Colors.white), SizedBox(width: 8), - Text( - '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', - style: TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500), + Expanded( + child: Text( + '网络连接已断开 · 当前显示离线缓存数据,恢复后将自动刷新', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500), + ), ), + NetworkRetryButton(), ], ), ), diff --git a/client/lib/widgets/network_retry_button.dart b/client/lib/widgets/network_retry_button.dart new file mode 100644 index 0000000..3865c90 --- /dev/null +++ b/client/lib/widgets/network_retry_button.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/theme/app_theme.dart'; +import '../providers/connectivity_provider.dart'; + +/// 离线提示里的「重试」按钮: +/// - 检测中显示转圈 +「重试中…」(过程可感知) +/// - 重试结束用 SnackBar 反馈成功/失败(结果可感知) +/// +/// [foreground] 适配不同底色(红色横幅用白字,琥珀框用深色)。 +class NetworkRetryButton extends ConsumerWidget { + final Color foreground; + const NetworkRetryButton({super.key, this.foreground = Colors.white}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final checking = ref.watch(connectivityCheckingProvider); + + return TextButton( + onPressed: checking + ? null + : () async { + final ok = + await ref.read(connectivityProvider.notifier).retry(); + if (!context.mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar( + content: Text(ok ? '已重新连接服务器' : '仍无法连接服务器,请稍后重试'), + backgroundColor: ok ? AppTheme.success : AppTheme.danger, + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + )); + }, + style: TextButton.styleFrom( + foregroundColor: foreground, + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (checking) + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(foreground), + ), + ) + else + Icon(Icons.refresh, size: 16, color: foreground), + const SizedBox(width: 6), + Text( + checking ? '重试中…' : '重试', + style: TextStyle( + color: foreground, fontSize: 13, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } +} diff --git a/todo/todo.html b/todo/todo.html index 30b06b6..bbff4cc 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -131,37 +131,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } } .reject-date { color: #ef9999; font-size: 12px; } -/* ── 确认闸(方案/改动说明)── */ -.gate-block { margin: 10px 0 4px; padding: 10px 14px; border-radius: 8px; font-size: 13px; } -.gate-pending { background: #fff7ed; border: 1px solid #fdba74; } -.gate-granted { background: #f0fdf4; border: 1px solid #bbf7d0; } -.gate-info { background: #f1f5f9; border: 1px solid #e2e8f0; } -.gate-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } -.gate-badge { padding: 2px 9px; border-radius: 10px; font-size: 11.5px; font-weight: 700; white-space: nowrap; } -.gate-badge.pending { background: #f97316; color: #fff; } -.gate-badge.granted { background: #22c55e; color: #fff; } -.gate-badge.info { background: #94a3b8; color: #fff; } -.gate-kind { font-size: 12px; color: #52606d; } -.gate-date { font-size: 12px; color: #8aa3c4; margin-left: auto; } -.gate-note { color: #52606d; margin: 4px 0; white-space: pre-wrap; } -.gate-ref { font-size: 12.5px; color: #52606d; margin-top: 4px; } -.gate-note code, .gate-ref code { - background: #fff; padding: 1px 5px; border-radius: 3px; - font-family: "JetBrains Mono", monospace; font-size: 12px; color: #b91c1c; -} -.approve-btn { - margin-top: 8px; padding: 5px 14px; border-radius: 6px; border: none; - background: #16a34a; color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer; - transition: background .15s; -} -.approve-btn:hover { background: #15803d; } -.approve-cmd { margin-top: 10px; padding: 10px 12px; background: #1e293b; border-radius: 8px; } -.approve-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 6px; } -.approve-cmd-code { font-family: "JetBrains Mono", monospace; font-size: 13px; color: #86efac; display: block; word-break: break-all; } -.approve-copy-btn { margin-top: 8px; padding: 4px 12px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; } -.approve-copy-btn:hover { background: #475569; } -.stat-pill.gate-stat { background: #f97316; } - /* ── 子任务区 ── */ .subtask-block { margin-top: 12px; padding: 10px 14px; @@ -252,14 +221,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }

酒库管理系统 — 项目 TODO

-
生成于 2026-06-14 · 真相源 todo/todo.json
+
生成于 2026-06-16 · 真相源 todo/todo.json
-
47全部
+
48全部
1待开始
0开发中
-
32待验收
+
33待验收
14已验收
-
@@ -326,7 +294,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
路线已定:CI 构建签名 IPA → 上传 TestFlight。前置(用户侧):Apple Developer Program($99/年)+App Store Connect 建 App(com.yanmei.jiu)+三件套凭证(.p12/.mobileprovision/.p8)+7个 Forgejo secrets+TestFlight 公开链接填 version.yaml。代码缺口(我方):compile-ios.sh 需在 flutter build ipa 前向 Release.xcconfig 注入 manual 签名(CODE_SIGN_STYLE/DEVELOPMENT_TEAM/PROVISIONING_PROFILE_SPECIFIER/CODE_SIGN_IDENTITY),否则 archive 阶段因 Automatic 无 team 必失败。详见计划 luminous-hugging-platypus.md。暂不调度。
-