feat: app 骨架 + 网络层 + 认证(登录/注册)

- 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>
This commit is contained in:
2026-07-31 12:39:01 +08:00
co-authored by Claude Opus 4.7
commit acbe60c13c
144 changed files with 6887 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../network/api_client.dart';
import '../storage/token_storage.dart';
class AuthState {
final bool authenticated;
final int? userId;
final String? name;
const AuthState({
this.authenticated = false,
this.userId,
this.name,
});
}
final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage());
final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(tokenStorage: ref.watch(tokenStorageProvider));
client.onUnauthorized = () {
ref.read(authProvider.notifier).logout();
};
return client;
});
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
final storage = ref.watch(tokenStorageProvider);
await storage.load();
return AuthState(authenticated: storage.hasToken);
}
Future<void> login(String account, String password) async {
state = const AsyncLoading();
try {
final client = ref.read(apiClientProvider);
final data = await client.post<Map<String, dynamic>>('/user/login', {
'account': account,
'password': password,
});
final token = data['token'] as String? ?? '';
final user = data['user'] as Map<String, dynamic>? ?? {};
await ref.read(tokenStorageProvider).save(token);
state = AsyncData(AuthState(
authenticated: true,
userId: (user['id'] as num?)?.toInt(),
name: user['name'] as String?,
));
} catch (e) {
state = AsyncError(e, StackTrace.current);
}
}
Future<void> logout() async {
await ref.read(tokenStorageProvider).clear();
state = const AsyncData(AuthState());
}
}
final authProvider =
AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
+5
View File
@@ -0,0 +1,5 @@
/// 全局配置
class AppConfig {
/// 后端地址:iOS 模拟器用 127.0.0.1Android 模拟器用 10.0.2.2;真机填局域网 IP
static const String baseUrl = 'http://127.0.0.1:3007';
}
+77
View File
@@ -0,0 +1,77 @@
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;
}
}
+10
View File
@@ -0,0 +1,10 @@
/// 业务异常(后端统一响应 code != 0)
class ApiException implements Exception {
final int code;
final String message;
ApiException(this.code, this.message);
@override
String toString() => message;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:shared_preferences/shared_preferences.dart';
/// JWT token 本地存储
class TokenStorage {
static const _key = 'auth_token';
String? _token;
String? get token => _token;
bool get hasToken => _token != null && _token!.isNotEmpty;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_key);
}
Future<void> save(String token) async {
_token = token;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, token);
}
Future<void> clear() async {
_token = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}