817 lines
27 KiB
Dart
817 lines
27 KiB
Dart
/// ============================================================
|
||
/// 【课堂习题 · 单题提交 / 失败补提 / 交卷】
|
||
///
|
||
/// 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<String, dynamic> 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<String, _FailedEntry> _failedSubmitRequestMap = {};
|
||
|
||
String get _pendingStorageKey =>
|
||
'classExercisesDraft:${session.courseId}';
|
||
|
||
// ---------------- 失败队列 ----------------
|
||
|
||
String _buildFailedKey(Map<String, dynamic> payload) {
|
||
final id = payload['id'] ?? '';
|
||
final childId = payload['childId'] ?? 0;
|
||
final index = payload['index'] ?? '';
|
||
return '$id|$childId|$index';
|
||
}
|
||
|
||
void _upsertFailedSubmitRequest(
|
||
String submitType,
|
||
Map<String, dynamic> payload,
|
||
) {
|
||
final key = _buildFailedKey(payload);
|
||
_failedSubmitRequestMap[key] = _FailedEntry(
|
||
key: key,
|
||
submitType: submitType,
|
||
payload: Map<String, dynamic>.from(payload),
|
||
);
|
||
}
|
||
|
||
void _removeFailedSubmitRequest(
|
||
String submitType,
|
||
Map<String, dynamic> payload,
|
||
) {
|
||
_failedSubmitRequestMap.remove(_buildFailedKey(payload));
|
||
}
|
||
|
||
/// 进入页面时从本地缓存恢复失败队列
|
||
Future<void> 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<String, dynamic>.from(payload);
|
||
final failedKey = _buildFailedKey(payloadMap);
|
||
_failedSubmitRequestMap[failedKey] = _FailedEntry(
|
||
key: failedKey,
|
||
submitType: submitType,
|
||
payload: payloadMap,
|
||
);
|
||
}
|
||
} catch (_) {
|
||
// 缓存损坏:忽略,从空队列开始
|
||
}
|
||
}
|
||
|
||
/// 把当前失败队列写回本地缓存(空队列时清缓存)
|
||
Future<void> 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<RetryResult> 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<String, dynamic>.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<dynamic> 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<String, dynamic> normalizeSubmitTimeRange(
|
||
Map<String, dynamic> queryData,
|
||
) {
|
||
final qd = Map<String, dynamic>.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<void> submitQuestionsReview(
|
||
Map<String, dynamic> 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<void> pushAIGradingFn(
|
||
QuestionModel data,
|
||
String? id, {
|
||
bool throwOnError = false,
|
||
}) async {
|
||
session.processedQuestionIds.add(data.id);
|
||
|
||
List<dynamic> answerImgs;
|
||
if (id != null && id.isNotEmpty) {
|
||
final userAnswer = data.userAnswer;
|
||
final List<dynamic> parsed = userAnswer.isEmpty
|
||
? const []
|
||
: (jsonDecode(userAnswer) as List? ?? const []);
|
||
answerImgs = parsed.whereType<Map>().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 = <String, dynamic>{
|
||
'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<Map<String, dynamic>> _normalizeAnswerImgs(List<dynamic> rawList) {
|
||
final result = <Map<String, dynamic>>[];
|
||
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<void> 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 = <String, dynamic>{
|
||
'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<void> 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 = <String, dynamic>{
|
||
'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(<String, dynamic>{});
|
||
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<Map<String, dynamic>> 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:
|
||
'您还有<span class="spans" style="font-size:32rpx;color:#3D7DF5;"> $data</span> 道题未完成,是否确定提交练习?',
|
||
confirmText: '提交',
|
||
cancelText: '取消',
|
||
);
|
||
} else {
|
||
// time
|
||
final isPad = bridge.isPad;
|
||
config = ConquerPopupConfig(
|
||
show: true,
|
||
type: 'time',
|
||
title:
|
||
"<div style='font-size:${isPad ? '16px' : '14px'};'>根据你过往数据,建议攻克$data</div>",
|
||
subtitle:
|
||
"<div style=\"font-size:${isPad ? '13px' : '11px'};\">仓促速学掌握不牢,之后还要重新复习</div>"
|
||
"<div style=\"font-size:${isPad ? '13px' : '11px'};\">本次攻克成功,学习时长会逐步减少</div>",
|
||
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<void> _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(<String, dynamic>{}),
|
||
item.id,
|
||
throwOnError: true,
|
||
);
|
||
return;
|
||
}
|
||
await questionRenewTimeFn(item, null, throwOnError: true);
|
||
}
|
||
|
||
bool shouldCountSubQuestionsLocal(QuestionModel item) =>
|
||
item.hasOptionChildrenQuestion && item.subQuestions.isNotEmpty;
|
||
|
||
/// 交卷入口(type=='submit' 表示用户已确认,跳过未答确认弹窗)
|
||
Future<void> 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:
|
||
'您还有<span class="spans" style="font-size:32rpx;color:#3D7DF5;"> ${counts.unansweredCount}</span> 道题未作答',
|
||
);
|
||
return;
|
||
}
|
||
if (counts.pendingNotesCount > 0) {
|
||
bridge.setAnswerSheetVisible(false);
|
||
getConquerPopupCon(
|
||
'submit',
|
||
counts.pendingNotesCount,
|
||
confirmText: '确定',
|
||
isConfirm: true,
|
||
confirmAction: 'showAnswerSheet',
|
||
title: '暂时无法提交',
|
||
subtitle:
|
||
'您还有<span class="spans" style="font-size:32rpx;color:#3D7DF5;"> ${counts.pendingNotesCount}</span> 道题待补笔记',
|
||
);
|
||
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<void>.delayed(const Duration(milliseconds: 1500));
|
||
return;
|
||
}
|
||
|
||
await Future<void>.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<void>.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<void>.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;
|
||
}
|
||
}
|