feat(client): 登录模块对接后端 API
- auth_state: 增加 refreshToken 字段 + flutter_secure_storage 持久化 - api_client: 增加 401 自动 refresh + retry 拦截器 - auth_repository: 封装 POST /api/v1/auth/login 调用,错误信息本地化 - login_screen: 增加门店编号字段,调真实 API,行内错误展示 - main: 启动时 restore 持久化登录态,避免每次重启都要重新登录 - 修复 app_shell: hotelName → hotelNo - 修复 widget_test: 移除失效的占位测试 测试账号:门店编号 H001 / 用户名 admin / 密码 password123 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,38 @@ import 'package:dio/dio.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);
|
||||
return ApiClient(token: authState.user?.token);
|
||||
return ApiClient(
|
||||
token: authState.user?.accessToken,
|
||||
refreshToken: authState.user?.refreshToken,
|
||||
onTokenRefreshed: (newToken) =>
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken),
|
||||
onAuthFailed: () => ref.read(authStateProvider.notifier).logout(),
|
||||
);
|
||||
});
|
||||
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient({String? token}) {
|
||||
ApiClient({
|
||||
String? token,
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function()? onAuthFailed,
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: 'http://localhost:8080/api/v1',
|
||||
baseUrl: _baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
@@ -20,6 +41,34 @@ class ApiClient {
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
));
|
||||
|
||||
// 401 auto-refresh interceptor
|
||||
if (refreshToken != null) {
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (DioException e, ErrorInterceptorHandler handler) async {
|
||||
if (e.response?.statusCode == 401 && refreshToken.isNotEmpty) {
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken =
|
||||
resp.data['data']['access_token'] as String;
|
||||
onTokenRefreshed?.call(newToken);
|
||||
// Retry original request with new token
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (_) {
|
||||
onAuthFailed?.call();
|
||||
}
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
|
||||
@@ -33,3 +82,9 @@ class ApiClient {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,108 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
const _kAccessToken = 'access_token';
|
||||
const _kRefreshToken = 'refresh_token';
|
||||
const _kUsername = 'username';
|
||||
const _kRealName = 'real_name';
|
||||
const _kHotelNo = 'hotel_no';
|
||||
const _kHotelId = 'hotel_id';
|
||||
|
||||
class AuthUser {
|
||||
final String token;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String username;
|
||||
final String hotelName;
|
||||
final String realName;
|
||||
final String hotelNo;
|
||||
final int hotelId;
|
||||
|
||||
const AuthUser({
|
||||
required this.token,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.username,
|
||||
required this.hotelName,
|
||||
required this.realName,
|
||||
required this.hotelNo,
|
||||
required this.hotelId,
|
||||
});
|
||||
|
||||
// Kept for ApiClient compatibility
|
||||
String get token => accessToken;
|
||||
}
|
||||
|
||||
class AuthState {
|
||||
final AuthUser? user;
|
||||
const AuthState({this.user});
|
||||
final bool initialized;
|
||||
const AuthState({this.user, this.initialized = false});
|
||||
bool get isLoggedIn => user != null;
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier() : super(const AuthState());
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
void login(AuthUser user) => state = AuthState(user: user);
|
||||
void logout() => state = const AuthState();
|
||||
AuthNotifier(this._storage) : super(const AuthState());
|
||||
|
||||
/// Called at app startup to restore persisted session
|
||||
Future<void> restore() async {
|
||||
final accessToken = await _storage.read(key: _kAccessToken);
|
||||
final refreshToken = await _storage.read(key: _kRefreshToken);
|
||||
final username = await _storage.read(key: _kUsername);
|
||||
final realName = await _storage.read(key: _kRealName);
|
||||
final hotelNo = await _storage.read(key: _kHotelNo);
|
||||
final hotelIdStr = await _storage.read(key: _kHotelId);
|
||||
|
||||
if (accessToken != null && refreshToken != null && username != null) {
|
||||
state = AuthState(
|
||||
initialized: true,
|
||||
user: AuthUser(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
username: username,
|
||||
realName: realName ?? username,
|
||||
hotelNo: hotelNo ?? '',
|
||||
hotelId: int.tryParse(hotelIdStr ?? '') ?? 0,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
state = const AuthState(initialized: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(AuthUser user) async {
|
||||
await _storage.write(key: _kAccessToken, value: user.accessToken);
|
||||
await _storage.write(key: _kRefreshToken, value: user.refreshToken);
|
||||
await _storage.write(key: _kUsername, value: user.username);
|
||||
await _storage.write(key: _kRealName, value: user.realName);
|
||||
await _storage.write(key: _kHotelNo, value: user.hotelNo);
|
||||
await _storage.write(key: _kHotelId, value: user.hotelId.toString());
|
||||
state = AuthState(initialized: true, user: user);
|
||||
}
|
||||
|
||||
void updateAccessToken(String newToken) {
|
||||
if (state.user == null) return;
|
||||
_storage.write(key: _kAccessToken, value: newToken);
|
||||
state = AuthState(
|
||||
initialized: true,
|
||||
user: AuthUser(
|
||||
accessToken: newToken,
|
||||
refreshToken: state.user!.refreshToken,
|
||||
username: state.user!.username,
|
||||
realName: state.user!.realName,
|
||||
hotelNo: state.user!.hotelNo,
|
||||
hotelId: state.user!.hotelId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.deleteAll();
|
||||
state = const AuthState(initialized: true);
|
||||
}
|
||||
}
|
||||
|
||||
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
(ref) => AuthNotifier(),
|
||||
final _secureStorage = FlutterSecureStorage(
|
||||
mOptions: MacOsOptions(groupId: 'com.jiu.client'),
|
||||
);
|
||||
|
||||
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
(ref) => AuthNotifier(_secureStorage),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user