diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index 0df216f..48a26c0 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -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((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 get(String path, {Map? params}) => @@ -33,3 +82,9 @@ class ApiClient { Future delete(String path) => _dio.delete(path); } + +/// Unauthenticated client for login/refresh +class PublicApiClient { + static Future post(String path, {dynamic data}) => + _publicDio.post(path, data: data); +} diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index 7d0a4ce..dfb0451 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -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 { - 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 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 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 logout() async { + await _storage.deleteAll(); + state = const AuthState(initialized: true); + } } -final authStateProvider = StateNotifierProvider( - (ref) => AuthNotifier(), +final _secureStorage = FlutterSecureStorage( + mOptions: MacOsOptions(groupId: 'com.jiu.client'), +); + +final authStateProvider = StateNotifierProvider( + (ref) => AuthNotifier(_secureStorage), ); diff --git a/client/lib/main.dart b/client/lib/main.dart index 88d812b..3473184 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/auth/auth_state.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; @@ -10,6 +11,51 @@ void main() { class JiuApp extends ConsumerWidget { const JiuApp({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp( + title: '酒库管理系统', + theme: AppTheme.light(), + debugShowCheckedModeBanner: false, + home: const _AppBootstrap(), + ); + } +} + +/// Restores persisted auth before handing off to the router. +class _AppBootstrap extends ConsumerStatefulWidget { + const _AppBootstrap(); + + @override + ConsumerState<_AppBootstrap> createState() => _AppBootstrapState(); +} + +class _AppBootstrapState extends ConsumerState<_AppBootstrap> { + bool _ready = false; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + await ref.read(authStateProvider.notifier).restore(); + if (mounted) setState(() => _ready = true); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + return _RouterApp(); + } +} + +class _RouterApp extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(appRouterProvider); diff --git a/client/lib/repositories/auth_repository.dart b/client/lib/repositories/auth_repository.dart new file mode 100644 index 0000000..bdc3008 --- /dev/null +++ b/client/lib/repositories/auth_repository.dart @@ -0,0 +1,56 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; + +class AuthException implements Exception { + final String message; + const AuthException(this.message); + @override + String toString() => message; +} + +class AuthRepository { + /// POST /api/v1/auth/login + /// Request: { hotel_code, username, password } + /// Response: { data: { access_token, refresh_token, expires_in, user: { id, username, real_name, role } } } + static Future login({ + required String hotelCode, + required String username, + required String password, + }) async { + try { + final resp = await PublicApiClient.post('/auth/login', data: { + 'hotel_code': hotelCode, + 'username': username, + 'password': password, + }); + + final data = resp.data['data'] as Map; + final user = data['user'] as Map; + + return AuthUser( + accessToken: data['access_token'] as String, + refreshToken: data['refresh_token'] as String, + username: user['username'] as String, + realName: user['real_name'] as String? ?? username, + hotelNo: hotelCode, + hotelId: (user['id'] as num).toInt(), + ); + } on DioException catch (e) { + final msg = e.response?.data?['error'] as String?; + throw AuthException(msg ?? _networkError(e)); + } + } + + static String _networkError(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.receiveTimeout: + return '连接超时,请检查网络'; + case DioExceptionType.connectionError: + return '无法连接到服务器(localhost:8080),请先启动后端'; + default: + return '网络错误:${e.message}'; + } + } +} diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index b3508d8..824335b 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_state.dart'; import '../../core/theme/app_theme.dart'; +import '../../repositories/auth_repository.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -13,13 +14,16 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); + final _hotelCodeCtrl = TextEditingController(); final _usernameCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); bool _loading = false; bool _obscure = true; + String? _errorMessage; @override void dispose() { + _hotelCodeCtrl.dispose(); _usernameCtrl.dispose(); _passwordCtrl.dispose(); super.dispose(); @@ -27,20 +31,26 @@ class _LoginScreenState extends ConsumerState { Future _login() async { if (!_formKey.currentState!.validate()) return; - setState(() => _loading = true); - await Future.delayed(const Duration(milliseconds: 600)); - // Mock login for UI demo - ref.read(authStateProvider.notifier).login(AuthUser( - token: 'demo-token', - username: _usernameCtrl.text, - hotelName: '示范大酒店', - hotelNo: 'H001', - hotelId: 1, - )); - if (mounted) { - context.go('/stock-in'); + setState(() { + _loading = true; + _errorMessage = null; + }); + + try { + final user = await AuthRepository.login( + hotelCode: _hotelCodeCtrl.text.trim(), + username: _usernameCtrl.text.trim(), + password: _passwordCtrl.text, + ); + await ref.read(authStateProvider.notifier).login(user); + if (mounted) context.go('/stock-in'); + } on AuthException catch (e) { + setState(() => _errorMessage = e.message); + } catch (e) { + setState(() => _errorMessage = '登录失败:$e'); + } finally { + if (mounted) setState(() => _loading = false); } - if (mounted) setState(() => _loading = false); } @override @@ -80,7 +90,7 @@ class _LoginScreenState extends ConsumerState { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: AppTheme.primary.withOpacity(0.4), + color: AppTheme.primary.withAlpha(102), blurRadius: 12, offset: const Offset(0, 4), ), @@ -105,20 +115,37 @@ class _LoginScreenState extends ConsumerState { style: TextStyle( fontSize: 13, color: AppTheme.textSecondary), ), - const SizedBox(height: 36), + const SizedBox(height: 32), + + // Hotel code + TextFormField( + controller: _hotelCodeCtrl, + decoration: const InputDecoration( + labelText: '门店编号', + hintText: '请输入门店编号', + prefixIcon: Icon(Icons.store_outlined, size: 20), + ), + validator: (v) => + (v == null || v.isEmpty) ? '请输入门店编号' : null, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 14), + + // Username TextFormField( controller: _usernameCtrl, decoration: const InputDecoration( labelText: '用户名', hintText: '请输入用户名', - prefixIcon: - Icon(Icons.person_outline, size: 20), + prefixIcon: Icon(Icons.person_outline, size: 20), ), validator: (v) => (v == null || v.isEmpty) ? '请输入用户名' : null, textInputAction: TextInputAction.next, ), - const SizedBox(height: 16), + const SizedBox(height: 14), + + // Password TextFormField( controller: _passwordCtrl, obscureText: _obscure, @@ -141,7 +168,38 @@ class _LoginScreenState extends ConsumerState { (v == null || v.isEmpty) ? '请输入密码' : null, onFieldSubmitted: (_) => _login(), ), - const SizedBox(height: 28), + + // Inline error message + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.danger.withAlpha(15), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: AppTheme.danger.withAlpha(80)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, + color: AppTheme.danger, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle( + color: AppTheme.danger, fontSize: 13), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 24), SizedBox( width: double.infinity, height: 46, @@ -164,18 +222,12 @@ class _LoginScreenState extends ConsumerState { letterSpacing: 2)), ), ), - const SizedBox(height: 12), - TextButton( - onPressed: () {}, - child: const Text('忘记密码?'), - ), ], ), ), ), ), ), - // Bottom version info Positioned( bottom: 16, left: 0, @@ -184,7 +236,7 @@ class _LoginScreenState extends ConsumerState { child: Text( '© 2026 酒库管理系统 v1.0.0', style: TextStyle( - color: Colors.white.withOpacity(0.4), fontSize: 12), + color: Colors.white.withAlpha(102), fontSize: 12), ), ), ), @@ -198,13 +250,13 @@ class _BackgroundPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() - ..color = const Color(0xFF1976D2).withOpacity(0.15) + ..color = const Color(0xFF1976D2).withAlpha(38) ..style = PaintingStyle.fill; canvas.drawCircle( Offset(size.width * 0.1, size.height * 0.2), 180, paint); canvas.drawCircle( Offset(size.width * 0.9, size.height * 0.8), 220, paint); - paint.color = const Color(0xFF0D47A1).withOpacity(0.1); + paint.color = const Color(0xFF0D47A1).withAlpha(26); canvas.drawCircle( Offset(size.width * 0.8, size.height * 0.1), 140, paint); } diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index b9c12c7..3d6d4df 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -92,7 +92,7 @@ class _AppShellState extends ConsumerState { const Icon(Icons.business, color: Colors.white70, size: 14), const SizedBox(width: 4), - Text(user.hotelName, + Text(user.hotelNo, style: const TextStyle( color: Colors.white70, fontSize: 13)), const SizedBox(width: 20), @@ -222,7 +222,7 @@ class _NavItem { {required this.icon, required this.label, required this.path}); } -class _SidebarItem extends StatefulWidget { +class _SidebarItem extends StatelessWidget { final _NavItem item; final bool isActive; final bool expanded; @@ -235,37 +235,21 @@ class _SidebarItem extends StatefulWidget { required this.onTap, }); - @override - State<_SidebarItem> createState() => _SidebarItemState(); -} - -class _SidebarItemState extends State<_SidebarItem> { - bool _hovered = false; - @override Widget build(BuildContext context) { - final isActive = widget.isActive; - final expanded = widget.expanded; - - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: 48, - color: isActive - ? AppTheme.primary.withOpacity(0.3) - : _hovered - ? Colors.white.withOpacity(0.05) - : Colors.transparent, - padding: EdgeInsets.symmetric(horizontal: expanded ? 0 : 0), + return SizedBox( + height: 48, + child: Material( + color: isActive ? AppTheme.primary.withAlpha(76) : Colors.transparent, + child: InkWell( + onTap: onTap, + hoverColor: Colors.white.withAlpha(13), + splashColor: Colors.white.withAlpha(26), + highlightColor: Colors.white.withAlpha(13), child: Row( children: [ - // Active indicator - AnimatedContainer( - duration: const Duration(milliseconds: 150), + // Active indicator bar + Container( width: 3, height: isActive ? 28 : 0, color: Colors.white, @@ -279,7 +263,7 @@ class _SidebarItemState extends State<_SidebarItem> { : MainAxisAlignment.center, children: [ Icon( - widget.item.icon, + item.icon, color: isActive ? Colors.white : Colors.white60, size: 20, ), @@ -287,11 +271,9 @@ class _SidebarItemState extends State<_SidebarItem> { const SizedBox(width: 12), Flexible( child: Text( - widget.item.label, + item.label, style: TextStyle( - color: isActive - ? Colors.white - : Colors.white70, + color: isActive ? Colors.white : Colors.white70, fontSize: 14, fontWeight: isActive ? FontWeight.w500 diff --git a/client/lib/widgets/data_table_card.dart b/client/lib/widgets/data_table_card.dart index 0603124..1b11ca3 100644 --- a/client/lib/widgets/data_table_card.dart +++ b/client/lib/widgets/data_table_card.dart @@ -36,7 +36,7 @@ class DataTableCard extends StatelessWidget { children: [ ConstrainedBox( constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width - 212 - 24, + minWidth: (MediaQuery.of(context).size.width - 212 - 24).clamp(0.0, double.infinity), ), child: toolbar!, ),