449 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			449 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
| import 'dart:async';
 | ||
| import 'package:flutter/material.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/pages/home/study/study_detail_page.dart';
 | ||
| import 'package:qhd_prevention/pages/my_appbar.dart';
 | ||
| import 'package:qhd_prevention/http/ApiService.dart';
 | ||
| import 'dart:convert';
 | ||
| 
 | ||
| import 'package:qhd_prevention/tools/tools.dart';
 | ||
| 
 | ||
| class Question {
 | ||
|   final Map<String, dynamic> rawData;
 | ||
|   final String questionDry;
 | ||
|   final String questionType;
 | ||
|   final Map<String, String> options;
 | ||
|   String checked;
 | ||
| 
 | ||
|   Question({
 | ||
|     required this.rawData,
 | ||
|     required this.questionDry,
 | ||
|     required this.questionType,
 | ||
|     required this.options,
 | ||
|     this.checked = '',
 | ||
|   });
 | ||
| 
 | ||
|   factory Question.fromJson(Map<String, dynamic> json) {
 | ||
|     final type = json['QUESTIONTYPE'] as String? ?? '1';
 | ||
|     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 raw = Map<String, dynamic>.from(json);
 | ||
|     return Question(
 | ||
|       rawData: raw,
 | ||
|       questionDry: json['QUESTIONDRY'] as String? ?? '',
 | ||
|       questionType: type,
 | ||
|       options: opts,
 | ||
|     );
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| class TakeExamPage extends StatefulWidget {
 | ||
|   const TakeExamPage({
 | ||
|     required this.examInfo,
 | ||
|     required this.examType,
 | ||
|     required this.jumpType,
 | ||
|     super.key,
 | ||
|   });
 | ||
| 
 | ||
|   final Map<String, dynamic> examInfo;
 | ||
|   final TakeExamType examType;
 | ||
|   final int jumpType;
 | ||
| 
 | ||
| 
 | ||
|   @override
 | ||
|   State<TakeExamPage> createState() => _TakeExamPageState();
 | ||
| }
 | ||
| 
 | ||
| class _TakeExamPageState extends State<TakeExamPage> {
 | ||
|   late final List<Question> questions;
 | ||
|   late final Map<String, dynamic> info;
 | ||
|   int current = 0;
 | ||
|   late int remainingSeconds;
 | ||
|   Timer? _timer;
 | ||
| 
 | ||
|   final questionTypeMap = <String, String>{
 | ||
|     '1': '单选题',
 | ||
|     '2': '多选题',
 | ||
|     '3': '判断题',
 | ||
|     '4': '填空题',
 | ||
|   };
 | ||
| 
 | ||
|   @override
 | ||
|   void initState() {
 | ||
|     super.initState();
 | ||
|     info = widget.examInfo['pd'] as Map<String, dynamic>? ?? {};
 | ||
|     final rawList = widget.examInfo['inputQue'] as List<dynamic>? ?? [];
 | ||
|     questions =
 | ||
|         rawList
 | ||
|             .map((e) => Question.fromJson(e as Map<String, dynamic>))
 | ||
|             .toList();
 | ||
| 
 | ||
|     final numberOfExams = widget.examInfo['NUMBEROFEXAMS'];
 | ||
|     WidgetsBinding.instance.addPostFrameCallback((_) async{
 | ||
|       if (numberOfExams is int) {
 | ||
|         if (numberOfExams > 0) {
 | ||
|         } else if (numberOfExams == -9999) {
 | ||
|           _showTip('强化学习考试开始,限时${info['ANSWERSHEETTIME']}分钟,请注意答题时间!');
 | ||
|         } else {
 | ||
| 
 | ||
|           final ok = await CustomAlertDialog.showConfirm(
 | ||
|             context,
 | ||
|             title: '温馨提示',
 | ||
|             content: '您无考试次数!',
 | ||
|           );
 | ||
|           if (ok) {
 | ||
|             Navigator.pop(context);
 | ||
| 
 | ||
|           };
 | ||
|         }
 | ||
|       } else if (numberOfExams is String) {
 | ||
|         if (numberOfExams == '-9999') {
 | ||
|           _showTip('强化学习考试开始,限时${info['ANSWERSHEETTIME']}分钟,请注意答题时间!');
 | ||
|         }
 | ||
|       } else {
 | ||
|         final ok = await CustomAlertDialog.showConfirm(
 | ||
|           context,
 | ||
|           title: '温馨提示',
 | ||
|           content: '您无考试次数!',
 | ||
|         );
 | ||
|         if (ok) {
 | ||
|           Navigator.pop(context);
 | ||
|         };
 | ||
|       }
 | ||
|     });
 | ||
| 
 | ||
|     final minutes = info['ANSWERSHEETTIME'] 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.checked.isEmpty) {
 | ||
|       _showTip('请对本题进行作答。');
 | ||
|       return false;
 | ||
|     }
 | ||
|     if (q.questionType == '2' && q.checked.split(',').length < 2) {
 | ||
|       _showTip('多选题最少需要选择两个答案。');
 | ||
|       return false;
 | ||
|     }
 | ||
|     return true;
 | ||
|   }
 | ||
| 
 | ||
|   void _startTimer() {
 | ||
|     _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
 | ||
|       if (remainingSeconds <= 0) {
 | ||
|         timer.cancel();
 | ||
|         _onTimeUp();
 | ||
|       } else {
 | ||
|         setState(() => remainingSeconds--);
 | ||
|       }
 | ||
|     });
 | ||
|   }
 | ||
