diff --git a/analyze_ctrl.txt b/analyze_ctrl.txt
new file mode 100644
index 0000000..ac00337
--- /dev/null
+++ b/analyze_ctrl.txt
@@ -0,0 +1,76 @@
+Resolving dependencies...
+Downloading packages...
+ _fe_analyzer_shared 96.0.0 (108.0.0 available)
+ analyzer 10.2.0 (14.4.0 available)
+ archive 4.2.0 (4.3.0 available)
+ build 4.0.7 (4.0.11 available)
+ build_runner 2.15.1 (2.16.1 available)
+ cached_network_image 3.4.1 (4.0.2 available)
+ cached_network_image_platform_interface 4.1.1 (5.0.2 available)
+ cached_network_image_web 1.3.1 (2.0.2 available)
+ camera_android_camerax 0.7.4+7 (0.7.5 available)
+ camera_avfoundation 0.10.3 (0.11.0 available)
+ camera_platform_interface 2.13.1 (2.14.0 available)
+ camera_web 0.3.5+5 (0.3.5+6 available)
+ chewie 1.14.0 (1.17.2 available)
+ clock 1.1.2 (1.1.3 available)
+ code_assets 1.2.1 (2.1.0 available)
+ cross_file 0.3.5+5 (0.4.0 available)
+ dart_style 3.1.7 (3.1.13 available)
+ dbus 0.7.15 (0.8.0 available)
+ equatable 2.1.0 (3.0.0 available)
+ flutter_cache_manager 3.4.2 (3.4.5 available)
+ flutter_local_notifications 22.3.0 (22.3.1 available)
+ flutter_secure_storage 10.3.1 (11.2.0 available)
+ flutter_secure_storage_darwin 0.3.2 (0.4.3 available)
+ flutter_secure_storage_linux 3.0.2 (3.0.3 available)
+ flutter_secure_storage_platform_interface 2.0.3 (2.1.1 available)
+ flutter_widget_from_html_core 0.17.3 (0.17.4 available)
+ go_router 17.5.0 (18.0.1 available)
+ hooks 2.0.2 (2.2.0 available)
+ image 4.9.2 (4.10.1 available)
+ image_picker_android 0.8.13+22 (0.8.13+23 available)
+ image_picker_ios 0.8.13+7 (0.8.13+8 available)
+ internet_connection_checker_plus 3.1.1 (3.1.2 available)
+ matcher 0.12.19 (0.12.20 available)
+ material_color_utilities 0.13.0 (0.13.1 available)
+ meta 1.18.0 (1.19.0 available)
+ nm 0.5.0 (0.6.0 available)
+ objective_c 9.5.0 (9.6.0 available)
+ octo_image 2.1.0 (2.1.1 available)
+ package_config 2.2.0 (3.0.0 available)
+ package_info_plus 10.0.0 (10.2.1 available)
+ permission_handler 11.4.0 (13.0.2 available)
+ permission_handler_android 12.1.0 (14.1.0 available)
+ permission_handler_apple 9.6.1 (9.6.2 available)
+ platform 3.1.6 (3.2.0 available)
+ record_use 0.6.0 (1.1.1 available)
+ share_plus 13.2.0 (13.3.0 available)
+ source_gen 4.2.4 (4.3.0 available)
+ sqflite 2.4.3 (2.4.4 available)
+ sqflite_android 2.4.3 (2.4.4 available)
+ sqflite_common 2.5.11 (2.5.13 available)
+ sqflite_darwin 2.4.3+1 (2.4.4 available)
+ sqflite_platform_interface 2.4.1 (2.4.2 available)
+ stack_trace 1.12.1 (1.12.2 available)
+ synchronized 3.4.1+2 (3.4.2 available)
+ test_api 0.7.11 (0.7.14 available)
+ vector_math 2.2.0 (2.4.3 available)
+ wakelock_plus 1.6.0 (1.8.0 available)
+ webdriver 3.1.0 (3.2.0 available)
+ wechat_assets_picker 9.8.0 (10.1.3 available)
+Got dependencies!
+59 packages have newer versions incompatible with dependency constraints.
+Try `flutter pub outdated` for more information.
+Analyzing controllers...
+
+ info - The import of 'package:new_learn/core/utils/question/question_parser_base.dart' is unnecessary because all of the used elements are also provided by the import of 'package:new_learn/core/utils/question/question_parser.dart'. Try removing the import directive - lib\features\conquerpackage\classexercises\controllers\exercise_load.dart:16:8 - unnecessary_import
+ info - The import of 'package:new_learn/core/utils/question/question_parser_base.dart' is unnecessary because all of the used elements are also provided by the import of 'package:new_learn/core/utils/question/question_parser.dart'. Try removing the import directive - lib\features\conquerpackage\classexercises\controllers\exercise_submit.dart:23:8 - unnecessary_import
+
+flutter : 2 issues found. (ran in 3.2s)
+At line:15 char:2543
++ ... pp_Flutter; flutter analyze lib/features/conquerPackage/classExercise ...
++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ + CategoryInfo : NotSpecified: (2 issues found. (ran in 3.2s):String) [], RemoteException
+ + FullyQualifiedErrorId : NativeCommandError
+
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
index 2743973..4dcacad 100644
--- a/android/build/reports/problems/problems-report.html
+++ b/android/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
diff --git a/assets/mock/class_exercises_edit.json b/assets/mock/class_exercises_edit.json
new file mode 100644
index 0000000..06778d7
--- /dev/null
+++ b/assets/mock/class_exercises_edit.json
@@ -0,0 +1,183 @@
+[{"id":"3395139052553926","questionId":"3386370619293766","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"
听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“Where is the woman going with her children?”题干中的疑问词Where是在询问什么信息?
2. Where is the woman going with her children?
A. Japan.B. Canada.C. Australia.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":718.7429999999999,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"自创题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386370619801669","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“Where is the woman going with her children?”题干中的疑问词Where是在询问什么信息?
","correctAnswer":"","userAnswer":"B","score":5.0,"userScore":5.0,"answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553928"},{"childQuestionId":"3386370621538373","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. Where is the woman going with her children?
A. Japan.B. Canada.C. Australia.
","correctAnswer":"","userAnswer":"A","score":5.0,"userScore":5.0,"answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553930"}],"indexes":[],"notes":[]},{"id":"3395139052553932","questionId":"3386370111438918","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“What did Captain Brown usually wear inside the space station?”题干中的疑问词What是在询问什么信息?
2. What did Captain Brown usually wear inside the space station?
A. Shorts and a T-shirt.B. A spacesuit.C. A coat and trousers.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"人教版必修三教材改编题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386370111979589","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“What did Captain Brown usually wear inside the space station?”题干中的疑问词What是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553934"},{"childQuestionId":"3386370113880133","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. What did Captain Brown usually wear inside the space station?
A. Shorts and a T-shirt.B. A spacesuit.C. A coat and trousers.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553936"}],"indexes":[],"notes":[]},{"id":"3395139052553938","questionId":"3386368593657926","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“Where is Miss Jones probably now?”题干中的疑问词Where是在询问什么信息?
2. Where is Miss Jones probably now?
A. In the bank.B. In the office.C. In the travel agency.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"自创题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386368594182213","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“Where is Miss Jones probably now?”题干中的疑问词Where是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553940"},{"childQuestionId":"3386368595820613","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. Where is Miss Jones probably now?
A. In the bank.B. In the office.C. In the travel agency.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553942"}],"indexes":[],"notes":[]},{"id":"3395139052553944","questionId":"3386369097728070","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“Who is the guest today?”题干中的疑问词Who是在询问什么信息?
2. Who is the guest today?
A. Steve Fox.B. Captain Brown.C. Max.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"人教版必修三教材改编题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386369098252357","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“Who is the guest today?”题干中的疑问词Who是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553946"},{"childQuestionId":"3386369100120133","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. Who is the guest today?
A. Steve Fox.B. Captain Brown.C. Max.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553948"}],"indexes":[],"notes":[]},{"id":"3395139052553950","questionId":"3386370366013446","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“How did the astronauts wash themselves in space?”题干中的疑问词How是在询问什么信息?
2. How did the astronauts wash themselves in space?
A. By taking a shower.B. By using a soapy towel.C. By washing with floating water.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"人教版必修三教材改编题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386370366750725","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“How did the astronauts wash themselves in space?”题干中的疑问词How是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553952"},{"childQuestionId":"3386370369781765","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. How did the astronauts wash themselves in space?
A. By taking a shower.B. By using a soapy towel.C. By washing with floating water.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553954"}],"indexes":[],"notes":[]},{"id":"3395139052553956","questionId":"3386370874605574","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"
听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“Who broke the pen?”题干中的疑问词Who是在询问什么信息?
2.Who broke the pen?
A. The man's mother.B. The woman.C. The man.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"自创题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386370875359237","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“Who broke the pen?”题干中的疑问词Who是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553958"},{"childQuestionId":"3386370878177285","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"2.Who broke the pen?
A. The man's mother.B. The woman.C. The man.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553960"}],"indexes":[],"notes":[]},{"id":"3395139052553962","questionId":"3386369351237638","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段独白,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。独白读2遍。
1. 听前训练(本题无需听音频)\n“Where did Chen Liyan find the plastic bag?”题干中的疑问词Where是在询问什么信息?
2. Where did Chen Liyan find the plastic bag?
A. At a hospital.B. At a police station.C. At Taiyuan Railway Station.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"人教版必修三教材改编题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386369351991301","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“Where did Chen Liyan find the plastic bag?”题干中的疑问词Where是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553964"},{"childQuestionId":"3386369354448901","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. Where did Chen Liyan find the plastic bag?
A. At a hospital.B. At a police station.C. At Taiyuan Railway Station.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553966"}],"indexes":[],"notes":[]},{"id":"3395139052553968","questionId":"3386368845627462","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“When will the play be performed?”题干中的疑问词When是在询问什么信息?
2. When will the play be performed?
A. On October 15th.B. On October 16th.C. On October 17th.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"自创题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386368846151749","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“When will the play be performed?”题干中的疑问词When是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553970"},{"childQuestionId":"3386368847675461","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. When will the play be performed?
A. On October 15th.B. On October 16th.C. On October 17th.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553972"}],"indexes":[],"notes":[]},{"id":"3395139052553974","questionId":"3386369858633734","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段独白,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。独白读2遍。
1. 听前训练(本题无需听音频)\n“When did the boy and his family open their presents?”题干中的疑问词When是在询问什么信息?
2. When did the boy and his family open their presents?
A. During dinner.B. After dinner.C. On Christmas morning.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"人教版必修三教材改编题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386369859403781","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“When did the boy and his family open their presents?”题干中的疑问词When是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553976"},{"childQuestionId":"3386369861959685","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. When did the boy and his family open their presents?
A. During dinner.B. After dinner.C. On Christmas morning.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553978"}],"indexes":[],"notes":[]},{"id":"3395139052553980","questionId":"3386369606221894","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听下面1段对话,回答以下2个小题,从题中所给的A、B、C三个选项中选出最佳选项。对话读2遍。
1. 听前训练(本题无需听音频)\n“Why did the girl ask Li Mei to change her dress?”题干中的疑问词Why是在询问什么信息?
2. Why did the girl ask Li Mei to change her dress?
A. It was too thick for the hot weather.B. Its colour was not bright enough.C. It was too old for the festival.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":0.0,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"人教版必修三教材改编题","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3386369606746181","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. 听前训练(本题无需听音频)\n“Why did the girl ask Li Mei to change her dress?”题干中的疑问词Why是在询问什么信息?
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052553982"},{"childQuestionId":"3386369608433733","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. Why did the girl ask Li Mei to change her dress?
A. It was too thick for the hot weather.B. Its colour was not bright enough.C. It was too old for the festival.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3395139052570309"}],"indexes":[],"notes":[]},{"id":"1628832686055622","questionId":"806664578842694","questionTypeId":"805449231827078","questionTypeName":"七选五","isObjective":true,"isSelect":true,"stemIsLatex":false,"isUploadNotes":false,"stem":"In my ninth-grade writing class last year 1 , I met a cowboy who saved his town, a strict father who demanded his son earn s 2 traight A’s, and a modern-day Juliet who died of heartbreak after her parents rejected the love of her young life. More than onc 3 e, I found myself wondering just how my students, who’d cr 4 eated these people, knew their subjects so well. But things were different for their first essay, which was about the question: “Why is writing important?” Most of the essays filled less than one page, and 5 few contained a sentence that could be interpreted as a thesis (论点) statement. I was shocked. Then I realized that the problem was the question itself. They could have written pages on the necessity of computers, but writing, in and of itself, simply didn’t strike them as important. This would have to change. As a new unit started, I asked everyone to write a persuasive piece on a health-related topic of their choice. This time they found the exercise much more interesting. For the next two assignments, a personal-narrative unit followed by a creative-writing workshop, I only required that the piece meet the specifications of its genre (体裁) and that it contain a thesis. The results were staggering. The students took on diverse topics and turned in stories, 10 to 20 pages each, with characters that broadened my view and touched my heart. I walked into class believing that writing is important as a means of communication. However, my students demonstrated something more important to me. When the final bell rang in June, I walked away with a yearbook full of messages about writing’s most powerful significance — the ability to connect people, to put us in another’s skin, to teach us what it means to be human.\nWho are the people mentioned at the beginning of paragraph 。
A. Ninth graders. B. Students’ parents. C. Modern writers. D. Fictional characters. E. Fictional characters. F. Fictional characters. G. Fictional characters.
","correctAnswer":"","userAnswer":"","answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":" 贾岛初赴举在京师,一日于驴上得句云:“鸟宿池边树,僧敲月下门。”又欲“推”字,炼之未定,于驴上吟哦,引手作推敲之势,观者讶之。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":8,"planTimeSecond":0,"isWrongCollect":true,"wrongCollectId":1628837019566278,"isCollect":false,"collectId":null,"hasChild":false,"hasIndex":true,"childs":[],"indexes":[{"index":1,"userAnswer":"A","answerStatus":10},{"index":2,"userAnswer":"A","answerStatus":11},{"index":3,"userAnswer":"A","answerStatus":11},{"index":4,"userAnswer":"A","answerStatus":11},{"index":5,"userAnswer":"A","answerStatus":11}],"notes":[]},{"id":"1628832686055634","questionId":"806664527253574","questionTypeId":"792171174588486","questionTypeName":"阅读理解","isObjective":true,"isSelect":false,"stemIsLatex":false,"isUploadNotes":false,"stem":" In my ninth-grade writing class last year, I met a cowboy who saved his town, a strict father who demanded his son earn straight A’s, and a modern-day Juliet who died of heartbreak after her parents rejected the love of her young life. More than once, I found myself wondering just how my students, who’d created these people, knew their subjects so well.\nBut things were different for their first essay, which was about the question: “Why is writing important?” Most of the essays filled less than one page, and few contained a sentence that could be interpreted as a thesis (论点) statement. I was shocked. Then I realized that the problem was the question itself. They could have written pages on the necessity of computers, but writing, in and of itself, simply didn’t strike them as important. This would have to change.\nAs a new unit started, I asked everyone to write a persuasive piece on a health-related topic of their choice. This time they found the exercise much more interesting. For the next two assignments, a personal-narrative unit followed by a creative-writing workshop, I only required that the piece meet the specifications of its genre (体裁) and that it contain a thesis. The results were staggering. The students took on diverse topics and turned in stories, 10 to 20 pages each, with characters that broadened my view and touched my heart.\nI walked into class believing that writing is important as a means of communication. However, my students demonstrated something more important to me. When the final bell rang in June, I walked away with a yearbook full of messages about writing’s most powerful significance — the ability to connect people, to put us in another’s skin, to teach us what it means to be human.
【请输入第1小题题目内容】 Who are the people mentioned at the beginning of paragraph 1?
A. Ninth graders. B. Students’ parents. C. Modern writers. D. Fictional characters.
【请输入第2小题题目内容】 Why did the students perform poorly in writing their first essay?
A. They were not given enough time. B. They had a very limited vocabulary. C. They misunderstood the question. D. They had little interest in the topic.
【请输入第3小题题目内容】 What does the underlined word “staggering” in paragraph 3 mean?
A. Mixed. B. Amazing. C. Similar. D. Disturbing.
【请输入第4小题题目内容】 What does the author’s experience show?
A. Teaching is learning. B. Still waters run deep. C. Knowledge is power. D. Practice makes perfect.
","correctAnswer":"","userAnswer":"","answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":5,"planTimeSecond":0,"isWrongCollect":true,"wrongCollectId":1628837019566280,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"childs":[{"childQuestionId":"806664527265864","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 【请输入第1小题题目内容】 Who are the people mentioned at the beginning of paragraph 1?
A. Ninth graders. B. Students’ parents. C. Modern writers. D. Fictional characters.
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"细节理解题。根据第一句 “In my ninth-grade writing class last year,...” 和最后一句 “... how my students, who’d created these people...” 可知,people是作文中创作出来的人物。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055636"},{"childQuestionId":"806664527265873","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 【请输入第2小题题目内容】 Why did the students perform poorly in writing their first essay?
A. They were not given enough time. B. They had a very limited vocabulary. C. They misunderstood the question. D. They had little interest in the topic.
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"推理判断题。从 “Then I realized that the problem was the question itself.” 和 “but writing, in and of itself, simply didn’t strike them as important.” 可知,话题本身不能引起学生的重视。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055638"},{"childQuestionId":"806664527265882","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 【请输入第3小题题目内容】 What does the underlined word “staggering” in paragraph 3 mean?
A. Mixed. B. Amazing. C. Similar. D. Disturbing.
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"词义猜测题。根据 “The results were staggering.” 推测结果是什么样的,再根据后面“The students took on diverse topics and turned in stories, ... , touched my heart.”可知,这次的作文是作者满意的。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055640"},{"childQuestionId":"806664527265891","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 【请输入第4小题题目内容】 What does the author’s experience show?
A. Teaching is learning. B. Still waters run deep. C. Knowledge is power. D. Practice makes perfect.
","correctAnswer":"","userAnswer":"A","answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"主旨大意题。文章内容主要围绕教学实践中对 “writing” 的认知转变展开,描述调整教学策略后,学生写作成果的显著改善,体现教学相长中对 “沟通与共情” 的共同认知。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055642"}],"indexes":[],"notes":[]},{"id":"1628832686055644","questionId":"806636319580230","questionTypeId":"12357397088370693","questionTypeName":"完型填空","isObjective":true,"isSelect":false,"stemIsLatex":false,"isUploadNotes":false,"stem":"阅读下面短文,从每题所给的A,B,C,D四个选项中选出可以填入空白处的最佳选项。\nOn Oct. 11, hundreds of runners competed in a cross - country race in Minnesota. Melanie Bailey should have 1 the course earlier than she did. Her 2 came because she was carrying a 3 across the finish line.\nAs reported by a local newspaper, Bailey was more than two - thirds of the way through her 4 when a runner in front of her began crying in pain. She 5 to help her fellow runner, Danielle Lenoue. Bailey took her arm to see if she could walk forward with 6 . She couldn’t. Bailey then 7 to let Lenoue climb onto her back and carried her all the way to the finish line, then another 300 feet to where Lenoue could get 8 attention.\nOnce there, Lenoue was 9 and later taken to a hospital, where she learned that she had serious injuries in one of her knees. She would have struggled with extreme 10 to make it to that aid checkpoint without Bailey’s help.\nAs for Bailey, she is more 11 about why her act is considered a big 12 . “She was just crying. I couldn’t 13 her,” Bailey told the reporter. “I feel like I was just doing the right thing.”\nAlthough the two young women were strangers before the 14 , they’ve since become friends. Neither won the race, but the 15 of human kindness won the day.
1. A. designed B. followed C. changed D. Finished
2. A. delay B. chance C. trouble D. excuse
3. A. judge B. volunteer C. classmate D. competitor
4. A. race B. school C. town D. training
5. A. agreed B. returned C. stopped D. promised
6. A. courage B. aid C. patience D. advice
7. A. went away B. stood up C. stepped aside D. bent down
8. A. medical B. public C. constant D. equal
9. A. interrupted B. assessed C. identified D. appreciated
10. A. hunger B. pain C. cold D. tiredness
11. A. worried B. ashamed C. confused D. discouraged
12. A. game B. problem C. lesson D. deal
13. A. leave B. cure C. bother D. understand
14. A. ride B. test C. meet D. show
15. A. secret B. display C. benefit D. exchange
","correctAnswer":"","userAnswer":"","answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":22,"planTimeSecond":0,"isWrongCollect":true,"wrongCollectId":1628837019566282,"isCollect":true,"collectId":1627954765308037,"hasChild":true,"hasIndex":false,"childs":[{"childQuestionId":"806636319600709","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 1. A. designed B. followed C. changed D. Finished
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:should have done(虚拟语气)表示“本应该做某事但实际未成”。“the course”指赛程。后文其“延迟(delay)”的原因就是没有更早“完成”比赛。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055646"},{"childQuestionId":"806636319662151","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 2. A. delay B. chance C. trouble D. excuse
","correctAnswer":"","userAnswer":"A","answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"上下文复现:前句“earlier than she did”(比她实际完成得早)已暗示“延误”。后文“because she was carrying...”直接解释了“延误”的原因。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055648"},{"childQuestionId":"806636319662160","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 3. A. judge B. volunteer C. classmate D. competitor
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"上下文复现:上文“hundreds of runners competed”中的“competed”是解题关键。“competitor”是其同根词,指“竞争者、对手”。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055650"},{"childQuestionId":"806636319662169","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 4. A. race B. school C. town D. training
","correctAnswer":"","userAnswer":"A","answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"上下文复现:文章首句“...competed in a cross-country race”已点明事件。此处“through her race”是原词复现,指在她的比赛过程中。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055652"},{"childQuestionId":"806636319662178","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 5. A. agreed B. returned C. stopped D. promised
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:看到他人哭泣疼痛,自然先“停下(stopped)”脚步去查看并提供帮助。“stopped to help”表示“停下来去帮助”。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055654"},{"childQuestionId":"806636319662187","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 6. A. courage B. aid C. patience D. advice
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"上下文复现:前句“to help”已表明目的是提供“帮助”。此处“with aid”是“帮助(help)”的同义复现,指在她的帮助下行走。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055656"},{"childQuestionId":"806636319662196","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 7. A. went away B. stood up C. stepped aside D. bent down
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:要让别人爬到背上,自己必须先“弯下腰(bent down)”。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055658"},{"childQuestionId":"806636319662205","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 8. A. medical B. public C. constant D. equal
","correctAnswer":"","userAnswer":"A","answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:前文提到 runner “crying in pain”(因疼痛哭泣),且后被送往医院,可知需要的是“医疗救助(medical attention)”。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055660"},{"childQuestionId":"806636319666251","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 9. A. interrupted B. assessed C. identified D. appreciated
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:送到医疗点后,正常的程序是先“检查、评估(assessed)”伤情,根据情况再决定后续处理(如送医)。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055662"},{"childQuestionId":"806636319666260","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 10. A. hunger B. pain C. cold D. tiredness
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"上下文复现:前文多次提及“crying in pain”、“serious injuries”,疼痛(pain)是贯穿伤者状态的主线。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055664"},{"childQuestionId":"806636319666269","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 11. A. worried B. ashamed C. confused D. discouraged
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:后文Bailey的直接引语“I feel like I was just doing the right thing”体现她认为这是很自然的事,因此她对人们认为其行为是件“大事(a big deal)”感到“困惑(confused)”。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055666"},{"childQuestionId":"806636319666278","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 12. A. game B. problem C. lesson D. deal
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"词义辨析与固定搭配:a big deal是固定短语,意为“重要的事”、“了不起的事”,与前文她的困惑相呼应。
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055668"},{"childQuestionId":"806636319666287","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 13. A. leave B. cure C. bother D. understand
","correctAnswer":"","userAnswer":"A","answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"逻辑关系与上下文推理:Bailey说“She was just crying. I couldn't ___ her.” 面对他人的痛苦和哭泣,根据其善良的本性,最合理的解释是“我不能离开她(leave her)”
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055670"},{"childQuestionId":"806636319666296","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 14. A. ride B. test C. meet D. show
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"词义辨析与固定搭配:meet常见意为“遇见”,但在体育赛事语境中,可指“比赛、运动会”(如 sports meet)。此处“before the meet”即“在那场比赛之前”
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055672"},{"childQuestionId":"806636319670342","questionTypeId":"0","questionTypeName":"","isObjective":true,"stemIsLatex":false,"stem":" 15. A. secret B. display C. benefit D. exchange
","correctAnswer":"","userAnswer":"A","answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"词义辨析与固定搭配:文章主旨是颂扬这种善举的“展现”(display)本身,而非其带来的“秘密”(secret)或“利益”(benefit)。“display of human kindness”指“人性善良的展现”.
","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"1628832686055674"}],"indexes":[],"notes":[]},
+ {
+"id": "1630968201887944",
+"questionId": "1624485218476102",
+"questionTypeId": "12352069399805957",
+"questionTypeName": "解答题",
+"isObjective": false,
+"isSelect": false,
+"stemIsLatex": false,
+"stem": "下列量中哪些是向量?\n悬挂物受到的拉力,压强,摩擦力,频率,加速度.
",
+"correctAnswer": "",
+"userAnswer": "",
+"answerStatus": 0,
+"answerAnalysisIsLatex": false,
+"answerAnalysis": "",
+"answerAnalysisVideo": {},
+"answerTimeSecond": 1,
+"planTimeSecond": 360,
+"isWrongCollect": false,
+"wrongCollectId": null,
+"isCollect": false,
+"collectId": null,
+"hasChild": false,
+"hasIndex": false,
+"childs": [],
+"indexes": [],
+"notes": []
+}
+ ,
+ {
+ "id": "1631580884754630",
+ "questionId": "1630062914486278",
+ "questionTypeId": "12352069399805957",
+ "questionTypeName": "解答题",
+ "isObjective": false,
+ "isSelect": false,
+ "stemIsLatex": false,
+ "isUploadNotes": false,
+ "stem": "如图所示(可想象): 一个质量为 m = 2 kg m = 2 kg 的物体放在水平地面上,物体与地面间的动摩擦因数为 μ = 0.3 μ = 0.3 。现对物体施加一个与水平方向成 37 ∘ 3 7 ∘ 斜向上的拉力 F = 10 N F = 10 N 。(取 g = 10 m/s 2 g = 10 m/s 2 , sin 37 ∘ = 0.6 sin 3 7 ∘ = 0.6 , cos 37 ∘ = 0.8 cos 3 7 ∘ = 0.8 )
求:
【请输入第1小题题目内容】
(1)物体受到的摩擦力大小;
【请输入第2小题题目内容】
(2)物体的加速度大小。
",
+ "correctAnswer": "",
+ "userAnswer": "",
+ "answerStatus": 0,
+ "answerAnalysisIsLatex": false,
+ "answerAnalysis": "",
+ "answerAnalysisVideo": {},
+ "answerTimeSecond": 0,
+ "planTimeSecond": 2,
+ "isWrongCollect": false,
+ "wrongCollectId": null,
+ "isCollect": false,
+ "collectId": null,
+ "hasChild": true,
+ "hasIndex": false,
+ "childs": [],
+ "indexes": [],
+ "notes": []
+ }
+ ,
+ {
+"id": "1630870489718920",
+"questionId": "1623132402377030",
+"questionTypeId": "12352067804725253",
+"questionTypeName": "填空题",
+"isObjective": false,
+"isSelect": false,
+"stemIsLatex": false,
+"stem": "某种产品的原料提价,因而厂家决定对产品进行提价,现有三种方案:\n(1) 第一次提价 \\(p \\%\\) ,第二次提价 \\(q \\%\\) ;\n(2) 第一次提价 \\(q \\%\\) ,第二次提价 \\(p \\%\\) ;\n(3) 第一、二次提价均为 \\(\\frac{p+q}{2} \\%\\) .\n其中 \\(p\\) , \\(q\\) 是不相等的正数.三种方案中,提价最多的是方案 1 (填序号).
",
+"correctAnswer": "",
+"userAnswer": "",
+"answerStatus": 0,
+"answerAnalysisIsLatex": false,
+"answerAnalysis": "",
+"answerAnalysisVideo": {
+"videoName": "",
+"videoUrl": "",
+"videoCoverUrl": "",
+"htmlId": ""
+},
+"answerTimeSecond": 0,
+"planTimeSecond": 0,
+"isWrongCollect": false,
+"wrongCollectId": null,
+"isCollect": false,
+"collectId": null,
+"hasChild": false,
+"hasIndex": true,
+"childs": [],
+"indexes": [
+{
+"index": 1,
+"userAnswer": "[{\"ThumbnailUrl\":\"https://hx.cnzhyx.cn/AIGrading/2026-06-13/tmp_48db1f8a6c855245944c3530563e0074.png\",\"Url\":\"https://hx.cnzhyx.cn/AIGrading/2026-06-13/tmp_48db1f8a6c855245944c3530563e0074.png\",\"Type\":1}]",
+"answerStatus": 10
+}
+],
+"notes": []
+}
+ ,{
+"id": "1628631605723152",
+"questionId": "806614179516486",
+"questionTypeId": "12352067804725253",
+"questionTypeName": "填空题",
+"isObjective": false,
+"isSelect": false,
+"stemIsLatex": false,
+"isUploadNotes": false,
+"stem": "阅读下面短文,在空白处填入1个适当的单词或括号内单词的正确形式。\n I was born and raised in Cleveland, Ohio in the United States. Yet now, I live in the countryside of Zhejiang, China with my Chinese husband and his family, 1 bamboo and tea bushes (灌木) grow wild in the mountains, chickens are always free-range, and 2 (center) heating doesn’t exist.\n Nothing in my life before prepared me 3 this one-and to be sure, the first time I came here I never imagined I would ever feel comfortable in this area. But it’s amazing how you can adapt 4 learn in a new environment. Over time, I’ve found 5 (I) feeling extremely at home here. And in the process, I’ve experienced things that really surprise me at times. The “sunshine scent (香味)” of freshly sunned clothes 6 (be) one of them.\n Growing up, my family and our neighbors never used clotheslines to dry clothing. denying me the chance 7 (discover) one of the great wonders of sunshine — the sweet “sunshine scent” after sunning clothes for an entire day. The sun-dried clothes smell especially pleasant where I live, thanks to the 8 (absent) of smog and plenty of blue sky 9 (afternoon) with lots of fresh air.\n If you’ve never experienced the “sunshine scent” from a sheet or shirt 10 (leave) to sun for a day, well, you’re missing out on one of life’s wonders.
",
+"correctAnswer": "
",
+"userAnswer": "",
+"answerStatus": 0,
+"answerAnalysisIsLatex": false,
+"answerAnalysis": "",
+"answerAnalysisVideo": {
+"videoName": "",
+"videoUrl": "",
+"videoCoverUrl": "",
+"htmlId": ""
+},
+"answerTimeSecond": 0,
+"planTimeSecond": 0,
+"isWrongCollect": false,
+"wrongCollectId": null,
+"isCollect": false,
+"collectId": null,
+"hasChild": false,
+"hasIndex": true,
+"childs": [],
+"indexes": [
+{
+"index": 1,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 2,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 3,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 4,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 5,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 6,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 7,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 8,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 9,
+"userAnswer": "",
+"answerStatus": 0
+},
+{
+"index": 10,
+"userAnswer": "",
+"answerStatus": 0
+}
+],
+"notes": []
+ },
+ {"id":"3329845433319568","questionId":"3327023041069254","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"","correctAnswer":"","userAnswer":"","score":5,"userScore":0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":762.264,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3327023042101445","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"","correctAnswer":"","userAnswer":"","score":5,"userScore":0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3329845433319570"}],"indexes":[],"notes":[]},
+ {"id":"3329845433319568","questionId":"3327023041069254","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"","correctAnswer":"","userAnswer":"","score":5,"userScore":0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":762.264,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3327023042101445","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"","correctAnswer":"","userAnswer":"","score":5,"userScore":0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3329845433319570"}],"indexes":[],"notes":[]}
+ ,{"id":"3405044049118150","questionId":"3390814745051206","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听这段材料,综合信息,确定听力任务,回答下面2个小题。
1. When does the bank close at weekdays?
A. At 3 pm.B. At 4:30 pm.C. At 5 pm.
2. What does the man probably want to do?
A. Cancel a credit card.B. Open a bank account.C. Withdraw some money.
","correctAnswer":"","userAnswer":"","score":10.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":157.824,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3390814745641029","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. When does the bank close at weekdays?
A. At 3 pm.B. At 4:30 pm.C. At 5 pm.
","correctAnswer":"","userAnswer":"A","score":5.0,"userScore":0.0,"answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118152"},{"childQuestionId":"3390814747639877","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"2. What does the man probably want to do?
A. Cancel a credit card.B. Open a bank account.C. Withdraw some money.
","correctAnswer":"","userAnswer":"B","score":5.0,"userScore":5.0,"answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118154"}],"indexes":[],"notes":[]},{"id":"3405044049118156","questionId":"3390815258787846","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"
听这段材料,综合信息,确定听力任务,回答下面3个小题。
1. What exam is the woman worried about?
A. The chemistry exam.B. The math exam.C. The history exam.
2. What is the man poor at doing?
A. Studying for exams.B. Finishing projects.C. Reciting essays.
3. What percentage of the grade depends on the examination results?
","correctAnswer":"","userAnswer":"","score":15.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":154.91099999999994,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3390815259459589","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. What exam is the woman worried about?
A. The chemistry exam.B. The math exam.C. The history exam.
","correctAnswer":"","userAnswer":"A","score":5.0,"userScore":0.0,"answerStatus":11,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118158"},{"childQuestionId":"3390815262228485","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"2. What is the man poor at doing?
A. Studying for exams.B. Finishing projects.C. Reciting essays.
","correctAnswer":"","userAnswer":"B","score":5.0,"userScore":5.0,"answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118160"},{"childQuestionId":"3390815264735237","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"3. What percentage of the grade depends on the examination results?
","correctAnswer":"","userAnswer":"C","score":5.0,"userScore":5.0,"answerStatus":10,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118162"}],"indexes":[],"notes":[]},{"id":"3405044049118164","questionId":"3390815002165318","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"
听这段材料,综合信息,确定听力任务,回答下面3个小题。
1. Why does the woman exercise?
A. To lose weight.B. To be healthy.C. To be strong.
2. What sport does the man usually do?
A. Running.B. Swimming.C. Weightlifting.
3. Where probably are the speakers?
A. In a gym.B. In an office.C. In a canteen.
","correctAnswer":"","userAnswer":"","score":15.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":150.248,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3390815002689605","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"1. Why does the woman exercise?
A. To lose weight.B. To be healthy.C. To be strong.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118166"},{"childQuestionId":"3390815005081669","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"2. What sport does the man usually do?
A. Running.B. Swimming.C. Weightlifting.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118168"},{"childQuestionId":"3390815006867525","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"3. Where probably are the speakers?
A. In a gym.B. In an office.C. In a canteen.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118170"}],"indexes":[],"notes":[]},{"id":"3405044049118172","questionId":"3390814488346630","questionTypeId":"12357387196366853","questionTypeName":"听力","questionTypeCode":"9","isObjective":true,"isSelect":false,"isUploadNotes":false,"stem":"听3段材料,综合信息,确定听力任务,回答下面3个小题。
1. What will the speakers probably do on Saturday?
A. Go to a book sale.B. Borrow some books.C. Visit a children’s park.
2. What is the man going to do first?
A. Pick up his son.B. Attend a meeting.C. Work on a report.
3. What does the woman suggest the man do?
A. Avoid washing clothes at night.B. Get all the parts from Japan.C. Buy a new washing machine.
","correctAnswer":"","userAnswer":"","score":15.0,"userScore":0.0,"answerStatus":0,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"answerTimeSecond":40.303,"planTimeSecond":0,"isWrongCollect":false,"wrongCollectId":null,"isCollect":false,"collectId":null,"hasChild":true,"hasIndex":false,"source":"","wrongReason":"","wrongAdvice":"","childs":[{"childQuestionId":"3390814489051141","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
1. What will the speakers probably do on Saturday?
A. Go to a book sale.B. Borrow some books.C. Visit a children’s park.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118174"},{"childQuestionId":"3390814491394053","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
2. What is the man going to do first?
A. Pick up his son.B. Attend a meeting.C. Work on a report.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118176"},{"childQuestionId":"3390814494130181","questionTypeId":"12352055339057157","questionTypeName":"单选题","questionTypeCode":"1","isObjective":true,"stem":"
3. What does the woman suggest the man do?
A. Avoid washing clothes at night.B. Get all the parts from Japan.C. Buy a new washing machine.
","correctAnswer":"","userAnswer":"","score":5.0,"userScore":0.0,"answerStatus":0,"answerAnalysisIsLatex":false,"answerAnalysis":"","answerAnalysisVideo":{"videoName":"","videoUrl":"","videoCoverUrl":"","htmlId":""},"id":"3405044049118178"}],"indexes":[],"notes":[]}]
\ No newline at end of file
diff --git a/lib/core/api/auth/api.dart b/lib/core/api/auth/api.dart
index 08f6328..666f3c9 100644
--- a/lib/core/api/auth/api.dart
+++ b/lib/core/api/auth/api.dart
@@ -20,8 +20,7 @@ import 'package:new_learn/core/storage/user_storage.dart';
/// updateUserAvatar → 更改用户头像
///
/// 二、后续继续写(对齐 Vue,用到再加):
-/// checkStudyPackStatus / setChangeUserOnline
-/// getUserPointLevel / UpdateGuideStatus / UpdateReportStatus
+/// checkStudyPackStatus / setChangeUserOnline / getUserPointLevel
///
/// 三、高频坑:
/// 1. 登录密码 MD5 加密(对齐 Vue stringToMd5),提交前在 Api 层完成;
@@ -157,6 +156,15 @@ class UserApi {
await DioClient.instance.post('/api/v1/User/ChangeShowAfterReport');
}
+ /// 更新攻克报告引导状态(报告页引导只出现一次,默认 false,触发后变 true)
+ /// → POST /api/v1/User/ChangeShowGuide
+ ///
+ /// 对齐 Vue api/user/index.ts 的 UpdateGuideStatus(),
+ /// 在攻克报告页 GuideModal 关闭时调用。
+ static Future changeShowGuide() async {
+ await DioClient.instance.post('/api/v1/User/ChangeShowGuide');
+ }
+
/// MD5 加密(UniApp stringToMd5 的等价实现)
static String _md5(String text) {
return md5.convert(utf8.encode(text)).toString();
diff --git a/lib/core/api/conquerPackage/captureList/model.dart b/lib/core/api/conquerPackage/captureList/model.dart
index 8232622..f3f3f5b 100644
--- a/lib/core/api/conquerPackage/captureList/model.dart
+++ b/lib/core/api/conquerPackage/captureList/model.dart
@@ -16,6 +16,7 @@ class CourseRecord {
this.accuracyRate = 0,
this.status = 0,
this.statusName = '',
+ this.overcomeTimeSecond = 0,
});
final int id; // 记录 id
@@ -23,6 +24,7 @@ class CourseRecord {
final int accuracyRate; // 正确率(0-100)
final int status; // 状态:0 未开始 / 1 进行中 / 2 成功 / 3 失败 等(按后端枚举)
final String statusName; // 状态名称
+ final int overcomeTimeSecond; // 攻克用时(秒)
factory CourseRecord.fromJson(Map json) {
return CourseRecord(
@@ -31,6 +33,7 @@ class CourseRecord {
accuracyRate: _parseInt(json['accuracyRate']),
status: _parseInt(json['status']),
statusName: json['statusName'] as String? ?? '',
+ overcomeTimeSecond: _parseInt(json['overcomeTimeSecond']),
);
}
}
diff --git a/lib/core/api/conquerPackage/classExercises/api.dart b/lib/core/api/conquerPackage/classExercises/api.dart
new file mode 100644
index 0000000..74e207c
--- /dev/null
+++ b/lib/core/api/conquerPackage/classExercises/api.dart
@@ -0,0 +1,107 @@
+import 'package:new_learn/core/api/conquerPackage/classExercises/model.dart';
+import 'package:new_learn/core/network/dio_client.dart';
+
+/// ============================================================
+/// 【课堂习题模块接口】
+///
+/// 对标 Vue 端 api/task/index.ts、api/userCollect/index.ts:
+///
+/// GetRecordData GET /api/v1/StudyPack/GetRecord/{recordId}
+/// GetListByRecordQuestion GET /api/v1/Question/GetListByRecord/{recordId}
+/// QuestionRenewTime POST /api/v1/Question/RenewTime
+/// PushAIGrading POST /api/v1/Question/PushAIGrading
+/// RecordSubmit POST /api/v1/StudyPack/RecordSubmit
+/// GetSubmitReport GET /api/v1/StudyPack/GetReport/{packId}(60s 超时)
+/// AddUserCollect POST /api/v1/UserCollect/AddUserCollect
+/// CancleCollect POST /api/v1/UserCollect/CancleCollect
+/// Feedback POST /api/v1/Question/Feedback
+///
+/// 说明:
+/// - RenewTime / PushAIGrading / RecordSubmit 不脱壳,返回原始 data,
+/// 由提交控制器读取 code / message / aiGradingCount / id;
+/// - 异常统一抛 ApiException,交页面/控制器处理。
+/// ============================================================
+class ClassExercisesApi {
+ ClassExercisesApi._();
+
+ static final ClassExercisesApi instance = ClassExercisesApi._();
+
+ /// 获取学习记录信息(edit 模式 onLoad 第一步)
+ Future getRecordData(String recordId) async {
+ final data = await DioClient.instance.get(
+ '/api/v1/StudyPack/GetRecord/$recordId',
+ );
+ return ClassExercisesRecord.fromJson(data);
+ }
+
+ /// 根据学习记录获取题目列表
+ Future getListByRecordQuestion(
+ String recordId,
+ ) async {
+ final data = await DioClient.instance.get(
+ '/api/v1/Question/GetListByRecord/$recordId',
+ );
+ return ClassExercisesQuestionList.fromJson(data);
+ }
+
+ /// 提交单题作答 / 更新作答时长
+ /// body: {id, childId, courseId, userAnswer, startTime, endTime,
+ /// answerStatus, index?}
+ /// 返回原始响应(提交失败重试队列会读 requestParams/data 兜底)
+ Future questionRenewTime(Map body) {
+ return DioClient.instance.post('/api/v1/Question/RenewTime', data: body);
+ }
+
+ /// 提交 AI 图片批改
+ /// body: {id, childId, courseId, startTime, endTime,
+ /// answerImgs:[{url,thumbnailUrl}]}
+ Future pushAIGrading(Map body) {
+ return DioClient.instance.post(
+ '/api/v1/Question/PushAIGrading',
+ data: body,
+ );
+ }
+
+ /// 交卷
+ /// body: {recordId};返回 {code,message,aiGradingCount}
+ Future recordSubmit(String recordId) {
+ return DioClient.instance.post(
+ '/api/v1/StudyPack/RecordSubmit',
+ data: {'recordId': recordId},
+ );
+ }
+
+ /// 获取攻克报告(view 模式数据源,题目批改可能耗时,超时 60s)
+ Future getSubmitReport(String packId) async {
+ final data = await DioClient.instance.get(
+ '/api/v1/StudyPack/GetReport/$packId',
+ receiveTimeout: const Duration(seconds: 60),
+ );
+ return ClassExercisesReport.fromJson(data);
+ }
+
+ /// 添加收藏 / 错题本
+ /// body: {type, packId, questionId, recordQuestionId}
+ /// 返回原始响应(成功时 res.id 为收藏记录 id)
+ Future addUserCollect(Map body) {
+ return DioClient.instance.post(
+ '/api/v1/UserCollect/AddUserCollect',
+ data: body,
+ );
+ }
+
+ /// 取消收藏 / 移出错题本
+ /// body: {type, id}
+ Future cancleCollect(Map body) {
+ return DioClient.instance.post(
+ '/api/v1/UserCollect/CancleCollect',
+ data: body,
+ );
+ }
+
+ /// 题目反馈
+ /// body: {id, questionId, feedbackTypes, description, images}
+ Future feedback(Map body) {
+ return DioClient.instance.post('/api/v1/Question/Feedback', data: body);
+ }
+}
diff --git a/lib/core/api/conquerPackage/classExercises/model.dart b/lib/core/api/conquerPackage/classExercises/model.dart
new file mode 100644
index 0000000..33ba86b
--- /dev/null
+++ b/lib/core/api/conquerPackage/classExercises/model.dart
@@ -0,0 +1,127 @@
+/// ============================================================
+/// 【课堂习题模块模型】接口响应壳解析
+///
+/// 对标 Vue 端:
+/// api/task/index.ts → GetRecordData / GetListByRecordQuestion
+/// / GetSubmitReport / RecordSubmit
+/// api/question/index.ts → RenewTime / PushAIGrading / Feedback
+///
+/// 说明:
+/// 题目字段非常多且解析器(questionParser)会动态增删字段,
+/// 题目列表统一以 `Map` 承载,业务侧再包 QuestionModel,
+/// 全量保留后端原字段名,不做字段裁剪。
+/// ============================================================
+library;
+
+/// 从响应中取业务 payload(兼容 {code,message,data:{...}} 脱壳)
+Map unwrapData(dynamic resp) {
+ if (resp is Map) {
+ final data = resp['data'];
+ if (data is Map) return data;
+ return resp;
+ }
+ return {};
+}
+
+/// GET StudyPack/GetRecord/{recordId} 响应
+class ClassExercisesRecord {
+ const ClassExercisesRecord({
+ this.recordId = '',
+ this.name = '',
+ this.baseSubjectId,
+ this.raw = const {},
+ });
+
+ /// 学习记录 id
+ final String recordId;
+
+ /// 攻克包名称(用于 NavBar 标题:`${name}专项智能攻克`)
+ final String name;
+
+ /// 学科 id(决定 iSFilllankItemUpload:0 输入框 / 1 图片作答)
+ final int? baseSubjectId;
+
+ final Map raw;
+
+ factory ClassExercisesRecord.fromJson(dynamic json) {
+ final data = unwrapData(json);
+ return ClassExercisesRecord(
+ recordId:
+ data['recordId']?.toString() ?? data['id']?.toString() ?? '',
+ name: data['name']?.toString() ?? '',
+ baseSubjectId:
+ int.tryParse('${data['baseSubjectId'] ?? ''}'),
+ raw: data,
+ );
+ }
+}
+
+/// GET Question/GetListByRecord/{recordId} 响应
+class ClassExercisesQuestionList {
+ const ClassExercisesQuestionList({
+ this.items = const [],
+ this.raw = const {},
+ });
+
+ /// 原始题目列表(字段全量保留,交给 parseQuestionData 处理)
+ final List> items;
+
+ final Map raw;
+
+ factory ClassExercisesQuestionList.fromJson(dynamic json) {
+ final data = unwrapData(json);
+ final rawList = data['questions'] ?? data['list'] ?? data['data'];
+ final list = >[];
+ if (rawList is List) {
+ for (final item in rawList) {
+ if (item is Map) {
+ list.add(Map.from(item));
+ }
+ }
+ }
+ return ClassExercisesQuestionList(items: list, raw: data);
+ }
+}
+
+/// GET StudyPack/GetReport/{packId} 响应(view 模式数据源)
+class ClassExercisesReport {
+ const ClassExercisesReport({
+ this.id = '',
+ this.name = '',
+ this.baseSubjectId,
+ this.questions = const [],
+ this.raw = const {},
+ });
+
+ /// 报告/记录 id(view 模式下作为 recordId 使用)
+ final String id;
+
+ final String name;
+
+ final int? baseSubjectId;
+
+ /// 报告内题目列表(字段全量保留)
+ final List> questions;
+
+ final Map raw;
+
+ factory ClassExercisesReport.fromJson(dynamic json) {
+ final data = unwrapData(json);
+ final rawList = data['questions'];
+ final list = >[];
+ if (rawList is List) {
+ for (final item in rawList) {
+ if (item is Map) {
+ list.add(Map.from(item));
+ }
+ }
+ }
+ return ClassExercisesReport(
+ id: data['id']?.toString() ?? data['recordId']?.toString() ?? '',
+ name: data['name']?.toString() ?? '',
+ baseSubjectId: int.tryParse('${data['baseSubjectId'] ?? ''}'),
+ questions: list,
+ raw: data,
+ );
+ }
+}
diff --git a/lib/core/components/audio_player_card.dart b/lib/core/components/audio_player_card.dart
index e37c10d..aa25b8c 100644
--- a/lib/core/components/audio_player_card.dart
+++ b/lib/core/components/audio_player_card.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
+import 'media_pause_scope.dart';
/// ============================================================
/// 【AudioPlayerCard】通用音频播放控制条(在线音频)
@@ -43,7 +44,7 @@ import 'package:just_audio/just_audio.dart';
/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新
/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统
/// 缓存时会看似正常,极易误判)。验收新音频源:
-/// curl -s -D - -o NUL -H "Range: bytes=0-1023" 看状态码。
+/// curl -s -D - -o NUL -H "Range: bytes=0-1023" `` 看状态码。
/// ============================================================
class AudioPlayerCard extends StatefulWidget {
const AudioPlayerCard({
@@ -88,10 +89,14 @@ class AudioPlayerCard extends StatefulWidget {
}
class AudioPlayerCardState extends State
- with WidgetsBindingObserver {
+ with WidgetsBindingObserver
+ implements MediaPausable {
/// 底层播放器(just_audio)
final AudioPlayer _player = AudioPlayer();
+ /// 所属媒体暂停作用域(随挂载位置自动注册/注销)
+ MediaPauseRegistry? _pauseScope;
+
/// 是否正在加载(setUrl 未完成)
bool _loading = true;
@@ -115,15 +120,29 @@ class AudioPlayerCardState extends State
_initPlayer();
}
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ // 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
+ final scope = MediaPauseScope.of(context);
+ if (scope != _pauseScope) {
+ _pauseScope?.unregister(this);
+ _pauseScope = scope;
+ _pauseScope?.register(this);
+ }
+ }
+
@override
void dispose() {
+ _pauseScope?.unregister(this);
WidgetsBinding.instance.removeObserver(this);
// 必须释放播放器,否则音频解码资源泄漏
_player.dispose();
super.dispose();
}
- /// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
+ /// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
+ @override
void pause() {
_player.pause();
}
@@ -403,19 +422,11 @@ class AudioPlayerCardState extends State
);
}
- /// 标题区:标题 + 地址/缓冲提示小字
+ /// 标题区:标题 + 缓冲提示小字(不展示音频源地址)
Widget _buildTitle({required bool buffering, required int percent}) {
- // 小字:缓冲中显示百分比,否则显示源地址
- final String subText;
- if (buffering) {
- subText = '缓冲中 $percent%...';
- } else if (widget.asset != null) {
- subText = widget.asset!;
- } else {
- subText = widget.url ?? '';
- }
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title ?? (widget.asset != null ? '本地音频' : '音频'),
@@ -427,16 +438,19 @@ class AudioPlayerCardState extends State
color: Color(0xFF333333),
),
),
- const SizedBox(height: 2),
- Text(
- subText,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: TextStyle(
- fontSize: 11,
- color: buffering ? const Color(0xFFF59E0B) : const Color(0xFF999999),
+ // 仅缓冲中展示百分比小字,不展示播放链接 URL
+ if (buffering) ...[
+ const SizedBox(height: 2),
+ Text(
+ '缓冲中 $percent%...',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 11,
+ color: Color(0xFFF59E0B),
+ ),
),
- ),
+ ],
],
);
}
diff --git a/lib/core/components/media_pause_scope.dart b/lib/core/components/media_pause_scope.dart
new file mode 100644
index 0000000..e106f4a
--- /dev/null
+++ b/lib/core/components/media_pause_scope.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/widgets.dart';
+
+/// 可被外部暂停的媒体(音频/视频播放器 State 实现)
+abstract class MediaPausable {
+ void pause();
+}
+
+/// 媒体暂停注册表:播放器挂载时注册、卸载时注销,
+/// 暂停时直接遍历注册表,不再运行时递归 visitChildElements,
+/// 避免在 build/翻页动画/element 停用阶段遍历 element 树
+/// 导致的生命周期断言(RenderViewport 收到 RenderErrorBox 等连锁报错)。
+class MediaPauseRegistry {
+ final Set _items = {};
+
+ void register(MediaPausable item) => _items.add(item);
+
+ void unregister(MediaPausable item) => _items.remove(item);
+
+ /// 暂停注册表里的全部媒体。
+ /// 复制一份再遍历,防止 pause 触发的异步卸载修改集合。
+ void pauseAll() {
+ for (final item in _items.toList(growable: false)) {
+ item.pause();
+ }
+ }
+}
+
+/// 媒体暂停作用域:包裹需要统一暂停媒体的区域(如单个题目页/子题页)。
+/// 子树中的 AudioPlayerCard/VideoPlayerView 会自动注册到最近的作用域。
+class MediaPauseScope extends InheritedWidget {
+ final MediaPauseRegistry registry;
+
+ const MediaPauseScope({
+ super.key,
+ required this.registry,
+ required super.child,
+ });
+
+ /// 查找最近的作用域注册表;不存在返回 null(不建立依赖关系)。
+ static MediaPauseRegistry? of(BuildContext context) {
+ final widget = context
+ .getElementForInheritedWidgetOfExactType()
+ ?.widget;
+ return widget is MediaPauseScope ? widget.registry : null;
+ }
+
+ @override
+ bool updateShouldNotify(MediaPauseScope oldWidget) =>
+ registry != oldWidget.registry;
+}
diff --git a/lib/core/components/rich_text_content.dart b/lib/core/components/rich_text_content.dart
index d7f7b7e..fcb7fd2 100644
--- a/lib/core/components/rich_text_content.dart
+++ b/lib/core/components/rich_text_content.dart
@@ -22,6 +22,8 @@ import 'package:new_learn/core/components/video_player_view.dart';
/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮)
/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget)
/// renderMode ← 渲染模式(默认 column;列表预览可用 listView)
+/// debugImage ← 是否打印 img 解析日志
+/// assumeRpx ← HTML 里的 width/height 是否按 rpx 处理
///
/// 三、内置扩展点(本组件已经帮你处理的):
/// 1. 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条)
@@ -29,7 +31,12 @@ import 'package:new_learn/core/components/video_player_view.dart';
/// 2. 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器,
/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里
/// 多个视频同时解码占内存),取 title 属性作为错误提示;
-/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。
+/// 3. 标签 → 统一接管:
+/// - .svg 走 flutter_svg(带 SSAA 超采样,去锯齿);
+/// - 位图走 Image.network;
+/// - 公式图(有明确 width/height,或 parent 是行内标签)
+/// 用 InlineCustomWidget,按原始尺寸渲染,不缩放;
+/// - 块级大图宽度撑满父容器,高度按比例自适应。
///
/// 四、学生端场景:
/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范);
@@ -54,8 +61,15 @@ class RichTextContent extends StatelessWidget {
this.customStylesBuilder,
this.customWidgetBuilder,
this.renderMode = RenderMode.column,
+ this.htmlWidgetKey,
+ this.debugImage = false,
+ this.assumeRpx = true,
});
+ /// 透传给内部 HtmlWidget 的 key(外部用于调用
+ /// HtmlWidgetState.scrollToAnchor 滚动到 #anchor)
+ final Key? htmlWidgetKey;
+
/// HTML 字符串(后端返回)
final String html;
@@ -74,65 +88,124 @@ class RichTextContent extends StatelessWidget {
/// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级)
final RenderMode renderMode;
+ /// 是否打印 img 解析日志(定位“图片特别大”时打开)
+ final bool debugImage;
+
+ /// HTML 里的 width/height 是否按 rpx 处理。
+ /// true:750 设计稿,除以 2 再 .w;
+ /// false:直接当 px,走 .w。
+ final bool assumeRpx;
+
+ /// 行内图片最大高度(逻辑像素)。
+ /// 没给 width/height 的图片按这个高度兜底,避免撑满整行。
+ static const double _inlineMaxHeight = 28.0;
+
@override
Widget build(BuildContext context) {
return HtmlWidget(
+ key: htmlWidgetKey,
html,
textStyle: textStyle,
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
- customStylesBuilder: customStylesBuilder,
+ customStylesBuilder: _combinedStylesBuilder,
customWidgetBuilder: _combinedCustomWidget(context),
renderMode: renderMode,
);
}
+ /// 合并默认样式 + 用户自定义样式
+ CustomStylesBuilder? get _combinedStylesBuilder {
+ return (element) {
+ final userResult = customStylesBuilder?.call(element);
+ if (element.localName == 'img') {
+ final base = {
+ 'max-width': '100%',
+ 'height': 'auto',
+ };
+ if (userResult != null) base.addAll(userResult);
+ return base;
+ }
+ return userResult;
+ };
+ }
+
/// 合并默认替换 + 用户自定义替换
CustomWidgetBuilder _combinedCustomWidget(BuildContext context) => (element) {
- // 内置: → flutter_svg 行内渲染
- // core 版 flutter_widget_from_html 不支持 SVG,手动处理
+ // ============================================================
+ // 1. 统一处理 :SVG + 位图
+ // ============================================================
if (element.localName == 'img') {
- final src = element.attributes['src'];
- if (src != null && src.toLowerCase().endsWith('.svg')) {
- final width = double.tryParse(element.attributes['width'] ?? '');
- final height = double.tryParse(element.attributes['height'] ?? '');
- final w = width != null ? width.w : null;
- final h = height != null ? height.h : null;
- // 超采样(SSAA):SVG 以 3 倍尺寸光栅化再缩回来,消除真机边缘锯齿
- const scale = 3.0;
- return InlineCustomWidget(
- child: SizedBox(
- width: w,
- height: h,
- child: OverflowBox(
- maxWidth: w != null ? w * scale : double.infinity,
- maxHeight: h != null ? h * scale : double.infinity,
- child: Transform.scale(
- scale: 1 / scale,
- alignment: Alignment.center,
- child: SvgPicture.network(
- src,
- width: w != null ? w * scale : null,
- height: h != null ? h * scale : null,
- fit: BoxFit.contain,
- clipBehavior: Clip.antiAlias,
- ),
- ),
- ),
- ),
- );
- }
- }
- // 内置: → 本项目 AudioPlayerCard
+ final src = element.attributes['src'];
+ if (src == null || src.isEmpty) return null;
+
+ final isSvg = src.toLowerCase().split('?').first.endsWith('.svg');
+
+ // 解析 width / height(有就用,没有就 null)
+ double? parsePx(String? raw) {
+ if (raw == null) return null;
+ return double.tryParse(raw.replaceAll(RegExp(r'[^0-9.]'), ''));
+ }
+
+ double? width = parsePx(element.attributes['width']);
+ double? height = parsePx(element.attributes['height']);
+
+ final style = element.attributes['style'];
+ if (style != null) {
+ final wMatch = RegExp(r'width\s*:\s*([0-9.]+)px').firstMatch(style);
+ final hMatch = RegExp(r'height\s*:\s*([0-9.]+)px').firstMatch(style);
+ width = double.tryParse(wMatch?.group(1) ?? '') ?? width;
+ height = double.tryParse(hMatch?.group(1) ?? '') ?? height;
+ }
+
+ if (isSvg) {
+ return InlineCustomWidget(
+ alignment: PlaceholderAlignment.middle, // ← 关键
+ child: SvgPicture.network(
+ src,
+ width: width,
+ height: height,
+ fit: BoxFit.fill,
+ clipBehavior: Clip.antiAlias,
+ // SVG 解码/下载期间显示加载动效,否则默认空白
+ placeholderBuilder: (_) =>
+ _buildLoadingPlaceholder(width, height),
+ ),
+ );
+ } else {
+ return InlineCustomWidget(
+ alignment: PlaceholderAlignment.middle, // ← 关键
+ child: Image.network(
+ src,
+ width: width,
+ height: height,
+ fit: BoxFit.fill,
+ // 网络图片加载期间显示进度小菊花,否则默认空白
+ loadingBuilder: (context, child, loadingProgress) {
+ if (loadingProgress == null) return child;
+ final expected = loadingProgress.expectedTotalBytes;
+ final progress = expected == null
+ ? null
+ : loadingProgress.cumulativeBytesLoaded / expected;
+ return _buildLoadingPlaceholder(width, height, progress);
+ },
+ errorBuilder: (_, __, ___) => const SizedBox.shrink(),
+ ),
+ );
+ }
+}
+
+ // ============================================================
+ // 2. 内置: → 本项目 AudioPlayerCard
+ // ============================================================
if (element.localName == 'audio') {
final src = element.attributes['src'];
if (src != null && src.isNotEmpty) {
// 支持 asset:// 协议 → 本地音频(seek 秒响应)
// 注意:网络源必须支持 HTTP Range(响应 206),
// 否则 ExoPlayer 无法字节定位,seek 会失效"回到起始"。
- // 免费测试站(如 samplelib)常返回 200 不支持 Range,请勿用。
final isAsset = src.startsWith('asset://');
return Padding(
- padding: const EdgeInsets.symmetric(vertical: 8),
+ padding: EdgeInsets.symmetric(vertical: 8.h),
child: AudioPlayerCard(
url: isAsset ? null : src,
asset: isAsset ? src.substring('asset://'.length) : null,
@@ -141,13 +214,16 @@ class RichTextContent extends StatelessWidget {
);
}
}
- // 内置: → 本项目 VideoPlayerView(内嵌播放器)
- // 默认不自动播,避免富文本里多个视频同时解码占内存
+
+ // ============================================================
+ // 3. 内置: → 本项目 VideoPlayerView(内嵌播放器)
+ // 默认不自动播,避免富文本里多个视频同时解码占内存
+ // ============================================================
if (element.localName == 'video') {
final src = element.attributes['src'];
if (src != null && src.isNotEmpty) {
return Padding(
- padding: const EdgeInsets.symmetric(vertical: 8),
+ padding: EdgeInsets.symmetric(vertical: 8.h),
child: VideoPlayerView(
url: src,
title: element.attributes['title'] ?? '题目视频',
@@ -156,13 +232,36 @@ class RichTextContent extends StatelessWidget {
);
}
}
- // 透传用户自定义替换(公式等扩展点)
+
+ // ============================================================
+ // 4. 透传用户自定义替换(公式等扩展点)
+ // ============================================================
return customWidgetBuilder?.call(element);
};
+ /// 图片/SVG 加载中的占位:居中的小菊花(有已知大小时按图片尺寸占位,
+ /// 行内无尺寸图给一个最小兜底盒子,避免占位为 0 看不到动效)
+ Widget _buildLoadingPlaceholder(double? width, double? height,
+ [double? progress]) {
+ return Container(
+ width: width ?? 40.w,
+ height: height ?? _inlineMaxHeight.h,
+ alignment: Alignment.center,
+ child: SizedBox(
+ width: 14.w,
+ height: 14.w,
+ child: CircularProgressIndicator(
+ strokeWidth: 1.5,
+ // 拿不到总字节数时传 null,小菊花保持 indeterminate 匀速旋转
+ value: progress,
+ ),
+ ),
+ );
+ }
+
/// 默认链接拦截:防止题目里夹带外部危险链接
static Future _defaultOnTapUrl(String url) async {
debugPrint('[RichTextContent] 已拦截外部链接: $url');
return true;
}
-}
+}
\ No newline at end of file
diff --git a/lib/core/components/upload.dart b/lib/core/components/upload.dart
index b2be613..18caef4 100644
--- a/lib/core/components/upload.dart
+++ b/lib/core/components/upload.dart
@@ -1,8 +1,10 @@
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/foundation.dart' show compute, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:image/image.dart' as img;
import 'package:new_learn/core/api/public_upload/api.dart';
import 'package:new_learn/core/api/public_upload/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
@@ -58,6 +60,9 @@ enum UploadMode { edit, view }
/// 上传组件类型
enum UploadType { handouts, questions, aiGrading, feedback }
+/// 选图后的“原始图 + 压缩处理图”配对
+typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
+
/// 图片项(统一承载:本地已�?+ 已上传)
class UploadItem {
const UploadItem({
@@ -267,18 +272,28 @@ class UploadState extends State {
processingLoadingShown = true;
// 压缩 + EXIF 修正 + 尺寸过滤
- final List<({PickedImage origin, ProcessedImage processed})> validList =
- [];
+ final List validList = [];
for (final img in picked) {
- final bytes = img.bytes ?? await img.entity?.originBytes;
- if (bytes == null || bytes.isEmpty) continue;
-
- final processed = await ImageProcessor.process(
- bytes: bytes,
- fileName: img.name,
- maxDimension: 1920,
- );
+ final ProcessedImage? processed;
+ if (kIsWeb) {
+ // Web:只有字节,走 bytes 压缩
+ final bytes = img.bytes;
+ if (bytes == null || bytes.isEmpty) continue;
+ processed = await ImageProcessor.process(
+ bytes: bytes,
+ fileName: img.name,
+ maxDimension: 1920,
+ );
+ } else {
+ // 原生:只传文件路径,原图字节不进 Dart 堆、UI 线程不解码
+ final path = img.path;
+ if (path == null || path.isEmpty) continue;
+ processed = await ImageProcessor.processFile(
+ path: path,
+ maxDimension: 1920,
+ );
+ }
if (processed == null) {
// 压缩失败 / 解码失败 �?跳过
continue;
@@ -301,9 +316,27 @@ class UploadState extends State {
return;
}
- // TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
- // 暂时直接上传,后续有需求再�?
- await _startUpload(validList);
+ // AIGrading(上传图片作答):先进入全屏旋转预览,
+ // 用户点“完成”才烘焙旋转并上传;点“取消”则整批放弃(对齐 Vue)
+ List pairsToUpload = validList;
+ if (widget.type == UploadType.aiGrading) {
+ _hideLoading();
+ processingLoadingShown = false;
+ if (!mounted) return;
+
+ final rotated = await _ImageRotateEditor.open(
+ context,
+ items: validList,
+ );
+ if (rotated == null) return; // 用户取消
+ pairsToUpload = rotated;
+ if (!mounted) return;
+
+ _showLoading('上传中...');
+ processingLoadingShown = true;
+ }
+
+ await _startUpload(pairsToUpload);
} catch (e) {
_toast('图片处理失败,请重试');
debugPrint('[Upload] 选图异常: $e');
@@ -488,7 +521,7 @@ class UploadState extends State {
@override
Widget build(BuildContext context) {
- // 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
+ // 模式:view 且没有图片时不显示(对齐 Vue v-if)
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
return _buildUploadBox();
}
@@ -520,31 +553,38 @@ class UploadState extends State {
Widget _buildHeader() {
return Row(
children: [
- Container(
- padding: EdgeInsets.only(right: 14.w),
- decoration: BoxDecoration(
- image: DecorationImage(
- image: NetworkImage(
+ Stack(
+ children: [
+ // 标题装饰底图:固定尺寸钉在右下角(对标 background-size: 119rpx auto)
+ Positioned(
+ right: 10.w,
+ bottom: 0,
+ child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
+ width: 59.5.w,
+ height: 9.h,
+ fit: BoxFit.fill,
+ errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
- alignment: Alignment.bottomRight,
- repeat: ImageRepeat.noRepeat,
),
- ),
- child: Text(
- widget.title,
- style: TextStyle(
- fontSize: 15.sp,
- fontWeight: FontWeight.w600,
- color: const Color(0xFF1D2B47),
+ Padding(
+ padding: EdgeInsets.only(right: 14.w),
+ child: Text(
+ widget.title,
+ style: TextStyle(
+ fontSize: 15.sp,
+ fontWeight: FontWeight.w600,
+ color: const Color(0xFF1D2B47),
+ ),
+ ),
),
- ),
+ ],
),
SizedBox(width: 14.w),
Text(
widget.mode == UploadMode.edit
? '已上传:${_imageList.length}/${widget.maxCount}张'
- : '共 $_imageList.length 张',
+ : '共 ${_imageList.length} 张',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
),
],
@@ -672,3 +712,268 @@ class UploadState extends State {
);
}
}
+
+/// ============================================================
+/// 全屏旋转预览(对标 Vue components/ImagePreviewPopup):
+/// - 左右滑动逐张查看;
+/// - “↻ 旋转90°”只改当前图片的视觉角度;
+/// - “完成”才把角度烘焙进图片字节并返回(随后才上传);
+/// - “取消”/系统返回 → 返回 null,整批放弃。
+/// ============================================================
+class _ImageRotateEditor extends StatefulWidget {
+ const _ImageRotateEditor({required this.items});
+
+ final List items;
+
+ /// 打开旋转预览;完成返回旋转后的列表,取消返回 null
+ static Future?> open(
+ BuildContext context, {
+ required List items,
+ }) {
+ return Navigator.of(context).push>(
+ PageRouteBuilder>(
+ opaque: true,
+ transitionDuration: const Duration(milliseconds: 200),
+ reverseTransitionDuration: const Duration(milliseconds: 200),
+ pageBuilder: (_, __, ___) => _ImageRotateEditor(items: items),
+ transitionsBuilder: (_, animation, __, child) =>
+ FadeTransition(opacity: animation, child: child),
+ ),
+ );
+ }
+
+ @override
+ State<_ImageRotateEditor> createState() => _ImageRotateEditorState();
+}
+
+class _ImageRotateEditorState extends State<_ImageRotateEditor> {
+ late final List _angles = List.filled(widget.items.length, 0);
+ final PageController _pageController = PageController();
+ int _currentIndex = 0;
+ bool _busy = false;
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ void _onPageChanged(int index) => setState(() => _currentIndex = index);
+
+ void _handleRotate() {
+ if (_busy) return;
+ setState(() {
+ _angles[_currentIndex] = (_angles[_currentIndex] + 90) % 360;
+ });
+ }
+
+ Future _handleConfirm() async {
+ if (_busy) return;
+ setState(() => _busy = true);
+ try {
+ final result = [];
+ for (var i = 0; i < widget.items.length; i++) {
+ final pair = widget.items[i];
+ final angle = _angles[i] % 360;
+ if (angle == 0) {
+ result.add(pair);
+ } else {
+ result.add((
+ origin: pair.origin,
+ processed: await _bakeRotation(pair.processed, angle),
+ ));
+ }
+ }
+ if (mounted) Navigator.of(context).pop(result);
+ } catch (_) {
+ if (mounted) {
+ setState(() => _busy = false);
+ CenterToast.show(context, '图片处理失败,请重试');
+ }
+ }
+ }
+
+ void _handleCancel() {
+ if (_busy) return;
+ Navigator.of(context).pop();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ body: Stack(
+ children: [
+ PageView.builder(
+ controller: _pageController,
+ itemCount: widget.items.length,
+ onPageChanged: _onPageChanged,
+ itemBuilder: (context, index) {
+ return Center(
+ child: AnimatedRotation(
+ turns: _angles[index] / 360,
+ duration: const Duration(milliseconds: 200),
+ child: Image.memory(
+ widget.items[index].processed.bytes,
+ fit: BoxFit.contain,
+ ),
+ ),
+ );
+ },
+ ),
+
+ // 指示点(多图时)
+ if (widget.items.length > 1)
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 72.h,
+ child: _buildDots(),
+ ),
+
+ // 底部操作栏
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: _buildFooter(),
+ ),
+
+ // “处理图片中...”遮罩
+ if (_busy)
+ const ColoredBox(
+ color: Colors.black38,
+ child: Center(
+ child: CircularProgressIndicator(color: Colors.white),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildDots() {
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ for (var i = 0; i < widget.items.length; i++)
+ Container(
+ width: 7.w,
+ height: 7.w,
+ margin: EdgeInsets.symmetric(horizontal: 4.w),
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: i == _currentIndex
+ ? Colors.white
+ : Colors.white.withValues(alpha: 0.3),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildFooter() {
+ return SafeArea(
+ top: false,
+ child: Container(
+ padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
+ decoration: const BoxDecoration(color: Color(0xB3000000)),
+ child: Row(
+ children: [
+ _FooterTextButton(label: '取消', onTap: _handleCancel),
+ Expanded(
+ child: Center(
+ child: GestureDetector(
+ onTap: _handleRotate,
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: 20.w,
+ vertical: 8.h,
+ ),
+ decoration: BoxDecoration(
+ color: const Color(0x26FFFFFF),
+ borderRadius: BorderRadius.circular(30.r),
+ ),
+ child: Text(
+ '↻ 旋转90°',
+ style: TextStyle(
+ fontSize: 14.sp,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ _FooterTextButton(label: '完成', primary: true, onTap: _handleConfirm),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _FooterTextButton extends StatelessWidget {
+ const _FooterTextButton({
+ required this.label,
+ required this.onTap,
+ this.primary = false,
+ });
+
+ final String label;
+ final VoidCallback onTap;
+ final bool primary;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 15.sp,
+ color: primary ? const Color(0xFF2979FF) : Colors.white,
+ fontWeight: primary ? FontWeight.bold : FontWeight.normal,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// 旋转 isolate 任务
+typedef _RotateTask = ({Uint8List bytes, int angle, bool isPng});
+
+/// 把旋转角度烘焙进 [processed] 的字节(失败时保留原图)
+Future _bakeRotation(
+ ProcessedImage processed,
+ int angle,
+) async {
+ final isPng = processed.contentType == 'image/png';
+ final rotated = await compute<_RotateTask, Uint8List?>(
+ _rotateInIsolate,
+ (bytes: processed.bytes, angle: angle, isPng: isPng),
+ );
+ if (rotated == null || rotated.isEmpty) return processed;
+
+ return ProcessedImage(
+ bytes: rotated,
+ extension: isPng ? 'png' : 'jpg',
+ contentType: isPng ? 'image/png' : 'image/jpeg',
+ originalBytes: processed.originalBytes,
+ processedBytes: rotated.length,
+ );
+}
+
+/// isolate 内:解码 → 顺时针旋转 → 编码(JPEG 质量 90,对齐 Vue canvas 0.9)
+Uint8List? _rotateInIsolate(_RotateTask task) {
+ final src = img.decodeImage(task.bytes);
+ if (src == null) return null;
+ final dst = img.copyRotate(src, angle: task.angle);
+ final encoded =
+ task.isPng ? img.encodePng(dst) : img.encodeJpg(dst, quality: 90);
+ return Uint8List.fromList(encoded);
+}
diff --git a/lib/core/components/video_player_view.dart b/lib/core/components/video_player_view.dart
index c33fe60..4b78228 100644
--- a/lib/core/components/video_player_view.dart
+++ b/lib/core/components/video_player_view.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
+import 'media_pause_scope.dart';
/// ============================================================
/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar)
@@ -111,7 +112,8 @@ class VideoPlayerView extends StatefulWidget {
}
class VideoPlayerViewState extends State
- with WidgetsBindingObserver {
+ with WidgetsBindingObserver
+ implements MediaPausable {
/// 官方底层控制器
VideoPlayerController? _videoController;
@@ -127,6 +129,9 @@ class VideoPlayerViewState extends State
/// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9)
double? _aspectRatio;
+ /// 所属媒体暂停作用域(随挂载位置自动注册/注销)
+ MediaPauseRegistry? _pauseScope;
+
@override
void initState() {
super.initState();
@@ -135,8 +140,21 @@ class VideoPlayerViewState extends State
_initPlayer();
}
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ // 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
+ final scope = MediaPauseScope.of(context);
+ if (scope != _pauseScope) {
+ _pauseScope?.unregister(this);
+ _pauseScope = scope;
+ _pauseScope?.register(this);
+ }
+ }
+
@override
void dispose() {
+ _pauseScope?.unregister(this);
WidgetsBinding.instance.removeObserver(this);
// 释放顺序不能反:先 chewie 再 video_player(反了会闪退)
_chewieController?.dispose();
@@ -144,7 +162,8 @@ class VideoPlayerViewState extends State
super.dispose();
}
- /// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
+ /// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
+ @override
void pause() {
_videoController?.pause();
}
diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart
index ec68d7e..a6e5883 100644
--- a/lib/core/network/dio_client.dart
+++ b/lib/core/network/dio_client.dart
@@ -1,3 +1,5 @@
+import 'dart:typed_data';
+
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:new_learn/core/logger/app_talker.dart';
@@ -53,6 +55,10 @@ class DioClient {
),
) {
if (enableNetworkLog) {
+ // 必须在 Talker 之前:把二进制 body 临时换成占位文本,
+ // 防止 Talker 把整张图(上百万字节)转成巨型日志字符串导致 OOM。
+ // 真实字节存在 extra 里,由 _buildInterceptors 的 onRequest 还原。
+ _dio.interceptors.add(_BinaryLogMaskInterceptor());
_dio.interceptors.add(
TalkerDioLogger(
talker: talker,
@@ -88,6 +94,11 @@ class DioClient {
return InterceptorsWrapper(
// ① 请求发出前:加 Token + 打印日志
onRequest: (options, handler) {
+ // 还原被 _BinaryLogMaskInterceptor 临时替换的二进制 body,保证实际上传内容不变
+ final realBody = options.extra.remove(_kRealBodyKey);
+ if (realBody != null) {
+ options.data = realBody;
+ }
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
final hasAuth =
@@ -177,16 +188,20 @@ class DioClient {
ProgressCallback? onReceiveProgress,
int retries = 0,
Map? headers,
+ Duration? receiveTimeout,
}) async {
var attempt = 0;
while (true) {
try {
+ final options = (headers != null || receiveTimeout != null)
+ ? Options(headers: headers, receiveTimeout: receiveTimeout)
+ : null;
final resp = await _dio.get(
path,
queryParameters: query,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
- options: headers == null ? null : Options(headers: headers),
+ options: options,
);
return resp.data;
} on DioException catch (e) {
@@ -337,3 +352,25 @@ class ApiException implements Exception {
@override
String toString() => message;
}
+
+/// extra 中暂存真实二进制 body 的 key
+const String _kRealBodyKey = '_realBinaryBody@dio';
+
+/// 二进制日志屏蔽拦截器(必须在 TalkerDioLogger 之前执行):
+/// 把二进制 body 暂存到 extra 并替换成简短占位文本,
+/// 避免 Talker 把整张图转成巨型日志字符串、堆积在日志历史里造成 OOM。
+/// 真实字节由 _buildInterceptors 的 onRequest 还原,不影响实际请求内容。
+class _BinaryLogMaskInterceptor extends Interceptor {
+ @override
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
+ final data = options.data;
+ if (data is TypedData) {
+ options.extra[_kRealBodyKey] = data;
+ options.data = '';
+ } else if (data is Stream>) {
+ options.extra[_kRealBodyKey] = data;
+ options.data = '';
+ }
+ handler.next(options);
+ }
+}
diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart
index 525b305..27a6cc3 100644
--- a/lib/core/router/app_router.dart
+++ b/lib/core/router/app_router.dart
@@ -16,6 +16,8 @@ import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/my/note/note_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
import 'package:new_learn/features/conquerPackage/beforeDuringClass/index.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/class_exercises_page.dart';
+import 'package:new_learn/features/conquerPackage/report/report_page.dart';
import 'package:new_learn/features/conquerPackage/transitionPage/transition_page.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/my/question_list/question_list_page.dart';
@@ -217,6 +219,20 @@ final GoRouter appRouter = GoRouter(
extra: state.extra as Map?,
),
),
+ // ---------- 攻克包 - 课后习题 ----------
+ GoRoute(
+ path: '/conquerPackage/classExercises',
+ builder: (_, state) => ClassExercisesPage(
+ extra: state.extra as Map?,
+ ),
+ ),
+ // ---------- 攻克包 - 学习报告(占位) ----------
+ GoRoute(
+ path: '/conquerPackage/report',
+ builder: (_, state) => ReportPage(
+ extra: state.extra as Map?,
+ ),
+ ),
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
// app_router.dart 的 routes 里加,不用管跳转逻辑
diff --git a/lib/core/services/image_file_header.dart b/lib/core/services/image_file_header.dart
new file mode 100644
index 0000000..55f5777
--- /dev/null
+++ b/lib/core/services/image_file_header.dart
@@ -0,0 +1,10 @@
+// ============================================================
+// 图片文件头信息(条件导出):
+// native(dart.library.io 为真)→ image_file_header_io.dart
+// web(dart.library.io 为假) → image_file_header_web.dart
+//
+// 仅用 RandomAccessFile 读 JPEG/PNG 文件头获取尺寸/格式,
+// 不把整张图读进内存、不做像素解码。
+// ============================================================
+export 'image_file_header_web.dart'
+ if (dart.library.io) 'image_file_header_io.dart';
diff --git a/lib/core/services/image_file_header_io.dart b/lib/core/services/image_file_header_io.dart
new file mode 100644
index 0000000..84677bd
--- /dev/null
+++ b/lib/core/services/image_file_header_io.dart
@@ -0,0 +1,118 @@
+// 仅当 dart.library.io 为真(native)时编译。
+import 'dart:io';
+import 'dart:typed_data';
+
+/// 图片文件头信息(仅读头部,不加载像素)
+class ImageFileHeader {
+ const ImageFileHeader({
+ required this.width,
+ required this.height,
+ required this.length,
+ required this.isPng,
+ required this.isJpeg,
+ });
+
+ final int width;
+ final int height;
+ final int length;
+ final bool isPng;
+ final bool isJpeg;
+}
+
+/// 读取本地图片文件头:尺寸 / 格式 / 文件大小。
+///
+/// 使用 RandomAccessFile 按 marker 顺序扫描,每次仅读几个字节:
+/// - PNG:IHDR 固定在偏移 16;
+/// - JPEG:从 SOI 后逐段扫描,SOF 段内即宽高(在 SOS 之前)。
+/// 不支持的格式 / 损坏文件返回 null。
+Future readImageFileHeader(String path) async {
+ final file = File(path);
+ final raf = await file.open();
+ try {
+ final sig = await raf.read(4);
+ if (sig.length < 4) return null;
+
+ // -------- PNG:89 50 4E 47 0D 0A 1A 0A,IHDR 在偏移 16 --------
+ if (sig[0] == 0x89 &&
+ sig[1] == 0x50 &&
+ sig[2] == 0x4E &&
+ sig[3] == 0x47) {
+ await raf.setPosition(16);
+ final d = await raf.read(8);
+ if (d.length < 8) return null;
+ final width = (d[0] << 24) | (d[1] << 16) | (d[2] << 8) | d[3];
+ final height = (d[4] << 24) | (d[5] << 16) | (d[6] << 8) | d[7];
+ return ImageFileHeader(
+ width: width,
+ height: height,
+ length: await raf.length(),
+ isPng: true,
+ isJpeg: false,
+ );
+ }
+
+ // -------- JPEG:FF D8 FF --------
+ if (sig[0] == 0xFF && sig[1] == 0xD8 && sig[2] == 0xFF) {
+ await raf.setPosition(2);
+ while (true) {
+ // 定位段 marker(跳过填充字节)
+ final prefix = await raf.read(1);
+ if (prefix.isEmpty || prefix[0] != 0xFF) return null;
+ int marker;
+ do {
+ final m = await raf.read(1);
+ if (m.isEmpty) return null;
+ marker = m[0];
+ } while (marker == 0xFF);
+
+ // 无长度段:SOI / TEM / RSTn
+ if (marker == 0xD8 || marker == 0x01) continue;
+ if (marker >= 0xD0 && marker <= 0xD9) {
+ if (marker == 0xD9) return null; // EOI:未找到 SOF
+ continue;
+ }
+
+ final lenBytes = await raf.read(2);
+ if (lenBytes.length < 2) return null;
+ final segLen = (lenBytes[0] << 8) | lenBytes[1];
+ if (segLen < 2) return null;
+
+ // SOF 段(排除 C4=DHT、C8=JPG、CC=DAC)
+ if (marker >= 0xC0 &&
+ marker <= 0xCF &&
+ marker != 0xC4 &&
+ marker != 0xC8 &&
+ marker != 0xCC) {
+ // precision(1) + height(2) + width(2)
+ final sof = await raf.read(5);
+ if (sof.length < 5) return null;
+ final height = (sof[1] << 8) | sof[2];
+ final width = (sof[3] << 8) | sof[4];
+ return ImageFileHeader(
+ width: width,
+ height: height,
+ length: await raf.length(),
+ isPng: false,
+ isJpeg: true,
+ );
+ }
+
+ // 其它段:跳过段体(长度含自身 2 字节)
+ await raf.setPosition(await raf.position() + segLen - 2);
+ }
+ }
+
+ return null;
+ } finally {
+ await raf.close();
+ }
+}
+
+/// 读取文件全部字节(仅用于桌面等不支持原生压缩时的回退路径)
+Future readImageFileBytes(String path) async {
+ try {
+ return Uint8List.fromList(await File(path).readAsBytes());
+ } catch (_) {
+ return null;
+ }
+}
diff --git a/lib/core/services/image_file_header_web.dart b/lib/core/services/image_file_header_web.dart
new file mode 100644
index 0000000..f7c7da6
--- /dev/null
+++ b/lib/core/services/image_file_header_web.dart
@@ -0,0 +1,25 @@
+// 仅当 dart.library.io 为假(Web)时编译。
+// Web 端选图直接拿到字节,不需要读文件头。
+import 'dart:typed_data';
+
+class ImageFileHeader {
+ const ImageFileHeader({
+ required this.width,
+ required this.height,
+ required this.length,
+ required this.isPng,
+ required this.isJpeg,
+ });
+
+ final int width;
+ final int height;
+ final int length;
+ final bool isPng;
+ final bool isJpeg;
+}
+
+/// Web 端无文件系统,返回 null
+Future readImageFileHeader(String path) async => null;
+
+/// Web 端无文件系统,返回 null
+Future readImageFileBytes(String path) async => null;
diff --git a/lib/core/services/image_processor.dart b/lib/core/services/image_processor.dart
index 18d1eca..3548aad 100644
--- a/lib/core/services/image_processor.dart
+++ b/lib/core/services/image_processor.dart
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
+import 'image_file_header.dart';
+
/// ============================================================
/// 【ImageProcessor】图片压缩 + 方向修正 + 尺寸过滤
///
@@ -87,6 +89,68 @@ class ImageProcessor {
processedBytes: result.processedBytes,
);
}
+
+ /// 处理本地图片【文件】(原生端推荐入口):
+ /// - 尺寸/格式只读文件头(RandomAccessFile 读几字节),
+ /// UI 线程不做完整解码;
+ /// - 压缩交给原生侧直接读文件,Dart 堆不持有原图;
+ /// - 原生压缩不可用(如桌面平台)时回退纯 Dart isolate。
+ static Future processFile({
+ required String path,
+ int maxDimension = 1920,
+ int quality = 85,
+ }) async {
+ final header = await readImageFileHeader(path);
+ if (header == null) return null;
+ if (!_isInAllowedSize(header.width, header.height)) return null;
+
+ final target = _targetSize(
+ width: header.width,
+ height: header.height,
+ maxDimension: maxDimension,
+ );
+
+ final compressed = await FlutterImageCompress.compressWithFile(
+ path,
+ minWidth: target.width,
+ minHeight: target.height,
+ quality: quality,
+ format: header.isPng ? CompressFormat.png : CompressFormat.jpeg,
+ autoCorrectionAngle: true,
+ );
+
+ if (compressed != null && compressed.isNotEmpty) {
+ final bytes = Uint8List.fromList(compressed);
+ return ProcessedImage(
+ bytes: bytes,
+ extension: header.isPng ? 'png' : 'jpg',
+ contentType: header.isPng ? 'image/png' : 'image/jpeg',
+ originalBytes: header.length,
+ processedBytes: bytes.length,
+ );
+ }
+
+ // 回退:读文件字节 → 纯 Dart isolate(桌面等场景)
+ final fileBytes = await readImageFileBytes(path);
+ if (fileBytes == null || fileBytes.isEmpty) return null;
+ final result = await compute(
+ _processInIsolate,
+ (
+ bytes: fileBytes,
+ fileName: null,
+ maxDimension: maxDimension,
+ quality: quality,
+ ),
+ );
+ if (result == null) return null;
+ return ProcessedImage(
+ bytes: result.bytes,
+ extension: result.extension,
+ contentType: result.contentType,
+ originalBytes: result.originalBytes,
+ processedBytes: result.processedBytes,
+ );
+ }
}
typedef _ProcessTask = ({
diff --git a/lib/core/services/picker_service.dart b/lib/core/services/picker_service.dart
index 950a421..e21c8c5 100644
--- a/lib/core/services/picker_service.dart
+++ b/lib/core/services/picker_service.dart
@@ -106,8 +106,10 @@ class PickerService {
return image == null ? null : [image];
}
// 相册权限:Android 13+ 用 photos (READ_MEDIA_IMAGES),旧版用 storage
+ final galleryPermission = await _resolveGalleryPermission();
+ if (!context.mounted) return null;
final ok = await _ensurePermission(
- permission: await _resolveGalleryPermission(),
+ permission: galleryPermission,
title: '需要相册权限',
purpose: '从相册选取图片用于上传错题、作业图片及更换头像',
context: context,
@@ -190,10 +192,10 @@ class PickerService {
// 不再强制锁拍照方向。题目/解答/讲义拍摄经常需要横拍,
// 后续交给 ImageProcessor 的原生压缩链做 autoCorrectionAngle。
onEntitySaving: (dialogContext, viewType, file) async {
- final xFile = XFile(file.path);
+ // 只持有文件路径,不 readAsBytes:原图字节不进 Dart 堆,
+ // 后续压缩由原生侧直接读文件(避免大图 OOM)。
picked = PickedImage(
path: file.path,
- bytes: await xFile.readAsBytes(),
name: file.path.split('/').last,
);
if (dialogContext.mounted) {
diff --git a/lib/core/utils/question/exam_answer_card.dart b/lib/core/utils/question/exam_answer_card.dart
new file mode 100644
index 0000000..24882ca
--- /dev/null
+++ b/lib/core/utils/question/exam_answer_card.dart
@@ -0,0 +1,303 @@
+/// ============================================================
+/// 答题卡构建(1:1 移植 Vue utils/examAnswerCard.js)
+/// ============================================================
+library;
+
+/// 子题带选项、需按“小题答题卡”处理的复合题型
+const Set kQuestionTypesWithOptionChildren = {
+ 'yuedulijie',
+ 'yueduxuanzeti',
+ 'qixuanwu',
+ 'wanxingtiankongxuanzeti',
+ 'tingli',
+};
+
+/// 仅这些学科按正常逻辑判断 hasOptionChildrenQuestion,其余学科直接 true
+const List kSubjectIdsForNormalCheck = [2, 4, 5, 8, 9];
+
+bool _truthy(dynamic v) {
+ if (v == null) return false;
+ if (v is bool) return v;
+ if (v is num) return v != 0;
+ if (v is String) return v.isNotEmpty && v != '0';
+ return true;
+}
+
+dynamic _or(dynamic a, dynamic b) => _truthy(a) ? a : b;
+
+int _num(dynamic v, [int def = 0]) {
+ if (v is num) return v.toInt();
+ final n = int.tryParse('${v ?? ''}');
+ return n ?? def;
+}
+
+/// 答案是否有值('' / null / '[]' 视为未答)
+bool hasValue(dynamic value) {
+ if (value == null) return false;
+ final str = '$value'.trim();
+ if (str.isEmpty || str == '[]') return false;
+ return true;
+}
+
+List _metaList(Map? question) {
+ if (question == null) return const [];
+ final v = question['bkTagMetaList'];
+ return v is List ? v : const [];
+}
+
+/// 填空题:每个空都作答才算已答;非填空题返回 null
+bool? isFillBlankFullyAnswered(Map? question) {
+ final metas = _metaList(question);
+ if (metas.isEmpty) return null;
+
+ bool metaHasAnswer(dynamic rawMeta) {
+ final meta = rawMeta is Map ? rawMeta : const {};
+ final answer = meta['myAnswer'] ?? meta['userAnswer'];
+ if (answer is List) return answer.isNotEmpty;
+ return hasValue(answer);
+ }
+
+ return metas.every(metaHasAnswer);
+}
+
+/// 题目是否已答
+bool isQuestionAnswered(Map question) {
+ final fillBlankAnswered = isFillBlankFullyAnswered(question);
+ final answered = (fillBlankAnswered == null || !fillBlankAnswered)
+ ? hasValue(question['userAnswer'])
+ : fillBlankAnswered;
+ return answered;
+}
+
+/// 读取题目子项(subQuestions 优先,其次 childs)
+List getQuestionChildren(Map? item) {
+ if (item == null) return const [];
+ if (item['subQuestions'] is List && (item['subQuestions'] as List).isNotEmpty) {
+ return item['subQuestions'] as List;
+ }
+ if (item['childs'] is List && (item['childs'] as List).isNotEmpty) {
+ return item['childs'] as List;
+ }
+ return const [];
+}
+
+/// 给题目列表补充 hasOptionChildrenQuestion(会就地改写 item 个别字段)
+List> appendExamQuestionChildOptionStatus(
+ List? list,
+ dynamic subjectId,
+) {
+ return (list ?? const []).map((rawItem) {
+ final item = Map.from(rawItem as Map);
+ final childs = item['childs'] is List ? item['childs'] as List : const [];
+
+ // 学科不在白名单:默认按小题答题卡处理
+ if (!kSubjectIdsForNormalCheck.contains(_num(subjectId, -1))) {
+ var hasOptionFlag = childs.isNotEmpty;
+
+ // 输入型填空题(仅一空)合并到大题
+ if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
+ final firstChild = childs.first as Map;
+ item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
+ item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
+ hasOptionFlag = false;
+ }
+
+ // 解答题/问答题有子题:整题按一道题提交,图片作答
+ if ((item['questionTypes'] == 'jiedati' || item['questionTypes'] == 'wendati') &&
+ childs.isNotEmpty) {
+ hasOptionFlag = false;
+ item['iSFilllankItemUpload'] = 1;
+ }
+
+ return {
+ ...item,
+ 'hasOptionChildrenQuestion': hasOptionFlag,
+ };
+ }
+
+ final childList = getQuestionChildren(item);
+ final hasOptionChildren =
+ kQuestionTypesWithOptionChildren.contains(item['questionTypes']) &&
+ childList.isNotEmpty &&
+ childList.any((c) =>
+ c is Map && c['options'] is List && (c['options'] as List).isNotEmpty);
+ final isInputFillBlankQuestion = item['questionTypes'] == 'tiankongti' &&
+ _num(item['iSFilllankItemUpload']) == 0 &&
+ (item['childs'] is List && (item['childs'] as List).length > 1);
+
+ return {
+ ...item,
+ 'hasOptionChildrenQuestion': hasOptionChildren || isInputFillBlankQuestion,
+ };
+ }).toList();
+}
+
+Map _rawId(dynamic sub) => sub is Map && sub['raw'] is Map
+ ? Map.from(sub['raw'] as Map)
+ : const {};
+
+/// 将题目拍平成答题卡题号列表
+List> buildExamAnswerCardQuestions(
+ List? questionsList, {
+ bool isExamAnswerCard = true,
+}) {
+ final list = >[];
+ var displaySort = 1;
+
+ (questionsList ?? const []).asMap().forEach((index, rawItem) {
+ final item = Map.from(rawItem as Map);
+ final subQuestions =
+ item['subQuestions'] is List ? item['subQuestions'] as List : const [];
+
+ if (isExamAnswerCard != true) {
+ if (item['hasChild'] == true && subQuestions.isNotEmpty) {
+ subQuestions.asMap().forEach((subIndex, rawSub) {
+ final sub = Map.from(rawSub as Map);
+ final subRaw = _rawId(sub);
+ final question = {
+ ...sub,
+ 'questionId': subRaw['id'] ?? '${item['id'] ?? index}-$subIndex',
+ 'parentIndex': index,
+ 'subIndex': subIndex,
+ 'sort': _num(sub['sort']),
+ 'targetSort': _num(sub['sort']),
+ 'questionTypeName': item['questionTypeName'] ?? '题目',
+ 'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
+ 'correctAnswer': _or(sub['answer'], subRaw['answer']) ?? '',
+ 'answerStatus': _or(sub['answerStatus'], 0),
+ 'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
+ };
+ list.add({...question, 'answered': isQuestionAnswered(question)});
+ });
+ return;
+ }
+
+ final question = {
+ ...item,
+ 'questionId': item['id'] ?? index,
+ 'parentIndex': index,
+ 'subIndex': -1,
+ 'sort': _num(item['sort']),
+ 'targetSort': _num(item['sort']),
+ 'questionTypeName': item['questionTypeName'] ?? '题目',
+ 'userAnswer': item['userAnswer'] ?? '',
+ 'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
+ 'answerStatus': item['answerStatus'] ?? 0,
+ 'bkTagMetaList': item['bkTagMetaList'] ?? const [],
+ };
+ list.add({...question, 'answered': isQuestionAnswered(question)});
+ return;
+ }
+
+ final children = getQuestionChildren(item);
+ final collapseAsSingleQuestion = item['hasOptionChildrenQuestion'] == false;
+
+ if (collapseAsSingleQuestion) {
+ final childs = item['childs'] is List ? item['childs'] as List : const [];
+ final firstChildSort = children.isNotEmpty
+ ? _num((children.first as Map)['sort'])
+ : 0;
+ var targetSort = _num(item['sort']);
+ if (targetSort == 0) targetSort = firstChildSort;
+
+ if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
+ final firstChild = childs.first as Map;
+ firstChild['questionTypeName'] = item['questionTypeName'] ?? '题目';
+ item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
+ item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
+ }
+
+ final question = {
+ ...item,
+ 'questionId': item['id'] ?? index,
+ 'parentIndex': index,
+ 'subIndex': -1,
+ 'sort': displaySort++,
+ 'targetSort': targetSort,
+ 'questionTypeName': item['questionTypeName'] ?? '题目',
+ 'userAnswer': item['userAnswer'] ?? '',
+ 'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
+ 'answerStatus': item['answerStatus'] ?? 0,
+ 'bkTagMetaList': item['bkTagMetaList'] ?? const [],
+ };
+ list.add({...question, 'answered': isQuestionAnswered(question)});
+ return;
+ }
+
+ if (children.isNotEmpty) {
+ children.asMap().forEach((subIndex, rawSub) {
+ final sub = Map.from(rawSub as Map);
+ final subRaw = _rawId(sub);
+ final question = {
+ ...sub,
+ 'questionId': subRaw['id'] ?? sub['id'] ?? '${item['id'] ?? index}-$subIndex',
+ 'parentIndex': index,
+ 'subIndex': subIndex,
+ 'sort': displaySort++,
+ 'targetSort': _num(sub['sort']),
+ 'questionTypeName': item['questionTypeName'] ?? '题目',
+ 'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
+ 'correctAnswer':
+ sub['correctAnswer'] ?? sub['answer'] ?? subRaw['answer'] ?? '',
+ 'answerStatus': sub['answerStatus'] ?? 0,
+ 'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
+ };
+ list.add({...question, 'answered': isQuestionAnswered(question)});
+ });
+ return;
+ }
+
+ final question = {
+ ...item,
+ 'questionId': item['id'] ?? index,
+ 'parentIndex': index,
+ 'subIndex': -1,
+ 'sort': displaySort++,
+ 'targetSort': _num(item['sort']),
+ 'questionTypeName': item['questionTypeName'] ?? '题目',
+ 'userAnswer': item['userAnswer'] ?? '',
+ 'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
+ 'answerStatus': item['answerStatus'] ?? 0,
+ 'bkTagMetaList': item['bkTagMetaList'] ?? const [],
+ };
+ list.add({...question, 'answered': isQuestionAnswered(question)});
+ });
+
+ final filtered = list.where((item) {
+ final sort = _num(item['sort']);
+ if (sort <= 0) return false;
+ if (isExamAnswerCard == true) return true;
+ return _num(item['targetSort']) > 0 || sort > 0;
+ }).toList();
+ filtered.sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
+ return filtered;
+}
+
+/// 按相邻 questionTypeName 分组,供答题卡渲染
+List> buildExamAnswerCard(
+ List? questionsList, {
+ bool isExamAnswerCard = true,
+}) {
+ final sortedQuestions = buildExamAnswerCardQuestions(
+ questionsList,
+ isExamAnswerCard: isExamAnswerCard,
+ );
+ final result = >[];
+ Map? currentGroup;
+
+ for (final question in sortedQuestions) {
+ final typeName = '${question['questionTypeName'] ?? '题目'}';
+ if (currentGroup == null || currentGroup['name'] != typeName) {
+ currentGroup = {
+ 'name': typeName,
+ 'questions': >[],
+ };
+ result.add(currentGroup);
+ }
+ (currentGroup['questions'] as List).add({
+ ...question,
+ 'status': question['answerStatus'],
+ });
+ }
+ return result;
+}
diff --git a/lib/core/utils/question/extract_media_from_stem.dart b/lib/core/utils/question/extract_media_from_stem.dart
new file mode 100644
index 0000000..f65fcdc
--- /dev/null
+++ b/lib/core/utils/question/extract_media_from_stem.dart
@@ -0,0 +1,150 @@
+/// ============================================================
+/// 题干媒体拆分(1:1 移植 Vue utils/extractMediaFromStem.js)
+///
+/// 从题干 HTML 中拆出内嵌 audio/video,避免富文本直接渲染媒体异常。
+/// ============================================================
+library;
+
+class StemMediaBlock {
+ const StemMediaBlock({required this.type, this.content, this.audioUrl, this.videoUrl, this.videoCoverUrl});
+
+ /// html / audio / video
+ final String type;
+ final String? content;
+ final String? audioUrl;
+ final String? videoUrl;
+ final String? videoCoverUrl;
+
+ Map toJson() => {
+ 'type': type,
+ if (content != null) 'content': content,
+ if (audioUrl != null) 'audioUrl': audioUrl,
+ if (videoUrl != null) 'videoUrl': videoUrl,
+ if (videoCoverUrl != null) 'videoCoverUrl': videoCoverUrl,
+ };
+}
+
+class StemMedia {
+ const StemMedia({
+ this.content = '',
+ this.audios = const [],
+ this.videos = const [],
+ this.blocks = const [],
+ });
+
+ final String content;
+ final List> audios;
+ final List> videos;
+ final List blocks;
+}
+
+String _normalizeMediaAttr(String? value) =>
+ (value ?? '').replaceAll(RegExp(r'''[`'"\s]'''), '').trim();
+
+String? _firstNonEmpty(List groups) {
+ for (final g in groups) {
+ if (g != null && g.isNotEmpty) return g;
+ }
+ return null;
+}
+
+({String src, String poster}) _extractMediaAttrs(String tagHtml) {
+
+ final srcMatch = RegExp(
+ r'''\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
+ caseSensitive: false,
+ ).firstMatch(tagHtml);
+ final posterMatch = RegExp(
+ r'''\bposter\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
+ caseSensitive: false,
+ ).firstMatch(tagHtml);
+ final sourceMatch = RegExp(
+ r''']*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
+ caseSensitive: false,
+ ).firstMatch(tagHtml);
+
+ final src = _normalizeMediaAttr(_firstNonEmpty([
+ sourceMatch?.group(1),
+ sourceMatch?.group(2),
+ sourceMatch?.group(3),
+ srcMatch?.group(1),
+ srcMatch?.group(2),
+ srcMatch?.group(3),
+ ]));
+ final poster = _normalizeMediaAttr(_firstNonEmpty([
+ posterMatch?.group(1),
+ posterMatch?.group(2),
+ posterMatch?.group(3),
+ ]));
+ return (src: src, poster: poster);
+}
+
+StemMedia extractMediaFromStem(String? html) {
+ if (html == null || html.isEmpty) {
+ return const StemMedia();
+ }
+
+ final rawHtml = html;
+ final mediaTagReg = RegExp(
+ r'|',
+ caseSensitive: false,
+ );
+
+ final audios = >[];
+ final videos = >[];
+ final blocks = [];
+ var lastIndex = 0;
+
+ void pushHtmlBlock(String content) {
+ if (content.trim().isEmpty) return;
+ blocks.add(StemMediaBlock(type: 'html', content: content));
+ }
+
+ for (final m in mediaTagReg.allMatches(rawHtml)) {
+ final tagHtml = m.group(0)!;
+ final offset = m.start;
+ pushHtmlBlock(rawHtml.substring(lastIndex, offset));
+
+ if (RegExp(r'^', caseSensitive: false), '')
+ .replaceAll(RegExp(r'', caseSensitive: false), '');
+
+ content = content
+ .replaceAll(
+ RegExp(
+ r'<(div|p)([^>]*)>\s*(?: | |\s)*\1>',
+ caseSensitive: false,
+ ),
+ '',
+ )
+ .trim();
+
+ return StemMedia(content: content, audios: audios, videos: videos, blocks: blocks);
+}
diff --git a/lib/core/utils/question/formula.dart b/lib/core/utils/question/formula.dart
new file mode 100644
index 0000000..ece85b3
--- /dev/null
+++ b/lib/core/utils/question/formula.dart
@@ -0,0 +1,10 @@
+/// ============================================================
+/// 公式渲染(对标 Vue utils/formula.js 的 renderLatexInHtml)
+///
+/// 注意:Vue 端该函数第 960 行 `return content` 是空操作(公式已由
+/// 后端转成 SVG,见 RichTextContent 内置 flutter_svg 渲染)。
+/// Flutter 侧保持 identity,不引入任何 LaTeX 依赖。
+/// ============================================================
+library;
+
+String renderLatexInHtml(String? content) => content ?? '';
diff --git a/lib/core/utils/question/js_regex.dart b/lib/core/utils/question/js_regex.dart
new file mode 100644
index 0000000..db8dce8
--- /dev/null
+++ b/lib/core/utils/question/js_regex.dart
@@ -0,0 +1,139 @@
+/// ============================================================
+/// JS 正则兼容层(JRegExp / JMatch)
+///
+/// Vue 端大量解析脚本依赖 JavaScript 正则语义:
+/// - 带 g 标志时 exec() 从 lastIndex 开始,命中后把 lastIndex
+/// 推进到本次匹配末尾;失配后 lastIndex 归 0;
+/// - replace(regex, fn) 回调参数为 (match, p1..pn, offset, src);
+/// - 默认 i(忽略大小写)+ s(dotAll,. 匹配换行)。
+///
+/// Dart 的 RegExp 语法/语义与 JS 基本一致(支持反向引用 \1、
+/// 先行断言 (?=...),不支持后行断言),但没有 lastIndex 游标,
+/// 这里做一层薄封装,供移植代码保持原有 while(exec) 写法。
+/// ============================================================
+library;
+
+/// JS 风格单次匹配结果
+class JMatch {
+ JMatch(this._m, this.source);
+
+ final RegExpMatch _m;
+
+ /// 原始字符串
+ final String source;
+
+ /// 匹配起始下标(JS match.index)
+ int get index => _m.start;
+
+ /// 匹配结束下标
+ int get end => _m.end;
+
+ /// 整个匹配文本(JS match[0])
+ String get group0 => _m.group(0)!;
+
+ /// 捕获组总数
+ int get groupCount => _m.groupCount;
+
+ /// 取捕获组(JS match[n]),无匹配返回 null
+ String? call(int n) => _m.group(n);
+
+ /// 取捕获组,空串也按空串返回;不存在才返回 null
+ String? groupOrNull(int n) =>
+ n <= _m.groupCount ? _m.group(n) : null;
+
+ RegExpMatch get raw => _m;
+}
+
+/// JS 风格正则(默认 global + ignoreCase + dotAll)
+class JRegExp {
+ JRegExp(
+ String pattern, {
+ this.global = true,
+ bool caseInsensitive = true,
+ bool dotAll = true,
+ bool multiLine = false,
+ }) : _re = RegExp(
+ pattern,
+ caseSensitive: !caseInsensitive,
+ dotAll: dotAll,
+ multiLine: multiLine,
+ );
+
+ final RegExp _re;
+ final bool global;
+
+ /// JS regex.lastIndex
+ int lastIndex = 0;
+
+ /// JS regex.exec(string)
+ JMatch? exec(String input) {
+ if (lastIndex < 0) lastIndex = 0;
+ if (lastIndex > input.length) {
+ lastIndex = 0;
+ return null;
+ }
+ RegExpMatch? found;
+ for (final m in _re.allMatches(input, lastIndex)) {
+ found = m;
+ break;
+ }
+ if (found == null) {
+ lastIndex = 0;
+ return null;
+ }
+ final match = JMatch(found, input);
+ if (global) {
+ // 零宽匹配时 JS 会让 lastIndex 前进 1 位,避免死循环
+ lastIndex = found.end == found.start ? found.end + 1 : found.end;
+ }
+ return match;
+ }
+
+ /// 重置游标
+ void reset() => lastIndex = 0;
+
+ /// JS String.match(regex):非 global 返回首个匹配,global 返回全量
+ List? all(String input) {
+ if (!global) {
+ final m = _re.firstMatch(input);
+ return m == null ? null : [JMatch(m, input)];
+ }
+ return _re.allMatches(input).map((m) => JMatch(m, input)).toList();
+ }
+}
+
+/// 非全局取首个匹配(对标 string.match(/.../i))
+JMatch? jsFirstMatch(String input, String pattern, {bool dotAll = true}) {
+ final m = RegExp(pattern, dotAll: dotAll).firstMatch(input);
+ return m == null ? null : JMatch(m, input);
+}
+
+/// 对标 JS parseInt(value, radix):允许前导空白、前缀数字,尾部垃圾忽略
+int? jsParseInt(dynamic value, [int radix = 10]) {
+ if (value == null) return null;
+ var s = '$value'.trim();
+ if (s.isEmpty) return null;
+ if (radix == 10) {
+ final m = RegExp(r'^[+-]?\d+').firstMatch(s);
+ if (m == null) return null;
+ return int.tryParse(m.group(0)!);
+ }
+ return int.tryParse(s, radix: radix);
+}
+
+/// 对标 JS Number(value)(仅在需要时使用)
+num? jsNumber(dynamic value) {
+ if (value == null) return 0;
+ if (value is num) return value;
+ final s = '$value'.trim();
+ if (s.isEmpty) return 0;
+ return num.tryParse(s);
+}
+
+/// 对标 JS String.prototype.replace(regex, '$1') 中最常用的形态:
+/// 用捕获组 1 替换每处匹配
+String replaceWithGroup1(String input, String pattern) {
+ return input.replaceAllMapped(RegExp(pattern, dotAll: true), (m) {
+ return m.group(1) ?? '';
+ });
+}
diff --git a/lib/core/utils/question/question_constant.dart b/lib/core/utils/question/question_constant.dart
new file mode 100644
index 0000000..6af728f
--- /dev/null
+++ b/lib/core/utils/question/question_constant.dart
@@ -0,0 +1,99 @@
+/// ============================================================
+/// 题型 / 学科常量(对标 Vue utils/constant.js)
+/// ============================================================
+library;
+
+/// 根据 questionTypeId 和 isObjective 获取对应的题型代码
+class QuestionTypeCodeMap {
+ QuestionTypeCodeMap._();
+
+ /// 客观题(isObjective=true 时查这张表)
+ static const Map objective = {
+ '12352055339057157': 'danxuanti', // 单选题
+ '12352065806598149': 'duoxuanti', // 多选题
+ '12352066848751621': 'panduanti', // 判断题
+ '792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
+ '805449231827078': 'qixuanwu', // 七选五
+ '807952051372102': 'qixuanwu', // 七选五
+ '12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
+ '12357391078981637': 'yueduxuanzeti', // 阅读选择题
+ '806897094647878': 'wendati', // 问答题
+ '12357387196366853': 'tingli', // 听力
+ '792171174588486': 'yuedulijie', // 阅读理解
+ '12352067804725253': 'tiankongti', // 填空题
+ '12352069399805957': 'jiedati', // 解答题
+ '12352070327861253': 'jisuanti', // 计算题
+ '12352071266336773': 'yingyongti', // 应用题
+ '12352072842215429': 'shiyanti', // 实验题
+ '12357399440457733': 'xiezuo', // 写作
+ '12361758319509574': 'zhengmingti', // 证明题
+ };
+
+ /// 主观题(isObjective=false 时查这张表)
+ static const Map subjective = {
+ '12352055339057157': 'danxuanti', // 单选题
+ '12352065806598149': 'duoxuanti', // 多选题
+ '12352066848751621': 'panduanti', // 判断题
+ '792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
+ '792171174588486': 'yuedulijie', // 阅读理解
+ '12352067804725253': 'tiankongti', // 填空题
+ '12352069399805957': 'jiedati', // 解答题
+ '12352070327861253': 'jisuanti', // 计算题
+ '12352071266336773': 'yingyongti', // 应用题
+ '12352072842215429': 'shiyanti', // 实验题
+ '12357387196366853': 'tingli', // 听力
+ '12357391078981637': 'yueduxuanzeti', // 阅读选择题
+ '12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
+ '12357399440457733': 'xiezuo', // 写作
+ '12361758319509574': 'zhengmingti', // 证明题
+ '805449231827078': 'qixuanwu', // 七选五
+ '807952051372102': 'qixuanwu', // 七选五
+ '806897094647878': 'wendati', // 问答题
+ };
+
+ /// 通过 Code 获取题型
+ static const Map typeCode = {
+ '1': 'danxuanti', // 单选题
+ '2': 'duoxuanti', // 多选题
+ '3': 'panduanti', // 判断题
+ '4': 'tiankongti', // 填空题
+ '5': 'jiedati', // 解答题
+ '9': 'tingli', // 听力
+ 'qixuanwu': 'qixuanwu', // 七选五
+ 'wangxingtiankong': 'wanxingtiankongxuanzeti', // 完形填空选择题
+ 'wendati': 'wendati', // 问答题
+ 'yueduljie': 'yuedulijie', // 阅读理解
+ };
+}
+
+/// 学科作答方式(type:0=输入框,1=图片作答/手写作答)
+class SubjectInfo {
+ const SubjectInfo(this.id, this.name, this.type);
+
+ final int id;
+ final String name;
+ final int type;
+}
+
+const List kSubjectList = [
+ SubjectInfo(1, '语文', 0),
+ SubjectInfo(2, '数学', 1),
+ SubjectInfo(3, '英语', 0),
+ SubjectInfo(4, '物理', 1),
+ SubjectInfo(5, '化学', 1),
+ SubjectInfo(6, '生物学', 0),
+ SubjectInfo(7, '政治(道法)', 0),
+ SubjectInfo(8, '历史', 1),
+ SubjectInfo(9, '地理', 1),
+ SubjectInfo(100013, '艺起看世界 ', 0), // 只有客观题,没有主观题
+];
+
+/// Vue: getSubjectTypeById —— 找不到返回 ''(load 处 `|| 0` 兜底)
+int? getSubjectTypeById(dynamic subjectId) {
+ final id = int.tryParse('$subjectId');
+ if (id == null || id == 0) return null;
+ for (final item in kSubjectList) {
+ if (item.id == id) return item.type;
+ }
+ return null;
+}
diff --git a/lib/core/utils/question/question_parser.dart b/lib/core/utils/question/question_parser.dart
new file mode 100644
index 0000000..89a6e99
--- /dev/null
+++ b/lib/core/utils/question/question_parser.dart
@@ -0,0 +1,150 @@
+/// ============================================================
+/// 题目解析总入口(1:1 移植 Vue utils/questionParser.ts)
+///
+/// parseQuestionData:根据 hasChild/childs 分发给 Standard /
+/// NonStandard 解析器,并支持把后端原始 childs 按索引合并回来。
+/// ============================================================
+library;
+
+import 'js_regex.dart';
+import 'question_parser_base.dart';
+import 'question_parser_standard.dart';
+import 'question_parser_non_standard.dart';
+
+export 'question_parser_base.dart';
+export 'question_parser_standard.dart' show parseStandardQuestion, analysisTextOf;
+export 'question_parser_non_standard.dart';
+
+/// 对标 Vue formatQuestionText:去掉空格与换行
+String formatQuestionText(String? s) {
+ if (s == null) return '';
+ return s.replaceAll(RegExp(r'[ \r\n]'), '');
+}
+
+/// 去掉 ... 占位盒
+String _stripHxdbPlaceholder(String? html) {
+ if (html == null || html.isEmpty) return '';
+ var result = html;
+
+ result = result.replaceAll(
+ RegExp(
+ r"""<([a-z0-9]+)\b[^>]*\bclass\s*=\s*(["'])[^"']*\bhxdb-placeholder\b[^"']*\2[^>]*/>""",
+ caseSensitive: false,
+ ),
+ '',
+ );
+
+ final startTagRegex = JRegExp(
+ r"""<([a-z0-9]+)\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-placeholder\b[^"']*)\3([^>]*)>""",
+ );
+ while (true) {
+ final match = startTagRegex.exec(result);
+ if (match == null) break;
+ final fullBlock = findFullElement(result, match);
+ result = result.replaceFirst(fullBlock, '');
+ startTagRegex.reset();
+ }
+ return result;
+}
+
+/// 按索引把原始 childs 合并回解析后的列表({...parsed, ...original})
+List> _mergeItemListWithOriginalChilds(
+ List? parsedList,
+ List originalChilds,
+) {
+ final list = parsedList ?? const [];
+ final maxLen = list.length > originalChilds.length
+ ? list.length
+ : originalChilds.length;
+ if (maxLen == 0) return const [];
+
+ return List.generate(maxLen, (index) {
+ final parsedItem = index < list.length && list[index] is Map
+ ? Map.from(list[index] as Map)
+ : null;
+ final original = index < originalChilds.length && originalChilds[index] is Map
+ ? Map.from(originalChilds[index] as Map)
+ : null;
+ if (parsedItem != null && original != null) {
+ return {...parsedItem, ...original};
+ }
+ if (parsedItem != null) return parsedItem;
+ return {...?original};
+ });
+}
+
+Map _mergeParsedWithOriginalChilds(
+ Map parsed,
+ List originalChilds,
+) {
+ final mergedSubQuestions =
+ _mergeItemListWithOriginalChilds(parsed['subQuestions'] as List?, originalChilds);
+ // JS: parsed.childs 非空时用 childs,否则回退 subQuestions
+ final parsedChildsSource = (parsed['childs'] is List &&
+ (parsed['childs'] as List).isNotEmpty)
+ ? parsed['childs'] as List
+ : (parsed['subQuestions'] as List? ?? const []);
+ final mergedChilds =
+ _mergeItemListWithOriginalChilds(parsedChildsSource, originalChilds);
+ final mergedBkTagMetaList =
+ _mergeItemListWithOriginalChilds(parsed['bkTagMetaList'] as List?, originalChilds);
+
+ return {
+ ...parsed,
+ 'subQuestions': mergedSubQuestions,
+ 'childs': mergedChilds,
+ 'bkTagMetaList':
+ mergedBkTagMetaList.isNotEmpty ? mergedBkTagMetaList : parsed['bkTagMetaList'],
+ };
+}
+
+Map _emptyParsed() => {
+ 'stem': '',
+ 'options': const >[],
+ 'isSelect': false,
+ 'answer': '',
+ 'analysis': '',
+ 'questionTypes': '',
+ };
+
+/// 解析单道后端题目
+Map parseQuestionData(
+ Map? detail, {
+ bool mergeOriginalChilds = false,
+}) {
+ // 兼容外层包了一层 questions
+ final questionData = (detail?['questions'] is Map
+ ? detail!['questions'] as Map
+ : detail)
+ as Map?;
+
+ if (questionData == null) return _emptyParsed();
+
+ final rawStem = _stripHxdbPlaceholder('${questionData['stem'] ?? ''}');
+ final rawAnswer = '${questionData['answer'] ?? ''}';
+ final rawCorrectAnswer = '${questionData['correctAnswer'] ?? ''}';
+ final rawAnalysisValue = questionData['answerAnalysis'];
+ final rawAnalysis = rawAnalysisValue is Map
+ ? '${rawAnalysisValue['analysis'] ?? ''}'
+ : '${rawAnalysisValue ?? ''}';
+ // 只有空标签的解析置空
+ final analysis = cleanRichText(rawAnalysis, '').isNotEmpty ? rawAnalysis : '';
+
+ final hasChildsArray =
+ questionData['childs'] is List && (questionData['childs'] as List).isNotEmpty;
+ final originalChilds =
+ hasChildsArray ? (questionData['childs'] as List) : const [];
+
+ final hasChilds = questionData['hasChild'] == true && hasChildsArray;
+
+ final parsed = hasChilds
+ ? parseStandardQuestion(
+ questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis)
+ : parseNonStandardQuestion(
+ questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
+
+ if (mergeOriginalChilds && originalChilds.isNotEmpty) {
+ return _mergeParsedWithOriginalChilds(parsed, originalChilds);
+ }
+ return parsed;
+}
diff --git a/lib/core/utils/question/question_parser_base.dart b/lib/core/utils/question/question_parser_base.dart
new file mode 100644
index 0000000..3ca37b9
--- /dev/null
+++ b/lib/core/utils/question/question_parser_base.dart
@@ -0,0 +1,913 @@
+/// ============================================================
+/// 题目解析基础工具(1:1 移植 Vue utils/questionParserBase.ts)
+///
+/// 包含:选项提取、平衡闭合标签查找、`` 标签解析、判断题处理、
+/// 题型代码、排序、富文本清洗、数学填空圈号、复合题题干格式化等。
+/// ============================================================
+library;
+
+import 'dart:convert';
+
+import 'question_constant.dart';
+import 'js_regex.dart';
+
+/// 填空占位元数据(对标 Vue BkTagMeta)
+typedef BkTagMeta = Map;
+
+/// 统一的选项结构
+Map makeOption(String label, String content) =>
+ {'optionLabel': label, 'options': content};
+
+// ------------------------------------------------------------
+// 选项提取
+// ------------------------------------------------------------
+
+/// 从 HTML 字符串中提取选项(hxdb-option-table-td / hxdb-sub-option-table-td)
+List> extractOptionsFromHtml(String html) {
+ final options = >[];
+ final tdRegex = JRegExp(
+ r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table-td[^"]*"[^>]*>([\s\S]*?)\1>',
+ );
+ const labelSrc = r'^(\s|<[^>]+>)*([A-Z])[\.\、\:\s]\s*( )?';
+ var index = 0;
+ JMatch? tdMatch;
+ while ((tdMatch = tdRegex.exec(html)) != null) {
+ final tdContent = tdMatch!(2) ?? '';
+ final labelMatch = RegExp(labelSrc, caseSensitive: false).firstMatch(tdContent);
+ var optionLabel = labelMatch != null ? labelMatch.group(2)! : '';
+ if (optionLabel.isEmpty) {
+ optionLabel = String.fromCharCode(65 + index);
+ }
+ // 去掉选项字母与分隔符,保留前面的空白/标签结构
+ final cleanedContent = tdContent.replaceFirstMapped(
+ RegExp(labelSrc, caseSensitive: false),
+ (m) => m.group(1) ?? '',
+ );
+ options.add(makeOption(optionLabel, cleanedContent));
+ index++;
+ }
+ return options;
+}
+
+/// 寻找平衡的闭合标签(解决嵌套 div/table 匹配问题)
+String findFullElement(String html, JMatch startTagMatch) {
+ final startIdx = startTagMatch.index;
+ final startTagFull = startTagMatch.group0;
+
+ final tagNameMatch = RegExp(r'^<([a-z0-9]+)', caseSensitive: false)
+ .firstMatch(startTagFull);
+ if (tagNameMatch == null) return startTagFull;
+
+ final tagName = tagNameMatch.group(1)!;
+ var depth = 1;
+ var currentPos = startIdx + startTagFull.length;
+
+ final tagRegex = JRegExp('<(\\/)?$tagName[^>]*>');
+ tagRegex.lastIndex = currentPos;
+
+ JMatch? tagMatch;
+ while ((tagMatch = tagRegex.exec(html)) != null) {
+ if (tagMatch!(1) == '/') {
+ depth--;
+ } else {
+ if (!tagMatch.group0.endsWith('/>')) {
+ depth++;
+ }
+ }
+ if (depth == 0) {
+ return html.substring(startIdx, tagMatch.end);
+ }
+ }
+ return startTagFull;
+}
+
+/// 剔除 stem 中的 hxdb-sub-option-table 选项表格
+String stripOptionTable(String html) {
+ if (html.isEmpty) return '';
+ var result = html;
+ final tableRegex = JRegExp(
+ r']*class="[^"]*hxdb-sub-option-table[^"]*"[^>]*>',
+ );
+ while (true) {
+ final match = tableRegex.exec(result);
+ if (match == null) break;
+ final fullBlock = findFullElement(result, match);
+ result = result.replaceFirst(fullBlock, '');
+ tableRegex.reset();
+ }
+ return result;
+}
+
+/// 纯文本 A. xxx 选项兜底提取
+List> extractPlainTextOptions(String html) {
+ final result = >[];
+ final plain = html
+ .replaceAll(RegExp(r'<[^>]+>'), '\n')
+ .replaceAll(' ', ' ')
+ .replaceAll(RegExp(r'\n{2,}'), '\n')
+ .trim();
+ final lines = plain.split('\n').where((l) => l.trim().isNotEmpty);
+ final optionRegex = RegExp(r'^([A-Z])[\.\、\s]\s*(.+)$');
+ for (final line in lines) {
+ final m = optionRegex.firstMatch(line.trim());
+ if (m != null) {
+ final label = m.group(1)!;
+ result.add(makeOption(
+ label,
+ '$label. ${m.group(2)!.trim()}',
+ ));
+ }
+ }
+ return result;
+}
+
+// ------------------------------------------------------------
+// 填空标签解析
+// ------------------------------------------------------------
+
+final RegExp _attrIndexRe = RegExp(
+ r'''\bindex\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
+ caseSensitive: false,
+);
+final RegExp _attrTypeRe = RegExp(
+ r'''\btype\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
+ caseSensitive: false,
+);
+final RegExp _attrDataInputRe = RegExp(
+ r'''\bdata-input-type\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
+ caseSensitive: false,
+);
+
+String? _attrValue(RegExpMatch? m) =>
+ m == null ? null : (m.group(2) ?? m.group(3));
+
+/// 解析填空题的 `` 标签,生成每个空的元数据
+List parseBkTagMetaFromStem(
+ String? stem,
+ String? correctAnswerHtml,
+ String? userAnswerJson, [
+ dynamic iSFilllankItemUpload,
+ List>? options,
+]) {
+ if (stem == null || stem.isEmpty) return const [];
+ final correctAnswerMap = correctAnswerHtml != null && correctAnswerHtml.isNotEmpty
+ ? parseCorrectAnswerMapFromHtml(correctAnswerHtml)
+ : {};
+ final userAnswerMap = parseUserAnswerMapFromJson(
+ userAnswerJson,
+ jsNumber(iSFilllankItemUpload) == 1,
+ );
+
+ final list = [];
+ final seenIndexes = {};
+ final tagRegex = JRegExp(r']*)>', caseInsensitive: true);
+ JMatch? m;
+ while ((m = tagRegex.exec(stem)) != null) {
+ final attrs = m!(1) ?? '';
+ final indexMatch = _attrIndexRe.firstMatch(attrs);
+ if (indexMatch == null) continue;
+
+ final index = jsNumber(_attrValue(indexMatch))?.toInt() ?? 0;
+ if (index <= 0) continue;
+ if (seenIndexes.contains(index)) continue;
+ seenIndexes.add(index);
+
+ final typeMatch = _attrTypeRe.firstMatch(attrs);
+ final dataInputTypeMatch = _attrDataInputRe.firstMatch(attrs);
+
+ final item = {
+ 'index': index,
+ 'type': _attrValue(typeMatch) ?? '',
+ 'dataInputType': _attrValue(dataInputTypeMatch) ?? '',
+ 'correctAnswer': correctAnswerMap[index] ?? '',
+ 'options': options ?? const [],
+ };
+
+ final fromUser = userAnswerMap[index];
+ final isUpload = jsNumber(iSFilllankItemUpload) == 1;
+ final myAnswer = fromUser?['myAnswer'];
+ final hasImgArray = myAnswer is List &&
+ myAnswer.isNotEmpty &&
+ (myAnswer.first is Map) &&
+ (myAnswer.first as Map)['url'] != null;
+
+ if (isUpload || hasImgArray) {
+ final imgs = myAnswer is List ? myAnswer : const [];
+ if (imgs.isNotEmpty) item['myAnswer'] = imgs;
+ } else {
+ final my = myAnswer is String ? myAnswer.trim() : '';
+ if (my.isNotEmpty) item['myAnswer'] = my;
+ }
+ if (fromUser?['answerStatus'] is int) {
+ item['answerStatus'] = fromUser!['answerStatus'];
+ }
+ list.add(item);
+ }
+ return list;
+}
+
+String _normalizeAnswerUrl(dynamic u) =>
+ '$u'.replaceAll(RegExp(r'[`\s]'), '').trim();
+
+/// 将用户作答 JSON 字符串解析为 {空索引: {myAnswer, answerStatus}}
+Map> parseUserAnswerMapFromJson([
+ String? userAnswerJson,
+ bool isUpload = false,
+]) {
+ final map = >{};
+ if (userAnswerJson == null || userAnswerJson.isEmpty) return map;
+ dynamic parsed;
+ try {
+ parsed = _jsonDecode(userAnswerJson);
+ } catch (_) {
+ return map;
+ }
+ if (parsed is! List) return map;
+
+ for (final rawItem in parsed) {
+ if (rawItem is! Map) continue;
+ final item = Map.from(rawItem);
+ final idx = jsNumber(item['index'] ?? item['Index'])?.toInt() ?? 0;
+ if (idx <= 0) continue;
+
+ final hasImg = item['Url'] != null ||
+ item['url'] != null ||
+ item['ThumbnailUrl'] != null ||
+ item['thumbnailUrl'] != null;
+
+ if (isUpload || hasImg) {
+ if (item.containsKey('myAnswer') || item.containsKey('answerStatus')) {
+ dynamic myAnswer = item['myAnswer'];
+ if (myAnswer is String) {
+ try {
+ final arr = _jsonDecode(myAnswer);
+ myAnswer = arr is List ? arr : [];
+ } catch (_) {
+ myAnswer = [];
+ }
+ }
+ if (myAnswer is! List) myAnswer = [];
+
+ final answerStatus = item['answerStatus'];
+ final payload = {};
+ if (myAnswer.isNotEmpty) payload['myAnswer'] = myAnswer;
+ if (answerStatus is int) payload['answerStatus'] = answerStatus;
+
+ final merged = Map.from(map[idx] ?? const {});
+ if (payload.containsKey('myAnswer')) merged['myAnswer'] = payload['myAnswer'];
+ if (payload.containsKey('answerStatus')) {
+ merged['answerStatus'] = payload['answerStatus'];
+ }
+ if (merged.containsKey('myAnswer') || merged.containsKey('answerStatus')) {
+ map[idx] = merged;
+ }
+ continue;
+ }
+
+ final url = _normalizeAnswerUrl(item['Url'] ?? item['url']);
+ if (url.isEmpty) continue;
+ final thumbnailUrl = _normalizeAnswerUrl(
+ item['ThumbnailUrl'] ?? item['thumbnailUrl'] ?? url,
+ );
+ final type = item['Type'] ?? item['type'] ?? 0;
+ final imgItem = {
+ ...item,
+ 'index': idx,
+ 'type': type,
+ 'url': url,
+ 'thumbnailUrl': thumbnailUrl,
+ };
+
+ final existing = map[idx];
+ final list = existing?['myAnswer'] is List
+ ? List.from(existing!['myAnswer'] as List)
+ : [];
+ list.add(imgItem);
+ map[idx] = {
+ ...?existing,
+ 'myAnswer': list,
+ };
+ } else {
+ final myAnswer = item['myAnswer'] is String ? item['myAnswer'] as String : '';
+ final answerStatus = item['answerStatus'];
+ final payload = {};
+ if (myAnswer.trim().isNotEmpty) payload['myAnswer'] = myAnswer;
+ if (answerStatus is int) payload['answerStatus'] = answerStatus;
+ if (payload.containsKey('myAnswer') || payload.containsKey('answerStatus')) {
+ map[idx] = payload;
+ }
+ }
+ }
+ return map;
+}
+
+/// 从标准答案 HTML 中按 data-index 切分 hxdb-subquestion 块
+Map parseCorrectAnswerMapFromHtml(String html) {
+ final result = {};
+ if (html.isEmpty) return result;
+
+ final startTagRegex = JRegExp(
+ r''']*)\bclass\s*=\s*(["'])([^"']*\bhxdb-subquestion\b[^"']*)\2([^>]*)>''',
+ );
+ JMatch? match;
+ while ((match = startTagRegex.exec(html)) != null) {
+ final attrs = '${match!(1) ?? ''} ${match(3) ?? ''} ${match(4) ?? ''}';
+ final indexMatch = RegExp(
+ r'''\bdata-index\s*=\s*(["'])(\d+)\1''',
+ caseSensitive: false,
+ ).firstMatch(attrs);
+ if (indexMatch == null) continue;
+ final idx = int.tryParse(indexMatch.group(2)!) ?? 0;
+ if (idx <= 0) continue;
+ final fullBlock = findFullElement(html, match);
+ result[idx] = fullBlock;
+ }
+ return result;
+}
+
+// ------------------------------------------------------------
+// 题型 / 排序 / 答案清洗
+// ------------------------------------------------------------
+
+/// 判断题识别:命中则向 options 补“正确/错误”两项
+bool processPanDuan(Map
? detail, List> options) {
+ final answer = '${detail?['answer'] ?? ''}';
+ final typeId = '${detail?['questionTypeId'] ?? ''}';
+
+ final isPanduan = answer.contains('data-hxdb-type="panduanti"') ||
+ typeId == '12352066848751621' ||
+ typeId == '792156155195462';
+
+ if (isPanduan) {
+ if (options.isEmpty) {
+ options
+ ..add(makeOption('正确', '正确'))
+ ..add(makeOption('错误', '错误'));
+ }
+ return true;
+ }
+ return false;
+}
+
+/// 题型代码(对标 Vue getQuestionTypeCode)
+String getQuestionTypeCode(dynamic typeId, dynamic isObjective, [dynamic typecodeKey]) {
+ final id = '$typeId';
+ if (typecodeKey != null &&
+ '$typecodeKey'.isNotEmpty &&
+ QuestionTypeCodeMap.typeCode.containsKey('$typecodeKey')) {
+ return QuestionTypeCodeMap.typeCode['$typecodeKey']!;
+ }
+ if (isObjective == true) {
+ return QuestionTypeCodeMap.objective[id] ?? '';
+ }
+ return QuestionTypeCodeMap.subjective[id] ?? '';
+}
+
+/// 给题目(含子题)追加从 1 开始的连续 sort
+List> appendQuestionSort(List? questions) {
+ if (questions == null) return const [];
+ var sort = 1;
+ return questions.map((rawItem) {
+ final item = rawItem is Map
+ ? Map.from(rawItem)
+ : {};
+ final hasChild = item['hasChild'] == true;
+ final hasSubQuestions = item['subQuestions'] is List &&
+ (item['subQuestions'] as List).isNotEmpty;
+
+ if (hasChild && hasSubQuestions) {
+ final subQuestions = (item['subQuestions'] as List).map((rawSub) {
+ final sub = Map.from(rawSub as Map);
+ sub['sort'] = sort++;
+ return sub;
+ }).toList();
+ return {
+ ...item,
+ 'subQuestions': subQuestions,
+ };
+ }
+ return {
+ ...item,
+ 'sort': sort++,
+ };
+ }).toList();
+}
+
+/// 去 HTML 标签(对标 Vue stripAnswerPlain)
+String stripAnswerPlain(dynamic html) {
+ if (html == null) return '';
+ return '$html'.replaceAll(RegExp(r'<[^>]+>'), '').trim();
+}
+
+// ------------------------------------------------------------
+// 富文本清洗
+// ------------------------------------------------------------
+
+final RegExp _htmlAttrNameRe = RegExp(r'^[A-Za-z_:][A-Za-z0-9:._-]*$');
+
+String _normalizeRichTextUrl(String value) =>
+ value.replaceAll(RegExp(r'[`\s]'), '').trim();
+
+/// 统一处理 HTML 中图片尺寸样式(Flutter 为非 MP 端,尺寸输出 px)
+String fixImageStyles(String? html) {
+ if (html == null || html.isEmpty) return '';
+ return html.replaceAllMapped(RegExp(r' ]*)>', caseSensitive: false),
+ (match) {
+ var newAttrs = match.group(1)!;
+ var extractedWidth = '';
+ var extractedHeight = '';
+
+ // 提取并移除原有 width/height 属性
+ newAttrs = newAttrs.replaceAllMapped(
+ RegExp(
+ r'''\b(width|height)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))''',
+ caseSensitive: false,
+ ),
+ (m) {
+ final attrName = (m.group(1) ?? '').toLowerCase();
+ final val = m.group(3) ?? m.group(4) ?? '';
+ if (attrName == 'width') extractedWidth = val;
+ if (attrName == 'height') extractedHeight = val;
+ return '';
+ },
+ );
+
+ final styleObj = {};
+ if (RegExp(r'\bstyle\s*=\s*', caseSensitive: false).hasMatch(newAttrs)) {
+ newAttrs = newAttrs.replaceAllMapped(
+ RegExp(r'''\bstyle\s*=\s*(["'])([\s\S]*?)\1''', caseSensitive: false),
+ (m) {
+ final styleValue = m.group(2) ?? '';
+ for (final item in styleValue.split(';')) {
+ final idx = item.indexOf(':');
+ if (idx > 0) {
+ final key = item.substring(0, idx).trim().toLowerCase();
+ final value = item.substring(idx + 1).trim();
+ if (key.isNotEmpty && value.isNotEmpty) styleObj[key] = value;
+ }
+ }
+ return '';
+ },
+ );
+ }
+
+ if (styleObj['width'] != null && extractedWidth.isEmpty) {
+ extractedWidth = styleObj['width']!;
+ }
+ if (styleObj['height'] != null && extractedHeight.isEmpty) {
+ extractedHeight = styleObj['height']!;
+ }
+
+ styleObj.remove('width');
+ styleObj.remove('height');
+ styleObj.remove('display');
+
+ // 非 MP-WEIXIN:纯数字尺寸输出 px
+ String processSize(String sizeVal) {
+ if (sizeVal.isEmpty) return '';
+ sizeVal = sizeVal.trim().toLowerCase();
+ if (RegExp(r'(ex|em|rem|%|vw|vh|rpx|auto)$').hasMatch(sizeVal)) {
+ return sizeVal;
+ }
+ final numMatch = RegExp(r'^([0-9.]+)').firstMatch(sizeVal);
+ if (numMatch != null) {
+ final num = double.tryParse(numMatch.group(1)!);
+ if (num != null) {
+ return '${num}px';
+ }
+ }
+ return sizeVal;
+ }
+
+ final finalWidth = processSize(extractedWidth);
+ final finalHeight = processSize(extractedHeight);
+
+ if (finalWidth.isNotEmpty) styleObj['width'] = finalWidth;
+ if (finalHeight.isNotEmpty) styleObj['height'] = finalHeight;
+
+ styleObj['max-width'] = '100% !important';
+
+ if (finalWidth.isEmpty && finalHeight.isEmpty) {
+ styleObj['display'] = 'block';
+ styleObj['margin'] = '10rpx auto';
+ } else {
+ styleObj['display'] = 'inline-block !important';
+ styleObj['vertical-align'] = 'middle !important';
+ styleObj['padding'] = '5px 0 !important';
+ styleObj['margin'] = '0 2px !important';
+ }
+
+ final styleStr = styleObj.entries
+ .map((e) => '${e.key}: ${e.value}')
+ .join('; ');
+ newAttrs += ' style="$styleStr"';
+ return ' ';
+ });
+}
+
+String _sanitizeHtmlAttributeValue(String name, String value) {
+ var normalized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
+ if (RegExp(r'^(src|href|poster)$', caseSensitive: false).hasMatch(name)) {
+ normalized = _normalizeRichTextUrl(normalized);
+ } else if (name == 'style') {
+ normalized = normalized.replaceAll('"', '').replaceAll(RegExp(r'\s+'), ' ').trim();
+ }
+ return normalized.replaceAll('"', '"');
+}
+
+String _sanitizeHtmlTag(String tag) {
+ if (tag.isEmpty || tag.startsWith('') || tag.startsWith('$').firstMatch(tag);
+ if (tagMatch == null) return tag;
+
+ final tagName = tagMatch.group(1)!;
+ final rawAttrs = tagMatch.group(2) ?? '';
+ final selfClosing = tagMatch.group(3) ?? '';
+ final attrs = [];
+ final attrRegex = RegExp(
+ r'''([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?''',
+ );
+ for (final attrMatch in attrRegex.allMatches(rawAttrs)) {
+ final attrName = (attrMatch.group(1) ?? '').trim();
+ if (!_htmlAttrNameRe.hasMatch(attrName)) continue;
+
+ final hasValue = attrMatch.group(2) != null ||
+ attrMatch.group(3) != null ||
+ attrMatch.group(4) != null;
+ if (!hasValue) {
+ attrs.add(attrName);
+ continue;
+ }
+ final attrValue = attrMatch.group(2) ?? attrMatch.group(3) ?? attrMatch.group(4) ?? '';
+ attrs.add('$attrName="${_sanitizeHtmlAttributeValue(attrName, attrValue)}"');
+ }
+
+ return '<$tagName${attrs.isNotEmpty ? ' ${attrs.join(' ')}' : ''}${selfClosing.isNotEmpty ? ' /' : ''}>';
+}
+
+/// 修复编辑器产生的破碎 style 属性(font-family 被拆碎等)
+String _repairMalformedTagAttributes(String html) {
+ if (html.isEmpty) return '';
+ return html.replaceAllMapped(RegExp(r'<[^>]+>'), (m) {
+ var tag = m.group(0)!;
+ if (tag.startsWith('') || tag.startsWith('])'''),
+ '',
+ );
+ tag = tag.replaceAll(RegExp(r'\s{2,}'), ' ');
+ return tag;
+ });
+}
+
+/// 将 / 内 提升到父标签
+String _hoistMediaSourceToParent(String html) {
+ if (html.isEmpty) return '';
+ return html.replaceAllMapped(
+ RegExp(r'<(audio|video)\b([^>]*)>([\s\S]*?)\1>', caseSensitive: false),
+ (m) {
+ final tagName = m.group(1)!;
+ final rawAttrs = m.group(2) ?? '';
+ final innerHtml = m.group(3) ?? '';
+
+ final directSrcMatch = RegExp(
+ r'''\bsrc\s*=\s*(["'])(.*?)\1''',
+ caseSensitive: false,
+ ).firstMatch(rawAttrs);
+ final sourceSrcMatch = RegExp(
+ r''']*\bsrc\s*=\s*(["'])(.*?)\1''',
+ caseSensitive: false,
+ ).firstMatch(innerHtml);
+ final finalSrc = _normalizeRichTextUrl(
+ (directSrcMatch?.group(2) ?? sourceSrcMatch?.group(2) ?? ''),
+ );
+ if (finalSrc.isEmpty) return m.group(0)!;
+
+ var nextAttrs = rawAttrs;
+ if (directSrcMatch != null) {
+ nextAttrs = nextAttrs.replaceFirst(directSrcMatch.group(0)!, 'src="$finalSrc"');
+ } else {
+ nextAttrs = '$nextAttrs src="$finalSrc"';
+ }
+ return '<$tagName$nextAttrs>$tagName>';
+ },
+ );
+}
+
+/// 富文本进入组件前的轻量清洗
+String sanitizeRichTextHtml(String? content) {
+ if (content == null || content.isEmpty) return '';
+ var result = fixImageStyles(content);
+ result = _repairMalformedTagAttributes(result);
+ result = _hoistMediaSourceToParent(result);
+
+ result = result.replaceAllMapped(
+ RegExp(r'''\sstyle=(["'])([\s\S]*?)\1''', caseSensitive: false),
+ (m) {
+ final raw = m.group(2) ?? '';
+ if (RegExp(r'^\s*font-family\s*:\s*;?\s*$', caseSensitive: false).hasMatch(raw)) {
+ return '';
+ }
+ return m.group(0)!;
+ },
+ );
+
+ // 移除 KaTeX 隐藏 MathML
+ result = result.replaceAll(
+ RegExp(
+ r''']*class=(["'])[^"']*\bkatex-mathml\b[^"']*\1[^>]*>[\s\S]*? ''',
+ caseSensitive: false,
+ ),
+ '',
+ );
+ result = result.replaceAllMapped(
+ RegExp(
+ r'''<(audio|video|img|source)\b[^>]*?(?:src|poster)\s*=\s*(["'])(.*?)\2''',
+ caseSensitive: false,
+ ),
+ (m) {
+ final cleanedUrl = _normalizeRichTextUrl(m.group(3) ?? '');
+ return m.group(0)!.replaceFirst(m.group(3)!, cleanedUrl);
+ },
+ );
+
+ return result.replaceAllMapped(
+ RegExp(r'?[A-Za-z][^>]*>'),
+ (m) => _sanitizeHtmlTag(m.group(0)!),
+ );
+}
+
+/// 富文本清洗 + 空内容兜底
+String cleanRichText(String? content, [String defaultText = '']) {
+ if (content == null || content.isEmpty) return defaultText;
+
+ var result = fixImageStyles(content);
+ result = sanitizeRichTextHtml(result).replaceAll(
+ RegExp(r' \s* '),
+ ' ',
+ );
+ result = result.replaceAllMapped(
+ RegExp(r'''src=(["'])(.*?)\1'''),
+ (m) => 'src=${m.group(1)}${(m.group(2) ?? '').replaceAll(RegExp(r'[`\s]'), '')}${m.group(1)}',
+ );
+
+ final plainText = result
+ .replaceAll(RegExp(r'<[^>]+>'), '')
+ .replaceAll(' ', '')
+ .trim();
+ final hasImgOrVideo = RegExp(
+ r'<(img|video|audio|iframe|embed|object|source)[^>]*>',
+ caseSensitive: false,
+ ).hasMatch(result);
+
+ if (plainText.isEmpty && !hasImgOrVideo) return defaultText;
+ return result.isEmpty ? defaultText : result;
+}
+
+// ------------------------------------------------------------
+// 数学填空圈号
+// ------------------------------------------------------------
+
+const List _circledNumbers = [
+ '①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩',
+ '⑪','⑫','⑬','⑭','⑮','⑯','⑰','⑱','⑲','⑳',
+ '㉑','㉒','㉓','㉔','㉕','㉖','㉗','㉘','㉙','㉚',
+ '㉛','㉜','㉝','㉞','㉟','㊱','㊲','㊳','㊴','㊵',
+ '㊶','㊷','㊸','㊹','㊺','㊻','㊼','㊽','㊾','㊿',
+];
+
+/// 将题干数字占位符替换成圈号(数学学科)
+String convertBlankToCircled(String? stem) {
+ if (stem == null || stem.isEmpty) return stem ?? '';
+
+ // 1. ___数字___ → ___①___(跳过 URL/文件名场景)
+ var result = stem.replaceAllMapped(
+ RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
+ (m) {
+ final prevChar = m.start > 0 ? stem[m.start - 1] : '';
+ final nextChar = m.end < stem.length ? stem[m.end] : '';
+ if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
+ RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
+ return m.group(0)!;
+ }
+ final num = int.tryParse(m.group(2)!) ?? 0;
+ if (num >= 1 && num <= 50) {
+ return '${m.group(1)}${_circledNumbers[num - 1]}${m.group(3)}';
+ }
+ return m.group(0)!;
+ },
+ );
+
+ // 2. text-decoration:underline 标签内纯数字 → 圈号
+ result = result.replaceAllMapped(
+ RegExp(
+ r'''(<[^>]*style=["'][^"']*text-decoration:\s*underline[^"']*["'][^>]*>)([\s\S]*?)([a-zA-Z][^>]*>)''',
+ caseSensitive: false,
+ ),
+ (m) {
+ final openTag = m.group(1)!;
+ final content = m.group(2)!;
+ final closeTag = m.group(3)!;
+ final textOnly = content
+ .replaceAll(RegExp(r'<[^>]+>'), '')
+ .replaceAll(' ', ' ')
+ .trim();
+ if (RegExp(r'^\d+$').hasMatch(textOnly)) {
+ final num = int.tryParse(textOnly) ?? 0;
+ if (num >= 1 && num <= 50) {
+ final newOpenTag = openTag.replaceAll(
+ RegExp(r'text-decoration:\s*underline', caseSensitive: false),
+ 'text-decoration:underline;text-underline-offset:3px',
+ );
+ return '$newOpenTag${content.replaceFirst(RegExp(r'\d+'), _circledNumbers[num - 1])}$closeTag';
+ }
+ }
+ return m.group(0)!;
+ },
+ );
+
+ return result;
+}
+
+// ------------------------------------------------------------
+// 复合题题干: → 可点击
+// ------------------------------------------------------------
+
+/// 格式化复合题题干(subQuestions 为解析后的子题 Map 列表)
+String formatCompoundStem(
+ String? stem,
+ int activeSubIndex, {
+ bool disableHighlight = false,
+ List> subQuestions = const [],
+}) {
+ if (stem == null || stem.isEmpty) return '暂无题干';
+ var formattedStem = stem;
+
+ // 非自闭合 ...
+ formattedStem = formattedStem.replaceAllMapped(
+ RegExp(r']*)>([\s\S]*?) ', caseSensitive: false),
+ (m) {
+ final attrs = m.group(1) ?? '';
+ final inner = m.group(2) ?? '';
+ final indexMatch = RegExp(
+ r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
+ caseSensitive: false,
+ ).firstMatch(attrs);
+ if (indexMatch == null) return m.group(0)!;
+ final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
+ final hasNumber = RegExp(r'\d').hasMatch(inner);
+ final isOnlyNumber = RegExp(r'^\s*\d+\s*$').hasMatch(inner);
+ var content = inner;
+ if (!isOnlyNumber && !hasNumber) {
+ content = '___${idx + 1}___';
+ }
+ return '$content ';
+ },
+ );
+
+ // 自闭合
+ formattedStem = formattedStem.replaceAllMapped(
+ RegExp(r']*?)\s*/\s*>', caseSensitive: false),
+ (m) {
+ final attrs = m.group(1) ?? '';
+ final indexMatch = RegExp(
+ r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
+ caseSensitive: false,
+ ).firstMatch(attrs);
+ if (indexMatch == null) return m.group(0)!;
+ final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
+ return '___${idx + 1}___ ';
+ },
+ );
+
+ // 保护所有 HTML 标签,避免属性/标签内部文本被替换
+ final tagPlaceholders = [];
+ var tagCounter = 0;
+ final protectedStem = formattedStem.replaceAllMapped(
+ RegExp(r'''<(?:[^>"']|"[^"]*"|'[^']*')*>'''),
+ (m) {
+ final token = '__TAG_${tagCounter++}__';
+ tagPlaceholders.add(m.group(0)!);
+ return token;
+ },
+ );
+
+ // 纯文本 ___数字___ →
+ var processedStem = protectedStem.replaceAllMapped(
+ RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
+ (m) {
+ final src = m.input;
+ final prevChar = m.start > 0 ? src[m.start - 1] : '';
+ final nextChar = m.end < src.length ? src[m.end] : '';
+ if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
+ RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
+ return m.group(0)!;
+ }
+ final idx = (int.tryParse(m.group(2)!) ?? 1) - 1;
+ return ' ${m.group(0)} ';
+ },
+ );
+
+ processedStem = processedStem.replaceAllMapped(
+ RegExp(r'__TAG_(\d+)__'),
+ (m) => tagPlaceholders[int.parse(m.group(1)!)],
+ );
+ formattedStem = processedStem;
+
+ // 注入样式/激活态/跳转链接
+ formattedStem = formattedStem.replaceAllMapped(
+ RegExp(r'([\s\S]*?) ', caseSensitive: false),
+ (m) {
+ final idxStr = m.group(1)!;
+ final content = m.group(2) ?? '';
+ final textOnly = content
+ .replaceAll(RegExp(r'<[^>]+>'), '')
+ .replaceAll(' ', ' ')
+ .trim();
+ final hasImg = RegExp(r' = 0 && dataIdx < subQuestions.length) {
+ targetSubIndex = dataIdx;
+ isActive = dataIdx == activeSubIndex;
+ } else {
+ final numMatch = RegExp(r'\d+').firstMatch(textOnly);
+ var matchedIndex = -1;
+ if (numMatch != null) {
+ final textNum = int.parse(numMatch.group(0)!);
+ matchedIndex = subQuestions.indexWhere(
+ (q) => jsNumber(q['sort'])?.toInt() == textNum);
+ if (matchedIndex == -1) {
+ matchedIndex = subQuestions.indexWhere((q) {
+ final stemMatch = RegExp(
+ r''']*>.*?(\d+)\.\s*(?: )* |.*?(\d+)\.\s*(?: )*
''',
+ caseSensitive: false,
+ dotAll: true,
+ ).firstMatch('${q['stem'] ?? ''}');
+ if (stemMatch != null) {
+ final n = int.tryParse(stemMatch.group(1) ?? stemMatch.group(2) ?? '');
+ return n == textNum;
+ }
+ return false;
+ });
+ }
+ }
+ if (matchedIndex != -1) {
+ targetSubIndex = matchedIndex;
+ isActive = matchedIndex == activeSubIndex;
+ } else {
+ targetSubIndex = -1;
+ isActive = false;
+ }
+ }
+ } else {
+ isActive = dataIdx == activeSubIndex;
+ }
+
+ const baseStyle =
+ 'text-decoration: none; color: inherit; cursor: pointer; display: inline-block; line-height: 1; margin: 0 4px; white-space: nowrap; word-break: keep-all; vertical-align: middle;';
+ var activeStyle = 'border: 1px solid transparent; padding: 0 2px;';
+
+ if (isActive && !disableHighlight) {
+ final shouldHighlight = RegExp(r'\d').hasMatch(textOnly) ||
+ RegExp(r'_+').hasMatch(textOnly) ||
+ hasImg;
+ if (shouldHighlight) {
+ activeStyle =
+ 'line-height:24rpx;height:24px;border: 1px solid rgba(61, 125, 245, 1); border-radius: 8px; background: rgba(41, 121, 255, 0.1); box-sizing: border-box; padding: 0 4px;';
+ }
+ }
+
+ return '$content ';
+ },
+ );
+
+ return formattedStem;
+}
+
+// ------------------------------------------------------------
+// 内部工具
+// ------------------------------------------------------------
+
+dynamic _jsonDecode(String text) => jsonDecode(text);
diff --git a/lib/core/utils/question/question_parser_non_standard.dart b/lib/core/utils/question/question_parser_non_standard.dart
new file mode 100644
index 0000000..856086f
--- /dev/null
+++ b/lib/core/utils/question/question_parser_non_standard.dart
@@ -0,0 +1,204 @@
+/// ============================================================
+/// 非标准题 / 单题解析器(1:1 移植 Vue utils/questionParserNonStandard.ts)
+///
+/// 处理不带 childs 的独立题目:单选 / 多选 / 判断 / 填空。
+/// 七选五与多空填空题会在本地构造 childs 后转交 Standard 解析器。
+/// ============================================================
+library;
+
+import 'question_parser_base.dart';
+import 'question_parser_standard.dart';
+import 'formula.dart';
+
+Map parseNonStandardQuestion(
+ Map questionData,
+ String rawStemIn,
+ String rawAnswer,
+ String rawCorrectAnswer,
+ String analysis,
+) {
+ var rawStem = rawStemIn;
+ final options = >[];
+ var isSelect = questionData['isSelect'] == true;
+ var isMainPanDuan = false;
+
+ // 1) 主判断题
+ if (processPanDuan(questionData, options)) {
+ isSelect = true;
+ isMainPanDuan = true;
+ }
+
+ // 2) 题干选项表
+ final optionTableRegex = RegExp(
+ r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)\1>',
+ caseSensitive: false,
+ );
+ final match = optionTableRegex.firstMatch(rawStem);
+ if (match != null) {
+ isSelect = true;
+ options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
+ rawStem = rawStem.replaceFirst(optionTableRegex, '');
+ }
+
+ var finalQuestionTypes = getQuestionTypeCode(
+ questionData['questionTypeId'],
+ questionData['isObjective'],
+ questionData['questionTypeCode'],
+ );
+
+ // 3) 纯文本选项兜底
+ if (options.isEmpty &&
+ (finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
+ final plainOptions = extractPlainTextOptions(rawStem);
+ if (plainOptions.isNotEmpty) {
+ options.addAll(plainOptions);
+ isSelect = true;
+ }
+ }
+
+ if (isMainPanDuan) {
+ finalQuestionTypes = 'panduanti';
+ }
+
+ // --- 七选五:按填空元数据构造 childs,转复合题 ---
+ if (finalQuestionTypes == 'qixuanwu') {
+ final metas = parseBkTagMetaFromStem(
+ rawStem,
+ rawCorrectAnswer,
+ questionData['userAnswer']?.toString(),
+ questionData['iSFilllankItemUpload'],
+ options,
+ );
+ if (metas.isNotEmpty) {
+ final indexes = (questionData['indexes'] as List?) ?? const [];
+ final generatedChilds = List.generate(metas.length, (index) {
+ final meta = metas[index];
+ return {
+ ...meta,
+ 'id': '${questionData['id']}_${meta['index']}',
+ 'stem': '',
+ 'answer': meta['correctAnswer'],
+ 'isObjective': true,
+ 'questionTypes': 'qixuanwu_sub',
+ 'options': options,
+ if (index < indexes.length && indexes[index] is Map)
+ ...Map.from(indexes[index] as Map),
+ 'sort': meta['index'],
+ };
+ });
+
+ final compositeData = {
+ ...questionData,
+ 'hasChild': true,
+ 'isObjective': false,
+ 'childs': generatedChilds,
+ 'questionTypes': 'qixuanwu',
+ };
+ return parseStandardQuestion(
+ compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
+ }
+ }
+
+ // --- 填空题:构造 childs 转复合题 ---
+ if (finalQuestionTypes == 'tiankongti') {
+ var metasList = parseBkTagMetaFromStem(
+ rawStem,
+ rawCorrectAnswer,
+ questionData['userAnswer']?.toString(),
+ questionData['iSFilllankItemUpload'],
+ );
+ if (metasList.isEmpty) {
+ metasList = [
+ {
+ 'index': 1,
+ 'type': 0,
+ 'dataInputType': 0,
+ 'correctAnswer': rawCorrectAnswer,
+ 'questionTypes': 'tiankongti',
+ 'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
+ }
+ ];
+ final userAnswerMap = parseUserAnswerMapFromJson(
+ questionData['userAnswer']?.toString(),
+ questionData['iSFilllankItemUpload'] == 1,
+ );
+ final fromUser = userAnswerMap[1];
+ if (fromUser != null) {
+ final isUpload = questionData['iSFilllankItemUpload'] == 1;
+ final myAnswer = fromUser['myAnswer'];
+ final hasImgArray = myAnswer is List &&
+ myAnswer.isNotEmpty &&
+ myAnswer.first is Map &&
+ (myAnswer.first as Map)['url'] != null;
+ if (isUpload || hasImgArray) {
+ metasList[0]['myAnswer'] = myAnswer is List ? myAnswer : const [];
+ } else {
+ metasList[0]['myAnswer'] = myAnswer is String ? myAnswer.trim() : '';
+ }
+ if (fromUser['answerStatus'] is int) {
+ metasList[0]['answerStatus'] = fromUser['answerStatus'];
+ }
+ }
+ }
+ if (metasList.isNotEmpty) {
+ final indexes = (questionData['indexes'] as List?) ?? const [];
+ final generatedChilds = List.generate(metasList.length, (index) {
+ final meta = metasList[index];
+ return {
+ ...meta,
+ 'id': '${questionData['id']}_${meta['index']}',
+ 'questionId': questionData['id'],
+ 'stem': '',
+ 'answer': meta['correctAnswer'],
+ 'isObjective': false,
+ 'questionTypes': 'tiankongti',
+ 'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
+ if (index < indexes.length && indexes[index] is Map)
+ ...Map.from(indexes[index] as Map),
+ 'sort': meta['index'],
+ };
+ });
+ final compositeData = {
+ ...questionData,
+ 'hasChild': true,
+ 'isObjective': false,
+ 'childs': generatedChilds,
+ 'questionTypes': 'tiankongti',
+ 'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
+ };
+ return parseStandardQuestion(
+ compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
+ }
+ }
+
+ final optionSource = options.isNotEmpty
+ ? options
+ : ((questionData['options'] as List?)?.whereType().toList() ?? const []);
+
+ return {
+ ...questionData,
+ 'raw': questionData,
+ 'stem': fixImageStyles(renderLatexInHtml(rawStem)),
+ 'options': optionSource.map((rawOpt) {
+ final opt = Map.from(rawOpt);
+ return {
+ ...opt,
+ 'options': fixImageStyles(renderLatexInHtml('${opt['options'] ?? ''}')),
+ };
+ }).toList(),
+ 'isSelect': isSelect,
+ 'questionTypes': finalQuestionTypes,
+ 'answer': rawAnswer,
+ 'analysis': fixImageStyles(renderLatexInHtml(analysis)),
+ 'subQuestions': const [],
+ if (finalQuestionTypes == 'tiankongti')
+ 'bkTagMetaList': [
+ {
+ 'index': 1,
+ 'type': 0,
+ 'dataInputType': 0,
+ 'correctAnswer': fixImageStyles(renderLatexInHtml(rawCorrectAnswer)),
+ }
+ ],
+ };
+}
diff --git a/lib/core/utils/question/question_parser_standard.dart b/lib/core/utils/question/question_parser_standard.dart
new file mode 100644
index 0000000..f3331d0
--- /dev/null
+++ b/lib/core/utils/question/question_parser_standard.dart
@@ -0,0 +1,337 @@
+/// ============================================================
+/// 标准复合题解析器(1:1 移植 Vue utils/questionParserStandard.ts)
+///
+/// 处理带 childs 的复合题(阅读理解、完形填空、听力等):
+/// 提取子题题干 / 选项 / 题型 / 答案,清洗大题干。
+/// ============================================================
+library;
+
+import 'question_parser_base.dart';
+import 'formula.dart';
+import 'js_regex.dart';
+
+bool _truthy(dynamic v) {
+ if (v == null) return false;
+ if (v is bool) return v;
+ if (v is num) return v != 0;
+ if (v is String) return v.isNotEmpty && v != '0';
+ return true;
+}
+
+/// answerAnalysis 字段可能是字符串,也可能是 {analysis: '...'}
+String analysisTextOf(dynamic v) {
+ if (v == null) return '';
+ if (v is Map) return '${v['analysis'] ?? ''}';
+ return '$v';
+}
+
+String _latex(dynamic v) => renderLatexInHtml(v == null ? '' : '$v');
+
+Map _renderBk(Map bk) {
+ return {
+ ...bk,
+ 'correctAnswer': fixImageStyles(_latex(bk['correctAnswer'])),
+ };
+}
+
+Map parseStandardQuestion(
+ Map questionData,
+ String rawStem,
+ String rawAnswer,
+ String rawCorrectAnswer,
+ String analysis,
+) {
+ final options = >[];
+ var isSelect = _truthy(questionData['isSelect']);
+
+ final subQuestions = >[];
+ var newStem = rawStem;
+
+ final childsRaw = (questionData['childs'] as List?) ?? const [];
+
+ for (var index = 0; index < childsRaw.length; index++) {
+ final child = Map.from(childsRaw[index] as Map);
+ final subOptions = >[];
+ var childStem = _latex(child['stem']);
+ var childAnswer = '${child['answer'] ?? ''}';
+ var childRawAnalysis = _latex(
+ child['answerAnalysis'] is Map
+ ? (child['answerAnalysis'] as Map)['analysis']
+ : (child['answerAnalysis'] ?? child['analysis']),
+ );
+
+ // 子题无独立答案时,从大题干答案 rawAnswer 中按 data-index 提取
+ if (childAnswer.isEmpty && rawAnswer.isNotEmpty) {
+ final subAnswerRegex = JRegExp(
+ '<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*hxdb-subquestion[^"]*")'
+ '(?=[^>]*data-index="${index + 1}")[^>]*>',
+ );
+ final match = subAnswerRegex.exec(rawAnswer);
+ if (match != null) {
+ childAnswer = findFullElement(rawAnswer, match);
+ }
+ }
+
+ final childAnalysis = childRawAnalysis;
+
+ // 1) 从大题干中提取属于该子题的内容 / 选项表格
+ final subquestionRegex = JRegExp(
+ '<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")'
+ '(?=[^>]*data-index="${index + 1}")[^>]*>',
+ );
+
+ JMatch? subMatch;
+ while ((subMatch = subquestionRegex.exec(newStem)) != null) {
+ final fullBlock = findFullElement(newStem, subMatch!);
+ final startTag = subMatch.group0;
+ final tagNameLen = (subMatch(1) ?? '').length;
+ final divContent = fullBlock.substring(
+ startTag.length,
+ fullBlock.length - (tagNameLen + 3),
+ );
+
+ if (childStem.isEmpty || childStem == '
') {
+ childStem = divContent;
+ }
+
+ subOptions.addAll(extractOptionsFromHtml(divContent));
+ newStem = newStem.replaceFirst(fullBlock, '');
+ subquestionRegex.reset();
+ }
+
+ // 2) 从子题自身 stem 提取选项
+ final optionTableRegex = RegExp(
+ r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)\1>',
+ caseSensitive: false,
+ );
+ final childMatch = optionTableRegex.firstMatch(childStem);
+ if (childMatch != null) {
+ if (subOptions.isEmpty) {
+ subOptions.addAll(extractOptionsFromHtml(childMatch.group(2) ?? ''));
+ }
+ childStem = childStem.replaceFirst(optionTableRegex, '');
+ }
+
+ if (subOptions.isEmpty &&
+ RegExp(r'hxdb-sub-option-table', caseSensitive: false).hasMatch(childStem)) {
+ subOptions.addAll(extractOptionsFromHtml(childStem));
+ }
+
+ // 按 optionLabel 去重
+ final seenLabels = {};
+ final deduped = subOptions.where((opt) {
+ if (opt['optionLabel']!.isEmpty) return true;
+ if (seenLabels.contains(opt['optionLabel'])) return false;
+ seenLabels.add(opt['optionLabel']!);
+ return true;
+ }).toList();
+ subOptions
+ ..clear()
+ ..addAll(deduped);
+
+ // 3) 判断题
+ final childIsPanDuan = processPanDuan(
+ {...child, 'answer': childAnswer},
+ subOptions,
+ );
+ child['questionTypeId'] =
+ (_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
+ ? child['questionTypeId']
+ : questionData['questionTypeId'];
+ var childQuestionTypes = getQuestionTypeCode(
+ child['questionTypeId'],
+ child['isObjective'],
+ child['questionTypeCode'],
+ );
+ if (childIsPanDuan) {
+ childQuestionTypes = 'panduanti';
+ }
+
+ // 4) 组装子题(注意:...child 覆盖前面的显式字段,与 JS 展开顺序一致)
+ final computedOptions = subOptions.isNotEmpty
+ ? subOptions
+ .map((opt) => makeOption(opt['optionLabel']!, _latex(opt['options'])))
+ .toList()
+ : (child['options'] is List
+ ? (child['options'] as List)
+ .whereType()
+ .map((opt) => makeOption(
+ '${opt['optionLabel'] ?? ''}',
+ _latex(opt['options']),
+ ))
+ .toList()
+ : >[]);
+
+ final subQuestionObj = {
+ 'index': '${index + 1}.',
+ 'stem': childStem,
+ 'answer': childAnswer,
+ 'analysis': childAnalysis,
+ 'options': computedOptions,
+ 'isSelect': childIsPanDuan || subOptions.isNotEmpty || _truthy(child['isSelect']),
+ };
+ subQuestionObj.addAll(child); // ...child
+ subQuestionObj['questionTypeId'] =
+ (_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
+ ? child['questionTypeId']
+ : questionData['questionTypeId'];
+ subQuestionObj['questionTypeName'] = _truthy(child['questionTypeName'])
+ ? child['questionTypeName']
+ : questionData['questionTypeName'];
+ subQuestionObj['questionTypes'] = childQuestionTypes;
+ subQuestionObj['raw'] = child;
+
+ if (childQuestionTypes == 'tiankongti') {
+ final childBkTagMetaList = parseBkTagMetaFromStem(
+ childStem,
+ '${child['correctAnswer'] ?? rawCorrectAnswer}',
+ child['userAnswer']?.toString(),
+ questionData['iSFilllankItemUpload'],
+ );
+ if (childBkTagMetaList.isNotEmpty) {
+ subQuestionObj['bkTagMetaList'] = childBkTagMetaList;
+ }
+ }
+
+ subQuestions.add(subQuestionObj);
+ }
+
+ // 清洗主干残留的子题块
+ final cleanupRegex = JRegExp(
+ '<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")[^>]*>',
+ );
+ while (true) {
+ final cleanupMatch = cleanupRegex.exec(newStem);
+ if (cleanupMatch == null) break;
+ final fullBlock = findFullElement(newStem, cleanupMatch);
+ newStem = newStem.replaceFirst(fullBlock, '');
+ cleanupRegex.reset();
+ }
+
+ var finalStem = newStem;
+
+ // 大题干自身的选项表(普通单选/多选)
+ final optionTableRegex = RegExp(
+ r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)\1>',
+ caseSensitive: false,
+ );
+ final match = optionTableRegex.firstMatch(finalStem);
+ if (match != null) {
+ isSelect = true;
+ options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
+ finalStem = finalStem.replaceFirst(optionTableRegex, '');
+ }
+
+ var finalQuestionTypes = getQuestionTypeCode(
+ questionData['questionTypeId'],
+ questionData['isObjective'],
+ questionData['questionTypeCode'],
+ );
+
+ if (options.isEmpty &&
+ (finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
+ final plainOptions = extractPlainTextOptions(finalStem);
+ if (plainOptions.isNotEmpty) {
+ options.addAll(plainOptions);
+ isSelect = true;
+ }
+ }
+
+ var bkTagMetaList = >[];
+ if (finalQuestionTypes == 'tiankongti') {
+ bkTagMetaList = parseBkTagMetaFromStem(
+ finalStem,
+ rawCorrectAnswer,
+ questionData['userAnswer']?.toString(),
+ questionData['iSFilllankItemUpload'],
+ );
+ if (bkTagMetaList.isEmpty) {
+ bkTagMetaList = [
+ {'index': 1, 'type': 0, 'dataInputType': 0, 'correctAnswer': rawCorrectAnswer}
+ ];
+ final userAnswerMap = parseUserAnswerMapFromJson(
+ questionData['userAnswer']?.toString(),
+ questionData['iSFilllankItemUpload'] == 1,
+ );
+ final fromUser = userAnswerMap[1];
+ if (fromUser != null) {
+ final isUpload = questionData['iSFilllankItemUpload'] == 1;
+ final myAnswer = fromUser['myAnswer'];
+ final hasImgArray = myAnswer is List &&
+ myAnswer.isNotEmpty &&
+ myAnswer.first is Map &&
+ (myAnswer.first as Map)['url'] != null;
+ if (isUpload || hasImgArray) {
+ if (myAnswer is List && myAnswer.isNotEmpty) {
+ bkTagMetaList[0]['myAnswer'] = myAnswer;
+ }
+ } else {
+ final my = myAnswer is String ? myAnswer.trim() : '';
+ if (my.isNotEmpty) bkTagMetaList[0]['myAnswer'] = my;
+ }
+ if (fromUser['answerStatus'] is int) {
+ bkTagMetaList[0]['answerStatus'] = fromUser['answerStatus'];
+ }
+ }
+ }
+ }
+
+ final renderedStem = fixImageStyles(_latex(finalStem));
+ final renderedAnalysis = fixImageStyles(_latex(analysis));
+ final optionSource = options.isNotEmpty
+ ? options
+ : ((questionData['options'] as List?)?.whereType().toList() ?? const []);
+ final renderedOptions = optionSource.map((rawOpt) {
+ final opt = Map.from(rawOpt);
+ return {
+ ...opt,
+ 'options': fixImageStyles(_latex(opt['options'])),
+ };
+ }).toList();
+
+ final renderedSubQuestions = subQuestions.map((sub) {
+ final rendered = {...sub};
+ rendered['stem'] = fixImageStyles(_latex(sub['stem']));
+ rendered['analysis'] = fixImageStyles(_latex(sub['analysis']));
+ rendered['answerAnalysis'] = fixImageStyles(_latex(sub['answerAnalysis']));
+ final subOpts = (sub['options'] as List?) ?? const [];
+ rendered['options'] = subOpts.whereType().map((rawOpt) {
+ final opt = Map.from(rawOpt);
+ return {
+ ...opt,
+ 'options': fixImageStyles(_latex(opt['options'])),
+ };
+ }).toList();
+ final bks = (sub['bkTagMetaList'] as List?) ?? const [];
+ rendered['bkTagMetaList'] =
+ bks.whereType().map((bk) => _renderBk(Map.from(bk))).toList();
+ return rendered;
+ }).toList();
+
+ final renderedBkTagMetaList = bkTagMetaList.map(_renderBk).toList();
+
+ return {
+ ...questionData,
+ 'raw': questionData,
+ 'stem': renderedStem,
+ 'options': renderedOptions,
+ 'isSelect': isSelect,
+ 'questionTypes': finalQuestionTypes,
+ 'childs': renderedSubQuestions,
+ 'answer': rawAnswer,
+ 'analysis': renderedAnalysis,
+ 'subQuestions': renderedSubQuestions,
+ 'bkTagMetaList': renderedBkTagMetaList.isNotEmpty
+ ? renderedBkTagMetaList
+ : (finalQuestionTypes == 'tiankongti'
+ ? [
+ {
+ 'index': 1,
+ 'type': 0,
+ 'dataInputType': 0,
+ 'correctAnswer': fixImageStyles(_latex(rawCorrectAnswer)),
+ }
+ ]
+ : const []),
+ };
+}
diff --git a/lib/core/utils/question/tag_color.dart b/lib/core/utils/question/tag_color.dart
new file mode 100644
index 0000000..dbead3f
--- /dev/null
+++ b/lib/core/utils/question/tag_color.dart
@@ -0,0 +1,92 @@
+/// ============================================================
+/// 题型标签渐变色(对标 Vue utils/tagQuestionTypeColor.js)
+/// getTagColor(tag) → 题型名对应渐变;找不到=默认单选题蓝
+/// ============================================================
+library;
+
+import 'package:flutter/material.dart';
+
+/// 题型渐变描述(起止色 + 角度近似:281.96deg≈右上→左下,126.17deg≈左上→右下)
+class TagGradient {
+ const TagGradient(this.begin, this.end, this.colors);
+
+ final Alignment begin;
+ final Alignment end;
+ final List colors;
+
+ Gradient get gradient => LinearGradient(
+ begin: begin,
+ end: end,
+ colors: colors,
+ );
+}
+
+const TagGradient _defaultTagGradient = TagGradient(
+ Alignment(-0.95, -0.2), // 281.96deg
+ Alignment(0.95, 0.2),
+ [Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
+);
+
+const Map _tagColorMap = {
+ '单选题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
+ ),
+ '多选题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF3DB8F5), Color(0xFF5AC8FF)],
+ ),
+ '判断题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF3FC8A6), Color(0xFF89E9D1)],
+ ),
+ '填空题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF66D969), Color(0xFF93E594)],
+ ),
+ '完形填空': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF81B84F), Color(0xFFB1E384)],
+ ),
+ '阅读理解': TagGradient(
+ Alignment(-0.44, -0.8), // 126.17deg
+ Alignment(0.44, 0.8),
+ [Color(0xFFFFC260), Color(0xFFFF9E45)],
+ ),
+ '听力理解': TagGradient(
+ Alignment(-0.44, -0.8),
+ Alignment(0.44, 0.8),
+ [Color(0xFFFFA052), Color(0xFFFF6F3A)],
+ ),
+ '7选5': TagGradient(
+ Alignment(-0.44, -0.8),
+ Alignment(0.44, 0.8),
+ [Color(0xFFFF7A41), Color(0xFFFE5837)],
+ ),
+ '解答题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFFBA59EB), Color(0xFFCD8AEF)],
+ ),
+ '作文题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF8845E6), Color(0xFFA87BF5)],
+ ),
+ '问答题': TagGradient(
+ Alignment(-0.95, -0.2),
+ Alignment(0.95, 0.2),
+ [Color(0xFF6864E4), Color(0xFF8582E9)],
+ ),
+};
+
+/// Vue: getTagColor(tag)
+TagGradient getTagColor(String? tag) {
+ if (tag == null || tag.isEmpty) return _defaultTagGradient;
+ return _tagColorMap[tag] ?? _defaultTagGradient;
+}
diff --git a/lib/features/conquerPackage/captureList/index.dart b/lib/features/conquerPackage/captureList/index.dart
index e8d3912..db17884 100644
--- a/lib/features/conquerPackage/captureList/index.dart
+++ b/lib/features/conquerPackage/captureList/index.dart
@@ -6,6 +6,7 @@ import 'package:new_learn/core/api/conquerPackage/captureList/api.dart';
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/utils/img_url.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
/// ============================================================
/// 【CaptureListPage】攻克列表页 —— 对标 Vue pages/conquerPackage/captureList/index.vue
@@ -234,8 +235,8 @@ class _CaptureListPageState extends State {
if (res.hasQuestion) {
// 有题目 → 过渡页 或 习题页
final pagePath = res.nowStudyObjectType == 0
- ? '/conquer/transitionPage'
- : '/conquer/classExercises';
+ ? '/conquerPackage/transitionPage'
+ : '/conquerPackage/classExercises';
_navigateTo(pagePath, {
'status': '3',
'courseId': widget.courseId,
@@ -272,12 +273,15 @@ class _CaptureListPageState extends State {
context.push(path, extra: params);
}
- /// 点击报告(跳转报告页)
+ /// 点击报告(跳转报告页,对标 Vue getReport)
+ /// recordId=攻克记录 id,packId=攻克包 id;判题中(status==3)不可点
void _getReport(CourseRecord record, _CapturePackViewModel pack) {
if (CaptureRecordStatus.fromInt(record.status) ==
CaptureRecordStatus.processing) return;
- // TODO(路由): 跳转报告页
- debugPrint('跳转报告页 recordId=${record.id}, packId=${pack.id}');
+ _navigateTo('/conquerPackage/report', {
+ 'recordId': '${record.id}',
+ 'packId': pack.id,
+ });
}
// ========== 工具方法 ==========
@@ -287,16 +291,6 @@ class _CaptureListPageState extends State {
return chineseNum[num.clamp(0, 9)];
}
- String _formatDuration(int seconds) {
- if (seconds <= 0) return '0秒';
- final h = seconds ~/ 3600;
- final m = (seconds % 3600) ~/ 60;
- final s = seconds % 60;
- if (h > 0) return '${h}时${m}分';
- if (m > 0) return '${m}分${s}秒';
- return '${s}秒';
- }
-
// ========== 构建 ==========
@override
@@ -888,7 +882,7 @@ class _CaptureListPageState extends State {
),
SizedBox(width: 13.w),
Text(
- _formatDuration(0), // TODO: 后端 record 没有用时字段,需确认
+ formatDuration(record.overcomeTimeSecond),
style: TextStyle(fontSize: 12.sp, color: _subtitleColor),
),
],
diff --git a/lib/features/conquerPackage/classExercises/class_exercises_page.dart b/lib/features/conquerPackage/classExercises/class_exercises_page.dart
new file mode 100644
index 0000000..f934e1a
--- /dev/null
+++ b/lib/features/conquerPackage/classExercises/class_exercises_page.dart
@@ -0,0 +1,1463 @@
+/// ============================================================
+/// 【ClassExercisesPage】课后练习装配页
+/// (1:1 迁移 Vue pages/conquerPackage/classExercises/classExercises.vue)
+///
+/// 结构(与 Vue 模板一致):
+/// HeaderBac 背景图
+/// ├ NavBar(左对齐标题 / 返回)
+/// ├ head(edit 计时胶囊 / view 解析标题 + 进度条题号答题卡入口)
+/// ├ PageView(左右滑动切题;左滑受倒计时/必传笔记限制)
+/// ├ CompoundQuestionPanel 浮层(当前题为复合题时)
+/// └ footer(上传笔记 / 收藏 / 错题 / 反馈 / 上一题·下一题 / 倒计时气泡)
+/// 弹窗:ConquerPopup / AnswerCardPopup / PausePopup /
+/// ExerciseConfirmBox / FeedbackPopup
+/// 页面级加载遮罩 isPageLoading
+///
+/// 状态全部收敛在普通 Dart 控制器(无 Riverpod):
+/// ExerciseSession + submit / navigation / timing / collect,
+/// 本 State implements ExerciseUiBridge 承接所有副作用。
+/// ============================================================
+library;
+
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart' show ScrollDirection;
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:go_router/go_router.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:new_learn/core/components/center_toast.dart';
+import 'package:new_learn/core/utils/img_url.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_collect.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_load.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_navigation.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_sort.dart'
+ as sort;
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_submit.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing_handlers.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/exercise_question_view.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/models/question_data_adapter.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/answer_card_popup.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/compound_question_panel.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/conquer_popup.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_confirm_box.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/feedback_popup.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/pause_popup.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/widgets/question_plan_countdown.dart';
+
+class ClassExercisesPage extends StatefulWidget {
+ const ClassExercisesPage({super.key, this.extra});
+
+ /// 路由入参(对标 onLoad options):
+ /// courseId / packId / totalPackId / recordId /
+ /// nowStudyObjectId / isWrong / targetSort / type(edit|view)
+ final Map? extra;
+
+ @override
+ State createState() => _ClassExercisesPageState();
+}
+
+class _ClassExercisesPageState extends State
+ implements ExerciseUiBridge {
+ // ===================== 会话与控制器 =====================
+
+ final ExerciseSession _session = ExerciseSession();
+ late final ExerciseSubmitHandlers _submit;
+ late final ExerciseNavigationHandlers _navigation;
+ late final ExerciseTimingHandlers _timing;
+ late final ExerciseCollectHandlers _collect;
+
+ final PageController _pageController = PageController();
+ final GlobalKey _feedbackKey = GlobalKey();
+
+ // ===================== 页面级 UI 状态 =====================
+
+ bool _isPageLoading = true;
+ bool _showAnswerSheet = false;
+ bool _showPausePopup = false;
+ ConquerPopupConfig? _popup;
+ bool _exitConfirmShow = false;
+
+ /// 面板高度(拖拽时高频更新,用 ValueNotifier 避免整页 setState)
+ final ValueNotifier _panelHeight = ValueNotifier(50);
+
+ // 报告页题号定位(pendingTargetSort)
+ int _pendingTargetSort = 0;
+ bool _isJumpDataReady = false;
+ bool _hasAutoJumped = false;
+
+ String _nowStudyObjectId = '';
+
+ // 节流(对标 createThrottle / onBackPress 节流)
+ DateTime? _lastStepAt;
+ DateTime? _lastBackAt;
+
+ // ===================== 全局 loading(Overlay,对标 uni.showLoading) =====================
+
+ final ValueNotifier _loadingText = ValueNotifier('加载中...');
+ OverlayEntry? _loadingEntry;
+
+ bool get _isEdit => _session.mode == 'edit';
+ bool get _isInteractionLocked => _popup != null;
+
+ // ===================== 生命周期 =====================
+
+ @override
+ void initState() {
+ super.initState();
+ _parseExtra();
+
+ _submit = ExerciseSubmitHandlers(session: _session, bridge: this);
+ _navigation = ExerciseNavigationHandlers(
+ session: _session,
+ bridge: this,
+ submit: _submit,
+ // 错题模式不拦截(与 Vue 页面包装一致)
+ openTimePopup: _openTimePopupWrapped,
+ openUploadNotesPopup: _openNotesPopupWrapped,
+ submitExamPaper: _submit.submitExamPaper,
+ );
+ _timing = ExerciseTimingHandlers(
+ session: _session,
+ bridge: this,
+ submit: _submit,
+ isPausePopupVisible: () => _showPausePopup,
+ onUiTick: () {
+ if (mounted) setState(() {});
+ },
+ );
+ _collect = ExerciseCollectHandlers(session: _session, bridge: this);
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _load();
+ });
+ }
+
+ @override
+ void dispose() {
+ // 对标 onUnload:计时落盘/停止 + 失败队列落盘
+ _timing.persistTimingCache();
+ _submit.persistPendingToStorage();
+ _timing.dispose();
+ _navigation.dispose();
+ _hideLoadingImmediately();
+ _loadingText.dispose();
+ _pageController.dispose();
+ _panelHeight.dispose();
+ super.dispose();
+ }
+
+ void _parseExtra() {
+ final extra = widget.extra ?? const {};
+ _session.courseId = '${extra['courseId'] ?? ''}';
+ _session.packId = '${extra['packId'] ?? ''}';
+ _session.totalPackId = '${extra['totalPackId'] ?? ''}';
+ _session.recordId = '${extra['recordId'] ?? ''}';
+ _nowStudyObjectId = '${extra['nowStudyObjectId'] ?? ''}';
+ _session.isWrong = extra['isWrong'] == true || extra['isWrong'] == 'true';
+ _pendingTargetSort = int.tryParse('${extra['targetSort'] ?? 0}') ?? 0;
+ _session.mode = extra['type'] == 'view' ? 'view' : 'edit';
+ }
+
+ // ===================== 数据加载(对标 onLoad) =====================
+
+ Future _load() async {
+ try {
+ if (_isEdit) {
+ // 先补提本地失败队列
+ await _submit.restorePendingFromStorage();
+ if (_submit.getPendingCount() > 0) {
+ showLoading('提交中...');
+ var success = true;
+ var failedCount = 0;
+ try {
+ final result = await _submit.retryFailedSubmitRequests(
+ maxRetryRounds: 3,
+ );
+ success = result.success;
+ failedCount = result.failedCount;
+ } finally {
+ if (success) {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs
+ .remove('classExercisesDraft:${_session.courseId}');
+ } catch (_) {}
+ } else {
+ await _submit.persistPendingToStorage();
+ }
+ if (!success && mounted) {
+ toast('网络异常,仍有 $failedCount 道题待提交,请稍后重试');
+ }
+ // 与 Vue 一致:补提结束后把 loading 文案切为“加载中...”
+ showLoading('加载中...');
+ }
+ }
+
+ final result = await ExerciseLoad.loadStartOvercome(
+ inputRecordId: _session.recordId,
+ );
+ _applyLoadResult(result);
+ _resolveTargetSort();
+ _isJumpDataReady = true;
+ _timing.restoreTimingCache();
+ _timing.timing.start();
+ _scheduleAutoJump();
+ } else {
+ final result = await ExerciseLoad.loadSubmitReport(
+ packId: _session.packId,
+ isWrong: _session.isWrong,
+ );
+ _applyLoadResult(result);
+ _session.originalMaxSort = result.originalMaxSort;
+ _resolveTargetSort();
+ _isJumpDataReady = true;
+ _timing.restoreTimingCache();
+ // view 模式不启动计时
+ _scheduleAutoJump();
+ }
+ } catch (e) {
+ if (mounted) toast('加载失败,请重试');
+ } finally {
+ hideLoading();
+ if (mounted) {
+ setState(() => _isPageLoading = false);
+ }
+ }
+ }
+
+ void _applyLoadResult(ExerciseLoadResult result) {
+ setState(() {
+ _session.questions = result.questions;
+ _session.recordId = result.recordId;
+ _session.navBarTitle = result.navBarTitle;
+ });
+ }
+
+ /// nowStudyObjectId → 起始题号(对标 getTargetSortByNowStudyObjectId)
+ void _resolveTargetSort() {
+ if (_pendingTargetSort != 0 || _nowStudyObjectId.isEmpty) return;
+ final targetId = _nowStudyObjectId;
+ for (final item in _session.questions) {
+ final itemId = item.id.isNotEmpty ? item.id : item.questionId;
+ if (itemId.isNotEmpty && itemId == targetId) {
+ _pendingTargetSort = sort.getQuestionSortRange(item).start;
+ return;
+ }
+ }
+ }
+
+ void _scheduleAutoJump() {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _tryAutoJumpToTargetSort();
+ });
+ }
+
+ /// 等 PageView 挂载完成后定位到 targetSort(对标 tryAutoJumpToTargetSort)
+ Future _tryAutoJumpToTargetSort({int attempts = 0}) async {
+ if (_hasAutoJumped) return;
+ if (_pendingTargetSort == 0 ||
+ !_isJumpDataReady ||
+ _session.questions.isEmpty) {
+ return;
+ }
+ if (!_pageController.hasClients) {
+ if (attempts >= 10 || !mounted) return;
+ await Future.delayed(const Duration(milliseconds: 100));
+ if (!mounted) return;
+ return _tryAutoJumpToTargetSort(attempts: attempts + 1);
+ }
+ try {
+ await _navigation
+ .handleAnswerCardSelect({'sort': _pendingTargetSort});
+ _hasAutoJumped = true;
+ } catch (_) {
+ _hasAutoJumped = false;
+ }
+ }
+
+ // ===================== ExerciseUiBridge =====================
+
+ @override
+ void showLoading([String message = '加载中...']) {
+ if (!mounted) return;
+ _loadingText.value = message;
+ if (_loadingEntry != null) return;
+ final overlay = Overlay.maybeOf(context, rootOverlay: true);
+ if (overlay == null) return;
+ final entry = OverlayEntry(
+ builder: (_) => _LoadingMask(text: _loadingText),
+ );
+ _loadingEntry = entry;
+ overlay.insert(entry);
+ }
+
+ @override
+ void hideLoading() {
+ _loadingEntry?.remove();
+ _loadingEntry = null;
+ }
+
+ void _hideLoadingImmediately() {
+ if (_loadingEntry != null) {
+ _loadingEntry?.remove();
+ _loadingEntry = null;
+ }
+ }
+
+ @override
+ void toast(String message) {
+ if (mounted) CenterToast.show(context, message);
+ }
+
+ @override
+ void notifyListeners() {
+ if (mounted) setState(() {});
+ }
+
+ @override
+ void jumpToPage(int index) {
+ if (_session.questions.isEmpty) return;
+ final target = index.clamp(0, _session.questions.length - 1);
+ void run() {
+ _pageController.animateToPage(
+ target,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOut,
+ );
+ }
+
+ if (!_pageController.hasClients) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted && _pageController.hasClients) run();
+ });
+ return;
+ }
+ run();
+ }
+
+ @override
+ void openExitPopup() {
+ setState(() => _exitConfirmShow = true);
+ }
+
+ @override
+ void showConquerPopup(ConquerPopupConfig config) {
+ setState(() => _popup = config);
+ }
+
+ @override
+ void hideConquerPopup() {
+ setState(() => _popup = null);
+ }
+
+ @override
+ void setAnswerSheetVisible(bool visible) {
+ setState(() => _showAnswerSheet = visible);
+ }
+
+ @override
+ void setPausePopupVisible(bool visible) {
+ setState(() => _showPausePopup = visible);
+ }
+
+ @override
+ void redirectToReport(String recordId, String packId) {
+ if (!mounted) return;
+ context.push(
+ '/conquerPackage/report',
+ extra: {
+ 'recordId': recordId,
+ 'packId': packId,
+ 'from': 'submit',
+ },
+ );
+ }
+
+ @override
+ void navigateBack({int delta = 1}) {
+ if (!mounted) return;
+ var n = delta;
+ while (n-- > 0 && context.canPop()) {
+ context.pop();
+ }
+ }
+
+ @override
+ void relaunchCaptureList(String courseId) {
+ if (!mounted) return;
+ context.go('/conquerPackage/captureList', extra: courseId);
+ }
+
+ @override
+ bool get isPad => isPadDevice(context);
+
+ // ===================== 页面级事件包装(与 Vue 同名同义) =====================
+
+ bool _openTimePopupWrapped() {
+ if (_session.isWrong) return false;
+ return _submit.openTimePopupIfNeeded();
+ }
+
+ bool _openNotesPopupWrapped() {
+ if (_session.isWrong) return false;
+ return _submit.openUploadNotesPopupIfNeeded();
+ }
+
+ void _switchStep(String type, [String? timePopupIfNeeded]) {
+ if (_isInteractionLocked) return;
+ final now = DateTime.now();
+ if (_lastStepAt != null &&
+ now.difference(_lastStepAt!).inMilliseconds < 400) {
+ return;
+ }
+ // 程序触发翻页后的 400ms 保护期内忽略重复触发
+ if (_navigation.isProgrammaticJump) return;
+ _lastStepAt = now;
+ _navigation.switchStep(type, timePopupIfNeeded);
+ }
+
+ void _handleNavBarBack() {
+ if (_isInteractionLocked) return;
+ if (!_isEdit) {
+ navigateBack(delta: 1);
+ return;
+ }
+ _navigation.clickBackNavBar();
+ }
+
+ void _handleSystemBack() {
+ if (!_isEdit) return;
+ final now = DateTime.now();
+ if (_lastBackAt != null &&
+ now.difference(_lastBackAt!).inMilliseconds < 300) {
+ return;
+ }
+ _lastBackAt = now;
+ _navigation.clickBackNavBar();
+ }
+
+ void _toggleTiming() {
+ final willPause = _timing.timing.isRunning() && !_showPausePopup;
+ _timing.toggleTiming();
+ if (willPause && _showPausePopup) {
+ _navigation.submitCurrentQuestionBeforePageSwitch();
+ }
+ }
+
+ /// 暂停弹窗关闭:继续计时 + 当前题 startTime 重置
+ void _handlePausePopupClose() {
+ _timing.handlePausePopupClose();
+ _resetCurrentQuestionStartTime();
+ }
+
+ void _resetCurrentQuestionStartTime() {
+ final index = _session.currentIndex;
+ final item = _session.currentQuestion;
+ if (item == null) return;
+ final now = getCurrentISO();
+ item.startTime = now;
+ final subIndex = _session.activeSubIndexMap[index] ?? -1;
+ if (subIndex >= 0) {
+ if (subIndex < item.subQuestions.length) {
+ item.subQuestions[subIndex].startTime = now;
+ }
+ if (subIndex < item.childs.length) {
+ item.childs[subIndex].startTime = now;
+ }
+ }
+ }
+
+ /// ConquerPopup 确认分发(页面在 submit 控制器之上加了 note / time 包装)
+ void _handlePopupConfirm(String action) {
+ final config = _popup;
+ if (config == null) return;
+ if (config.type == 'note') {
+ setState(() => _popup = null);
+ _navigation.uploadNotes();
+ return;
+ }
+ if (action == 'minimumLearningDuration') {
+ setState(() => _popup = null);
+ _navigation.switchStep('NextStep', 'minimumLearningDuration');
+ return;
+ }
+ _submit.handlePopupConfirm(config);
+ }
+
+ /// 答题卡选题(页面包装:先关弹层;向后跳题先查笔记必传)
+ Future _handleAnswerCardSelect(Map question) async {
+ setState(() => _showAnswerSheet = false);
+ final targetSort = int.tryParse('${question['sort'] ?? 0}') ?? 0;
+ if (targetSort > _navigation.currentSortLabel &&
+ _openNotesPopupWrapped()) {
+ return;
+ }
+ await _navigation.handleAnswerCardSelect(question);
+ }
+
+ void _handleUploadNotes() {
+ if (_isInteractionLocked) return;
+ _navigation.uploadNotes();
+ }
+
+ Future _handleExitConfirm() async {
+ _session.itemRefs[_session.currentIndex]?.currentState?.pauseMedia();
+ _navigation.submitCurrentQuestionBeforePageSwitch();
+ await _submit.persistPendingToStorage();
+ if (!mounted) return;
+ setState(() => _exitConfirmShow = false);
+ navigateBack(delta: 1);
+ }
+
+ void _handleOpenFeedback() {
+ if (_isInteractionLocked) return;
+ final info = _session.currentQuestion;
+ _feedbackKey.currentState?.open(info?.id, info?.questionId);
+ }
+
+ // ===================== Build =====================
+
+ @override
+ Widget build(BuildContext context) {
+ final current = _session.currentQuestion;
+ return PopScope(
+ canPop: !_isEdit,
+ onPopInvokedWithResult: (didPop, result) {
+ if (didPop) return;
+ _handleSystemBack();
+ },
+ child: Scaffold(
+ backgroundColor: ExerciseColors.bg,
+ body: Stack(
+ children: [
+ // ---- HeaderBac 顶部背景图 ----
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: CachedNetworkImage(
+ imageUrl: ImgUrl.build(
+ '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
+ ),
+ fit: BoxFit.fitWidth,
+ width: double.infinity,
+ placeholder: (_, _) => const SizedBox.shrink(),
+ errorWidget: (_, _, _) => const SizedBox.shrink(),
+ ),
+ ),
+
+ SafeArea(
+ bottom: false,
+ child: Column(
+ children: [
+ _buildNavBar(),
+ _buildHead(),
+ Expanded(child: _buildPageView()),
+ ],
+ ),
+ ),
+
+ // ---- 复合题浮层(swiper 外 Stack,与 Vue 一致) ----
+ if (current != null && current.hasOptionChildrenQuestion)
+ _PanelBinder(
+ key: ValueKey(
+ 'panel-${current.id.isNotEmpty ? current.id : current.questionId}-${_session.currentIndex}',
+ ),
+ session: _session,
+ detail: current,
+ activeSubIndex:
+ _session.activeSubIndexMap[_session.currentIndex] ?? 0,
+ mode: _isEdit ? CompoundMode.edit : CompoundMode.view,
+ onMyAnswer: (index, data) =>
+ _submit.getMyMinAnswer(index: index, data: data),
+ onSubSwitch: (index, data) =>
+ _submit.selectChildData(index: index, data: data),
+ onActiveSubIndexChanged: (index) =>
+ _navigation.handleSubQuestionChange(
+ _session.currentIndex, index),
+ onFillBlankImgChange: (subIndex, images) =>
+ _submit.fillBlankImgChange(
+ images, subIndex, _session.currentIndex),
+ onPanelHeightChanged: (h) {
+ // 不 setState:只更新 notifier,占位盒局部跟随
+ if (h != _panelHeight.value) _panelHeight.value = h;
+ },
+ ),
+
+ // ---- 底部操作栏(有题时才显示) ----
+ if (_session.questions.isNotEmpty) _buildFooter(current),
+
+ // ---- 弹窗(层级在 footer 之上) ----
+ ConquerPopup(
+ show: _popup?.show ?? false,
+ type: _popup?.type ?? 'note',
+ title: _popup?.title ?? '',
+ subtitle: _popup?.subtitle ?? '',
+ confirmText: _popup?.confirmText ?? '确定',
+ cancelText: _popup?.cancelText ?? '取消',
+ isConfirm: _popup?.isConfirm ?? false,
+ confirmAction: _popup?.confirmAction ?? '',
+ onConfirm: _handlePopupConfirm,
+ onCancel: () => setState(() => _popup = null),
+ ),
+ AnswerCardPopup(
+ show: _showAnswerSheet,
+ questions:
+ _session.questions.map((q) => q.m).toList(growable: false),
+ isExamAnswerCard: true,
+ enableExtendedAnswerStatus: true,
+ statusMode: _isEdit ? 'answer' : 'result',
+ onCancel: () => setState(() => _showAnswerSheet = false),
+ onSubmit: () => _submit.submitExamPaper(''),
+ onSelect: _handleAnswerCardSelect,
+ ),
+ PausePopup(
+ show: _showPausePopup,
+ totalCount: _navigation.totalSortCount,
+ remainingCount: _timing.remainingCount,
+ onClose: _handlePausePopupClose,
+ ),
+ ExerciseConfirmBox(
+ show: _exitConfirmShow,
+ title: '确定要退出任务攻克?',
+ content: '攻克记录会保存,后续可继续攻克',
+ confirmText: '确定',
+ cancelText: '取消',
+ showCancel: true,
+ onConfirm: _handleExitConfirm,
+ onCancel: () => setState(() => _exitConfirmShow = false),
+ ),
+ FeedbackPopup(key: _feedbackKey),
+
+ // ---- 页面加载遮罩 ----
+ if (_isPageLoading) const _PageLoadingMask(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // ===================== NavBar =====================
+
+ Widget _buildNavBar() {
+ return SizedBox(
+ height: 44.h,
+ child: Row(
+ children: [
+ SizedBox(width: 8.w),
+ GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: _handleNavBarBack,
+ child: Padding(
+ padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 4.h),
+ child: CachedNetworkImage(
+ imageUrl:
+ ImgUrl.build('/StaticResource/2026-04-01/return.png'),
+ width: 24.w,
+ height: 24.h,
+ placeholder: (_, _) => Icon(
+ Icons.arrow_back_ios,
+ size: 18.w,
+ color: ExerciseColors.subtitle,
+ ),
+ errorWidget: (_, _, _) => Icon(
+ Icons.arrow_back_ios,
+ size: 18.w,
+ color: ExerciseColors.subtitle,
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(left: 11.w),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ _session.navBarTitle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 12.sp,
+ color: ExerciseColors.subtitle,
+ ),
+ ),
+ ),
+ ),
+ ),
+ SizedBox(width: 16.w),
+ ],
+ ),
+ );
+ }
+
+ // ===================== head:计时/标题 + 进度 =====================
+
+ Widget _buildHead() {
+ return Padding(
+ padding: EdgeInsets.all(10.w),
+ child: Row(
+ children: [
+ if (_isEdit) _buildTimingCapsule() else _buildViewTitle(),
+ const Spacer(),
+ _buildProgress(),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildTimingCapsule() {
+ return Container(
+ padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [Color(0xFFFFFFFF), Color(0xFFF0FCFF)],
+ ),
+ border: Border.all(color: Colors.white, width: 1.w),
+ borderRadius: BorderRadius.circular(15.r),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CachedNetworkImage(
+ imageUrl: ImgUrl.build('/StaticResource/2026-04-02/timing.png'),
+ width: 18.w,
+ height: 18.h,
+ placeholder: (_, _) =>
+ Icon(Icons.timer, size: 18.w, color: ExerciseColors.primary),
+ errorWidget: (_, _, _) =>
+ Icon(Icons.timer, size: 18.w, color: ExerciseColors.primary),
+ ),
+ Padding(
+ padding: EdgeInsets.symmetric(horizontal: 5.w),
+ child: Text(
+ _timing.timingText,
+ style: TextStyle(
+ fontSize: 13.sp,
+ color: ExerciseColors.title,
+ height: 18 / 13,
+ ),
+ ),
+ ),
+ GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: _toggleTiming,
+ child: CachedNetworkImage(
+ imageUrl: ImgUrl.build(
+ _timing.timingRunning
+ ? '/StaticResource/2026-04-20/pause.png'
+ : '/StaticResource/2026-04-20/play.png',
+ ),
+ width: 18.w,
+ height: 18.h,
+ placeholder: (_, _) => Icon(
+ _timing.timingRunning ? Icons.pause : Icons.play_arrow,
+ size: 18.w,
+ color: ExerciseColors.primary,
+ ),
+ errorWidget: (_, _, _) => Icon(
+ _timing.timingRunning ? Icons.pause : Icons.play_arrow,
+ size: 18.w,
+ color: ExerciseColors.primary,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildViewTitle() {
+ return Stack(
+ children: [
+ // 标题装饰底图:位置可调(right 控制左右、bottom 控制上下)
+ Positioned(
+ right: 0,
+ bottom: 3.h,
+ child: Image(
+ image: CachedNetworkImageProvider(
+ ImgUrl.build(
+ '/K12/materials/img/2026-03-30/769ab865255c46f989ce793b121786d0.png'),
+ ),
+ width: 59.5.w,
+ height: 9.h,
+ fit: BoxFit.fill,
+ errorBuilder: (_, _, _) => const SizedBox.shrink(),
+ ),
+ ),
+ Container(
+ constraints: BoxConstraints(minHeight: 24.h),
+ padding: EdgeInsets.only(right: 4.w, bottom: 3.h),
+ child: Text(
+ _session.isWrong ? '错题解析' : '全部解析',
+ style: TextStyle(
+ fontSize: 17.sp,
+ fontWeight: FontWeight.w600,
+ color: ExerciseColors.title,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildProgress() {
+ final currentLabel = _session.isWrong
+ ? _navigation.currentWrongSortLabel
+ : _navigation.currentSortLabel;
+ final totalCount = _session.isWrong
+ ? _navigation.totalWrongSortCount
+ : _navigation.totalSortCount;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => setState(() => _showAnswerSheet = true),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: 150.w,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(4.r),
+ child: LinearProgressIndicator(
+ value: (_navigation.percentage / 100).clamp(0.0, 1.0),
+ minHeight: 8,
+ backgroundColor: Colors.white,
+ valueColor:
+ const AlwaysStoppedAnimation(ExerciseColors.primary),
+ ),
+ ),
+ ),
+ SizedBox(width: 10.w),
+ Text.rich(
+ TextSpan(
+ children: [
+ TextSpan(
+ text: '$currentLabel',
+ style: TextStyle(
+ fontSize: 15.sp,
+ fontWeight: FontWeight.w600,
+ color: ExerciseColors.title,
+ ),
+ ),
+ TextSpan(
+ text: '/$totalCount',
+ style: TextStyle(
+ fontSize: 12.sp,
+ color: ExerciseColors.subtitle,
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: 5.w),
+ CachedNetworkImage(
+ imageUrl: ImgUrl.build('/StaticResource/2026-04-02/answer.png'),
+ width: 18.w,
+ height: 18.h,
+ placeholder: (_, _) =>
+ Icon(Icons.list_alt, size: 18.w, color: ExerciseColors.primary),
+ errorWidget: (_, _, _) =>
+ Icon(Icons.list_alt, size: 18.w, color: ExerciseColors.primary),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ===================== PageView =====================
+
+ Widget _buildPageView() {
+ if (_session.questions.isEmpty) {
+ return const SizedBox.shrink();
+ }
+ // 左滑(下一题)在“倒计时未走完 / 必传笔记未传”时拦截;右滑始终放行。
+ // 关闭自带 pageSnapping:吸附由下方物理类统一处理,才能同时拦住“甩动翻页”。
+ return NotificationListener(
+ onNotification: _onPageUserScroll,
+ child: PageView(
+ controller: _pageController,
+ physics: _ForwardGuardScrollPhysics(canGoForward: _canSwipeToNext),
+ pageSnapping: false,
+ allowImplicitScrolling: true,
+ onPageChanged: _navigation.handlePageChange,
+ children: List.generate(
+ _session.questions.length,
+ _buildRealQuestionItem,
+ ),
+ ),
+ );
+ }
+
+ /// 被拦截的同一次左滑手势内只弹一次提示
+ bool _forwardBlockPopupShown = false;
+
+ /// 是否允许左滑到下一题(与点“下一题”按钮的两道拦截口径一致)
+ bool _canSwipeToNext() {
+ if (!_isEdit) return true; // view 模式不拦截
+ final info = _session.currentQuestion;
+ if (info == null) return false;
+ // 1. 建议作答时长倒计时没走完
+ if (info.planTimeSecond > 0 && !info.isFinishPlanTime) return false;
+ // 2. 必须上传笔记但还没传
+ if (info.isUploadNotes && info.notes.isEmpty) return false;
+ return true;
+ }
+
+ /// 左滑被拦截时,给出与“下一题”按钮相同的提示(倒计时优先,其次笔记)
+ bool _onPageUserScroll(UserScrollNotification n) {
+ if (n.direction == ScrollDirection.idle) {
+ _forwardBlockPopupShown = false;
+ return false;
+ }
+ if (_forwardBlockPopupShown) return false;
+ // 水平 PageView:reverse = 向左拖去下一题
+ if (n.direction == ScrollDirection.reverse && !_canSwipeToNext()) {
+ _forwardBlockPopupShown = true;
+ if (_openTimePopupWrapped()) return false;
+ _openNotesPopupWrapped();
+ }
+ return false;
+ }
+
+ /// 正式题目页 item
+ Widget _buildRealQuestionItem(int index) {
+ final key = _session.itemRefs[index] ??=
+ GlobalKey();
+ final question = _session.questions[index];
+ return ExerciseQuestionView(
+ key: key,
+ data: question,
+ isActive: index == _session.currentIndex,
+ isAdjacent: (index - _session.currentIndex).abs() <= 1,
+ mode: _isEdit ? ExerciseQuestionMode.edit : ExerciseQuestionMode.view,
+ activeSubIndex: _session.activeSubIndexMap[index] ?? 0,
+ panelHeight: _panelHeight,
+ onSelectAnswer: (payload) =>
+ _submit.selectAnswer(payload['userAnswer'], index),
+ onUpdateAnswer: (value) => _submit.updateAnswer(value, index),
+ onNotesChange: (items) {
+ // 对标 handleNotesChange:异步回调显式带索引,写回题目 notes
+ question.notes =
+ QuestionDataAdapter.uploadItemsToAnswerImgs(items);
+ },
+ onSubQuestionChange: (payload) =>
+ _navigation.handleSubQuestionChange(index, payload.subIndex),
+ onStemLinkTap: (subIndex) =>
+ _session.panelKey?.currentState?.onStemLinkTap(subIndex),
+ );
+ }
+
+ // ===================== footer =====================
+
+ Widget _buildFooter(QuestionModel? info) {
+ final pad = isPad;
+ return Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: Material(
+ color: Colors.white,
+ child: SafeArea(
+ top: false,
+ child: Container(
+ padding: EdgeInsets.fromLTRB(
+ pad ? 32.w : 14.w,
+ 12.h,
+ pad ? 32.w : 14.w,
+ 12.h,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ border: Border(
+ top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ if (_isEdit)
+ _buildFooterLi(
+ icon: '/StaticResource/2026-04-02/note.png',
+ text: '上传笔记',
+ fallback: Icons.note_add_outlined,
+ onTap: _handleUploadNotes,
+ ),
+ _buildFooterLi(
+ icon: (info?.isCollect ?? false)
+ ? '/StaticResource/2026-04-02/collected.png'
+ : '/StaticResource/2026-04-02/collect.png',
+ text: '收藏',
+ fallback: Icons.star_border,
+ onTap: () {
+ if (_isInteractionLocked) return;
+ _collect.onClickAddUserCollect(1);
+ },
+ ),
+ if (!_isEdit)
+ _buildFooterLi(
+ icon: '/StaticResource/2026-04-28/mistakes.png',
+ text: (info?.isWrongCollect ?? false) ? '取消错题' : '加入错题',
+ fallback: Icons.bookmark_border,
+ onTap: () {
+ if (_isInteractionLocked) return;
+ _collect.onClickAddUserCollect(2);
+ },
+ ),
+ _buildFooterLi(
+ icon: '/StaticResource/2026-04-01/feedback.png',
+ text: '题目反馈',
+ fallback: Icons.feedback_outlined,
+ onTap: _handleOpenFeedback,
+ ),
+ _buildStepButtons(pad),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildFooterLi({
+ required String icon,
+ required String text,
+ required IconData fallback,
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CachedNetworkImage(
+ imageUrl: ImgUrl.build(icon),
+ width: 24.w,
+ height: 24.h,
+ placeholder: (_, _) =>
+ Icon(fallback, size: 24.w, color: ExerciseColors.title),
+ errorWidget: (_, _, _) =>
+ Icon(fallback, size: 24.w, color: ExerciseColors.title),
+ ),
+ SizedBox(height: 5.h),
+ Text(
+ text,
+ style: TextStyle(fontSize: 10.sp, color: ExerciseColors.title),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStepButtons(bool pad) {
+ final buttonWidth = pad ? 125.w : 82.w;
+ final buttonHeight = 38.h;
+ final radius = Radius.circular(19.r);
+ final isLastSort =
+ _navigation.currentSortLabel == _navigation.totalSortCount &&
+ _navigation.totalSortCount > 0;
+ final showComplete = !_isEdit && isLastSort;
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 上一题
+ Opacity(
+ opacity: _navigation.isPrevDisabled ? 0.6 : 1,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => _switchStep('PreviousStep'),
+ child: Container(
+ width: buttonWidth,
+ height: buttonHeight,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: Colors.white,
+ border: Border.all(color: ExerciseColors.primary, width: 1.w),
+ borderRadius:
+ BorderRadius.only(topLeft: radius, bottomLeft: radius),
+ ),
+ child: Text(
+ '上一题',
+ style:
+ TextStyle(fontSize: 13.sp, color: ExerciseColors.title),
+ ),
+ ),
+ ),
+ ),
+ // 下一题 / 交卷 / 完成 + 倒计时气泡
+ Stack(
+ clipBehavior: Clip.none,
+ children: [
+ GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () {
+ if (showComplete) {
+ if (_isInteractionLocked) return;
+ _navigation.completeEnd();
+ return;
+ }
+ _switchStep('NextStep');
+ },
+ child: Container(
+ width: buttonWidth,
+ height: buttonHeight,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: ExerciseColors.primary,
+ borderRadius:
+ BorderRadius.only(topRight: radius, bottomRight: radius),
+ ),
+ child: Text(
+ showComplete
+ ? '完成'
+ : (isLastSort ? '交卷' : '下一题'),
+ style: const TextStyle(
+ fontSize: 13,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ),
+ if (_shouldShowTimeBubble()) _buildTimeBubble(),
+ ],
+ ),
+ ],
+ );
+ }
+
+ bool _shouldShowTimeBubble() {
+ if (!_isEdit) return false;
+ final info = _session.currentQuestion;
+ if (info == null || info.planTimeSecond <= 0 || info.isFinishPlanTime) {
+ return false;
+ }
+ return getDisplayPlanTimeSecond(info) > 0;
+ }
+
+ Widget _buildTimeBubble() {
+ final info = _session.currentQuestion!;
+ final questionKey = info.id.isNotEmpty ? info.id : _session.currentIndex;
+ final remainSecond = getDisplayPlanTimeSecond(info);
+ final timeKey =
+ '$questionKey-${info.m['NewPlanTimeSecond'] ?? info.planTimeSecond}-${info.isFinishPlanTime ? 1 : 0}';
+ return Positioned(
+ right: 0,
+ top: 0,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => _switchStep('NextStep'),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 气泡背景图保持原始尺寸不变形
+ Container(
+ width: 39.w,
+ height: 21.h,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ image: DecorationImage(
+ image: CachedNetworkImageProvider(
+ ImgUrl.build('/StaticResource/2026-04-17/bubble.png'),
+ ),
+ fit: BoxFit.fill,
+ ),
+ ),
+ child: _CountdownBinder(
+ key: ValueKey('countdown-$timeKey'),
+ session: _session,
+ questionKey: questionKey,
+ timeMs: remainSecond * 1000,
+ onFinish: _submit.finishPlanTime,
+ ),
+ ),
+ // 时间文字距下方 10px,不拉伸背景图
+ const SizedBox(height: 10),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// ============================================================
+/// 复合题面板绑定器:
+/// - 自身以 ValueKey(题目标识) 挂载,切大题整体重建(对标 Vue :key);
+/// - State 持有 GlobalKey 并回写 session.panelKey,供控制器命令式调用。
+/// ============================================================
+class _PanelBinder extends StatefulWidget {
+ const _PanelBinder({
+ super.key,
+ required this.session,
+ required this.detail,
+ required this.activeSubIndex,
+ required this.mode,
+ this.onMyAnswer,
+ this.onSubSwitch,
+ this.onActiveSubIndexChanged,
+ this.onFillBlankImgChange,
+ this.onPanelHeightChanged,
+ });
+
+ final ExerciseSession session;
+ final QuestionModel detail;
+ final int activeSubIndex;
+ final CompoundMode mode;
+ final CompoundMyAnswerCallback? onMyAnswer;
+ final CompoundSubSwitchCallback? onSubSwitch;
+ final CompoundActiveSubCallback? onActiveSubIndexChanged;
+ final CompoundFillBlankCallback? onFillBlankImgChange;
+ final CompoundPanelHeightCallback? onPanelHeightChanged;
+
+ @override
+ State<_PanelBinder> createState() => _PanelBinderState();
+}
+
+class _PanelBinderState extends State<_PanelBinder> {
+ final GlobalKey _panelKey = GlobalKey();
+
+ @override
+ void initState() {
+ super.initState();
+ widget.session.panelKey = _panelKey;
+ }
+
+ @override
+ void dispose() {
+ // 仅当持有的仍是自己的 key 时才清理,避免新旧 binder 切换时序问题
+ if (identical(widget.session.panelKey, _panelKey)) {
+ widget.session.panelKey = null;
+ }
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return CompoundQuestionPanel(
+ key: _panelKey,
+ detail: widget.detail,
+ mode: widget.mode,
+ activeSubIndex: widget.activeSubIndex,
+ isActive: true,
+ isBottomH: false,
+ isScore: true,
+ onMyAnswer: widget.onMyAnswer,
+ onSubSwitch: widget.onSubSwitch,
+ onActiveSubIndexChanged: widget.onActiveSubIndexChanged,
+ onFillBlankImgChange: widget.onFillBlankImgChange,
+ onPanelHeightChanged: widget.onPanelHeightChanged,
+ );
+ }
+}
+
+/// ============================================================
+/// 倒计时绑定器:key 三元组(题 id / 剩余秒 / 结束标志)变化即整体重建,
+/// 并把内部 GlobalKey 回写 session.countDownKey。
+/// ============================================================
+class _CountdownBinder extends StatefulWidget {
+ const _CountdownBinder({
+ super.key,
+ required this.session,
+ required this.questionKey,
+ required this.timeMs,
+ required this.onFinish,
+ });
+
+ final ExerciseSession session;
+ final Object questionKey;
+ final int timeMs;
+ final ValueChanged onFinish;
+
+ @override
+ State<_CountdownBinder> createState() => _CountdownBinderState();
+}
+
+class _CountdownBinderState extends State<_CountdownBinder> {
+ final GlobalKey _countdownKey = GlobalKey();
+
+ @override
+ void initState() {
+ super.initState();
+ widget.session.countDownKey = _countdownKey;
+ }
+
+ @override
+ void dispose() {
+ if (identical(widget.session.countDownKey, _countdownKey)) {
+ widget.session.countDownKey = null;
+ }
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return QuestionPlanCountDown(
+ key: _countdownKey,
+ questionKey: widget.questionKey,
+ timeMs: widget.timeMs,
+ onFinish: widget.onFinish,
+ textStyle: TextStyle(
+ color: Colors.white,
+ fontSize: 12.sp,
+ height: 19.h / (12.sp),
+ ),
+ );
+ }
+}
+
+/// ============================================================
+/// 单向拦截 PageView 物理(PageView 已关闭自带 pageSnapping):
+/// 拖动阶段:左滑(去下一题)由 [canGoForward] 判定,false 时位移为 0;
+/// 松手阶段:自行计算最近页吸附,被拦时目标绝不超过当前页
+/// ——同时挡住“甩动速度翻页”;
+/// 右滑(回上一题)始终放行。
+/// ============================================================
+class _ForwardGuardScrollPhysics extends ScrollPhysics {
+ const _ForwardGuardScrollPhysics({
+ required this.canGoForward,
+ super.parent,
+ });
+
+ /// 返回 true 才允许向前(下一题)拖动
+ final bool Function() canGoForward;
+
+ @override
+ _ForwardGuardScrollPhysics applyTo(ScrollPhysics? ancestor) {
+ return _ForwardGuardScrollPhysics(
+ canGoForward: canGoForward,
+ parent: buildParent(ancestor),
+ );
+ }
+
+ @override
+ double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
+ // 水平 PageView(非 reverse):offset < 0 = 手指左拖去下一题
+ if (offset < 0 && !canGoForward()) return 0;
+ return super.applyPhysicsToUserOffset(position, offset);
+ }
+
+ @override
+ Simulation? createBallisticSimulation(
+ ScrollMetrics position,
+ double velocity,
+ ) {
+ final tolerance = toleranceFor(position);
+ final viewport = position.viewportDimension;
+ if (viewport <= 0) return null;
+
+ final maxPage =
+ (position.maxScrollExtent / viewport).clamp(0, 1 << 31).round();
+ final currentPage =
+ (position.pixels / viewport).round().clamp(0, maxPage);
+
+ // 由甩动速度决定目标页(等价 PageScrollPhysics 的 ±1 页规则)
+ var targetPage = currentPage;
+ if (velocity < -tolerance.velocity) {
+ targetPage = currentPage - 1;
+ } else if (velocity > tolerance.velocity) {
+ targetPage = currentPage + 1;
+ } else {
+ // 无明显甩动:吸附到距当前位置最近的页
+ targetPage = (position.pixels / viewport).round();
+ }
+
+ // 关键:不允许向前时,目标页不得超过当前页(挡住甩动翻页)
+ if (!canGoForward() && targetPage > currentPage) {
+ targetPage = currentPage;
+ }
+ targetPage = targetPage.clamp(0, maxPage);
+
+ final targetPixels = targetPage * viewport;
+ if ((targetPixels - position.pixels).abs() < precisionErrorTolerance) {
+ return null;
+ }
+ return ScrollSpringSimulation(
+ spring,
+ position.pixels,
+ targetPixels.toDouble(),
+ velocity,
+ tolerance: tolerance,
+ );
+ }
+}
+
+/// 全局 loading 遮罩(对标 uni.showLoading({ mask: true }))
+class _LoadingMask extends StatelessWidget {
+ const _LoadingMask({required this.text});
+
+ final ValueListenable text;
+
+ @override
+ Widget build(BuildContext context) {
+ return Positioned.fill(
+ child: ColoredBox(
+ color: const Color(0x66000000),
+ child: Center(
+ child: Container(
+ padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h),
+ decoration: BoxDecoration(
+ color: Colors.black87,
+ borderRadius: BorderRadius.circular(12.r),
+ ),
+ child: ValueListenableBuilder(
+ valueListenable: text,
+ builder: (_, value, _) => Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const CircularProgressIndicator(color: Colors.white),
+ SizedBox(height: 16.h),
+ Text(
+ value,
+ style: TextStyle(color: Colors.white, fontSize: 14.sp),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// 页面级加载遮罩(对标 .page-loading-mask:rgba(0,0,0,.2) + 白色转圈)
+class _PageLoadingMask extends StatelessWidget {
+ const _PageLoadingMask();
+
+ @override
+ Widget build(BuildContext context) {
+ return Positioned.fill(
+ child: ColoredBox(
+ color: const Color(0x33000000),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: 40.w,
+ height: 40.w,
+ child: CircularProgressIndicator(
+ strokeWidth: 3.w,
+ valueColor:
+ const AlwaysStoppedAnimation(Colors.white),
+ backgroundColor: Colors.white.withValues(alpha: 0.3),
+ ),
+ ),
+ SizedBox(height: 12.h),
+ Text(
+ '加载中...',
+ style: TextStyle(color: Colors.white, fontSize: 14.sp),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/conquerPackage/classExercises/controllers/exercise_collect.dart b/lib/features/conquerPackage/classExercises/controllers/exercise_collect.dart
new file mode 100644
index 0000000..c3b08c6
--- /dev/null
+++ b/lib/features/conquerPackage/classExercises/controllers/exercise_collect.dart
@@ -0,0 +1,83 @@
+/// ============================================================
+/// 【课堂习题 · 收藏 / 错题本】(1:1 迁移 Vue exerciseCollect.js)
+///
+/// type=1 收藏;type=2 错题(仅 view 模式可用)。
+/// ============================================================
+library;
+
+import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
+
+class ExerciseCollectHandlers {
+ ExerciseCollectHandlers({
+ required this.session,
+ required this.bridge,
+ });
+
+ final ExerciseSession session;
+ final ExerciseUiBridge bridge;
+ final ClassExercisesApi _api = ClassExercisesApi.instance;
+
+ dynamic _field(dynamic resp, String key) {
+ if (resp is Map) {
+ if (resp[key] != null) return resp[key];
+ final data = resp['data'];
+ if (data is Map && data[key] != null) return data[key];
+ }
+ return null;
+ }
+
+ /// type:1=收藏,2=错题本
+ Future onClickAddUserCollect(int type) async {
+ // 错题仅查看模式可用
+ if (type == 2 && !session.isView) return;
+
+ final info = session.currentQuestion;
+ if (info == null) return;
+
+ final selected = type == 1 ? info.isCollect : info.isWrongCollect;
+ try {
+ if (selected) {
+ final id = type == 1 ? info.collectId : info.wrongCollectId;
+ await _api.cancleCollect({
+ 'type': type,
+ 'id': id ?? 0,
+ });
+ _syncCollectState(type, true, null, info);
+ bridge.toast(type == 1 ? '取消收藏成功' : '取消错题成功');
+ } else {
+ final packId =
+ session.totalPackId.isNotEmpty ? session.totalPackId : session.packId;
+ final res = await _api.addUserCollect({
+ 'type': type,
+ 'packId': packId,
+ 'questionId': info.questionId,
+ 'recordQuestionId': info.id,
+ });
+ final resId = _field(res, 'id');
+ _syncCollectState(type, false, resId, info);
+ bridge.toast(type == 1 ? '收藏成功' : '错题添加成功');
+ }
+ bridge.notifyListeners();
+ } catch (_) {
+ bridge.toast(type == 1 ? '收藏失败' : '失败');
+ }
+ }
+
+ /// 同步收藏态(对标 syncCollectState)
+ void _syncCollectState(
+ int type,
+ bool wasSelected,
+ dynamic resId,
+ QuestionModel info,
+ ) {
+ if (type == 2) {
+ info.isWrongCollect = !wasSelected;
+ info.wrongCollectId = wasSelected ? 0 : resId;
+ } else {
+ info.isCollect = !wasSelected;
+ info.collectId = wasSelected ? 0 : resId;
+ }
+ }
+}
diff --git a/lib/features/conquerPackage/classExercises/controllers/exercise_load.dart b/lib/features/conquerPackage/classExercises/controllers/exercise_load.dart
new file mode 100644
index 0000000..19b66b2
--- /dev/null
+++ b/lib/features/conquerPackage/classExercises/controllers/exercise_load.dart
@@ -0,0 +1,172 @@
+/// ============================================================
+/// 【课堂习题 · 数据加载】(1:1 迁移 Vue exerciseLoad.js)
+///
+/// - loadStartOvercome:edit 模式,GetRecordData + GetListByRecordQuestion,
+/// 解析题目、追加 sort、补 hasOptionChildrenQuestion、挂学科 id;
+/// - loadSubmitReport:view 模式,GetSubmitReport,错题模式过滤重排。
+///
+/// 调试:loadStartOvercome 在接口返回后会(仅 kDebugMode)追加
+/// assets/mock/class_exercises_edit.json(取自 Vue exerciseLoad.js)。
+/// ============================================================
+library;
+
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
+import 'package:new_learn/core/utils/question/exam_answer_card.dart';
+import 'package:new_learn/core/utils/question/question_constant.dart';
+import 'package:new_learn/core/utils/question/question_parser.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_sort.dart';
+import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
+
+class ExerciseLoadResult {
+ const ExerciseLoadResult({
+ required this.recordId,
+ required this.navBarTitle,
+ required this.baseSubjectId,
+ required this.questions,
+ required this.originalMaxSort,
+ });
+
+ final String recordId;
+ final String navBarTitle;
+ final int? baseSubjectId;
+ final List questions;
+
+ /// view 全量题号最大值(错题模式下供页面保留原始题号总数)
+ final int originalMaxSort;
+}
+
+class ExerciseLoad {
+ ExerciseLoad._();
+
+ /// edit 模式加载(对标 loadStartOvercome)
+ ///
+ /// [inputRecordId] 路由带入的 recordId;[currentIndex] 为起始大题索引,
+ /// 该题会写入当前 startTime;[baseSubjectIdOverride] 为路由/base 传入的
+ /// 学科 id(对应 Vue 的 baseSubjectId ?? resRecord.baseSubjectId)。
+ static Future loadStartOvercome({
+ required String inputRecordId,
+ int currentIndex = 0,
+ int? baseSubjectIdOverride,
+ }) async {
+ final api = ClassExercisesApi.instance;
+ final resRecord = await api.getRecordData(inputRecordId);
+ final resList = await api.getListByRecordQuestion(inputRecordId);
+
+ // 接口照常请求;debug 测试时再把 exerciseLoad.js 里的 JSON 试题追加进去
+ await _injectDebugQuestions(resList.items);
+
+ final navBarTitle = '${resRecord.name}专项智能攻克';
+ final subjectId = baseSubjectIdOverride ?? resRecord.baseSubjectId;
+ final subjectType = getSubjectTypeById(subjectId) ?? 0;
+
+ final parsed = >[];
+ for (var i = 0; i < resList.items.length; i++) {
+ final item = resList.items[i];
+ if (i == currentIndex) {
+ item['startTime'] = getCurrentISO();
+ }
+ // 1=图片作答 / 手写;0=输入框
+ item['iSFilllankItemUpload'] = subjectType;
+ parsed.add(parseQuestionData(item, mergeOriginalChilds: true));
+ }
+
+ var list = appendQuestionSort(parsed);
+ list = appendExamQuestionChildOptionStatus(list, subjectId);
+
+ final bsid = subjectId;
+ for (final q in list) {
+ q['baseSubjectId'] = bsid;
+ }
+
+ return ExerciseLoadResult(
+ recordId: resRecord.recordId,
+ navBarTitle: navBarTitle,
+ baseSubjectId: bsid,
+ questions: list.map(QuestionModel.new).toList(),
+ originalMaxSort: 0,
+ );
+ }
+
+ /// view 模式加载(对标 loadSubmitReport)
+ ///
+ /// [isWrong] 为 true 时只保留答错题并重排 wrongSort,同时把起始索引置 0
+ /// (调用方不要再用旧 currentIndex)。
+ static Future