diff --git a/lib/pages/exam_creation_page.dart b/lib/pages/exam_creation_page.dart index ee8a6e8..f4aab3a 100644 --- a/lib/pages/exam_creation_page.dart +++ b/lib/pages/exam_creation_page.dart @@ -4,6 +4,7 @@ import 'package:mathmate/pages/exam_taking_page.dart'; import 'package:mathmate/services/ability_score_service.dart'; import 'package:mathmate/services/auth_service.dart'; import 'package:mathmate/services/exam_api.dart'; +import 'package:mathmate/services/login_page.dart'; /// 自由组卷配置页 —— 选择维度、难度、题量后创建考试 class ExamCreationPage extends StatefulWidget { @@ -75,6 +76,8 @@ class _ExamCreationPageState extends State { } Future _startExam() async { + if (!await _ensureAuthenticated() || !mounted) return; + if (!_serverAvailable) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('考试服务不可用,请检查后端是否启动')), @@ -136,6 +139,24 @@ class _ExamCreationPageState extends State { } } + Future _ensureAuthenticated() async { + final AuthService auth = AuthService(); + if (auth.isLoggedIn && (auth.token?.isNotEmpty ?? false)) return true; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('考试功能需要登录,请先完成登录')), + ); + final bool? loggedIn = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const LoginPage(returnToPreviousPage: true), + ), + ); + if (!mounted) return false; + return loggedIn == true && + auth.isLoggedIn && + (auth.token?.isNotEmpty ?? false); + } + List? _boardsForSelectedDimension() { final String? dimension = _selectedDimension; if (dimension == null) return null; diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 74a953b..920b99a 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter/foundation.dart' show kIsWeb, kReleaseMode; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; @@ -47,23 +46,20 @@ class AuthResponse { final String? error; AuthResponse({this.token, this.user, this.error}); - bool get ok => token != null; + bool get ok => token != null || user != null; } /// 认证服务 —— 注册/登录/个人信息/Token 管理。 /// /// 与服务端 auth_server.js 通信,Token 存储在 SharedPreferences。 class AuthService { - // 公开构建通过 AUTH_BASE_URL 指向生产服务;本地开发仍保留原调试地址。 + // 默认与考试接口使用同一套线上用户和签名密钥;本地联调可显式配置地址。 static String get _baseUrl { final configured = (dotenv.env['AUTH_BASE_URL'] ?? '').trim(); if (configured.isNotEmpty) { return configured.replaceFirst(RegExp(r'/+$'), ''); } - if (kReleaseMode) return 'https://mathmate.top/api/auth'; - return kIsWeb - ? 'http://localhost:3002/api/auth' - : 'http://10.136.66.36:3002/api/auth'; + return 'https://mathmate.top/api/auth'; } static const String _tokenKey = 'auth_token'; diff --git a/lib/services/exam_api.dart b/lib/services/exam_api.dart index 1209176..743fa04 100644 --- a/lib/services/exam_api.dart +++ b/lib/services/exam_api.dart @@ -162,6 +162,9 @@ class ExamApi { if (response.statusCode >= 200 && response.statusCode < 300) { return decoded as Map; } + if (response.statusCode == 401) { + throw ExamApiException(401, '登录状态已失效,请重新登录'); + } final message = decoded is Map ? decoded['detail'] : decoded; diff --git a/lib/services/login_page.dart b/lib/services/login_page.dart index 7bda2bd..2342916 100644 --- a/lib/services/login_page.dart +++ b/lib/services/login_page.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import '../services/auth_service.dart'; class LoginPage extends StatefulWidget { - const LoginPage({super.key}); + /// 从业务页进入登录时,登录成功后返回原页,而不是重复创建主页面。 + final bool returnToPreviousPage; + + const LoginPage({super.key, this.returnToPreviousPage = false}); @override State createState() => _LoginPageState(); @@ -14,7 +17,7 @@ class _LoginPageState extends State { final _formKey = GlobalKey(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); - + bool _obscurePassword = true; bool _isLoading = false; @@ -35,9 +38,8 @@ class _LoginPageState extends State { password: _passwordController.text.trim(), ); - setState(() => _isLoading = false); - if (!mounted) return; + setState(() => _isLoading = false); if (response.ok) { ScaffoldMessenger.of(context).showSnackBar( @@ -46,16 +48,19 @@ class _LoginPageState extends State { backgroundColor: Colors.green, ), ); - - // 登录成功后,销毁登录页,跳转到 MathMate 主界面 - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => const MainScreen()), - ); - + + if (widget.returnToPreviousPage) { + Navigator.of(context).pop(true); + } else { + // 启动登录成功后,销毁登录页并进入主界面。 + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const MainScreen()), + ); + } } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(response.error ?? '登录失败,请检查账号密码'), + content: Text(response.error ?? '登录失败,请检查账号密码'), backgroundColor: Colors.red, ), ); @@ -76,10 +81,14 @@ class _LoginPageState extends State { // 右上角的跳过按钮 TextButton( onPressed: () { - // 点击跳过,直接以游客身份进入主界面,且无法返回登录页 - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (context) => const MainScreen()), - ); + if (widget.returnToPreviousPage) { + Navigator.of(context).pop(false); + } else { + // 点击跳过,直接以游客身份进入主界面,且无法返回登录页 + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const MainScreen()), + ); + } }, child: const Text( '跳过,直接使用', @@ -189,4 +198,4 @@ const Image( ), ); } -} \ No newline at end of file +} diff --git a/test/exam_api_test.dart b/test/exam_api_test.dart index 3c92aa8..0be9b76 100644 --- a/test/exam_api_test.dart +++ b/test/exam_api_test.dart @@ -60,4 +60,28 @@ void main() { expect(count, 2); }); + + test('protected requests report an actionable message for 401', () async { + final client = MockClient( + (_) async => http.Response( + '{"detail":"Valid Bearer token required"}', + 401, + headers: {'content-type': 'application/json'}, + ), + ); + final api = ExamApi( + baseUrl: 'https://example.test', + client: client, + tokenProvider: () => null, + ); + + await expectLater( + api.availableQuestionCount(), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.message, 'message', '登录状态已失效,请重新登录'), + ), + ); + }); }