- flutter create 初始化 + 依赖(riverpod 3/dio/go_router/three_dart 0.0.16) - ApiClient:JWT 拦截器 + 统一响应解包 + 401 登出回调 + multipart 上传 - TokenStorage(shared_preferences)+ AuthNotifier 登录/登出 - 登录页(含注册对话框)+ 4 Tab 主框架 - 6 个单元测试通过(网络层 4 + 认证 2) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
78 lines
2.4 KiB
Dart
78 lines
2.4 KiB
Dart
import 'package:dio/dio.dart';
|
||
|
||
import '../config/app_config.dart';
|
||
import '../storage/token_storage.dart';
|
||
import 'api_exception.dart';
|
||
|
||
/// 统一网络客户端:JWT 拦截器 + 统一响应解包(code != 0 抛 ApiException)
|
||
class ApiClient {
|
||
final TokenStorage _tokenStorage;
|
||
late final Dio _dio;
|
||
|
||
/// 401 时回调(登出)
|
||
void Function()? onUnauthorized;
|
||
|
||
// ignore: prefer_initializing_formals
|
||
ApiClient({required TokenStorage tokenStorage, Dio? dio})
|
||
: _tokenStorage = tokenStorage {
|
||
_dio = dio ??
|
||
Dio(BaseOptions(
|
||
baseUrl: AppConfig.baseUrl,
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 60),
|
||
));
|
||
_dio.interceptors.add(InterceptorsWrapper(
|
||
onRequest: (options, handler) {
|
||
final token = _tokenStorage.token;
|
||
if (token != null && token.isNotEmpty) {
|
||
options.headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
handler.next(options);
|
||
},
|
||
onError: (e, handler) {
|
||
if (e.response?.statusCode == 401) onUnauthorized?.call();
|
||
handler.next(e);
|
||
},
|
||
));
|
||
}
|
||
|
||
Future<T> post<T>(String path, Map<String, dynamic> body,
|
||
{T Function(dynamic data)? parse}) async {
|
||
final res = await _dio.post(path, data: body);
|
||
return _unwrap(res, parse);
|
||
}
|
||
|
||
Future<T> get<T>(String path,
|
||
{Map<String, dynamic>? query, T Function(dynamic data)? parse}) async {
|
||
final res = await _dio.get(path, queryParameters: query);
|
||
return _unwrap(res, parse);
|
||
}
|
||
|
||
/// multipart 上传:fields 为文本字段,fileField 为文件字段名
|
||
Future<T> upload<T>(String path, Map<String, dynamic> fields,
|
||
String fileField, String filePath,
|
||
{T Function(dynamic data)? parse}) async {
|
||
final form = FormData.fromMap({
|
||
...fields,
|
||
fileField: await MultipartFile.fromFile(filePath),
|
||
});
|
||
final res = await _dio.post(path, data: form);
|
||
return _unwrap(res, parse);
|
||
}
|
||
|
||
T _unwrap<T>(Response res, T Function(dynamic data)? parse) {
|
||
final data = res.data;
|
||
if (data is! Map<String, dynamic>) {
|
||
throw ApiException(-1, '响应格式错误');
|
||
}
|
||
final code = data['code'] as int? ?? -1;
|
||
final message = data['message'] as String? ?? '';
|
||
if (code != 0) {
|
||
throw ApiException(code, message);
|
||
}
|
||
final payload = data['data'];
|
||
if (parse != null) return parse(payload);
|
||
return payload as T;
|
||
}
|
||
}
|