Files
jiu/client/lib/core/api/api_client.dart
T
wangjia d717fb3735 feat(client): 全模块 API 对接 + 修复登陆跳转 + 菜单 UI 优化
API 对接:
- 入库/出库/库存/财务/往来单位/基础数据全部对接后端 REST API
- 新增 repositories、providers、models 层,统一分层架构
- auth 从 flutter_secure_storage 迁移到 shared_preferences

登陆跳转修复:
- 将 _RouterNotifier 提取为独立 Riverpod provider,appRouterProvider
  使用 ref.read 避免依赖链导致 router 重建后跳回 /login
- redirect 函数新增 initialized 守卫,防止 auth 未恢复时误重定向
- 添加调试日志(Router/Auth/ApiClient)定位 401 触发的 logout 链路

退出菜单 UI:
- 去掉 ListTile,改用 Row + 自定义 padding,文字左对齐
- MouseRegion + AnimatedContainer 实现 hover 高亮(普通项蓝底/退出红底)
- 菜单圆角 6px,elevation 8,分割线高度 1px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:20:28 +08:00

123 lines
4.1 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
const _baseUrl = 'http://localhost:8080/api/v1';
/// Public Dio instance for unauthenticated calls (login / refresh)
final _publicDio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
final apiClientProvider = Provider<ApiClient>((ref) {
final authState = ref.watch(authStateProvider);
final myToken = authState.user?.accessToken;
final client = ApiClient(
token: myToken,
refreshToken: authState.user?.refreshToken,
onTokenRefreshed: (newToken) {
debugPrint('[ApiClient] token refreshed');
if (ref.read(authStateProvider).user?.accessToken == myToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
}
},
onAuthFailed: () {
debugPrint('[ApiClient] onAuthFailed! myToken prefix: ${(myToken ?? '').substring(0, (myToken ?? '').length.clamp(0, 20))}');
if (ref.read(authStateProvider).user?.accessToken == myToken) {
debugPrint('[ApiClient] calling logout()');
ref.read(authStateProvider.notifier).logout();
} else {
debugPrint('[ApiClient] skipping logout (stale client)');
}
},
);
ref.onDispose(client.dispose);
return client;
});
class ApiClient {
late final Dio _dio;
bool _disposed = false;
ApiClient({
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function()? onAuthFailed,
}) {
_dio = Dio(BaseOptions(
baseUrl: _baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
));
// 401 auto-refresh 拦截器
if (refreshToken != null) {
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, ErrorInterceptorHandler handler) async {
// 如果这个 client 已被替换,忽略所有回调
if (_disposed) {
return handler.next(e);
}
if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final newToken =
resp.data['data']['access_token'] as String;
if (!_disposed) onTokenRefreshed?.call(newToken);
// 用新 token 重试原请求
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
if (!_disposed) onAuthFailed?.call();
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
}
return handler.next(e);
},
),
);
}
}
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
_dio.close(force: true);
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_dio.get(path, queryParameters: params);
Future<Response> post(String path, {dynamic data}) =>
_dio.post(path, data: data);
Future<Response> put(String path, {dynamic data}) =>
_dio.put(path, data: data);
Future<Response> delete(String path) => _dio.delete(path);
}
/// Unauthenticated client for login/refresh
class PublicApiClient {
static Future<Response> post(String path, {dynamic data}) =>
_publicDio.post(path, data: data);
}