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,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|