459 lines
14 KiB
Dart
459 lines
14 KiB
Dart
// lib/pages/study_take_exam_page.dart
|
||
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:qhd_prevention/customWidget/custom_alert_dialog.dart';
|
||
import 'package:qhd_prevention/customWidget/custom_button.dart';
|
||
import 'package:qhd_prevention/customWidget/toast_util.dart';
|
||
import 'package:qhd_prevention/http/modules/edu_api.dart';
|
||
import 'package:qhd_prevention/pages/my_appbar.dart';
|
||
import 'package:qhd_prevention/http/ApiService.dart';
|
||
import 'package:qhd_prevention/services/SessionService.dart';
|
||
import 'dart:convert';
|
||
|
||
import 'package:qhd_prevention/tools/tools.dart';
|
||
|
||
class Question {
|
||
final String questionDry;
|
||
final String questionType;
|
||
final String questionId;
|
||
final String answer; // 正确答案(如果接口返回)
|
||
final double score;
|
||
final Map<String, String> options;
|
||
|
||
// 用户选择的答案(UI 中变化)
|
||
String choiceAnswer;
|
||
|
||
Question({
|
||
required this.questionDry,
|
||
required this.questionType,
|
||
required this.questionId,
|
||
required this.answer,
|
||
required this.score,
|
||
required this.options,
|
||
this.choiceAnswer = '',
|
||
});
|
||
|
||
factory Question.fromJson(Map<String, dynamic> json) {
|
||
final type = '${json['questionType'] ?? ''}';
|
||
final opts = <String, String>{};
|
||
if (type == '1' || type == '2' || type == '3') {
|
||
opts['A'] = json['optionA'] as String? ?? '';
|
||
opts['B'] = json['optionB'] as String? ?? '';
|
||
if (type != '3') {
|
||
opts['C'] = json['optionC'] as String? ?? '';
|
||
opts['D'] = json['optionD'] as String? ?? '';
|
||
}
|
||
}
|
||
// 保留原接口回传的参考答案(若有)
|
||
final answer = json['answer'] as String? ?? '';
|
||
final qDry = json['questionDry'] as String? ?? '';
|
||
final qId = json['questionId'] as String? ?? '';
|
||
double score = json['score'];
|
||
|
||
return Question(
|
||
questionDry: qDry,
|
||
questionType: type,
|
||
questionId: qId,
|
||
answer: answer,
|
||
score: score,
|
||
options: opts,
|
||
);
|
||
}
|
||
|
||
/// 构造提交给后端的最小字段对象
|
||
Map<String, dynamic> toSubmitJson() {
|
||
return {
|
||
'questionId': questionId,
|
||
'answer': answer,
|
||
'choiceAnswer': choiceAnswer,
|
||
'score': score,
|
||
};
|
||
}
|
||
}
|
||
|
||
class StudyTakeExamPage extends StatefulWidget {
|
||
const StudyTakeExamPage({
|
||
required this.examInfo,
|
||
required this.signInfo,
|
||
super.key,
|
||
});
|
||
|
||
final Map<String, dynamic> examInfo;
|
||
final Map signInfo;
|
||
|
||
@override
|
||
State<StudyTakeExamPage> createState() => _StudyTakeExamPageState();
|
||
}
|
||
|
||
class _StudyTakeExamPageState extends State<StudyTakeExamPage> {
|
||
late final List<Question> questions;
|
||
late final Map<String, dynamic> info;
|
||
int current = 0;
|
||
late int remainingSeconds;
|
||
Timer? _timer;
|
||
final String _startExamTime = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
||
|
||
final questionTypeMap = <String, String>{
|
||
'1': '单选题',
|
||
'2': '多选题',
|
||
'3': '判断题',
|
||
'4': '填空题',
|
||
};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
info = widget.examInfo as Map<String, dynamic>? ?? {};
|
||
final rawList = widget.examInfo['questionList'] as List<dynamic>? ?? [];
|
||
questions = rawList.map((e) => Question.fromJson(e as Map<String, dynamic>)).toList();
|
||
final minutes = info['examTime'] as int? ?? 0;
|
||
remainingSeconds = minutes * 60;
|
||
_startTimer();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _showTip(String content) {
|
||
return CustomAlertDialog.showAlert(
|
||
context,
|
||
title: '温馨提示',
|
||
content: content,
|
||
confirmText: '确认',
|
||
);
|
||
}
|
||
|
||
bool _validateCurrentAnswer() {
|
||
final q = questions[current];
|
||
if (q.choiceAnswer.isEmpty) {
|
||
_showTip('请对本题进行作答。');
|
||
return false;
|
||
}
|
||
if (q.questionType == '2' && q.choiceAnswer.split(',').length < 2) {
|
||
_showTip('多选题最少需要选择两个答案。');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void _startTimer() {
|
||
_timer?.cancel();
|
||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
if (remainingSeconds <= 0) {
|
||
timer.cancel();
|
||
_onTimeUp();
|
||
} else {
|
||
setState(() => remainingSeconds--);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 重置页面以便“继续考试”
|
||
void _resetForRetry() {
|
||
// 清空每题的选择
|
||
for (var q in questions) {
|
||
q.choiceAnswer = '';
|
||
}
|
||
// 重置当前题索引、计时等
|
||
setState(() {
|
||
current = 0;
|
||
final minutes = info['examTime'] as int? ?? 0;
|
||
remainingSeconds = minutes * 60;
|
||
});
|
||
// 重新启动计时器
|
||
_startTimer();
|
||
}
|
||
|
||
void _chooseTopic(String type, String key) {
|
||
final q = questions[current];
|
||
if (type == 'radio' || type == 'judge') {
|
||
q.choiceAnswer = (q.choiceAnswer == key) ? '' : key;
|
||
} else {
|
||
final chars = q.choiceAnswer.isEmpty ? <String>[] : q.choiceAnswer.split(',');
|
||
if (chars.contains(key)) {
|
||
chars.remove(key);
|
||
} else {
|
||
chars.add(key);
|
||
}
|
||
chars.sort();
|
||
q.choiceAnswer = chars.join(',');
|
||
}
|
||
setState(() {});
|
||
}
|
||
|
||
void _nextQuestion() {
|
||
if (!_validateCurrentAnswer()) return;
|
||
if (current < questions.length - 1) setState(() => current++);
|
||
}
|
||
|
||
void _previousQuestion() {
|
||
if (current > 0) setState(() => current--);
|
||
}
|
||
|
||
void _confirmSubmit() async {
|
||
if (!_validateCurrentAnswer()) return;
|
||
final ok = await CustomAlertDialog.showConfirm(
|
||
context,
|
||
title: '温馨提示',
|
||
content: '请确认是否交卷!',
|
||
cancelText: '取消',
|
||
);
|
||
if (ok) {
|
||
_submit();
|
||
}
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
LoadingDialogHelper.show(message: '正在提交');
|
||
|
||
// 按原逻辑:若多选题需要去掉逗号(保留你的业务要求)
|
||
for (var q in questions) {
|
||
if (q.questionType == '2') {
|
||
// 如果后端期待无逗号格式的话保留这一步,否则可删
|
||
q.choiceAnswer = q.choiceAnswer.replaceAll(',', '');
|
||
}
|
||
}
|
||
|
||
// 构造真正的 JSON 数组(而不是 JSON 字符串)
|
||
final questionList = questions.map((q) => q.toSubmitJson()).toList();
|
||
|
||
final data = {
|
||
'studentId': widget.signInfo['studentId'] ?? '',
|
||
'classId': widget.examInfo['classId'] ?? '',
|
||
'corpinfoId': SessionService.instance.tenantId ?? '',
|
||
'classExamPaperId': widget.examInfo['classExamPaperId'] ?? '',
|
||
'examPaperId': widget.examInfo['examPaperId'] ?? '',
|
||
'studentSignId': widget.signInfo['studentSignId'],
|
||
'examTimeBegin': _startExamTime,
|
||
// 当前时间就是结束时间
|
||
'examTimeEnd': DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||
'questionList': questionList, // 这里放真正的 List<Map<String,dynamic>>
|
||
};
|
||
|
||
final res = await EduApi.submitExam(data);
|
||
LoadingDialogHelper.hide();
|
||
|
||
if (res['success']) {
|
||
final data = res['data'] as Map<String, dynamic>? ?? {};
|
||
final score = data['examScore'] ?? 0;
|
||
final passed = data['result'] == 1;
|
||
|
||
// 弹窗告诉用户结果:通过直接返回上一页;未通过给“继续考试 / 确定”两个按钮
|
||
final result = await CustomAlertDialog.showConfirm(
|
||
context,
|
||
title: '温馨提示',
|
||
content: passed
|
||
? '您的成绩为 $score 分,恭喜您通过本次考试,请继续保持!'
|
||
: '您的成绩为 $score 分,很遗憾您没有通过本次考试,请再接再厉!',
|
||
cancelText: passed ? '' : '继续考试',
|
||
confirmText: '确定',
|
||
);
|
||
|
||
// 如果考试通过或用户点击了“确定”,则返回上一页
|
||
if (passed || result) {
|
||
Navigator.of(context).pop();
|
||
return;
|
||
}
|
||
|
||
// 到这里说明:未通过且用户点击了“继续考试” -> 重置页面状态并继续考试
|
||
_resetForRetry();
|
||
} else {
|
||
// 可选:处理失败情况并提示错误信息
|
||
final msg = res['message'] ?? '提交失败,请重试';
|
||
ToastUtil.showError(context, msg);
|
||
}
|
||
}
|
||
|
||
void _onTimeUp() {
|
||
ToastUtil.showError(context, '考试时间已结束');
|
||
_submit();
|
||
}
|
||
|
||
Widget _buildOptions(Question q) {
|
||
if (q.questionType == '4') {
|
||
return TextField(
|
||
controller: TextEditingController(text: q.choiceAnswer),
|
||
onChanged: (val) => q.choiceAnswer = val,
|
||
maxLength: 255,
|
||
decoration: const InputDecoration(
|
||
hintText: '请输入内容',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
);
|
||
}
|
||
final keys = q.questionType == '3' ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
|
||
return Column(
|
||
children: keys.map((key) {
|
||
final active = q.choiceAnswer.split(',').contains(key);
|
||
return GestureDetector(
|
||
onTap: () => _chooseTopic(
|
||
q.questionType == '3' ? 'judge' : (q.questionType == '2' ? 'multiple' : 'radio'),
|
||
key,
|
||
),
|
||
child: Container(
|
||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: active ? Colors.blue : Colors.grey.shade200,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Text(
|
||
key,
|
||
style: TextStyle(
|
||
color: active ? Colors.white : Colors.black87,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Text(
|
||
q.options[key] ?? '',
|
||
style: const TextStyle(fontSize: 15),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
);
|
||
}
|
||
|
||
String get _formattedTime {
|
||
final m = remainingSeconds ~/ 60;
|
||
final s = remainingSeconds % 60;
|
||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final q = questions.isNotEmpty ? questions[current] : null;
|
||
return PopScope(
|
||
canPop: true, // 禁用返回
|
||
child: Scaffold(
|
||
backgroundColor: Colors.white,
|
||
appBar: const MyAppbar(title: '课程考试', isBack: false),
|
||
body: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Stack(
|
||
children: [
|
||
Container(
|
||
width: double.infinity,
|
||
height: 120,
|
||
decoration: BoxDecoration(
|
||
image: const DecorationImage(
|
||
image: AssetImage('assets/study/bgimg1.png'),
|
||
fit: BoxFit.cover,
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
),
|
||
Positioned.fill(
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'试卷名称:${widget.examInfo['examName'] ?? ''}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'当前试题 ${current + 1}/${questions.length}',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'考试剩余时间:$_formattedTime',
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (q != null)
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${current + 1}. ${q.questionDry} (${questionTypeMap[q.questionType] ?? ''})',
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w500,
|
||
fontSize: 15,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_buildOptions(q),
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
),
|
||
)
|
||
else
|
||
const Expanded(child: SizedBox()),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
if (current > 0)
|
||
Expanded(
|
||
child: CustomButton(
|
||
text: '上一题',
|
||
backgroundColor: const Color(0xFFD7D7D7),
|
||
textColor: Colors.black54,
|
||
onPressed: _previousQuestion,
|
||
),
|
||
),
|
||
if (current > 0 && current < questions.length - 1) const SizedBox(width: 16),
|
||
if (current < questions.length - 1)
|
||
Expanded(
|
||
child: CustomButton(
|
||
text: '下一题',
|
||
backgroundColor: Colors.blue,
|
||
onPressed: _nextQuestion,
|
||
),
|
||
),
|
||
if (current == questions.length - 1)
|
||
Expanded(
|
||
child: CustomButton(
|
||
text: '交卷',
|
||
backgroundColor: Colors.blue,
|
||
onPressed: _confirmSubmit,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|