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>
This commit is contained in:
wangjia
2026-04-07 22:20:28 +08:00
parent 31ea370cea
commit d717fb3735
70 changed files with 6919 additions and 2580 deletions
+42 -10
View File
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
@@ -14,17 +15,34 @@ final _publicDio = Dio(BaseOptions(
final apiClientProvider = Provider<ApiClient>((ref) {
final authState = ref.watch(authStateProvider);
return ApiClient(
token: authState.user?.accessToken,
final myToken = authState.user?.accessToken;
final client = ApiClient(
token: myToken,
refreshToken: authState.user?.refreshToken,
onTokenRefreshed: (newToken) =>
ref.read(authStateProvider.notifier).updateAccessToken(newToken),
onAuthFailed: () => ref.read(authStateProvider.notifier).logout(),
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,
@@ -42,27 +60,35 @@ class ApiClient {
},
));
// 401 auto-refresh interceptor
// 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;
onTokenRefreshed?.call(newToken);
// Retry original request with new token
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 (_) {
onAuthFailed?.call();
} 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);
},
@@ -71,6 +97,12 @@ class ApiClient {
}
}
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;
_dio.close(force: true);
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_dio.get(path, queryParameters: params);
+21 -16
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -5,24 +6,24 @@ const _kAccessToken = 'access_token';
const _kRefreshToken = 'refresh_token';
const _kUsername = 'username';
const _kRealName = 'real_name';
const _kHotelNo = 'hotel_no';
const _kHotelId = 'hotel_id';
const _kShopNo = 'shop_no';
const _kShopId = 'shop_id';
class AuthUser {
final String accessToken;
final String refreshToken;
final String username;
final String realName;
final String hotelNo;
final int hotelId;
final String shopNo;
final int shopId;
const AuthUser({
required this.accessToken,
required this.refreshToken,
required this.username,
required this.realName,
required this.hotelNo,
required this.hotelId,
required this.shopNo,
required this.shopId,
});
// Kept for ApiClient compatibility
@@ -47,8 +48,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
final refreshToken = prefs.getString(_kRefreshToken);
final username = prefs.getString(_kUsername);
final realName = prefs.getString(_kRealName);
final hotelNo = prefs.getString(_kHotelNo);
final hotelIdStr = prefs.getString(_kHotelId);
final shopNo = prefs.getString(_kShopNo);
final shopIdStr = prefs.getString(_kShopId);
if (accessToken != null && refreshToken != null && username != null) {
state = AuthState(
@@ -58,8 +59,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
refreshToken: refreshToken,
username: username,
realName: realName ?? username,
hotelNo: hotelNo ?? '',
hotelId: int.tryParse(hotelIdStr ?? '') ?? 0,
shopNo: shopNo ?? '',
shopId: int.tryParse(shopIdStr ?? '') ?? 0,
),
);
return;
@@ -71,14 +72,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<void> login(AuthUser user) async {
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, user.accessToken);
await prefs.setString(_kRefreshToken, user.refreshToken);
await prefs.setString(_kUsername, user.username);
await prefs.setString(_kRealName, user.realName);
await prefs.setString(_kHotelNo, user.hotelNo);
await prefs.setString(_kHotelId, user.hotelId.toString());
await prefs.setString(_kShopNo, user.shopNo);
await prefs.setString(_kShopId, user.shopId.toString());
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
state = AuthState(initialized: true, user: user);
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
}
void updateAccessToken(String newToken) {
@@ -92,20 +96,21 @@ class AuthNotifier extends StateNotifier<AuthState> {
refreshToken: state.user!.refreshToken,
username: state.user!.username,
realName: state.user!.realName,
hotelNo: state.user!.hotelNo,
hotelId: state.user!.hotelId,
shopNo: state.user!.shopNo,
shopId: state.user!.shopId,
),
);
}
Future<void> logout() async {
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kHotelNo);
await prefs.remove(_kHotelId);
await prefs.remove(_kShopNo);
await prefs.remove(_kShopId);
state = const AuthState(initialized: true);
}
}
+7
View File
@@ -0,0 +1,7 @@
class AppException implements Exception {
final String message;
final int? statusCode;
const AppException(this.message, {this.statusCode});
@override
String toString() => message;
}
+27
View File
@@ -0,0 +1,27 @@
class PageResult<T> {
final List<T> data;
final int total;
final int page;
final int pageSize;
const PageResult({
required this.data,
required this.total,
required this.page,
required this.pageSize,
});
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) fromJson,
) {
return PageResult(
data: (json['data'] as List)
.map((e) => fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int,
page: json['page'] as int,
pageSize: json['page_size'] as int,
);
}
}
+51 -10
View File
@@ -17,18 +17,55 @@ import '../auth/auth_state.dart';
Page<void> _noTransition(Widget child) =>
NoTransitionPage<void>(child: child);
final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
/// ChangeNotifier that bridges Riverpod auth state → GoRouter refreshListenable.
class _RouterNotifier extends ChangeNotifier {
final Ref _ref;
return GoRouter(
_RouterNotifier(this._ref) {
_ref.listen<AuthState>(authStateProvider, (prev, next) {
debugPrint('[Router] authState changed:'
' initialized=${next.initialized}'
' isLoggedIn=${next.isLoggedIn}'
' user=${next.user?.username}');
notifyListeners();
});
}
String? redirect(BuildContext context, GoRouterState state) {
final authState = _ref.read(authStateProvider);
final isLoggedIn = authState.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
final result = !authState.initialized
? null
: (!isLoggedIn && !isLoginRoute)
? '/login'
: (isLoggedIn && isLoginRoute)
? '/stock-in'
: null;
debugPrint('[Router] redirect: location=${state.matchedLocation}'
' initialized=${authState.initialized}'
' isLoggedIn=$isLoggedIn'
'${result ?? "null (no redirect)"}');
return result;
}
}
/// Separate provider so that appRouterProvider has NO dependencies and
/// is never rebuilt when auth state changes (prevents router reset to /login).
final _routerNotifierProvider = Provider<_RouterNotifier>((ref) {
final notifier = _RouterNotifier(ref);
ref.onDispose(notifier.dispose);
return notifier;
});
final appRouterProvider = Provider<GoRouter>((ref) {
// Use ref.read (not ref.watch) so appRouterProvider never rebuilds on auth change.
final notifier = ref.read(_routerNotifierProvider);
final router = GoRouter(
initialLocation: '/login',
redirect: (context, state) {
final isLoggedIn = authState.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoginRoute) return '/login';
if (isLoggedIn && isLoginRoute) return '/stock-in';
return null;
},
refreshListenable: notifier,
redirect: notifier.redirect,
routes: [
GoRoute(
path: '/login',
@@ -70,4 +107,8 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
);
ref.onDispose(router.dispose);
return router;
});