This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
@@ -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<void> 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(<String, dynamic>{
'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(<String, dynamic>{
'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;
}
}
}
@@ -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<QuestionModel> questions;
/// view 全量题号最大值(错题模式下供页面保留原始题号总数)
final int originalMaxSort;
}
class ExerciseLoad {
ExerciseLoad._();
/// edit 模式加载(对标 loadStartOvercome)
///
/// [inputRecordId] 路由带入的 recordId;[currentIndex] 为起始大题索引,
/// 该题会写入当前 startTime;[baseSubjectIdOverride] 为路由/base 传入的
/// 学科 id(对应 Vue 的 baseSubjectId ?? resRecord.baseSubjectId)。
static Future<ExerciseLoadResult> 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 = <Map<String, dynamic>>[];
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<ExerciseLoadResult> loadSubmitReport({
required String packId,
bool isWrong = false,
int currentIndex = 0,
}) async {
final api = ClassExercisesApi.instance;
final res = await api.getSubmitReport(packId);
final navBarTitle = '${res.name}专项智能攻克';
final subjectType = getSubjectTypeById(res.baseSubjectId) ?? 0;
final parsed = <Map<String, dynamic>>[];
for (var i = 0; i < res.questions.length; i++) {
final item = res.questions[i];
if (i == currentIndex) {
item['startTime'] = getCurrentISO();
}
item['iSFilllankItemUpload'] = subjectType;
parsed.add(parseQuestionData(item, mergeOriginalChilds: true));
}
var fullList = appendQuestionSort(parsed);
fullList = appendExamQuestionChildOptionStatus(fullList, res.baseSubjectId);
var maxSort = 0;
for (final item in fullList.map(QuestionModel.new).toList()) {
final end = getQuestionSortRange(item).end;
if (end > maxSort) maxSort = end;
}
List<QuestionModel> questions;
if (isWrong) {
questions = filterWrongQuestions(fullList.map(QuestionModel.new).toList());
} else {
questions = fullList.map(QuestionModel.new).toList();
}
for (final q in questions) {
q.baseSubjectId = res.baseSubjectId;
}
return ExerciseLoadResult(
recordId: res.id,
navBarTitle: navBarTitle,
baseSubjectId: res.baseSubjectId,
questions: questions,
originalMaxSort: maxSort,
);
}
/// 【仅 debug 测试】真实接口请求完成后,把 Vue exerciseLoad.js 里的
/// JSON 试题追加到列表尾部(对标 JS 里 `res.push(...listX)`)。
/// release 包直接 no-op;测完删掉 [loadStartOvercome] 里的调用即可。
static Future<void> _injectDebugQuestions(
List<Map<String, dynamic>> items,
) async {
if (!kDebugMode) return;
try {
final raw = await rootBundle
.loadString('assets/mock/class_exercises_edit.json');
final list = jsonDecode(raw);
if (list is List) {
for (final item in list) {
if (item is Map) {
items.add(Map<String, dynamic>.from(item));
}
}
debugPrint('[ExerciseLoad] 已追加 ${list.length} 道测试 JSON 试题');
}
} catch (e) {
debugPrint('[ExerciseLoad] 测试 JSON 追加失败: $e');
}
}
}
@@ -0,0 +1,418 @@
/// ============================================================
/// 【课堂习题 · 翻页 / 子题切换 / 答题卡定位】
///
/// 1:1 迁移 Vue exerciseNavigation.js(createNavigationHandlers)。
///
/// Flutter 端 PageView 使用 NeverScrollableScrollPhysics,所有翻页都由
/// 本控制器经 [ExerciseUiBridge.jumpToPage] 主动触发(对标程序 slideTo),
/// PageView.onPageChanged 只接 [handlePageChange] 做状态同步,不再重复提交。
/// ============================================================
library;
import 'dart:async';
import 'package:flutter/widgets.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/models/question_model.dart';
class ExerciseNavigationHandlers {
ExerciseNavigationHandlers({
required this.session,
required this.bridge,
required this.submit,
required this.openTimePopup,
required this.openUploadNotesPopup,
required this.submitExamPaper,
});
final ExerciseSession session;
final ExerciseUiBridge bridge;
/// 单题提交控制器(calculateRemainTime / QuestionRenewTimeFn)
final ExerciseSubmitHandlers submit;
/// 页面级“最低学习时长”拦截(含 isWrong 判断)
final bool Function() openTimePopup;
/// 页面级“上传笔记”拦截(含 isWrong 判断)
final bool Function() openUploadNotesPopup;
/// 交卷入口
final void Function(String type) submitExamPaper;
bool _isProgrammaticJump = false;
Timer? _programmaticJumpTimer;
/// 标记程序跳转(400ms 内忽略手势重复触发)
void markProgrammaticJump({Duration duration = const Duration(milliseconds: 400)}) {
_isProgrammaticJump = true;
_programmaticJumpTimer?.cancel();
_programmaticJumpTimer = Timer(duration, () {
_isProgrammaticJump = false;
_programmaticJumpTimer = null;
});
}
bool get isProgrammaticJump => _isProgrammaticJump;
// ---------------- 题号计算 ----------------
sort.SortRange getDisplayQuestionSortRange(int targetIndex) =>
sort.getDisplayQuestionSortRange(session.questions, targetIndex);
sort.ResolvedSortTarget resolveTargetByDisplaySort(int targetSort) =>
sort.resolveTargetByDisplaySort(session.questions, targetSort);
ExerciseSubmitHandlers get _submit => submit;
int get totalSortCount {
if (session.questions.isEmpty) return 0;
return getDisplayQuestionSortRange(session.questions.length - 1).end;
}
int get totalWrongSortCount {
var maxSort = 0;
for (final item in session.questions) {
final end = sort.getWrongQuestionSortRange(item).end;
if (end > maxSort) maxSort = end;
}
return maxSort;
}
int _safeActiveSubIndex(QuestionModel item) {
final subs = item.subQuestions;
if (subs.isEmpty) return 0;
final raw = session.activeSubIndexMap[session.currentIndex] ?? 0;
return raw < 0
? 0
: (raw > subs.length - 1 ? subs.length - 1 : raw);
}
/// 当前展示题号(currentSortLabel)
int get currentSortLabel {
final item = session.currentQuestion;
if (item == null) return 0;
final start = getDisplayQuestionSortRange(session.currentIndex).start;
if (sort.shouldCountSubQuestions(item)) {
return start + _safeActiveSubIndex(item);
}
return start;
}
/// 错题模式当前题号(currentWrongSortLabel)
int get currentWrongSortLabel {
final item = session.currentQuestion;
if (item == null) return 0;
final start = sort.getWrongQuestionSortRange(item).start;
if (item.hasChild && item.subQuestions.isNotEmpty) {
final sub = item.subQuestions[_safeActiveSubIndex(item)];
return sub.wrongSort ?? start;
}
return start;
}
/// 进度百分比(percentage,ceil)
int get percentage {
if (session.isWrong) {
final total = totalWrongSortCount;
if (total == 0) return 0;
final item = session.currentQuestion;
if (item == null) return 0;
final range = sort.getWrongQuestionSortRange(item);
var current = range.start;
if (sort.shouldCountSubQuestions(item)) {
final sub = item.subQuestions[_safeActiveSubIndex(item)];
current = sub.wrongSort ?? range.start;
}
return (current / total * 100).ceil();
}
final total = totalSortCount;
if (total == 0) return 0;
final item = session.currentQuestion;
if (item == null) return 0;
final start = getDisplayQuestionSortRange(session.currentIndex).start;
var current = start;
if (sort.shouldCountSubQuestions(item)) {
current = start + _safeActiveSubIndex(item);
}
return (current / total * 100).ceil();
}
bool get isPrevDisabled =>
session.currentIndex == 0 ? !canGoPrevSubQuestion() : false;
// ---------------- 切页状态同步 ----------------
/// 大题切页后的状态同步(对标 handlePageChange,不含提交)
void handlePageChange(int index) {
final prevIndex = session.currentIndex;
if (prevIndex != index) {
// 注册表暂停,不遍历 element 树,动画收尾帧调用也安全;
// 旧页 isActive 翻转后 didUpdateWidget 也会暂停一次(双保险)
session.itemRefs[prevIndex]?.currentState?.pauseMedia();
}
session.currentIndex = index;
final q = session.currentQuestion;
if (q != null) {
q.startTime = getCurrentISO();
}
final pendingSub = session.pendingSubIndexMap[index];
if (pendingSub != null) {
session.activeSubIndexMap[index] = pendingSub;
final pageKey = session.itemRefs[index];
WidgetsBinding.instance.addPostFrameCallback((_) {
Future<void>.delayed(const Duration(milliseconds: 500), () {
pageKey?.currentState?.setActiveSubIndex(pendingSub);
session.pendingSubIndexMap.remove(index);
});
});
}
session.countDownKey?.currentState?.reset();
bridge.notifyListeners();
}
bool _hasSubQuestions(QuestionModel? item) =>
sort.shouldCountSubQuestions(item);
int _currentActiveSubIndex(QuestionModel item) {
if (!_hasSubQuestions(item)) return -1;
final total = item.subQuestions.length;
final current = session.activeSubIndexMap[session.currentIndex] ?? 0;
return current < 0
? 0
: (current > total - 1 ? total - 1 : current);
}
/// 跨大题切页前提交当前页(对标 submitCurrentQuestionBeforePageSwitch)
void submitCurrentQuestionBeforePageSwitch() {
_submit.calculateRemainTime();
final item = session.currentQuestion;
if (item == null) return;
// 填空题单个空:直接提交大题
if (item.childs.length == 1 && item.questionTypes == 'tiankongti') {
_submit.questionRenewTimeFn(item, null);
return;
}
// hasOptionChildrenQuestion=false 且图片作答:整题算 1 题
if (item.isFillblankItemUpload == 1 &&
!item.hasOptionChildrenQuestion) {
_submit.questionRenewTimeFn(item, null);
return;
}
// 有小题:提交当前激活子题
if (_hasSubQuestions(item) || item.childs.isNotEmpty) {
final data = session.panelKey?.currentState?.getChildData();
_submit.questionRenewTimeFn(
data?.data ?? QuestionModel(<String, dynamic>{}),
item.id,
);
return;
}
_submit.questionRenewTimeFn(item, null);
}
/// 复合题面板子题切换(对标 handleSubQuestionChange)
void handleSubQuestionChange(int questionIndex, int subIndex) {
final item = questionIndex >= 0 && questionIndex < session.questions.length
? session.questions[questionIndex]
: null;
final subs = item == null
? const []
: (item.subQuestions.isNotEmpty
? item.mapList('subQuestions')
: item.mapList('bkTagMetaList'));
final safe = subs.isEmpty
? 0
: (subIndex < 0 ? 0 : (subIndex > subs.length - 1 ? subs.length - 1 : subIndex));
session.activeSubIndexMap[questionIndex] = safe;
}
bool canGoNextSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
final current = _currentActiveSubIndex(item!);
return current >= 0 && current < item.subQuestions.length - 1;
}
bool canGoPrevSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
return _currentActiveSubIndex(item!) > 0;
}
bool goNextSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
final current = _currentActiveSubIndex(item!);
if (current < 0 || current >= item.subQuestions.length - 1) return false;
_submit.calculateRemainTime();
final data = session.panelKey?.currentState?.getChildData();
_submit.questionRenewTimeFn(
data?.data ?? QuestionModel(<String, dynamic>{}),
session.currentQuestion?.id ?? '',
);
session.activeSubIndexMap[session.currentIndex] = current + 1;
bridge.notifyListeners();
return true;
}
bool goPrevSubQuestion() {
final item = session.currentQuestion;
if (!_hasSubQuestions(item)) return false;
final current = _currentActiveSubIndex(item!);
if (current <= 0) return false;
_submit.calculateRemainTime();
final data = session.panelKey?.currentState?.getChildData();
_submit.questionRenewTimeFn(
data?.data ?? QuestionModel(<String, dynamic>{}),
session.currentQuestion?.id ?? '',
);
session.activeSubIndexMap[session.currentIndex] = current - 1;
bridge.notifyListeners();
return true;
}
/// 答题卡定位(对标 handleAnswerCardSelect)
Future<void> handleAnswerCardSelect(Map<String, dynamic> question) async {
final currentQuestionIndex = session.currentIndex;
final currentInfo = session.currentQuestion;
final currentActiveSubIndex =
currentInfo == null ? -1 : _currentActiveSubIndex(currentInfo);
var targetQuestionIndex = -1;
var targetSubIndex = -1;
final parentIndex = question['parentIndex'];
if (parentIndex is int && parentIndex >= 0) {
targetQuestionIndex = parentIndex;
targetSubIndex = int.tryParse('${question['subIndex'] ?? -1}') ?? -1;
} else {
final displaySort = int.tryParse('${question['sort'] ?? 0}') ?? 0;
if (displaySort == 0) return;
final resolved = resolveTargetByDisplaySort(displaySort);
targetQuestionIndex = resolved.questionIndex;
targetSubIndex = resolved.subIndex;
}
if (targetQuestionIndex < 0) return;
final shouldSubmitBeforeJump =
targetQuestionIndex != currentQuestionIndex ||
(targetQuestionIndex == currentQuestionIndex &&
targetSubIndex >= 0 &&
targetSubIndex != currentActiveSubIndex);
if (!session.isView && shouldSubmitBeforeJump) {
// 只触发提交,不 await,避免跳题卡顿
submitCurrentQuestionBeforePageSwitch();
}
if (targetSubIndex >= 0) {
final targetItem = targetQuestionIndex >= 0 &&
targetQuestionIndex < session.questions.length
? session.questions[targetQuestionIndex]
: null;
final subTotal = targetItem?.subQuestions.length ?? 0;
if (subTotal > 0) {
targetSubIndex = targetSubIndex > subTotal - 1 ? subTotal - 1 : targetSubIndex;
if (targetSubIndex < 0) targetSubIndex = 0;
session.activeSubIndexMap[targetQuestionIndex] = targetSubIndex;
session.pendingSubIndexMap[targetQuestionIndex] = targetSubIndex;
} else {
targetSubIndex = -1;
}
}
if (targetQuestionIndex != currentQuestionIndex) {
markProgrammaticJump();
_submit.calculateRemainTime();
await Future<void>.delayed(const Duration(milliseconds: 500));
bridge.jumpToPage(targetQuestionIndex);
return;
}
if (targetSubIndex >= 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
session.itemRefs[targetQuestionIndex]
?.currentState
?.setActiveSubIndex(targetSubIndex);
});
bridge.notifyListeners();
}
}
/// 触发当前页“上传笔记”
Future<void>? uploadNotes() {
return session.itemRefs[session.currentIndex]
?.currentState
?.uploadLectureNotes();
}
/// 下一题(含复合题内部下一小题)
void nextStep() {
if (canGoNextSubQuestion()) {
goNextSubQuestion();
return;
}
submitCurrentQuestionBeforePageSwitch();
if (session.currentIndex == session.questions.length - 1) return;
markProgrammaticJump();
bridge.jumpToPage(session.currentIndex + 1);
}
/// 上一题(含复合题内部上一小题)
void previousStep() {
if (canGoPrevSubQuestion()) {
goPrevSubQuestion();
return;
}
if (session.currentIndex == 0) return;
submitCurrentQuestionBeforePageSwitch();
markProgrammaticJump();
bridge.jumpToPage(session.currentIndex - 1);
}
/// 顶部返回:通知页面打开退出确认弹窗
void clickBackNavBar() {
bridge.openExitPopup();
}
/// 底部上一题 / 下一题统一入口(对标 switchStep)
void switchStep(String type, [String? timePopupIfNeeded]) {
if (session.currentIndex == 0 &&
type == 'PreviousStep' &&
!canGoPrevSubQuestion()) {
return;
}
if (type == 'NextStep') {
// 最低学习时长只拦“跨大题”,不拦大题内子题切换
if (timePopupIfNeeded != 'minimumLearningDuration') {
if (!canGoNextSubQuestion() && openTimePopup()) return;
}
if (openUploadNotesPopup()) return;
if (currentSortLabel == totalSortCount) {
submitExamPaper('submit');
return;
}
nextStep();
} else if (type == 'PreviousStep') {
previousStep();
}
}
/// view 最后一题“完成”:回攻克列表
void completeEnd() {
bridge.relaunchCaptureList(session.courseId);
}
void dispose() {
_programmaticJumpTimer?.cancel();
}
}
@@ -0,0 +1,136 @@
/// ============================================================
/// 【课堂习题 · 会话状态与 UI 桥接】
///
/// Vue 端各 createXxxHandlers 通过一堆 ref 共享状态;Flutter 端按
/// 用户拍板用“普通 Dart 控制器(无 Riverpod)”,因此把跨控制器共享
/// 的可变状态收敛到 [ExerciseSession],把与 Widget/导航/弹窗相关的
/// 副作用收敛到 [ExerciseUiBridge],由页面 State 实现并注入。
/// ============================================================
library;
import 'package:flutter/widgets.dart';
import 'package:new_learn/features/conquerPackage/classExercises/exercise_question_view.dart';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/compound_question_panel.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/question_plan_countdown.dart';
/// 当前本地 ISO 时间(对标 getCurrentISO)
String getCurrentISO() => DateTime.now().toIso8601String();
/// 攻克流程弹窗配置(对标 Vue conquerPopupCon)
class ConquerPopupConfig {
ConquerPopupConfig({
this.show = false,
this.type = 'note',
this.title = '',
this.subtitle = '',
this.confirmText = '确定',
this.cancelText = '取消',
this.isConfirm = false,
this.confirmAction = '',
});
bool show;
String type; // note / time / submit / grading
String title;
String subtitle;
String confirmText;
String cancelText;
bool isConfirm;
String confirmAction;
}
/// 跨控制器共享的会话状态(字段名与 Vue 保持一致)
class ExerciseSession {
/// 题目列表(解析后的 QuestionModel,与 Map 同引用语义)
List<QuestionModel> questions = [];
/// 当前大题索引(swiper index)
int currentIndex = 0;
/// edit / view
String mode = 'edit';
String courseId = '';
String packId = '';
String totalPackId = '';
String recordId = '';
bool isWrong = false;
/// view 全量题号(originalMaxSort)
int originalMaxSort = 0;
/// 导航栏标题
String navBarTitle = '';
/// 大题索引 -> 激活子题索引
final Map<int, int> activeSubIndexMap = {};
/// 大题索引 -> 等待页面挂载后定位的子题索引
final Map<int, int> pendingSubIndexMap = {};
/// 已处理过的题目 id(避免重复提交,对应 processedQuestionIds)
final Set<String> processedQuestionIds = <String>{};
/// 单题页 ref(itemRefs)
final Map<int, GlobalKey<ExerciseQuestionViewState>> itemRefs = {};
/// 复合题面板 ref(compoundQuestionFn)
GlobalKey<CompoundQuestionPanelState>? panelKey;
/// 倒计时组件 ref(countDownRef)
GlobalKey<QuestionPlanCountDownState>? countDownKey;
bool get isView => mode == 'view';
/// 当前题(questionsInfo)。Flutter 与列表元素保持同一引用,
/// 不再做 Vue 的浅拷贝。
QuestionModel? get currentQuestion {
if (currentIndex < 0 || currentIndex >= questions.length) return null;
return questions[currentIndex];
}
}
/// 控制器 -> 页面的副作用桥接(页面 State implements 本接口)
abstract class ExerciseUiBridge {
/// 全局 loading(对标 uni.showLoading({mask:true}))
void showLoading([String message]);
void hideLoading();
/// 轻提示(对标 uni.showToast icon:none)
void toast(String message);
/// 通知页面刷新(对标 Vue ref 赋值触发渲染)
void notifyListeners();
/// PageController 切页(对标 swiper.slideTo)
void jumpToPage(int index);
/// 打开“退出攻克”确认框(openExitPopup)
void openExitPopup();
/// 展示攻克流程弹窗(getConquerPopupCon)
void showConquerPopup(ConquerPopupConfig config);
/// 关闭攻克流程弹窗
void hideConquerPopup();
/// 答题卡显隐(showAnswerSheet)
void setAnswerSheetVisible(bool visible);
/// 暂停弹窗显隐(showPausePopup)
void setPausePopupVisible(bool visible);
/// 交卷成功跳报告页(redirectTo report)
void redirectToReport(String recordId, String packId);
/// 返回上 N 页(navigateBack delta)
void navigateBack({int delta});
/// 重启到攻克列表(reLaunch captureList)
void relaunchCaptureList(String courseId);
/// 是否平板(对标 isPadDevice(),time 弹窗字号用)
bool get isPad;
}
@@ -0,0 +1,194 @@
/// ============================================================
/// 【课堂习题 · 题号区间 / 错题过滤】
///
/// 1:1 迁移 Vue exerciseNavigation.js 中的纯计算函数与
/// classExercises.vue 中的 filterWrongQuestions / appendWrongQuestionSort。
/// ============================================================
library;
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
class SortRange {
const SortRange(this.start, this.end);
final int start;
final int end;
}
bool isWrongQuestionStatus(dynamic status) {
if (status == null) return true; // 对标 Number(undefined) != 10
final n = int.tryParse('$status');
return n != 10;
}
/// hasOptionChildrenQuestion === true 且有 subQuestions
bool shouldCountSubQuestions(QuestionModel? item) {
if (item == null) return false;
return item.hasOptionChildrenQuestion && item.subQuestions.isNotEmpty;
}
/// 后端 sort 区间(Vue getQuestionSortRange:hasChild + subQuestions)
SortRange getQuestionSortRange(QuestionModel? item) {
if (item == null) return const SortRange(0, 0);
final subs = item.subQuestions;
if (item.hasChild && subs.isNotEmpty) {
final first = subs.first.sort ?? 0;
final last = subs.last.sort ?? 0;
return SortRange(first, last == 0 ? first : last);
}
final sort = item.sort ?? 0;
return SortRange(sort, sort);
}
/// 错题 wrongSort 区间(Vue getWrongQuestionSortRange)
SortRange getWrongQuestionSortRange(QuestionModel? item) {
if (item == null) return const SortRange(0, 0);
if (shouldCountSubQuestions(item)) {
final subs = item.subQuestions;
if (subs.isEmpty) {
final wrongSort = item.wrongSort ?? 0;
return SortRange(wrongSort, wrongSort);
}
final first = subs.first.wrongSort ?? 0;
final last = subs.last.wrongSort ?? 0;
return SortRange(first, last == 0 ? first : last);
}
final wrongSort = item.wrongSort ?? 0;
return SortRange(wrongSort, wrongSort);
}
/// 展示题号区间(按列表顺序,复合题占 N 个题号)
SortRange getDisplayQuestionSortRange(
List<QuestionModel> questions,
int targetIndex,
) {
var cursor = 1;
for (var i = 0; i < questions.length; i++) {
final item = questions[i];
final count = shouldCountSubQuestions(item) ? item.subQuestions.length : 1;
final start = cursor;
final end = cursor + count - 1;
if (i == targetIndex) return SortRange(start, end);
cursor = end + 1;
}
return const SortRange(0, 0);
}
/// 展示题号 -> 大题索引 + 子题索引
class ResolvedSortTarget {
const ResolvedSortTarget(this.questionIndex, this.subIndex);
final int questionIndex;
final int subIndex; // 非子题为 -1
}
ResolvedSortTarget resolveTargetByDisplaySort(
List<QuestionModel> questions,
int targetSort,
) {
if (targetSort <= 0) return const ResolvedSortTarget(-1, -1);
var cursor = 1;
for (var i = 0; i < questions.length; i++) {
final item = questions[i];
final count = shouldCountSubQuestions(item) ? item.subQuestions.length : 1;
final start = cursor;
final end = cursor + count - 1;
if (targetSort >= start && targetSort <= end) {
return ResolvedSortTarget(
i,
shouldCountSubQuestions(item) ? targetSort - start : -1,
);
}
cursor = end + 1;
}
return const ResolvedSortTarget(-1, -1);
}
String _getIdString(Map<String, dynamic> obj) {
final raw = obj['raw'];
if (raw is Map && raw['id'] != null) return '${raw['id']}';
if (obj['id'] != null) return '${obj['id']}';
if (obj['questionId'] != null) return '${obj['questionId']}';
return '';
}
/// 过滤错题并按原序重排 wrongSort(Vue filterWrongQuestions)
List<QuestionModel> filterWrongQuestions(List<QuestionModel> list) {
final filtered = <QuestionModel>[];
for (final item in list) {
if (shouldCountSubQuestions(item)) {
final subs = item.mapList('subQuestions');
final wrongSubs =
subs.where((sub) => isWrongQuestionStatus(sub['answerStatus'])).toList();
if (wrongSubs.isEmpty) continue;
final childs = item.mapList('childs');
final childById = <String, Map<String, dynamic>>{};
for (final child in childs) {
final key = _getIdString(child);
if (key.isNotEmpty) childById[key] = child;
}
final wrongChilds = <Map<String, dynamic>>[];
for (final sub in wrongSubs) {
final key = _getIdString(sub);
final byId = key.isNotEmpty ? childById[key] : null;
if (byId != null) {
wrongChilds.add(byId);
continue;
}
final subIndex = subs.indexOf(sub);
if (subIndex >= 0 && subIndex < childs.length) {
wrongChilds.add(childs[subIndex]);
continue;
}
wrongChilds.add(sub);
}
filtered.add(QuestionModel({
...item.m,
'subQuestions': wrongSubs,
'childs': wrongChilds,
}));
continue;
}
final childs = item.mapList('childs');
final hasWrongChild = childs.isNotEmpty &&
childs.any((child) => isWrongQuestionStatus(child['answerStatus']));
if (hasWrongChild || isWrongQuestionStatus(item.m['answerStatus'])) {
filtered.add(item);
}
}
int orderSort(QuestionModel item) {
final subs = item.subQuestions;
if (subs.isNotEmpty) return subs.first.sort ?? 0;
return item.sort ?? 0;
}
filtered.sort((a, b) => orderSort(a) - orderSort(b));
return appendWrongQuestionSort(filtered);
}
/// 给过滤后的错题顺序追加 wrongSort(Vue appendWrongQuestionSort)
List<QuestionModel> appendWrongQuestionSort(List<QuestionModel> list) {
var wrongSort = 1;
return list.map((item) {
if (shouldCountSubQuestions(item)) {
final newSubs = item.mapList('subQuestions').map((sub) {
return {
...sub,
'wrongSort': wrongSort++,
};
}).toList();
return QuestionModel({
...item.m,
'subQuestions': newSubs,
});
}
return QuestionModel({
...item.m,
'wrongSort': wrongSort++,
});
}).toList();
}
@@ -0,0 +1,816 @@
/// ============================================================
/// 【课堂习题 · 单题提交 / 失败补提 / 交卷】
///
/// 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;
}
}
@@ -0,0 +1,171 @@
/// ============================================================
/// 【课堂习题 · 计时】(1:1 迁移 Vue timing.js / exerciseTiming.js)
///
/// - formatElapsed / formatTime / formatDuration 与 Vue 纯函数一致;
/// - ExerciseTiming 对应 createTiming(每秒一次 onTick);
/// - getDisplayPlanTimeSecond 对应 exerciseTiming.js 的同名导出:
/// 优先用本地缓存的剩余秒数 NewPlanTimeSecond,否则用
/// planTimeSecond - answerTimeSecond;<=0 时置 ISFinishPlanTime=true。
/// ============================================================
library;
import 'dart:async';
import 'package:new_learn/features/conquerPackage/classExercises/models/question_model.dart';
String _pad2(int n) => n < 10 ? '0$n' : '$n';
/// mm:ss(不足 1 小时)/ hh:mm:ss(达到 1 小时)
String formatElapsed(int ms) {
final safeMs = ms < 0 ? 0 : ms;
final totalSeconds = safeMs ~/ 1000;
if (totalSeconds < 3600) {
final mm = totalSeconds ~/ 60;
final ss = totalSeconds % 60;
return '${_pad2(mm)}:${_pad2(ss)}';
}
final hh = totalSeconds ~/ 3600;
final mm = (totalSeconds % 3600) ~/ 60;
final ss = totalSeconds % 60;
return '${_pad2(hh)}:${_pad2(mm)}:${_pad2(ss)}';
}
/// 建议学习时长文案:N秒 / N分钟 / N小时 / Nd
String formatTime(int seconds) {
final t = seconds < 0 ? 0 : seconds;
if (t < 60) return '$t秒';
if (t < 3600) return '${t ~/ 60}分钟';
if (t < 86400) return '${t ~/ 3600}小时';
return '${t ~/ 86400}d';
}
/// 已学习时长文案:N秒 / N分钟N秒 / N小时N分钟 / Nd
String formatDuration(int seconds) {
final t = seconds < 0 ? 0 : seconds;
if (t < 60) return '$t秒';
if (t < 3600) {
final m = t ~/ 60;
final s = t % 60;
return '$m分钟$s秒';
}
if (t < 86400) {
final h = t ~/ 3600;
final m = (t % 3600) ~/ 60;
return '$h小时$m分钟';
}
return '${t ~/ 86400}d';
}
/// 计时回调载荷(对标 JS {elapsedMs,text,running})
class TimingTick {
const TimingTick({
required this.elapsedMs,
required this.text,
required this.running,
});
final int elapsedMs;
final String text;
final bool running;
}
/// 页面计时器(对应 createTiming)。
///
/// 注意与 JS 保持一致的几个细节:
/// - pause() 不取消周期定时器,仅冻结累计值(running=false),
/// 周期回调继续按冻结值触发;
/// - start() 已在计时时直接返回;
/// - stop() 才真正取消定时器;
/// - reset() 不清运行状态,只把累计值归零。
class ExerciseTiming {
ExerciseTiming({this.onTick, this.intervalMs = 1000});
final void Function(TimingTick tick)? onTick;
final int intervalMs;
Timer? _timer;
bool _running = false;
int _lastStartAt = 0;
int _elapsedMs = 0;
void _emit() {
final cb = onTick;
if (cb == null) return;
final now = DateTime.now().millisecondsSinceEpoch;
final current = _running ? _elapsedMs + (now - _lastStartAt) : _elapsedMs;
cb(TimingTick(
elapsedMs: current,
text: formatElapsed(current),
running: _running,
));
}
void start() {
if (_timer != null) return;
_running = true;
_lastStartAt = DateTime.now().millisecondsSinceEpoch;
_emit();
_timer = Timer.periodic(Duration(milliseconds: intervalMs), (_) => _emit());
}
void pause() {
if (!_running) return;
_elapsedMs += DateTime.now().millisecondsSinceEpoch - _lastStartAt;
_running = false;
_emit();
}
void resume() {
if (_running) return;
_running = true;
_lastStartAt = DateTime.now().millisecondsSinceEpoch;
_emit();
}
void toggle() {
if (_running) {
pause();
} else {
resume();
}
}
void stop() {
_timer?.cancel();
_timer = null;
_running = false;
}
void reset() {
_elapsedMs = 0;
_lastStartAt = DateTime.now().millisecondsSinceEpoch;
_emit();
}
int getElapsedMs() {
final now = DateTime.now().millisecondsSinceEpoch;
return _running ? _elapsedMs + (now - _lastStartAt) : _elapsedMs;
}
bool isRunning() => _running;
}
/// 单题倒计时展示秒数(对应 getDisplayPlanTimeSecond)。
///
/// NewPlanTimeSecond 是切题/暂停前本地缓存的“剩余秒数”,不是总时长;
/// 首次进入题目没有缓存时,用总时长 - 后端累计作答时长。
/// 结果 <=0 时直接视为倒计时结束并回写 ISFinishPlanTime。
int getDisplayPlanTimeSecond(QuestionModel? q) {
if (q == null) return 0;
final hasLocalRemain = q.m['NewPlanTimeSecond'] != null;
final remain = hasLocalRemain
? (q.newPlanTimeSecond ?? 0)
: q.planTimeSecond - q.answerTimeSecond;
if (remain <= 0) {
q.isFinishPlanTime = true;
return 0;
}
return remain;
}
@@ -0,0 +1,123 @@
/// ============================================================
/// 【课堂习题 · 顶部计时与暂停联动】
///
/// 1:1 迁移 Vue exerciseTiming.js(createTimingHandlers):
/// - 顶部计时(可暂停 / 继续),与倒计时组件联动;
/// - 按 recordId 缓存 / 恢复计时进度(实际逻辑按题目 answerTimeSecond
/// 汇总,SP 缓存代码在 Vue 端已注释,这里仍保留 persist 写缓存);
/// - 暂停时统计剩余未答题数,驱动暂停弹窗。
/// ============================================================
library;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_session.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_submit.dart';
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
class ExerciseTimingHandlers {
ExerciseTimingHandlers({
required this.session,
required this.bridge,
required this.submit,
required this.isPausePopupVisible,
this.onUiTick,
}) {
timing = ExerciseTiming(onTick: (tick) {
final total = timingOffsetMs + tick.elapsedMs;
timingText = formatElapsed(total);
timingRunning = tick.running;
// 页面层据此刷新顶部计时胶囊(对标 Vue ref 赋值触发渲染)
onUiTick?.call();
});
}
final ExerciseSession session;
final ExerciseUiBridge bridge;
final ExerciseSubmitHandlers submit;
/// 每次秒表 tick 后回调页面刷新 UI(计时文案)
final void Function()? onUiTick;
/// 暂停弹窗当前是否展示(对标 showPausePopup.value 读取)
final bool Function() isPausePopupVisible;
late final ExerciseTiming timing;
/// 顶部计时文案
String timingText = '00:00';
/// 计时是否运行中
bool timingRunning = true;
/// 累计偏移(恢复的已学时长)
int timingOffsetMs = 0;
/// 暂停时剩余未答题数
int remainingCount = 0;
/// 恢复计时缓存(Vue 端实际使用题目 answerTimeSecond 汇总)
void restoreTimingCache() {
try {
var totalStudyTime = 0;
for (final item in session.questions) {
totalStudyTime += item.answerTimeSecond;
}
if (totalStudyTime != 0) {
timingOffsetMs = totalStudyTime * 1000;
timingText = formatElapsed(totalStudyTime * 1000);
} else {
timingOffsetMs = 0;
timingText = '00:00';
}
} catch (_) {
timingOffsetMs = 0;
timingText = '00:00';
}
}
/// 持久化计时进度(view 模式 / 无 recordId 不写)
Future<void> persistTimingCache() async {
final keyId = session.recordId;
if (session.isView || keyId.isEmpty) return;
final elapsedMs = timingOffsetMs + timing.getElapsedMs();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'classExercises_timing_$keyId',
// 与 Vue 一致:历史上存的是 JSON 字符串
'{"elapsedMs":$elapsedMs}',
);
} catch (_) {
// 忽略落盘失败
}
}
/// 切换计时(对标 toggleTiming)
void toggleTiming() {
if (isPausePopupVisible()) return;
if (timing.isRunning()) {
timing.pause();
session.countDownKey?.currentState?.pause();
bridge.setPausePopupVisible(true);
final list = submit.batchProcessQuestions();
remainingCount =
list.where((res) => res['answered'] != true).length;
bridge.notifyListeners();
return;
}
timing.resume();
session.countDownKey?.currentState?.start();
}
/// 关闭暂停弹窗并继续(对标 handlePausePopupClose)
void handlePausePopupClose() {
bridge.setPausePopupVisible(false);
if (!timing.isRunning()) timing.resume();
session.countDownKey?.currentState?.start();
}
void dispose() {
timing.stop();
}
}