| 
 | ||
|   void _chooseTopic(String type, String key) {
 | ||
|     final q = questions[current];
 | ||
|     if (type == 'radio' || type == 'judge') {
 | ||
|       q.checked = (q.checked == key) ? '' : key;
 | ||
|     } else {
 | ||
|       final arr = q.checked.isNotEmpty ? q.checked.split(',') : <String>[];
 | ||
|       if (arr.contains(key))
 | ||
|         arr.remove(key);
 | ||
|       else
 | ||
|         arr.add(key);
 | ||
|       arr.sort();
 | ||
|       q.checked = arr.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.checked = q.checked.replaceAll(',', '');
 | ||
|       q.rawData['checked'] = q.checked;
 | ||
|     }
 | ||
|     final data = {
 | ||
|       'STAGEEXAMPAPERINPUT_ID': widget.examInfo['STRENGTHEN_PAPER_QUESTION_ID'],
 | ||
|       'STUDENT_ID': widget.examInfo['STUDENT_ID'],
 | ||
|       'CLASS_ID': widget.examInfo['CLASS_ID'],
 | ||
|       'NUMBEROFEXAMS': widget.examInfo['NUMBEROFEXAMS'],
 | ||
|       'entrySite': widget.examType.name,
 | ||
|       'PASSSCORE': info['PASSSCORE'],
 | ||
|       'EXAMSCORE': info['EXAMSCORE'],
 | ||
|       'EXAMTIMEBEGIN': info['EXAMTIMEBEGIN'],
 | ||
|       'options': jsonEncode(questions.map((q) => q.rawData).toList()),
 | ||
|     };
 | ||
|     final res = await ApiService.submitExam(data);
 | ||
|     LoadingDialogHelper.hide();
 | ||
|     if (res['result'] == 'success') {
 | ||
|       final score = res['examScore'] ?? '0';
 | ||
|       final passed = res['examResult'] != '0';
 | ||
|       await CustomAlertDialog.showConfirm(
 | ||
|         context,
 | ||
|         title: '温馨提示',
 | ||
|         content:
 | ||
|         passed
 | ||
|             ? '您的成绩为 $score 分,恭喜您通过本次考试,请继续保持!'
 | ||
|             : '您的成绩为 $score 分,很遗憾您没有通过本次考试,请再接再厉!',
 | ||
|         cancelText: '',
 | ||
|         confirmText: '确定',
 | ||
|       );
 | ||
|       if (widget.jumpType == 2) {
 | ||
|         Navigator.pop(context);
 | ||
|       }
 | ||
|       if (widget.jumpType == 3) {
 | ||
|         Navigator.pop(context);
 | ||
|         Navigator.pop(context);
 | ||
|       }
 | ||
|       Navigator.of(context).pop();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   void _onTimeUp() {
 | ||
|     ToastUtil.showError(context, '考试时间已结束');
 | ||
|     _submit();
 | ||
|   }
 | ||
| 
 | ||
|   Widget _buildOptions(Question q) {
 | ||
|     if (q.questionType == '4') {
 | ||
|       return TextField(
 | ||
|         controller: TextEditingController(text: q.checked),
 | ||
|         onChanged: (val) => q.checked = 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.checked.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: false, // 禁用返回
 | ||
|       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(
 | ||
|                             '考试科目:${info['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: Colors.grey.shade200,
 | ||
|                         textStyle: const TextStyle(color: 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,
 | ||
|                       ),
 | ||
|                     ),
 | ||
|                 ],
 | ||
|               ),
 | ||
|             ],
 | ||
|           ),
 | ||
|         ),
 | ||
|       ),
 | ||
|     );
 | ||
|   }
 | ||
| }
 |