/// ============================================================ /// 【课堂习题 · 单题提交 / 失败补提 / 交卷】 /// /// 1:1 迁移 Vue exerciseSubmit.js(createSubmitHandlers)。 /// /// 职责: /// 1) 单题提交(客观题 QuestionRenewTime / 主观题 PushAIGrading) /// 2) 填空题(输入框 / 图片作答)统一处理与提交 /// 3) 批量统计未处理 / 未作答题目(batchProcessQuestions) /// 4) 交卷弹窗与提交入口(submitExamPaper / handlePopupConfirm) /// 5) 失败队列本地持久化(shared_preferences,key classExercisesDraft:$courseId) /// ============================================================ library; import 'dart:async'; import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:new_learn/core/components/upload.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_parser.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart'; import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart'; import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart'; /// 失败补提结果(对标 Vue {success, failedCount}) class RetryResult { const RetryResult({required this.success, required this.failedCount}); final bool success; final int failedCount; } /// 答题卡额外计数 class AnswerCardExtraCounts { const AnswerCardExtraCounts({ this.unansweredCount = 0, this.pendingNotesCount = 0, }); final int unansweredCount; final int pendingNotesCount; } class _FailedEntry { _FailedEntry({ required this.key, required this.submitType, required this.payload, }); final String key; final String submitType; // QuestionRenewTime / PushAIGrading final Map payload; } class ExerciseSubmitHandlers { ExerciseSubmitHandlers({ required this.session, required this.bridge, }); final ExerciseSession session; final ExerciseUiBridge bridge; final ClassExercisesApi _api = ClassExercisesApi.instance; bool _submitLock = false; Timer? _submitLockTimer; /// 单题提交失败队列(交卷前统一补提) final Map _failedSubmitRequestMap = {}; String get _pendingStorageKey => 'classExercisesDraft:${session.courseId}'; // ---------------- 失败队列 ---------------- String _buildFailedKey(Map payload) { final id = payload['id'] ?? ''; final childId = payload['childId'] ?? 0; final index = payload['index'] ?? ''; return '$id|$childId|$index'; } void _upsertFailedSubmitRequest( String submitType, Map payload, ) { final key = _buildFailedKey(payload); _failedSubmitRequestMap[key] = _FailedEntry( key: key, submitType: submitType, payload: Map.from(payload), ); } void _removeFailedSubmitRequest( String submitType, Map payload, ) { _failedSubmitRequestMap.remove(_buildFailedKey(payload)); } /// 进入页面时从本地缓存恢复失败队列 Future restorePendingFromStorage() async { final key = _pendingStorageKey; if (key.endsWith(':')) return; _failedSubmitRequestMap.clear(); try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(key); if (raw == null || raw.isEmpty) return; final cached = jsonDecode(raw); final items = cached is Map && cached['items'] is List ? cached['items'] as List : const []; for (final rawItem in items) { if (rawItem is! Map) continue; final payload = rawItem['payload']; if (payload is! Map) continue; final submitType = '${rawItem['submitType'] ?? ''}'; if (submitType != 'QuestionRenewTime' && submitType != 'PushAIGrading') { continue; } final payloadMap = Map.from(payload); final failedKey = _buildFailedKey(payloadMap); _failedSubmitRequestMap[failedKey] = _FailedEntry( key: failedKey, submitType: submitType, payload: payloadMap, ); } } catch (_) { // 缓存损坏:忽略,从空队列开始 } } /// 把当前失败队列写回本地缓存(空队列时清缓存) Future persistPendingToStorage() async { final key = _pendingStorageKey; if (key.endsWith(':')) return; try { final prefs = await SharedPreferences.getInstance(); final entries = _failedSubmitRequestMap.values.toList(); if (entries.isEmpty) { await prefs.remove(key); return; } final items = entries .map((e) => { 'key': e.key, 'submitType': e.submitType, 'payload': e.payload, }) .toList(); await prefs.setString( key, jsonEncode({ 'version': 1, 'updatedAt': DateTime.now().millisecondsSinceEpoch, 'courseId': session.courseId, 'items': items, }), ); } catch (_) { // 落盘失败不阻塞流程 } } /// 补提失败队列,默认 3 轮 Future retryFailedSubmitRequests({ int maxRetryRounds = 3, }) async { for (var round = 0; round < maxRetryRounds; round++) { final currentFailedList = _failedSubmitRequestMap.values.toList(); if (currentFailedList.isEmpty) { return const RetryResult(success: true, failedCount: 0); } for (final item in currentFailedList) { try { final safePayload = normalizeSubmitTimeRange(Map.from(item.payload)); if (item.submitType == 'QuestionRenewTime') { await _api.questionRenewTime(safePayload); } else if (item.submitType == 'PushAIGrading') { await _api.pushAIGrading(safePayload); } _failedSubmitRequestMap.remove(item.key); } catch (_) { _upsertFailedSubmitRequest(item.submitType, item.payload); } } } return RetryResult( success: _failedSubmitRequestMap.isEmpty, failedCount: _failedSubmitRequestMap.length, ); } int getPendingCount() => _failedSubmitRequestMap.length; // ---------------- 答案回填 ---------------- /// 倒计时结束:仅处理当前可见题 void finishPlanTime(dynamic questionId) { final current = session.currentQuestion; final currentQuestionId = current?.id ?? '${session.currentIndex}'; if (questionId != null && '$questionId'.isNotEmpty && '$questionId' != currentQuestionId) { return; } current?.m['ISFinishPlanTime'] = true; session.countDownKey?.currentState?.reset(); } /// 客观题选择答案(e 为含 userAnswer 的回调载荷) void selectAnswer(dynamic userAnswer, [int? questionIndex]) { final idx = questionIndex ?? session.currentIndex; final current = idx >= 0 && idx < session.questions.length ? session.questions[idx] : null; if (current != null) current.userAnswer = userAnswer; } /// 单题答案更新(文本 / 图片 JSON 字符串) void updateAnswer(dynamic value, [int? questionIndex]) { final idx = questionIndex ?? session.currentIndex; final current = idx >= 0 && idx < session.questions.length ? session.questions[idx] : null; if (current != null) current.userAnswer = value; } /// 复合题:子题作答内容回填(startTime / userAnswer) void getMyMinAnswer({required int index, required QuestionModel data}) { final current = session.currentQuestion; final subs = current?.mapList('subQuestions') ?? const []; if (index >= 0 && index < subs.length) { subs[index]['startTime'] = data.m['startTime']; subs[index]['userAnswer'] = data.m['userAnswer']; } } /// 切题 / 切子题前,把当前倒计时剩余秒数写回题目 void calculateRemainTime() { final info = session.currentQuestion; if (info == null) return; if (info.planTimeSecond > 0 && !info.isFinishPlanTime) { final countdown = session.countDownKey?.currentState; if (countdown == null) return; final remainMs = countdown.getRemainTime(); final remain = (remainMs < 0 ? 0 : remainMs) ~/ 1000; info.newPlanTimeSecond = remain; } } /// 填空题(图片作答):subQuestions / childs 双写当前空的图片 JSON void fillBlankImgChange( List images, int index, [ int? questionIndex, ]) { final idx = questionIndex ?? session.currentIndex; if (idx < 0 || idx >= session.questions.length) return; final res = session.questions[idx]; final subs = res.mapList('subQuestions'); final childs = res.mapList('childs'); final value = jsonEncode(images); if (index >= 0 && index < subs.length) { subs[index]['userAnswer'] = value; } if (index >= 0 && index < childs.length) { childs[index]['userAnswer'] = value; } } // ---------------- 时间范围 / 提交分发 ---------------- /// endTime 不能早于 startTime(对标 normalizeSubmitTimeRange) Map normalizeSubmitTimeRange( Map queryData, ) { final qd = Map.from(queryData); if (qd['startTime'] == null || '${qd['startTime']}'.isEmpty) { qd['startTime'] = getCurrentISO(); } if (qd['endTime'] == null || '${qd['endTime']}'.isEmpty) { qd['endTime'] = getCurrentISO(); } final startRaw = qd['startTime']; final endRaw = qd['endTime']; if (startRaw == null || endRaw == null) return qd; final startMs = DateTime.tryParse('$startRaw')?.millisecondsSinceEpoch; final endMs = DateTime.tryParse('$endRaw')?.millisecondsSinceEpoch; if (startMs == null || endMs == null) return qd; if (endMs <= startMs) { qd['endTime'] = DateTime.fromMillisecondsSinceEpoch(startMs + 1000, isUtc: true) .toIso8601String(); } return qd; } /// 提交分发:userAnswer+answerStatus 走 RenewTime;answerImgs 走 PushAIGrading Future submitQuestionsReview( Map queryData, { bool throwOnError = false, }) async { if ('${queryData['id'] ?? ''}'.isEmpty) return; final safeQueryData = normalizeSubmitTimeRange(queryData); if (safeQueryData['startTime'] == null || safeQueryData['endTime'] == null) { return; } if (queryData.containsKey('userAnswer') && queryData.containsKey('answerStatus')) { try { await _api.questionRenewTime(safeQueryData); _removeFailedSubmitRequest('QuestionRenewTime', safeQueryData); } catch (err) { _upsertFailedSubmitRequest('QuestionRenewTime', safeQueryData); if (throwOnError) rethrow; } } if (queryData.containsKey('answerImgs')) { try { await _api.pushAIGrading(safeQueryData); _removeFailedSubmitRequest('PushAIGrading', safeQueryData); } catch (err) { _upsertFailedSubmitRequest('PushAIGrading', safeQueryData); if (throwOnError) rethrow; } } } /// AI 判题提交(主观题 / 图片作答) /// [id] 非空表示子题提交(id=父题 id,childId=子题 id) Future pushAIGradingFn( QuestionModel data, String? id, { bool throwOnError = false, }) async { session.processedQuestionIds.add(data.id); List answerImgs; if (id != null && id.isNotEmpty) { final userAnswer = data.userAnswer; final List parsed = userAnswer.isEmpty ? const [] : (jsonDecode(userAnswer) as List? ?? const []); answerImgs = parsed.whereType().map((raw) { return { 'url': raw['Url'] ?? raw['url'], 'thumbnailUrl': raw['ThumbnailUrl'] ?? raw['thumbnailUrl'], }; }).toList(); } else { final pageState = session.itemRefs[session.currentIndex]?.currentState; final rawList = pageState?.getImageList('uploadAnswerRef') ?? const []; answerImgs = _normalizeAnswerImgs(rawList); } final queryData = { 'id': (id != null && id.isNotEmpty) ? id : data.id, 'childId': (id != null && id.isNotEmpty) ? data.id : 0, 'courseId': session.courseId, 'startTime': data.m['startTime'], 'endTime': getCurrentISO(), 'answerImgs': answerImgs, }; await submitQuestionsReview( queryData, throwOnError: throwOnError, ); } List> _normalizeAnswerImgs(List rawList) { final result = >[]; for (final raw in rawList) { if (raw is UploadItem) { if (raw.url.isEmpty) continue; result.add({ 'url': raw.url, 'thumbnailUrl': raw.thumbnailUrl ?? raw.url, }); } else if (raw is Map) { final url = '${raw['Url'] ?? raw['url'] ?? ''}'; if (url.isEmpty) continue; result.add({ 'url': url, 'thumbnailUrl': '${raw['ThumbnailUrl'] ?? raw['thumbnailUrl'] ?? url}', }); } } return result; } /// 填空题图片作答提交(单空 / 多空统一走 answerImgs) Future handleFillBlankAnswer( QuestionModel data, String? id, { bool throwOnError = false, }) async { final userAnswer = data.userAnswer; final answerImgs = userAnswer.isEmpty ? const [] : (jsonDecode(userAnswer) as List? ?? const []); final queryData = { 'id': (id != null && id.isNotEmpty) ? id : data.id, 'childId': 0, 'courseId': session.courseId, 'startTime': data.m['startTime'], 'endTime': getCurrentISO(), 'answerImgs': answerImgs, }; await submitQuestionsReview( queryData, throwOnError: throwOnError, ); } /// 题目提交入口(对标 QuestionRenewTimeFn) Future questionRenewTimeFn( QuestionModel data, String? id, { bool throwOnError = false, }) async { if (session.isView) return; session.processedQuestionIds.add(data.id); // 非客观题 非填空 → AI 判题 if (data.options.isEmpty && data.questionTypes != 'tiankongti') { await pushAIGradingFn(data, id, throwOnError: throwOnError); return; } // 填空题(图片作答) if (data.isFillblankItemUpload == 1 && data.questionTypes == 'tiankongti') { await handleFillBlankAnswer(data, id, throwOnError: throwOnError); return; } // 客观题:对比前剥离 HTML(与 Vue 一致,直接回写题目数据) data.correctAnswer = stripAnswerPlain(data.correctAnswer); data.userAnswer = stripAnswerPlain(data.userAnswer); var answerStatus = 0; if (formatQuestionText(data.userAnswer) == formatQuestionText(data.correctAnswer)) { answerStatus = 10; } if (formatQuestionText(data.userAnswer) != formatQuestionText(data.correctAnswer)) { answerStatus = 11; } // 多选题必须完全匹配;其他题型保留“包含即对” if (data.questionTypes != 'duoxuanti' && data.correctAnswer.contains(data.userAnswer) && data.options.isNotEmpty) { answerStatus = 10; } if ('${data.m['userAnswer'] ?? ''}'.isEmpty) answerStatus = 0; final queryData = { 'id': (id != null && id.isNotEmpty) ? id : data.id, 'childId': (id != null && id.isNotEmpty) ? data.id : 0, 'courseId': session.courseId, 'userAnswer': data.userAnswer, 'startTime': data.m['startTime'], 'endTime': getCurrentISO(), 'answerStatus': answerStatus, }; // 七选五 if (data.questionTypes == 'qixuanwu_sub' || data.questionTypes == 'qixuanwu') { queryData['childId'] = 0; queryData['index'] = data.m['index']; final plainCorrect = stripAnswerPlain(data.correctAnswer); final plainUser = stripAnswerPlain(data.userAnswer); queryData['answerStatus'] = plainCorrect.contains(plainUser) ? 10 : 11; if ('${data.m['userAnswer'] ?? ''}'.isEmpty) { queryData['answerStatus'] = 0; } } // 填空输入类:走小题 if (data.isFillblankItemUpload == 0 && data.questionTypes == 'tiankongti') { queryData['childId'] = 0; queryData['index'] = data.m['index'] ?? 1; queryData['answerStatus'] = formatQuestionText(data.correctAnswer) == formatQuestionText(data.userAnswer) ? 10 : 11; if ('${data.m['userAnswer'] ?? ''}'.isEmpty) { queryData['answerStatus'] = 0; } } await submitQuestionsReview( queryData, throwOnError: throwOnError, ); } /// 复合题切子题时回填并提交(对标 seleCthildData) void selectChildData({required int index, required QuestionModel data}) { final currentItem = session.currentQuestion; final subs = currentItem?.mapList('subQuestions') ?? const []; var submitData = QuestionModel({}); if (index >= 0 && index < subs.length) { final sub = subs[index]; sub['startTime'] = data.m['startTime']; sub['userAnswer'] = data.m['userAnswer']; submitData = QuestionModel(sub); } final parentId = session.currentQuestion?.id ?? ''; questionRenewTimeFn(submitData, parentId); } // ---------------- 答题卡统计 ---------------- /// 拍平成答题卡题号列表(默认 isExamAnswerCard=true) List> batchProcessQuestions({ bool isExamAnswerCard = true, }) { return buildExamAnswerCardQuestions( session.questions.map((q) => q.m).toList(), isExamAnswerCard: isExamAnswerCard, ); } /// 未作答 / 待补笔记计数(口径与 Vue 一致) AnswerCardExtraCounts getAnswerCardExtraCounts({ bool isExamAnswerCard = true, }) { final list = batchProcessQuestions(isExamAnswerCard: isExamAnswerCard); var unansweredCount = 0; var pendingNotesCount = 0; for (final res in list) { final userAnswer = res['userAnswer']; final answerImgs = res['answerImgs']; final answered = userAnswer != null && '$userAnswer'.isNotEmpty || (answerImgs is List && answerImgs.isNotEmpty); if (!answered) unansweredCount += 1; if (res['isUploadNotes'] == true) { final notes = res['notes']; if (!(notes is List && notes.isNotEmpty)) pendingNotesCount += 1; } } return AnswerCardExtraCounts( unansweredCount: unansweredCount, pendingNotesCount: pendingNotesCount, ); } // ---------------- 弹窗配置 ---------------- /// 构建并展示攻克流程弹窗(对标 getConquerPopupCon) void getConquerPopupCon( String type, dynamic data, { String? confirmText, String? cancelText, bool? isConfirm, String? confirmAction, String? title, String? subtitle, }) { late ConquerPopupConfig config; if (type == 'note') { config = ConquerPopupConfig( show: true, type: 'note', title: '本题建议上传过程笔记', subtitle: '记录解题思维过程,有助于您更好地复盘错题,提升学习效率。', confirmText: '上传笔记', cancelText: '取消', ); } else if (type == 'grading') { config = ConquerPopupConfig( show: true, type: 'grading', title: '题目批改中', subtitle: '预计 3~5 分钟有结果', confirmText: '返回攻克列表', cancelText: '取消', ); } else if (type == 'submit') { config = ConquerPopupConfig( show: true, type: 'submit', title: '确认提交?', subtitle: '您还有 $data 道题未完成,是否确定提交练习?', confirmText: '提交', cancelText: '取消', ); } else { // time final isPad = bridge.isPad; config = ConquerPopupConfig( show: true, type: 'time', title: "
根据你过往数据,建议攻克$data
", subtitle: "
仓促速学掌握不牢,之后还要重新复习
" "
本次攻克成功,学习时长会逐步减少
", confirmText: '跳过', cancelText: '继续作答', confirmAction: 'minimumLearningDuration', ); } // con 覆盖项 if (confirmText != null) config.confirmText = confirmText; if (cancelText != null) config.cancelText = cancelText; if (isConfirm != null) config.isConfirm = isConfirm; if (confirmAction != null) config.confirmAction = confirmAction; if (title != null) config.title = title; if (subtitle != null) config.subtitle = subtitle; bridge.showConquerPopup(config); } // ---------------- 交卷 ---------------- Future _submitCurrentQuestionBeforeSubmitExamPaper() async { if (session.isView) return; final item = session.currentQuestion; if (item == null) return; // 填空题单个空:直接提交大题 if (item.childs.length == 1 && item.questionTypes == 'tiankongti') { await questionRenewTimeFn(item, null, throwOnError: true); return; } // hasOptionChildrenQuestion=false 且图片作答:整题算 1 题 if (item.isFillblankItemUpload == 1 && !item.hasOptionChildrenQuestion) { await questionRenewTimeFn(item, null, throwOnError: true); return; } // 有小题:提交当前激活子题 final hasSub = shouldCountSubQuestionsLocal(item) || item.childs.isNotEmpty; if (hasSub) { final childData = session.panelKey?.currentState?.getChildData(); await questionRenewTimeFn( childData?.data ?? QuestionModel({}), item.id, throwOnError: true, ); return; } await questionRenewTimeFn(item, null, throwOnError: true); } bool shouldCountSubQuestionsLocal(QuestionModel item) => item.hasOptionChildrenQuestion && item.subQuestions.isNotEmpty; /// 交卷入口(type=='submit' 表示用户已确认,跳过未答确认弹窗) Future submitExamPaper(String type) async { if (_submitLock) return; _submitLock = true; _submitLockTimer?.cancel(); _submitLockTimer = Timer(const Duration(milliseconds: 18000), () { _submitLock = false; _submitLockTimer = null; }); bridge.showLoading('提交中...'); try { await _submitCurrentQuestionBeforeSubmitExamPaper(); final counts = getAnswerCardExtraCounts(); if (counts.unansweredCount > 0) { bridge.setAnswerSheetVisible(false); getConquerPopupCon( 'submit', counts.unansweredCount, confirmText: '确定', isConfirm: true, confirmAction: 'showAnswerSheet', title: '暂时无法提交', subtitle: '您还有 ${counts.unansweredCount} 道题未作答', ); return; } if (counts.pendingNotesCount > 0) { bridge.setAnswerSheetVisible(false); getConquerPopupCon( 'submit', counts.pendingNotesCount, confirmText: '确定', isConfirm: true, confirmAction: 'showAnswerSheet', title: '暂时无法提交', subtitle: '您还有 ${counts.pendingNotesCount} 道题待补笔记', ); return; } final unprocessedList = batchProcessQuestions(); final noAnswer = unprocessedList.where((res) { final userAnswer = res['userAnswer']; final answerImgs = res['answerImgs']; return (userAnswer == null || '$userAnswer'.isEmpty) && !(answerImgs is List && answerImgs.isNotEmpty); }).toList(); if (!(noAnswer.isEmpty || type == 'submit')) { bridge.setAnswerSheetVisible(false); getConquerPopupCon('submit', noAnswer.length); return; } if (_failedSubmitRequestMap.isNotEmpty) { bridge.showLoading('提交中...'); } final retryResult = await retryFailedSubmitRequests(); if (!retryResult.success) { bridge.hideLoading(); bridge.toast('网络异常,仍有 ${retryResult.failedCount} 道题提交失败,请稍后重试'); await Future.delayed(const Duration(milliseconds: 1500)); return; } await Future.delayed(const Duration(milliseconds: 2000)); bridge.showLoading('提交中...'); bridge.hideLoading(); return; final res = await _api.recordSubmit(session.recordId); bridge.setAnswerSheetVisible(false); if (_field(res, 'code') == -1 || _field(res, 'code') == '-1') { bridge.hideLoading(); final message = '${_field(res, 'message') ?? ''}'; Future.delayed(const Duration(milliseconds: 500), () { if (message.isNotEmpty) bridge.toast(message); }); return; } final aiGradingCount = _field(res, 'aiGradingCount'); if (aiGradingCount != null && aiGradingCount != 0 && '$aiGradingCount' != '0') { getConquerPopupCon('grading', null); } else { bridge.redirectToReport(session.recordId, session.packId); } } catch (_) { bridge.hideLoading(); Future.delayed(const Duration(milliseconds: 500), () { bridge.toast('网络请求失败,请检查网络'); }); } finally { _submitLockTimer?.cancel(); _submitLockTimer = null; bridge.hideLoading(); _submitLock = false; } } 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; } /// 弹窗确认按钮分发(对标 handlePopupConfirm) void handlePopupConfirm(ConquerPopupConfig config) { if (config.type == 'grading') { bridge.navigateBack(delta: 3); } else if (config.type == 'submit') { if (config.confirmAction == 'showAnswerSheet') { bridge.hideConquerPopup(); bridge.setAnswerSheetVisible(true); return; } submitExamPaper('submit'); } else if (config.type == 'time' || config.type == 'note') { bridge.hideConquerPopup(); } } /// 最低作答时长提示(返回 true 表示拦截跳题/交卷) bool openTimePopupIfNeeded() { final info = session.currentQuestion; if (info == null) return false; if (!session.isView && info.planTimeSecond > 0 && !info.isFinishPlanTime) { getConquerPopupCon('time', formatTime(info.planTimeSecond)); return true; } return false; } /// 上传笔记提示(返回 true 表示拦截跳题/交卷) bool openUploadNotesPopupIfNeeded() { final info = session.currentQuestion; if (info != null && info.isUploadNotes && info.notes.isEmpty) { getConquerPopupCon('note', null); return true; } return false; } }