diff --git a/CLAUDE.md b/CLAUDE.md index a691480..3701b05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,4 +163,4 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con ## 其他说明 1. 需求文档:[已确认-污水毒品快检一体机_功能需求文档.md](docs/%E5%B7%B2%E7%A1%AE%E8%AE%A4-%E6%B1%A1%E6%B0%B4%E6%AF%92%E5%93%81%E5%BF%AB%E6%A3%80%E4%B8%80%E4%BD%93%E6%9C%BA_%E5%8A%9F%E8%83%BD%E9%9C%80%E6%B1%82%E6%96%87%E6%A1%A3.md) -2. 运行设备屏幕尺寸为1920*1080(横屏),UI设计必须支持此尺寸 \ No newline at end of file +2. 运行设备屏幕尺寸为1024x600(横屏),UI设计必须支持此尺寸 \ No newline at end of file diff --git a/lib/core/localization/app_localizations.dart b/lib/core/localization/app_localizations.dart index ba00cee..f774266 100644 --- a/lib/core/localization/app_localizations.dart +++ b/lib/core/localization/app_localizations.dart @@ -231,6 +231,20 @@ class AppLocalizations { String get usbUsageStep3 => _localizedValues[locale.languageCode]?['usbUsageStep3'] ?? '检测成功后点击"导入程序"加载程序列表'; String get usbPathInvalid => _localizedValues[locale.languageCode]?['usbPathInvalid'] ?? 'U盘路径无效'; + // ---- 启动认证 ---- + String get authTitle => _localizedValues[locale.languageCode]?['authTitle'] ?? '身份验证'; + String get authSubtitle => _localizedValues[locale.languageCode]?['authSubtitle'] ?? '请输入操作员密码以继续使用'; + String get enterPassword => _localizedValues[locale.languageCode]?['enterPassword'] ?? '请输入密码'; + String get passwordError => _localizedValues[locale.languageCode]?['passwordError'] ?? '密码错误'; + String lockCountdown(int seconds) => + locale.languageCode == 'en' + ? 'Please wait $seconds seconds to retry' + : '请等待 $seconds 秒后重试'; + String remainingAttempts(int count) => + locale.languageCode == 'en' + ? '$count attempt${count > 1 ? 's' : ''} remaining' + : '剩余 $count 次尝试机会'; + // ---- 通用 ---- String get back => _localizedValues[locale.languageCode]?['back'] ?? '返回'; String get totalProgress => _localizedValues[locale.languageCode]?['totalProgress'] ?? '总进度'; @@ -427,6 +441,10 @@ class AppLocalizations { 'usbUsageStep2': '插入 U盘后点击"重新检测"', 'usbUsageStep3': '检测成功后点击"导入程序"加载程序列表', 'usbPathInvalid': 'U盘路径无效', + 'authTitle': '身份验证', + 'authSubtitle': '请输入操作员密码以继续使用', + 'enterPassword': '请输入密码', + 'passwordError': '密码错误', 'back': '返回', 'totalProgress': '总进度', 'appTitle': '污水毒品快检一体机', @@ -607,6 +625,10 @@ class AppLocalizations { 'usbUsageStep2': 'Insert USB then click "Re-detect"', 'usbUsageStep3': 'After detection, click "Import Programs" to load', 'usbPathInvalid': 'USB path invalid', + 'authTitle': 'Authentication', + 'authSubtitle': 'Enter operator password to continue', + 'enterPassword': 'Enter password', + 'passwordError': 'Password incorrect', 'back': 'Back', 'totalProgress': 'Total Progress', 'appTitle': 'Wastewater Drug Detection System', diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a4b6009..8a3bcd4 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,22 +1,53 @@ +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../features/auth/pages/login_page.dart'; import '../../features/home/pages/home_page.dart'; import '../../features/programs/pages/programs_page.dart'; import '../../features/program_detail/pages/program_detail_page.dart'; import '../../features/settings/pages/settings_page.dart'; import '../../features/home/pages/complete_page.dart'; +import '../../features/auth/providers/auth_provider.dart'; + +final _authRefreshStream = AuthRefreshStream(); /// 应用路由配置 final goRouterProvider = Provider((ref) { + final authState = ref.watch(authProvider); + + // 监听认证状态变化,触发 GoRouter 重新评估 redirect + ref.listen(authProvider, (prev, next) { + _authRefreshStream.notify(); + }); + return GoRouter( initialLocation: '/', + redirect: (context, state) { + final isAuthenticated = authState.status == AuthStatus.authenticated; + final isLoginPage = state.uri.path == '/login'; + + if (!isAuthenticated && !isLoginPage) { + return '/login'; + } + + if (isAuthenticated && isLoginPage) { + return '/'; + } + + return null; + }, + refreshListenable: _authRefreshStream, routes: [ + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginPage(), + ), GoRoute( path: '/', name: 'home', builder: (context, state) { - // 支持 ?tab=N 查询参数,用于从其他页面跳回首页并切换到指定 tab final tabParam = state.uri.queryParameters['tab']; final initialTab = int.tryParse(tabParam ?? '') ?? 0; return HomePage(initialTab: initialTab); @@ -47,4 +78,9 @@ final goRouterProvider = Provider((ref) { ), ], ); -}); \ No newline at end of file +}); + +/// 桥接 Riverpod 到 GoRouter 的 refreshListenable +class AuthRefreshStream extends ChangeNotifier { + void notify() => notifyListeners(); +} diff --git a/lib/features/auth/pages/login_page.dart b/lib/features/auth/pages/login_page.dart new file mode 100644 index 0000000..fcafb3e --- /dev/null +++ b/lib/features/auth/pages/login_page.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/localization/app_localizations.dart'; +import '../../../core/theme/app_theme.dart'; +import '../providers/auth_provider.dart'; + +/// 启动密码验证页面 +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _passwordCtrl = TextEditingController(); + final _focusNode = FocusNode(); + String? _errorMsg; + bool _submitting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _passwordCtrl.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_submitting) return; + final password = _passwordCtrl.text.trim(); + if (password.isEmpty) return; + + setState(() { + _submitting = true; + _errorMsg = null; + }); + + final success = await ref.read(authProvider.notifier).verify(password); + + if (!mounted) return; + _passwordCtrl.clear(); + + if (!success) { + final l10n = AppLocalizations.of(context); + setState(() { + _submitting = false; + _errorMsg = l10n?.passwordError ?? '密码错误'; + }); + } + } + + void _onKeyEvent(KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter) { + _submit(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final authState = ref.watch(authProvider); + + final isLocked = authState.isLocked; + final lockSeconds = authState.lockSecondsRemaining; + + return Scaffold( + backgroundColor: AppTheme.bgPage, + body: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 420), + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: AppTheme.bgSurface, + borderRadius: BorderRadius.circular(AppTheme.radiusLg), + border: Border.all(color: AppTheme.borderLight), + boxShadow: AppTheme.shadowCard, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 设备名称标识 + Icon( + Icons.shield_outlined, + size: 48, + color: AppTheme.primaryColor, + ), + const SizedBox(height: 16), + Text( + l10n?.authTitle ?? '身份验证', + style: TextStyle( + color: AppTheme.textHeading, + fontSize: 22, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n?.authSubtitle ?? '请输入操作员密码以继续使用', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + const SizedBox(height: 32), + // 密码输入框 + KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: _onKeyEvent, + child: TextField( + controller: _passwordCtrl, + focusNode: _focusNode, + obscureText: true, + enabled: !isLocked && !_submitting, + decoration: InputDecoration( + labelText: l10n?.enterPassword ?? '请输入密码', + border: const OutlineInputBorder(), + errorText: _errorMsg, + prefixIcon: const Icon(Icons.lock_outline), + ), + ), + ), + const SizedBox(height: 24), + // 确认按钮 / 锁定提示 + if (isLocked) + Column( + children: [ + Icon(Icons.lock_clock, + size: 32, color: AppTheme.warningColor), + const SizedBox(height: 8), + Text( + l10n?.lockCountdown(lockSeconds) ?? + '请等待 $lockSeconds 秒后重试', + style: TextStyle( + color: AppTheme.warningColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ) + else + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _submitting ? null : _submit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _submitting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(l10n?.confirm ?? '确认'), + ), + ), + // 剩余尝试次数提示 + if (!isLocked && authState.remainingAttempts < 5) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + l10n?.remainingAttempts(authState.remainingAttempts) ?? + '剩余 ${authState.remainingAttempts} 次尝试机会', + style: TextStyle( + color: AppTheme.errorColor, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart new file mode 100644 index 0000000..425f7f5 --- /dev/null +++ b/lib/features/auth/providers/auth_provider.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../settings/services/settings_service.dart'; + +/// 认证状态枚举 +enum AuthStatus { unauthenticated, authenticated } + +/// 认证状态数据 +class AuthState { + final AuthStatus status; + final int remainingAttempts; + final DateTime? lockUntil; + + const AuthState({ + this.status = AuthStatus.unauthenticated, + this.remainingAttempts = 5, + this.lockUntil, + }); + + bool get isLocked => lockUntil != null && DateTime.now().isBefore(lockUntil!); + + int get lockSecondsRemaining { + if (lockUntil == null) return 0; + final diff = lockUntil!.difference(DateTime.now()).inSeconds; + return diff > 0 ? diff : 0; + } + + AuthState copyWith({ + AuthStatus? status, + int? remainingAttempts, + DateTime? Function()? lockUntil, + }) { + return AuthState( + status: status ?? this.status, + remainingAttempts: remainingAttempts ?? this.remainingAttempts, + lockUntil: lockUntil != null ? lockUntil() : this.lockUntil, + ); + } +} + +/// 认证状态管理器 +class AuthNotifier extends StateNotifier { + AuthNotifier() : super(const AuthState()); + + static const maxAttempts = 5; + static const lockDuration = Duration(seconds: 30); + + /// 验证密码 + Future verify(String password) async { + if (state.isLocked) { + return false; + } + + final isValid = await SettingsService.instance.verifyPassword(password); + + if (isValid) { + state = const AuthState(status: AuthStatus.authenticated); + return true; + } + + final newAttempts = state.remainingAttempts - 1; + + if (newAttempts <= 0) { + state = state.copyWith( + remainingAttempts: 0, + lockUntil: () => DateTime.now().add(lockDuration), + ); + _startLockTimer(); + } else { + state = state.copyWith(remainingAttempts: newAttempts); + } + + return false; + } + + /// 锁定倒计时结束后自动解锁 + void _startLockTimer() { + Timer(lockDuration, () { + if (state.status == AuthStatus.authenticated) return; + state = AuthState( + status: state.status, + remainingAttempts: maxAttempts, + lockUntil: null, + ); + }); + } + + /// 重置认证状态(用于登出) + void reset() { + state = const AuthState(); + } +} + +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier(); +});