Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/pages/exam_creation_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -75,6 +76,8 @@ class _ExamCreationPageState extends State<ExamCreationPage> {
}

Future<void> _startExam() async {
if (!await _ensureAuthenticated() || !mounted) return;

if (!_serverAvailable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('考试服务不可用,请检查后端是否启动')),
Expand Down Expand Up @@ -136,6 +139,24 @@ class _ExamCreationPageState extends State<ExamCreationPage> {
}
}

Future<bool> _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<bool>(
MaterialPageRoute(
builder: (_) => const LoginPage(returnToPreviousPage: true),
),
);
if (!mounted) return false;
return loggedIn == true &&
auth.isLoggedIn &&
(auth.token?.isNotEmpty ?? false);
}

List<String>? _boardsForSelectedDimension() {
final String? dimension = _selectedDimension;
if (dimension == null) return null;
Expand Down
10 changes: 3 additions & 7 deletions lib/services/auth_service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions lib/services/exam_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ class ExamApi {
if (response.statusCode >= 200 && response.statusCode < 300) {
return decoded as Map<String, dynamic>;
}
if (response.statusCode == 401) {
throw ExamApiException(401, '登录状态已失效,请重新登录');
}
final message = decoded is Map<String, dynamic>
? decoded['detail']
: decoded;
Expand Down
41 changes: 25 additions & 16 deletions lib/services/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoginPage> createState() => _LoginPageState();
Expand All @@ -14,7 +17,7 @@ class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();

bool _obscurePassword = true;
bool _isLoading = false;

Expand All @@ -35,9 +38,8 @@ class _LoginPageState extends State<LoginPage> {
password: _passwordController.text.trim(),
);

setState(() => _isLoading = false);

if (!mounted) return;
setState(() => _isLoading = false);

if (response.ok) {
ScaffoldMessenger.of(context).showSnackBar(
Expand All @@ -46,16 +48,19 @@ class _LoginPageState extends State<LoginPage> {
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,
),
);
Expand All @@ -76,10 +81,14 @@ class _LoginPageState extends State<LoginPage> {
// 右上角的跳过按钮
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(
'跳过,直接使用',
Expand Down Expand Up @@ -189,4 +198,4 @@ const Image(
),
);
}
}
}
24 changes: 24 additions & 0 deletions test/exam_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExamApiException>()
.having((e) => e.statusCode, 'statusCode', 401)
.having((e) => e.message, 'message', '登录状态已失效,请重新登录'),
),
);
});
}