更新
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
/// ============================================================
|
||||
/// 【ExerciseQuestionView】课后练习单题页
|
||||
/// (1:1 迁移 Vue pages/conquerPackage/classExercises/classExercisesPage.vue)
|
||||
///
|
||||
/// 结构(与 Vue 模板一致):
|
||||
/// source → 题型标签 → 题干合并音视频 → 题干(含 #subquestion-N 锚点)
|
||||
/// → 主体(客观选项 / 填空输入或图片作答 / 主观图片作答)
|
||||
/// → view 态 childs 解析列表 → 大题解析盒 → 底部笔记 Upload
|
||||
/// → 末尾 80.h + panelHeight 白色占位
|
||||
///
|
||||
/// 交互(与 Vue emit / defineExpose 同名同义):
|
||||
/// - onSelectAnswer(payload) → selectAnswer
|
||||
/// - onUpdateAnswer(String) → UpdateAnswer
|
||||
/// - onNotesChange(items) → handleNotesChange
|
||||
/// - onSubQuestionChange(payload) → subQuestionChange
|
||||
/// - onStemLinkTap(index) → 题干锚点点击,由父级驱动复合题面板
|
||||
/// - `GlobalKey<ExerciseQuestionViewState>` 暴露:
|
||||
/// UploadLectureNotes() / getImageList(type) /
|
||||
/// setActiveSubIndex(i) / pauseMedia() / scrollToOffset()
|
||||
///
|
||||
/// 说明:
|
||||
/// - 父级应以 ValueKey(data.id) 挂载,切题整体重建;
|
||||
/// - 锚点滚动:#subquestion-N 点击后 onTapUrl 返回 false,交由
|
||||
/// flutter_widget_from_html 内置 AnchorRegistry 滚动(等价 Vue
|
||||
/// emitScrollToTop → outer scroll-view 定位);面板 tab 切换等
|
||||
/// 外部回灌走 scrollToAnchor(编程式标记位,不再回调父级);
|
||||
/// - isActive/isAdjacent 变 false 暂停全部音视频(递归 visitChildElements)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||
import 'package:new_learn/core/components/audio_player_card.dart';
|
||||
import 'package:new_learn/core/components/media_pause_scope.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/components/upload.dart';
|
||||
import 'package:new_learn/core/components/video_player_view.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
import 'package:new_learn/core/utils/question/tag_color.dart';
|
||||
|
||||
import 'models/question_data_adapter.dart';
|
||||
import 'models/question_model.dart';
|
||||
import 'widgets/answer_view.dart';
|
||||
import 'widgets/exercise_styles.dart';
|
||||
import 'widgets/obj_options.dart';
|
||||
|
||||
/// 对应 Vue props.typeMode
|
||||
enum ExerciseQuestionMode { edit, view }
|
||||
|
||||
/// 对应 emit('subQuestionChange', {subIndex, sort})
|
||||
class SubQuestionChangePayload {
|
||||
const SubQuestionChangePayload({required this.subIndex, this.sort = 0});
|
||||
|
||||
final int subIndex;
|
||||
final dynamic sort;
|
||||
}
|
||||
|
||||
typedef QuestionSelectCallback = void Function(Map<String, dynamic> payload);
|
||||
typedef QuestionUpdateAnswerCallback = void Function(String value);
|
||||
typedef QuestionNotesCallback = void Function(List<UploadItem> items);
|
||||
typedef QuestionSubChangeCallback = void Function(SubQuestionChangePayload payload);
|
||||
typedef QuestionStemLinkCallback = void Function(int subIndex);
|
||||
|
||||
class ExerciseQuestionView extends StatefulWidget {
|
||||
const ExerciseQuestionView({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.isActive = false,
|
||||
this.isAdjacent = false,
|
||||
this.mode = ExerciseQuestionMode.edit,
|
||||
this.activeSubIndex = 0,
|
||||
required this.panelHeight,
|
||||
this.onSelectAnswer,
|
||||
this.onUpdateAnswer,
|
||||
this.onNotesChange,
|
||||
this.onSubQuestionChange,
|
||||
this.onStemLinkTap,
|
||||
});
|
||||
|
||||
/// 题目数据(与 questionsList 元素同一 Map 引用)
|
||||
final QuestionModel data;
|
||||
|
||||
final bool isActive;
|
||||
final bool isAdjacent;
|
||||
final ExerciseQuestionMode mode;
|
||||
|
||||
/// 当前激活子题下标(外部回灌)
|
||||
final int activeSubIndex;
|
||||
|
||||
/// 复合题浮层当前高度(逻辑像素),用于末尾占位。
|
||||
/// 用 ValueListenable 接收:拖拽面板时只重建占位盒,不整页重建。
|
||||
final ValueListenable<double> panelHeight;
|
||||
|
||||
final QuestionSelectCallback? onSelectAnswer;
|
||||
final QuestionUpdateAnswerCallback? onUpdateAnswer;
|
||||
final QuestionNotesCallback? onNotesChange;
|
||||
final QuestionSubChangeCallback? onSubQuestionChange;
|
||||
final QuestionStemLinkCallback? onStemLinkTap;
|
||||
|
||||
@override
|
||||
ExerciseQuestionViewState createState() => ExerciseQuestionViewState();
|
||||
}
|
||||
|
||||
class ExerciseQuestionViewState extends State<ExerciseQuestionView> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
/// 本题媒体暂停注册表:子树内播放器自动注册,切页时直接暂停,
|
||||
/// 不再运行时递归 visitChildElements(避免 element 树更新期断言)
|
||||
final MediaPauseRegistry _mediaRegistry = MediaPauseRegistry();
|
||||
|
||||
/// 题干 HtmlWidget(锚点滚动用)
|
||||
final GlobalKey<HtmlWidgetState> _stemHtmlKey = GlobalKey<HtmlWidgetState>();
|
||||
|
||||
/// 图片作答 Upload(uploadAnswerRef)
|
||||
final GlobalKey<UploadState> _answerUploadKey = GlobalKey<UploadState>();
|
||||
|
||||
/// 笔记 Upload(uploadRef)
|
||||
final GlobalKey<UploadState> _notesKey = GlobalKey<UploadState>();
|
||||
|
||||
late int _activeSubIndex = widget.activeSubIndex;
|
||||
|
||||
/// 图片作答缓存(惰性初始化,保持引用稳定,避免 Upload 被重置)
|
||||
List<UploadItem>? _answerItems;
|
||||
|
||||
/// 笔记图片缓存
|
||||
List<UploadItem>? _notesItems;
|
||||
|
||||
TextEditingController? _blankController;
|
||||
|
||||
/// 本次锚点切换是否来自题干点击(点击已由 fwfh 滚动,外部回灌跳过)
|
||||
int? _tapDrivenIndex;
|
||||
|
||||
/// 编程式锚点滚动标记(scrollToAnchor 期间不要再驱动面板)
|
||||
bool _programmaticAnchor = false;
|
||||
|
||||
bool get _isView => widget.mode == ExerciseQuestionMode.view;
|
||||
bool get _isEdit => widget.mode == ExerciseQuestionMode.edit;
|
||||
|
||||
// ===================== 生命周期 =====================
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 对标 watch(activeSubIndex, immediate: true):挂载即尝试定位锚点
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _scrollToAnchorExternal(widget.activeSubIndex);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExerciseQuestionView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 正常父级以 ValueKey(data.id) 重建;兜底处理
|
||||
if (widget.data.id != oldWidget.data.id) {
|
||||
_activeSubIndex = 0;
|
||||
_answerItems = null;
|
||||
_notesItems = null;
|
||||
pauseMedia();
|
||||
}
|
||||
|
||||
// 对标 watch(isActive) / watch(isAdjacent)
|
||||
if ((widget.isActive != oldWidget.isActive && !widget.isActive) ||
|
||||
(widget.isAdjacent != oldWidget.isAdjacent && !widget.isAdjacent)) {
|
||||
pauseMedia();
|
||||
}
|
||||
|
||||
// 对标 watch(() => props.activeSubIndex)
|
||||
if (widget.activeSubIndex != _activeSubIndex &&
|
||||
widget.activeSubIndex != oldWidget.activeSubIndex) {
|
||||
final ext = widget.activeSubIndex;
|
||||
if (ext == _tapDrivenIndex) {
|
||||
// 点击触发的回灌,fwfh 已在点击时滚动
|
||||
_tapDrivenIndex = null;
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _scrollToAnchorExternal(ext);
|
||||
});
|
||||
}
|
||||
setState(() => _activeSubIndex = ext);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_blankController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ===================== 对外暴露(对标 defineExpose) =====================
|
||||
|
||||
/// 触发笔记选图(对标 UploadLectureNotes → uploadRef.chooseImage)
|
||||
Future<void> uploadLectureNotes() async {
|
||||
return _notesKey.currentState?.chooseImage();
|
||||
}
|
||||
|
||||
/// 取图片列表(对标 getImageList)
|
||||
/// - type == 'uploadAnswerRef':作答图,兜底 safeImageAnswerList(userAnswer)
|
||||
/// - 其它:笔记图,兜底 safeImageAnswerList(notes)
|
||||
List<dynamic> getImageList(String type) {
|
||||
if (type == 'uploadAnswerRef') {
|
||||
final imageList = _answerUploadKey.currentState?.getImageList();
|
||||
if (imageList != null && imageList.isNotEmpty) return imageList;
|
||||
return QuestionDataAdapter.safeImageAnswerList(widget.data.userAnswer);
|
||||
}
|
||||
final imageList = _notesKey.currentState?.getImageList();
|
||||
if (imageList != null && imageList.isNotEmpty) return imageList;
|
||||
return QuestionDataAdapter.safeImageAnswerList(widget.data.m['notes']);
|
||||
}
|
||||
|
||||
/// 对标 setActiveSubIndex:clamp + emit subQuestionChange
|
||||
void setActiveSubIndex(int subIndex) {
|
||||
final total = widget.data.subQuestions.length;
|
||||
final next = total > 0
|
||||
? subIndex.clamp(0, total - 1)
|
||||
: 0;
|
||||
if (next == _activeSubIndex) return;
|
||||
setState(() => _activeSubIndex = next);
|
||||
final sort = widget.data.subAt(next)?.sort ?? 0;
|
||||
widget.onSubQuestionChange
|
||||
?.call(SubQuestionChangePayload(subIndex: next, sort: sort));
|
||||
}
|
||||
|
||||
/// 暂停视图内全部音视频(对标 pauseMedia)。
|
||||
/// 直接调注册表:播放器挂载/卸载已自动维护成员,
|
||||
/// 无需运行时递归遍历 element 树,build/翻页动画阶段调用同样安全。
|
||||
void pauseMedia() {
|
||||
_mediaRegistry.pauseAll();
|
||||
}
|
||||
|
||||
/// 父级直接驱动滚动(对标 emit scrollToOffset → outer scroll-view)
|
||||
void scrollToOffset(double offset) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final max = _scrollController.position.maxScrollExtent;
|
||||
_scrollController.jumpTo(offset.clamp(0, max));
|
||||
}
|
||||
|
||||
// ===================== 锚点滚动 =====================
|
||||
|
||||
/// 题干链接点击:驱动面板 + 交回 fwfh 滚动锚点(return false)
|
||||
Future<bool> _onStemTap(String url) async {
|
||||
if (url.startsWith('#subquestion-')) {
|
||||
final idx = int.tryParse(url.replaceFirst('#subquestion-', ''));
|
||||
if (idx != null && idx >= 0) {
|
||||
_tapDrivenIndex = idx;
|
||||
widget.onStemLinkTap?.call(idx);
|
||||
// false:fwfh 继续执行 AnchorRegistry.ensureVisible
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 其余链接一律拦截(RichTextContent 默认行为)
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 外部回灌(面板 tab / 切题恢复):只滚动,不再回调 onStemLinkTap
|
||||
void _scrollToAnchorExternal(int index, {int retries = 4}) {
|
||||
bool done = false;
|
||||
void run(int left) async {
|
||||
if (!mounted || done) return;
|
||||
_programmaticAnchor = true;
|
||||
bool ok = false;
|
||||
try {
|
||||
ok = await _stemHtmlKey.currentState
|
||||
?.scrollToAnchor('subquestion-$index') ??
|
||||
false;
|
||||
} finally {
|
||||
_programmaticAnchor = false;
|
||||
}
|
||||
if (ok) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
if (left > 0 && mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted) run(left - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
run(retries);
|
||||
}
|
||||
|
||||
// ===================== 作答回调 =====================
|
||||
|
||||
/// 对标 selectOption(父级写 current.userAnswer = payload.userAnswer)
|
||||
void _selectOption(Map<String, dynamic> payload) {
|
||||
if (!_isEdit) return;
|
||||
widget.onSelectAnswer?.call(payload);
|
||||
}
|
||||
|
||||
/// 对标 onInputBlank:写 data.userAnswer + 第一个 child,emit 纯文本
|
||||
void _onInputBlank(String value) {
|
||||
if (!_isEdit) return;
|
||||
widget.data.userAnswer = value;
|
||||
final childs = widget.data.childs;
|
||||
if (childs.isNotEmpty) {
|
||||
childs.first.userAnswer = value;
|
||||
}
|
||||
widget.onUpdateAnswer?.call(value);
|
||||
}
|
||||
|
||||
/// 对标 handleUploadChange:emit UpdateAnswer(JSON.stringify(e))
|
||||
void _onAnswerUploadChanged(List<UploadItem> items) {
|
||||
_answerItems = List<UploadItem>.from(items);
|
||||
widget.onUpdateAnswer
|
||||
?.call(QuestionDataAdapter.uploadItemsToJson(items));
|
||||
}
|
||||
|
||||
/// 对标 handleNotesChange
|
||||
void _onNotesChanged(List<UploadItem> items) {
|
||||
_notesItems = List<UploadItem>.from(items);
|
||||
widget.onNotesChange?.call(items);
|
||||
}
|
||||
|
||||
List<UploadItem> get _answerUploadItems {
|
||||
return _answerItems ??= QuestionDataAdapter.toUploadItems(
|
||||
QuestionDataAdapter.parseImageAnswerList(widget.data.userAnswer),
|
||||
);
|
||||
}
|
||||
|
||||
List<UploadItem> get _notesUploadItems {
|
||||
return _notesItems ??= QuestionDataAdapter.toUploadItems(
|
||||
widget.data.notes,
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Build =====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 题干渲染数据(对标 stemRenderData / mergedAudios / mergedVideos)
|
||||
final stemMedia =
|
||||
QuestionDataAdapter.buildStemMedia(widget.data, _activeSubIndex);
|
||||
|
||||
final children = <Widget>[
|
||||
// ---- source ----
|
||||
if (widget.data.source.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: Text(
|
||||
widget.data.source,
|
||||
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.subtitle),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- 题型标签 ----
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: _buildTypeLabel(),
|
||||
),
|
||||
|
||||
// ---- 题干合并音视频 ----
|
||||
for (final audio in stemMedia.audios)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.h),
|
||||
child: AudioPlayerCard(
|
||||
url: '${audio['audioUrl'] ?? audio['url'] ?? ''}',
|
||||
),
|
||||
),
|
||||
for (final video in stemMedia.videos)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 10.h),
|
||||
child: VideoPlayerView(
|
||||
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
|
||||
),
|
||||
),
|
||||
|
||||
// ---- 题干(line-height 58rpx,margin-b 30rpx)----
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 15.h),
|
||||
child: RichTextContent(
|
||||
htmlWidgetKey: _stemHtmlKey,
|
||||
html: stemMedia.content,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF333333),
|
||||
height: 58 / 32,
|
||||
),
|
||||
onTapUrl: (url) async {
|
||||
// 编程式滚动期间:锚点链接不拦截(让 fwfh 滚动),也不驱动面板
|
||||
if (_programmaticAnchor && url.startsWith('#subquestion-')) {
|
||||
return false;
|
||||
}
|
||||
return _onStemTap(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// ---- 主体(v-if="!data.hasOptionChildrenQuestion")----
|
||||
if (!widget.data.hasOptionChildrenQuestion) {
|
||||
_buildBody(children);
|
||||
}
|
||||
|
||||
// ---- childs 解析列表(view,且非 hasOptionChildrenQuestion)----
|
||||
if (!widget.data.hasOptionChildrenQuestion &&
|
||||
widget.data.childs.isNotEmpty &&
|
||||
_isView) {
|
||||
_buildChildsAnalysis(children);
|
||||
}
|
||||
|
||||
// ---- 大题解析盒(仅 view)----
|
||||
if (_isView) {
|
||||
_appendMainAnalysis(children);
|
||||
}
|
||||
|
||||
// ---- 底部笔记 Upload ----
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(top: isPadDevice(context) ? 40.h : 30.h),
|
||||
child: Upload(
|
||||
key: _notesKey,
|
||||
type: UploadType.questions,
|
||||
ids: widget.data.id,
|
||||
mode: _isEdit ? UploadMode.edit : UploadMode.view,
|
||||
initialImageList: _notesUploadItems,
|
||||
onChanged: _onNotesChanged,
|
||||
),
|
||||
));
|
||||
|
||||
return MediaPauseScope(
|
||||
registry: _mediaRegistry,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(14.r)),
|
||||
child: ColoredBox(
|
||||
color: Colors.white,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 对标 .classExercises-main(白底、上圆角 28rpx、padding 40rpx 28rpx)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.white,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 20.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
// 对标末尾占位 calc(160rpx + panelHeight px)。
|
||||
// 仅占位盒随面板高度重建,拖拽不触发整页重建。
|
||||
ValueListenableBuilder<double>(
|
||||
valueListenable: widget.panelHeight,
|
||||
builder: (_, h, __) => Container(
|
||||
width: double.infinity,
|
||||
height: 80.h + h,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 题型标签(对标 .classExercises-main-label + getTagColor)
|
||||
Widget _buildTypeLabel() {
|
||||
final gradient = getTagColor(widget.data.questionTypeName);
|
||||
return Container(
|
||||
height: 21.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient.gradient,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Center(
|
||||
widthFactor: 1,
|
||||
child: Text(
|
||||
widget.data.questionTypeName,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 主体分支 =====================
|
||||
|
||||
void _buildBody(List<Widget> children) {
|
||||
final data = widget.data;
|
||||
|
||||
// 分支 1:客观题(options 非空)
|
||||
if (data.options.isNotEmpty) {
|
||||
if (_isEdit) {
|
||||
children.add(ObjOptions(
|
||||
options: data.options,
|
||||
questionType: data.questionTypes,
|
||||
mode: ObjOptionMode.edit,
|
||||
answer: data.userAnswer,
|
||||
onChanged: _selectOption,
|
||||
));
|
||||
} else {
|
||||
children.add(ObjOptions(
|
||||
options: data.options,
|
||||
questionType: data.questionTypes,
|
||||
mode: ObjOptionMode.view,
|
||||
answerData: ObjOptionAnswerData(
|
||||
correctAnswer: data.correctAnswer,
|
||||
myAnswer: data.userAnswer,
|
||||
),
|
||||
onChanged: _selectOption,
|
||||
));
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: 10.h),
|
||||
child: ObjectiveAnswerView(
|
||||
answerStatus: data.answerStatus,
|
||||
userAnswer: data.userAnswer,
|
||||
correctAnswer: data.correctAnswer,
|
||||
),
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 分支 2:填空题
|
||||
if (data.questionTypes == 'tiankongti') {
|
||||
_buildBlankBody(children);
|
||||
return;
|
||||
}
|
||||
|
||||
// 分支 3:其他主观题(问答题等,图片作答)
|
||||
_buildSubjectiveBody(children);
|
||||
}
|
||||
|
||||
/// 填空题主体(对标 v-else-if="questionTypes == 'tiankongti'")
|
||||
void _buildBlankBody(List<Widget> children) {
|
||||
final data = widget.data;
|
||||
|
||||
if (_isView) {
|
||||
children.add(_buildStatusStrip(data));
|
||||
}
|
||||
|
||||
if (data.isFillblankItemUpload == 1) {
|
||||
// 图片作答填空题(注意:单题页 maxCount = 10)
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: Upload(
|
||||
key: _answerUploadKey,
|
||||
title: '上传图片作答',
|
||||
maxCount: 10,
|
||||
type: UploadType.aiGrading,
|
||||
ids: data.id,
|
||||
mode: _isEdit ? UploadMode.edit : UploadMode.view,
|
||||
initialImageList: _answerUploadItems,
|
||||
onChanged: _onAnswerUploadChanged,
|
||||
),
|
||||
));
|
||||
if (_isView) {
|
||||
_appendWrongReasonAdvice(children);
|
||||
}
|
||||
} else if (_isEdit) {
|
||||
children.add(_buildBlankTextarea());
|
||||
}
|
||||
|
||||
if (_isView) {
|
||||
if (data.isFillblankItemUpload != 1) {
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: _buildAnswerTitle('你的答案'),
|
||||
));
|
||||
children.add(_buildAnswerContent(
|
||||
data.effectiveAnswer,
|
||||
const Color(0xFF495A7A),
|
||||
));
|
||||
}
|
||||
if (data.hasOptionChildrenQuestion || data.childs.isEmpty) {
|
||||
children.add(_buildAnswerTitle('正确答案'));
|
||||
children.add(_buildAnswerContent(
|
||||
data.correctAnswer.isNotEmpty ? data.correctAnswer : data.answer,
|
||||
ExerciseColors.correct,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 主观题主体(问答题等)
|
||||
void _buildSubjectiveBody(List<Widget> children) {
|
||||
final data = widget.data;
|
||||
|
||||
if (_isView) {
|
||||
children.add(_buildStatusStrip(data));
|
||||
}
|
||||
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(top: isPadDevice(context) ? 40.h : 30.h),
|
||||
child: Upload(
|
||||
key: _answerUploadKey,
|
||||
title: '上传图片作答',
|
||||
maxCount: 10,
|
||||
type: UploadType.aiGrading,
|
||||
ids: data.id,
|
||||
mode: _isEdit ? UploadMode.edit : UploadMode.view,
|
||||
initialImageList: _answerUploadItems,
|
||||
onChanged: _onAnswerUploadChanged,
|
||||
),
|
||||
));
|
||||
|
||||
if (_isView) {
|
||||
_appendWrongReasonAdvice(children);
|
||||
if (data.hasOptionChildrenQuestion || data.childs.isEmpty) {
|
||||
children.add(_buildAnswerTitle('正确答案'));
|
||||
children.add(_buildAnswerContent(
|
||||
data.correctAnswer.isNotEmpty ? data.correctAnswer : data.answer,
|
||||
ExerciseColors.correct,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误原因 / 改进建议(仅 view,answerStatus 11/12)
|
||||
void _appendWrongReasonAdvice(List<Widget> children) {
|
||||
final data = widget.data;
|
||||
final status = data.answerStatus;
|
||||
if (status != 11 && status != 12) return;
|
||||
|
||||
final reason = data.wrongReason;
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: _buildAnswerTitle('错误原因'),
|
||||
));
|
||||
children.add(_buildPlainContent(reason, const Color(0xFF495A7A)));
|
||||
}
|
||||
final advice = data.wrongAdvice;
|
||||
if (advice != null && advice.isNotEmpty) {
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: _buildAnswerTitle('改进建议'),
|
||||
));
|
||||
children.add(_buildPlainContent(advice, const Color(0xFF495A7A)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 填空题输入框(对标 textarea.auto-height + .input-text)
|
||||
Widget _buildBlankTextarea() {
|
||||
final controller = _blankController ??= TextEditingController(
|
||||
text: QuestionDataAdapter.singleBlankInputValue(widget.data),
|
||||
);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.only(bottom: 10.h),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ExerciseColors.bg,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(color: ExerciseColors.border, width: 1.w),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
minLines: 1,
|
||||
maxLines: null,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
onChanged: _onInputBlank,
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
hintText: '请输入您的答案...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF94A5B8),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== childs 解析列表 =====================
|
||||
|
||||
void _buildChildsAnalysis(List<Widget> children) {
|
||||
final data = widget.data;
|
||||
final childs = data.childs;
|
||||
for (var i = 0; i < childs.length; i++) {
|
||||
final child = childs[i];
|
||||
|
||||
children.add(_buildAnswerTitle(
|
||||
'第${i + 1}${data.questionTypes == 'tiankongti' ? '空' : '题'}',
|
||||
fontSize: 17.sp,
|
||||
));
|
||||
|
||||
if (child.stem.isNotEmpty) {
|
||||
children.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: 5.h),
|
||||
child: RichTextContent(
|
||||
html: child.stem,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
children.add(_buildAnswerTitle('正确答案'));
|
||||
children.add(_buildAnswerContent(
|
||||
child.correctAnswer.isNotEmpty ? child.correctAnswer : child.answer,
|
||||
ExerciseColors.correct,
|
||||
));
|
||||
|
||||
final analysis = _analysisText(child);
|
||||
final videos = child.videos;
|
||||
final analysisVideo = child.answerAnalysisVideo;
|
||||
final hasVideo = videos.isNotEmpty &&
|
||||
'${videos.first['videoUrl'] ?? videos.first['url'] ?? ''}'
|
||||
.isNotEmpty;
|
||||
final hasAnalysisVideo =
|
||||
'${analysisVideo?['videoUrl'] ?? ''}'.isNotEmpty;
|
||||
|
||||
if (analysis.isNotEmpty || hasVideo || hasAnalysisVideo) {
|
||||
final content = <Widget>[
|
||||
if (analysis.isNotEmpty) _buildAnalysisBody(analysis),
|
||||
if (hasVideo)
|
||||
for (final video in videos)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: VideoPlayerView(
|
||||
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
|
||||
),
|
||||
),
|
||||
if (hasAnalysisVideo)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: VideoPlayerView(
|
||||
url: '${analysisVideo?['videoUrl'] ?? ''}',
|
||||
),
|
||||
),
|
||||
];
|
||||
children.add(_buildAnalysisBox(content, minHeight: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 大题解析盒 =====================
|
||||
|
||||
void _appendMainAnalysis(List<Widget> children) {
|
||||
final data = widget.data;
|
||||
final analysis = _analysisText(data);
|
||||
final audios = data.audios;
|
||||
final videos = data.videos;
|
||||
final analysisVideo = data.answerAnalysisVideo;
|
||||
final hasVideo = videos.isNotEmpty &&
|
||||
'${videos.first['videoUrl'] ?? videos.first['url'] ?? ''}'.isNotEmpty;
|
||||
final hasAnalysisVideo =
|
||||
'${analysisVideo?['videoUrl'] ?? ''}'.isNotEmpty;
|
||||
|
||||
// 对标 v-if:audios || videos.length>0 || answerAnalysisVideo.videoUrl || 解析
|
||||
if (analysis.isEmpty &&
|
||||
audios.isEmpty &&
|
||||
videos.isEmpty &&
|
||||
!hasAnalysisVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
final content = <Widget>[
|
||||
// 音视频在解析文字之前
|
||||
for (final audio in audios)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: AudioPlayerCard(
|
||||
url: '${audio['audioUrl'] ?? audio['url'] ?? ''}',
|
||||
),
|
||||
),
|
||||
if (hasVideo)
|
||||
for (final video in videos)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: VideoPlayerView(
|
||||
url: '${video['videoUrl'] ?? video['url'] ?? ''}',
|
||||
),
|
||||
),
|
||||
if (hasAnalysisVideo)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h),
|
||||
child: VideoPlayerView(
|
||||
url: '${analysisVideo?['videoUrl'] ?? ''}',
|
||||
),
|
||||
),
|
||||
if (analysis.isNotEmpty) _buildAnalysisBody(analysis),
|
||||
];
|
||||
|
||||
children.add(_buildAnalysisBox(content, minHeight: true));
|
||||
}
|
||||
|
||||
/// 对标 .analysis-body(32rpx / 1.55 / #1D2B47,margin 20rpx 0 24rpx)
|
||||
Widget _buildAnalysisBody(String analysis) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 10.h, bottom: 12.h),
|
||||
child: RichTextContent(
|
||||
html: cleanRichText(analysis),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
height: 1.55,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 对标 .analysis-box(单题页 CSS:margin 66rpx 0 40rpx、min-height 200rpx)
|
||||
Widget _buildAnalysisBox(List<Widget> content, {bool minHeight = false}) {
|
||||
return Container(
|
||||
constraints: minHeight ? BoxConstraints(minHeight: 100.h) : null,
|
||||
margin: EdgeInsets.only(top: 33.h, bottom: 20.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
// 对标 border-box 渐变 146.26deg
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF95F8E7), Color(0xFFB9DDFF), Color(0xFF99DCFE)],
|
||||
stops: [0, 0.4952, 1],
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.all(1.5.w),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.r - 1.5.w),
|
||||
// 对标 padding-box 渐变 180deg
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFE0F9FF), Colors.white, Colors.white],
|
||||
stops: [0, 0.3, 1],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Padding(
|
||||
// 对标 padding: 42rpx 28rpx 28rpx
|
||||
padding: EdgeInsets.fromLTRB(14.w, 21.h, 14.w, 14.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: content,
|
||||
),
|
||||
),
|
||||
// 对标 .analysis-left-bg(156rpx,left -2rpx,top -36rpx)
|
||||
Positioned(
|
||||
left: -1.w,
|
||||
top: -18.h,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl:
|
||||
ImgUrl.build('/StaticResource/2026-04-16/analysis.png'),
|
||||
width: 78.w,
|
||||
placeholder: (_, _) => SizedBox(width: 78.w),
|
||||
errorWidget: (_, _, _) => SizedBox(width: 78.w),
|
||||
),
|
||||
),
|
||||
// 对标 .analysis-right-icon(180rpx,right -10rpx)
|
||||
Positioned(
|
||||
right: -5.w,
|
||||
top: 0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-08/analysis-right-icon.png'),
|
||||
width: 90.w,
|
||||
placeholder: (_, _) => SizedBox(width: 90.w),
|
||||
errorWidget: (_, _, _) => SizedBox(width: 90.w),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 状态条 / 答案标题 / 答案内容 =====================
|
||||
|
||||
/// 对标 .obj-options-li-view-correct / error
|
||||
Widget _buildStatusStrip(QuestionModel data) {
|
||||
final status = data.answerStatus;
|
||||
final isCorrect = status == 10;
|
||||
final text = isCorrect
|
||||
? '作答正确'
|
||||
: status == 11
|
||||
? '作答有误'
|
||||
: status == 12
|
||||
? '部分正确'
|
||||
: '未作答';
|
||||
final color = isCorrect ? ExerciseColors.correct : ExerciseColors.error;
|
||||
final icon = isCorrect
|
||||
? '/StaticResource/2026-06-01/correct-icon1780284329572.png'
|
||||
: '/StaticResource/2026-06-01/error-icon1780284292405.png';
|
||||
|
||||
return GradientBorderBox(
|
||||
state: isCorrect ? ExerciseTileState.correct : ExerciseTileState.error,
|
||||
padding: EdgeInsets.all(12.w),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(icon),
|
||||
width: 20.w,
|
||||
height: 20.w,
|
||||
placeholder: (_, _) => SizedBox(width: 20.w, height: 20.w),
|
||||
errorWidget: (_, _, _) => SizedBox(width: 20.w, height: 20.w),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 16.sp, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 对标 .answer-box-title(title-bacs.png,bottom right,119rpx)
|
||||
Widget _buildAnswerTitle(String text, {double? fontSize}) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 标题装饰底图:固定尺寸,位置可调(对标 background-size: 119rpx auto)
|
||||
Positioned(
|
||||
right: 10.w,
|
||||
bottom: 0,
|
||||
child: Image.network(
|
||||
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
|
||||
width: 59.5.w,
|
||||
height: 9.h,
|
||||
fit: BoxFit.fill,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 14.w),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize ?? 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 对标 .answer-box-content(富文本)
|
||||
Widget _buildAnswerContent(String html, Color color) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.symmetric(vertical: 12.h),
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: ExerciseColors.bg,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
),
|
||||
child: RichTextContent(
|
||||
html: cleanRichText(html),
|
||||
textStyle: TextStyle(fontSize: 16.sp, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 对标 .answer-box-content(纯文本:wrongReason / wrongAdvice)
|
||||
Widget _buildPlainContent(String text, Color color) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.symmetric(vertical: 12.h),
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: ExerciseColors.bg,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 16.sp, color: color, height: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 对标 child.answerAnalysis?.analysis || child.analysis || child.answerAnalysis
|
||||
String _analysisText(QuestionModel q) {
|
||||
final raw = q.answerAnalysisRaw;
|
||||
if (raw is Map) {
|
||||
final first = '${raw['analysis'] ?? ''}';
|
||||
if (first.isNotEmpty) return first;
|
||||
}
|
||||
if (q.analysis.isNotEmpty) return q.analysis;
|
||||
if (raw != null && '$raw'.isNotEmpty) return '$raw';
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/// ============================================================
|
||||
/// 题目数据适配层
|
||||
///
|
||||
/// 集中处理 QuestionModel 与 UI 组件(RichTextContent / Upload)
|
||||
/// 之间的数据转换,对标 Vue classExercisesPage.vue 内的
|
||||
/// stemRenderData / mergedAudios / mergedVideos /
|
||||
/// getImageAnswerList / getSafeImageAnswerList。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:new_learn/core/components/upload.dart';
|
||||
import 'package:new_learn/core/utils/question/extract_media_from_stem.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
|
||||
import 'question_model.dart';
|
||||
|
||||
/// 题干渲染数据(已格式化 + 已抽取并合并媒体)
|
||||
class StemRenderData {
|
||||
const StemRenderData({
|
||||
required this.content,
|
||||
this.audios = const [],
|
||||
this.videos = const [],
|
||||
});
|
||||
|
||||
final String content;
|
||||
final List<Map<String, dynamic>> audios;
|
||||
final List<Map<String, dynamic>> videos;
|
||||
}
|
||||
|
||||
class QuestionDataAdapter {
|
||||
QuestionDataAdapter._();
|
||||
|
||||
/// 等价 Vue 页内 stemRenderData + mergedAudios + mergedVideos
|
||||
static StemRenderData buildStemMedia(QuestionModel q, int activeSubIndex) {
|
||||
var stem = q.stem.isEmpty ? '暂无题干' : q.stem;
|
||||
final hasChilds = q.childs.isNotEmpty;
|
||||
final hasBkTags = q.bkTagMetaList.isNotEmpty;
|
||||
|
||||
if (hasChilds || hasBkTags) {
|
||||
final disableHighlight = !hasChilds && q.bkTagMetaList.length == 1;
|
||||
final subQuestions =
|
||||
q.subQuestions.isNotEmpty ? q.subQuestions.map((e) => e.m).toList() : q.childs.map((e) => e.m).toList();
|
||||
// 图片作答(1)不做填空锚点格式化
|
||||
if (q.isFillblankItemUpload != 1) {
|
||||
stem = formatCompoundStem(
|
||||
stem,
|
||||
activeSubIndex,
|
||||
disableHighlight: disableHighlight,
|
||||
subQuestions: subQuestions,
|
||||
);
|
||||
}
|
||||
// 数学学科:数字占位符转圈号
|
||||
if ('${q.baseSubjectId}' == '2') {
|
||||
stem = convertBlankToCircled(stem);
|
||||
}
|
||||
}
|
||||
|
||||
final media = extractMediaFromStem(stem);
|
||||
|
||||
// 合并题干内嵌媒体与 q.audios/q.videos(按 url 去重,保持 JS 顺序)
|
||||
final audios = <Map<String, dynamic>>[];
|
||||
for (final item in [...media.audios, ...q.audios]) {
|
||||
final url = '${item['audioUrl'] ?? item['url'] ?? ''}';
|
||||
if (url.isEmpty) continue;
|
||||
if (audios.any((it) => '${it['audioUrl'] ?? it['url']}' == url)) continue;
|
||||
audios.add(item);
|
||||
}
|
||||
|
||||
final videos = <Map<String, dynamic>>[];
|
||||
for (final item in [...media.videos, ...q.videos]) {
|
||||
final url = '${item['videoUrl'] ?? item['url'] ?? ''}';
|
||||
if (url.isEmpty) continue;
|
||||
if (videos.any((it) => '${it['videoUrl'] ?? it['url']}' == url)) continue;
|
||||
videos.add(item);
|
||||
}
|
||||
|
||||
return StemRenderData(
|
||||
content: media.content.isNotEmpty ? media.content : (q.stem.isNotEmpty ? q.stem : '暂无题干'),
|
||||
audios: audios,
|
||||
videos: videos,
|
||||
);
|
||||
}
|
||||
|
||||
/// 单空填空题输入框值(对标 singleBlankInputValue)
|
||||
static String singleBlankInputValue(QuestionModel q) {
|
||||
final direct = q.userAnswer;
|
||||
if (direct.isNotEmpty) return direct;
|
||||
for (final child in q.childs) {
|
||||
final v = '${child.m['userAnswer'] ?? child.m['myAnswer'] ?? ''}';
|
||||
if (v.isNotEmpty) return v;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 严格解析:getImageAnswerList(外层保证是 JSON 数组字符串)
|
||||
static List<Map<String, dynamic>> parseImageAnswerList(dynamic value) {
|
||||
if (value == null || '$value'.isEmpty) return const [];
|
||||
dynamic data;
|
||||
try {
|
||||
data = value is String ? jsonDecode(value) : value;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
if (data is! List) return const [];
|
||||
return data.whereType<Map>().map((raw) {
|
||||
final item = Map<String, dynamic>.from(raw);
|
||||
item['url'] = item['Url'] ?? item['url'];
|
||||
item['thumbnailUrl'] = item['ThumbnailUrl'] ?? item['thumbnailUrl'];
|
||||
return item;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 安全解析:getSafeImageAnswerList(兜底解析 userAnswer / notes)
|
||||
static List<Map<String, dynamic>> safeImageAnswerList(dynamic value) {
|
||||
if (value == null || '$value'.isEmpty) return const [];
|
||||
dynamic list;
|
||||
try {
|
||||
list = value is String ? jsonDecode(value) : value;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
if (list is! List) return const [];
|
||||
return list
|
||||
.whereType<Map>()
|
||||
.map((raw) {
|
||||
final item = Map<String, dynamic>.from(raw);
|
||||
final url = '${item['url'] ?? item['Url'] ?? ''}';
|
||||
final thumbnailUrl =
|
||||
'${item['thumbnailUrl'] ?? item['ThumbnailUrl'] ?? url}';
|
||||
return {...item, 'url': url, 'thumbnailUrl': thumbnailUrl};
|
||||
})
|
||||
.where((item) => '${item['url']}'.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 后端图片数据 → Upload 组件入参
|
||||
static List<UploadItem> toUploadItems(List<Map<String, dynamic>> images) {
|
||||
return images
|
||||
.where((item) => '${item['url']}'.isNotEmpty)
|
||||
.map((item) => UploadItem(
|
||||
id: item['id'],
|
||||
url: '${item['url']}',
|
||||
thumbnailUrl: '${item['thumbnailUrl'] ?? item['url']}',
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Upload 组件当前列表 → 提交用 answerImgs
|
||||
/// (同时带 url/Url、thumbnailUrl/ThumbnailUrl 兼容后端)
|
||||
static List<Map<String, dynamic>> uploadItemsToAnswerImgs(
|
||||
List<UploadItem> items,
|
||||
) {
|
||||
return items
|
||||
.where((e) => e.url.isNotEmpty)
|
||||
.map((e) => {
|
||||
'url': e.url,
|
||||
'Url': e.url,
|
||||
'thumbnailUrl': e.thumbnailUrl ?? e.url,
|
||||
'ThumbnailUrl': e.thumbnailUrl ?? e.url,
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Upload 组件当前列表 → 写回 userAnswer 的 JSON 字符串
|
||||
/// (对标 Vue handleUploadChange: JSON.stringify(e))
|
||||
static String uploadItemsToJson(List<UploadItem> items) {
|
||||
return jsonEncode(uploadItemsToAnswerImgs(items));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/// ============================================================
|
||||
/// 【QuestionModel】题目数据模型(纯 Dart 可变类)
|
||||
///
|
||||
/// 设计说明(对应用户口型:纯 Dart 可变类、全量保留原字段名、
|
||||
/// 手写 fromJson/toJson、不用 Isar/codegen):
|
||||
/// - 内部以 `Map<String, dynamic>` 承载全部字段(后端字段 + 解析器
|
||||
/// 动态产出 + 运行时字段),字段名与 Vue 端完全一致;
|
||||
/// - 解析器(core/utils/question/)直接操作 Map,本类提供类型化
|
||||
/// get/set 给页面与控制器使用;
|
||||
/// - childs/subQuestions 每次访问都基于同一批 Map 生成包装对象,
|
||||
/// 因此通过子模型的写操作会落到原始 Map 上(等价 JS 引用语义)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
class QuestionModel {
|
||||
QuestionModel(this.m);
|
||||
|
||||
factory QuestionModel.fromJson(Map<String, dynamic> json) =>
|
||||
QuestionModel(Map<String, dynamic>.from(json));
|
||||
|
||||
/// 原始数据(解析后的 Map,toJson 即提交回这个结构)
|
||||
final Map<String, dynamic> m;
|
||||
|
||||
Map<String, dynamic> toJson() => m;
|
||||
|
||||
@override
|
||||
String toString() => 'QuestionModel(id: $id, questionTypes: $questionTypes)';
|
||||
|
||||
// ---------------- 基础读写 ----------------
|
||||
|
||||
bool has(String key) => m.containsKey(key) && m[key] != null;
|
||||
|
||||
T? get<T>(String key) => m[key] is T ? m[key] as T : null;
|
||||
|
||||
void set(String key, dynamic value) => m[key] = value;
|
||||
|
||||
String str(String key, [String def = '']) {
|
||||
final v = m[key];
|
||||
return v == null ? def : '$v';
|
||||
}
|
||||
|
||||
int? intOf(String key) {
|
||||
final v = m[key];
|
||||
if (v is num) return v.toInt();
|
||||
if (v == null) return null;
|
||||
return int.tryParse('$v');
|
||||
}
|
||||
|
||||
bool boolOf(String key, [bool def = false]) =>
|
||||
m[key] == true ? true : (m[key] == false ? false : def);
|
||||
|
||||
/// 注意:返回的是原 Map 的 cast 视图(非拷贝),
|
||||
/// 这样子模型上的所有写操作都会落回原始 JSON Map(等价 JS 引用语义)。
|
||||
List<Map<String, dynamic>> mapList(String key) {
|
||||
final v = m[key];
|
||||
if (v is! List) return const [];
|
||||
return [
|
||||
for (final e in v)
|
||||
if (e is Map) e.cast<String, dynamic>(),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------- 业务字段(原名) ----------------
|
||||
|
||||
String get id => str('id');
|
||||
set id(dynamic v) => m['id'] = v;
|
||||
|
||||
String get questionId => str('questionId');
|
||||
set questionId(dynamic v) => m['questionId'] = v;
|
||||
|
||||
dynamic get questionTypeId => m['questionTypeId'];
|
||||
set questionTypeId(dynamic v) => m['questionTypeId'] = v;
|
||||
|
||||
String get questionTypeName => str('questionTypeName');
|
||||
set questionTypeName(String v) => m['questionTypeName'] = v;
|
||||
|
||||
dynamic get questionTypeCode => m['questionTypeCode'];
|
||||
|
||||
bool get isObjective => boolOf('isObjective');
|
||||
bool get isSelect => boolOf('isSelect');
|
||||
|
||||
String get stem => str('stem');
|
||||
set stem(String v) => m['stem'] = v;
|
||||
|
||||
String get answer => str('answer');
|
||||
set answer(String v) => m['answer'] = v;
|
||||
|
||||
String get correctAnswer => str('correctAnswer');
|
||||
set correctAnswer(String v) => m['correctAnswer'] = v;
|
||||
|
||||
String get userAnswer => str('userAnswer');
|
||||
set userAnswer(dynamic v) => m['userAnswer'] = v;
|
||||
|
||||
/// 部分接口字段(与 userAnswer 互为兜底)
|
||||
String get myAnswer => str('myAnswer');
|
||||
set myAnswer(dynamic v) => m['myAnswer'] = v;
|
||||
|
||||
/// Vue 端 child.userAnswer || child.myAnswer
|
||||
String get effectiveAnswer {
|
||||
final u = m['userAnswer'];
|
||||
return u != null && '$u'.isNotEmpty ? '$u' : myAnswer;
|
||||
}
|
||||
|
||||
int? get answerStatus => intOf('answerStatus');
|
||||
set answerStatus(dynamic v) => m['answerStatus'] = v;
|
||||
|
||||
/// 解析(字符串或 {analysis: '...'})
|
||||
dynamic get answerAnalysisRaw => m['answerAnalysis'];
|
||||
set answerAnalysisRaw(dynamic v) => m['answerAnalysis'] = v;
|
||||
|
||||
String get answerAnalysisText {
|
||||
final v = m['answerAnalysis'];
|
||||
if (v is Map) return '${v['analysis'] ?? ''}';
|
||||
return v == null ? '' : '$v';
|
||||
}
|
||||
|
||||
Map<String, dynamic>? get answerAnalysisVideo {
|
||||
final v = m['answerAnalysisVideo'];
|
||||
return v is Map ? Map<String, dynamic>.from(v) : null;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get audios => mapList('audios');
|
||||
List<Map<String, dynamic>> get videos => mapList('videos');
|
||||
|
||||
int get planTimeSecond => intOf('planTimeSecond') ?? 0;
|
||||
set planTimeSecond(dynamic v) => m['planTimeSecond'] = v;
|
||||
|
||||
/// 运行时:本地剩余秒缓存
|
||||
int? get newPlanTimeSecond => intOf('NewPlanTimeSecond');
|
||||
set newPlanTimeSecond(int? v) => m['NewPlanTimeSecond'] = v;
|
||||
|
||||
/// 运行时:本题建议时长是否已完成
|
||||
bool get isFinishPlanTime => boolOf('ISFinishPlanTime');
|
||||
set isFinishPlanTime(bool v) => m['ISFinishPlanTime'] = v;
|
||||
|
||||
int get answerTimeSecond => intOf('answerTimeSecond') ?? 0;
|
||||
|
||||
dynamic get score => m['score'];
|
||||
dynamic get userScore => m['userScore'];
|
||||
|
||||
bool get isCollect => boolOf('isCollect');
|
||||
set isCollect(bool v) => m['isCollect'] = v;
|
||||
|
||||
dynamic get collectId => m['collectId'];
|
||||
set collectId(dynamic v) => m['collectId'] = v;
|
||||
|
||||
bool get isWrongCollect => boolOf('isWrongCollect');
|
||||
set isWrongCollect(bool v) => m['isWrongCollect'] = v;
|
||||
|
||||
dynamic get wrongCollectId => m['wrongCollectId'];
|
||||
set wrongCollectId(dynamic v) => m['wrongCollectId'] = v;
|
||||
|
||||
bool get hasChild => boolOf('hasChild');
|
||||
|
||||
List<QuestionModel> get childs =>
|
||||
mapList('childs').map(QuestionModel.new).toList();
|
||||
|
||||
List<dynamic> get indexes =>
|
||||
m['indexes'] is List ? m['indexes'] as List : const [];
|
||||
|
||||
/// 笔记图片({id,url,thumbnailUrl} 等)
|
||||
List<Map<String, dynamic>> get notes => mapList('notes');
|
||||
set notes(List<dynamic> v) => m['notes'] = v;
|
||||
|
||||
String get source => str('source');
|
||||
|
||||
String? get wrongReason =>
|
||||
m['wrongReason'] == null ? null : '${m['wrongReason']}';
|
||||
String? get wrongAdvice =>
|
||||
m['wrongAdvice'] == null ? null : '${m['wrongAdvice']}';
|
||||
|
||||
bool get isUploadNotes => boolOf('isUploadNotes');
|
||||
|
||||
/// 加载时注入:0 输入框 / 1 图片作答
|
||||
int get isFillblankItemUpload => intOf('iSFilllankItemUpload') ?? 0;
|
||||
set isFillblankItemUpload(dynamic v) => m['iSFilllankItemUpload'] = v;
|
||||
|
||||
dynamic get baseSubjectId => m['baseSubjectId'];
|
||||
set baseSubjectId(dynamic v) => m['baseSubjectId'] = v;
|
||||
|
||||
int? get sort => intOf('sort');
|
||||
set sort(dynamic v) => m['sort'] = v;
|
||||
|
||||
int? get wrongSort => intOf('wrongSort');
|
||||
set wrongSort(dynamic v) => m['wrongSort'] = v;
|
||||
|
||||
/// appendExamQuestionChildOptionStatus 注入
|
||||
bool get hasOptionChildrenQuestion => boolOf('hasOptionChildrenQuestion');
|
||||
set hasOptionChildrenQuestion(bool v) =>
|
||||
m['hasOptionChildrenQuestion'] = v;
|
||||
|
||||
List<QuestionModel> get subQuestions =>
|
||||
mapList('subQuestions').map(QuestionModel.new).toList();
|
||||
|
||||
List<QuestionModel> get bkTagMetaList =>
|
||||
mapList('bkTagMetaList').map(QuestionModel.new).toList();
|
||||
|
||||
/// 选项 [{optionLabel, options}]
|
||||
List<Map<String, String>> get options {
|
||||
final v = m['options'];
|
||||
if (v is! List) return const [];
|
||||
return v.whereType<Map>().map((e) {
|
||||
return {
|
||||
'optionLabel': '${e['optionLabel'] ?? ''}',
|
||||
'options': '${e['options'] ?? ''}',
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String get questionTypes => str('questionTypes');
|
||||
set questionTypes(String v) => m['questionTypes'] = v;
|
||||
|
||||
String get analysis => str('analysis');
|
||||
set analysis(String v) => m['analysis'] = v;
|
||||
|
||||
/// 解析时保留的原始对象
|
||||
Map<String, dynamic>? get raw {
|
||||
final v = m['raw'];
|
||||
return v is Map ? Map<String, dynamic>.from(v) : null;
|
||||
}
|
||||
|
||||
/// 运行时:进入本题时间(本地 ISO)
|
||||
String? get startTime => m['startTime'] as String?;
|
||||
set startTime(String? v) => m['startTime'] = v;
|
||||
|
||||
/// 运行时:离开本题时间(本地 ISO)
|
||||
String? get endTime => m['endTime'] as String?;
|
||||
set endTime(String? v) => m['endTime'] = v;
|
||||
|
||||
/// 子题号(如 "1.1",subQuestions 元素自带)
|
||||
dynamic get index => m['index'];
|
||||
|
||||
/// 运行时:列表下标
|
||||
int? get currentIndex => intOf('currentIndex');
|
||||
set currentIndex(int v) => m['currentIndex'] = v;
|
||||
|
||||
// ---------------- 便捷方法 ----------------
|
||||
|
||||
QuestionModel? childAt(int index) {
|
||||
final list = mapList('childs');
|
||||
if (index < 0 || index >= list.length) return null;
|
||||
return QuestionModel(list[index]);
|
||||
}
|
||||
|
||||
QuestionModel? subAt(int index) {
|
||||
final list = mapList('subQuestions');
|
||||
if (index < 0 || index >= list.length) return null;
|
||||
return QuestionModel(list[index]);
|
||||
}
|
||||
|
||||
/// 重置本题及其子题的 startTime(对应 Vue resetCurrentQuestionStartTime)
|
||||
void resetStartTime(String isoNow) {
|
||||
m['startTime'] = isoNow;
|
||||
for (final key in ['subQuestions', 'childs']) {
|
||||
final v = m[key];
|
||||
if (v is List) {
|
||||
for (final c in v) {
|
||||
if (c is Map) c['startTime'] = isoNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 选项取值辅助
|
||||
String optionLabelOf(Map<String, String> option) => option['optionLabel'] ?? '';
|
||||
String optionContentOf(Map<String, String> option) => option['options'] ?? '';
|
||||
@@ -0,0 +1,462 @@
|
||||
/// ============================================================
|
||||
/// 【AnswerCardPopup】答题卡弹层
|
||||
/// (1:1 迁移 Vue components/AnswerCardPopup/AnswerCardPopup.vue)
|
||||
///
|
||||
/// statusMode:
|
||||
/// answer 答题中:未答 / 已答 /(扩展)时长不足 / 待补笔记,底部双按钮
|
||||
/// result 查看结果:答对 / 答错 / 未答,无底部按钮
|
||||
///
|
||||
/// 数据:buildExamAnswerCardQuestions 拍平 → 按 sort 排序 →
|
||||
/// 相邻 questionTypeName 分组。
|
||||
/// 事件:onSelect(questionMap) 选择题目(同时关闭);
|
||||
/// onCancel 继续答题/关闭;onSubmit 交卷。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
/// 答题卡状态码
|
||||
class AnswerCardStatus {
|
||||
AnswerCardStatus._();
|
||||
|
||||
static const int unanswered = 0;
|
||||
static const int answered = 1;
|
||||
static const int correct = 2;
|
||||
static const int wrong = 3;
|
||||
static const int planTimeNotEnough = 20;
|
||||
static const int pendingNotes = 21;
|
||||
}
|
||||
|
||||
class AnswerCardPopup extends StatelessWidget {
|
||||
const AnswerCardPopup({
|
||||
super.key,
|
||||
required this.show,
|
||||
required this.questions,
|
||||
this.isExamAnswerCard = true,
|
||||
this.statusMode = 'answer',
|
||||
this.enableExtendedAnswerStatus = false,
|
||||
this.onCancel,
|
||||
this.onSubmit,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
|
||||
/// QuestionModel.m 原始题目 Map 列表(同一引用即可,内部只读取/拷贝)
|
||||
final List<Map<String, dynamic>> questions;
|
||||
final bool isExamAnswerCard;
|
||||
|
||||
/// answer / result
|
||||
final String statusMode;
|
||||
final bool enableExtendedAnswerStatus;
|
||||
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback? onSubmit;
|
||||
final ValueChanged<Map<String, dynamic>>? onSelect;
|
||||
|
||||
bool get _extended =>
|
||||
statusMode == 'answer' && enableExtendedAnswerStatus;
|
||||
|
||||
// ---------------- 状态判定(1:1 移植 Vue) ----------------
|
||||
|
||||
String _normalizeAnswer(dynamic value) =>
|
||||
'$value'.replaceAll(RegExp(r'<[^>]+>'), '').replaceAll(RegExp(r'\s+'), '').toUpperCase();
|
||||
|
||||
bool _hasUploadedNotes(dynamic notes) {
|
||||
if (notes is List) return notes.isNotEmpty;
|
||||
return hasValue(notes);
|
||||
}
|
||||
|
||||
bool _hasFinishedPlanTime(Map<String, dynamic> q) {
|
||||
final planTimeSecond = _num(q['planTimeSecond']);
|
||||
if (planTimeSecond <= 0) return true;
|
||||
if (q['ISFinishPlanTime'] == true) return true;
|
||||
final answerTimeSecond = _num(q['answerTimeSecond']);
|
||||
return answerTimeSecond > planTimeSecond;
|
||||
}
|
||||
|
||||
int _questionStatus(Map<String, dynamic> q) {
|
||||
final fillBlankAnswered = isFillBlankFullyAnswered(q);
|
||||
final answered = (fillBlankAnswered == null || !fillBlankAnswered)
|
||||
? hasValue(q['userAnswer'])
|
||||
: fillBlankAnswered;
|
||||
|
||||
if (statusMode == 'answer') {
|
||||
if (!answered) return AnswerCardStatus.unanswered;
|
||||
if (_extended &&
|
||||
_num(q['planTimeSecond']) > 0 &&
|
||||
!_hasFinishedPlanTime(q)) {
|
||||
return AnswerCardStatus.planTimeNotEnough;
|
||||
}
|
||||
if (_extended &&
|
||||
q['isUploadNotes'] == true &&
|
||||
!_hasUploadedNotes(q['notes'])) {
|
||||
return AnswerCardStatus.pendingNotes;
|
||||
}
|
||||
return AnswerCardStatus.answered;
|
||||
}
|
||||
|
||||
// result 模式
|
||||
if (!answered) return AnswerCardStatus.unanswered;
|
||||
|
||||
final rawStatus = q['answerStatus'];
|
||||
if (rawStatus != null) {
|
||||
final status = _num(rawStatus, -1);
|
||||
if (status == 10) return AnswerCardStatus.correct;
|
||||
if (status == 11 || status == 12 || status == 1) {
|
||||
return AnswerCardStatus.wrong;
|
||||
}
|
||||
if (status == 0) return AnswerCardStatus.unanswered;
|
||||
}
|
||||
|
||||
final myAnswer = _normalizeAnswer(q['userAnswer']);
|
||||
final correctAnswer =
|
||||
_normalizeAnswer(q['correctAnswer'] ?? q['answer']);
|
||||
return myAnswer == correctAnswer
|
||||
? AnswerCardStatus.correct
|
||||
: AnswerCardStatus.wrong;
|
||||
}
|
||||
|
||||
/// 拍平 + 按相邻题型分组
|
||||
List<Map<String, dynamic>> _groups() {
|
||||
final flat = buildExamAnswerCardQuestions(
|
||||
questions,
|
||||
isExamAnswerCard: isExamAnswerCard,
|
||||
)..sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
|
||||
|
||||
final result = <Map<String, dynamic>>[];
|
||||
Map<String, dynamic>? current;
|
||||
for (final q in flat) {
|
||||
final typeName = '${q['questionTypeName'] ?? '题目'}';
|
||||
if (current == null || current['name'] != typeName) {
|
||||
current = {
|
||||
'name': typeName,
|
||||
'questions': <Map<String, dynamic>>[],
|
||||
};
|
||||
result.add(current);
|
||||
}
|
||||
(current['questions'] as List).add({
|
||||
...q,
|
||||
'status': _questionStatus(q),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------- 渲染 ----------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!show) return const SizedBox.shrink();
|
||||
final isPad = isPadDevice(context);
|
||||
final screenH = MediaQuery.sizeOf(context).height;
|
||||
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onCancel,
|
||||
child: const ColoredBox(color: Color(0x99000000)),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
height: screenH * 0.8,
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
isPad
|
||||
? '/StaticResource/2026-06-02/answer-sheet-bg-Pad1780382627852.png'
|
||||
: '/StaticResource/2026-04-08/answer-sheet-bg.png',
|
||||
),
|
||||
),
|
||||
fit: isPad ? BoxFit.fill : BoxFit.fill,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(isPad),
|
||||
Expanded(child: _buildBody()),
|
||||
if (statusMode == 'answer') _buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// pad 右上关闭热区(50% 宽 × 400rpx)
|
||||
if (isPad)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: screenH * 0.04,
|
||||
width: MediaQuery.sizeOf(context).width * 0.5,
|
||||
height: 200.h,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onCancel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isPad) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: isPad ? 180.h : 95.h), // 190/360rpx
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题 "答题卡",右下角 title-bacs.png 装饰背景
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(right: 30.w),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/title-bacs.png'),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
fit: BoxFit.fitHeight,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'答题卡',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
if (statusMode == 'answer')
|
||||
_buildLegend(
|
||||
isPad,
|
||||
_extended
|
||||
? const [
|
||||
_LegendItem('未答', Color(0xFF94A5B8)),
|
||||
_LegendItem('已答', Color(0xFF3D7DF5)),
|
||||
_LegendItem('时长不足', Color(0xFFFF9F1A)),
|
||||
_LegendItem('待补笔记', Color(0xFF8B5CF6)),
|
||||
]
|
||||
: const [
|
||||
_LegendItem('未答', Color(0xFF94A5B8)),
|
||||
_LegendItem('已答', Color(0xFF3D7DF5)),
|
||||
],
|
||||
extended: _extended,
|
||||
)
|
||||
else
|
||||
_buildLegend(
|
||||
isPad,
|
||||
const [
|
||||
_LegendItem('答对', Color(0xFF00CC96)),
|
||||
_LegendItem('答错', Color(0xFFFF4D4D)),
|
||||
_LegendItem('未答', Color(0xFF94A5B8)),
|
||||
],
|
||||
extended: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend(
|
||||
bool isPad,
|
||||
List<_LegendItem> items, {
|
||||
required bool extended,
|
||||
}) {
|
||||
return Container(
|
||||
width: extended ? 320.w : 150.w,
|
||||
padding: EdgeInsets.all(5.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x05FFFFFF), Color(0x33FFFFFF)],
|
||||
),
|
||||
border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
for (final item in items)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
decoration: BoxDecoration(
|
||||
color: item.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
item.label,
|
||||
style:
|
||||
TextStyle(fontSize: 12.sp, color: ExerciseColors.subtitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final groups = _groups();
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 20.h, bottom: 20.h),
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: groups.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 20.h),
|
||||
itemBuilder: (context, i) {
|
||||
final g = groups[i];
|
||||
final qs = (g['questions'] as List).cast<Map<String, dynamic>>();
|
||||
return Container(
|
||||
padding: EdgeInsets.all(14.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: Text(
|
||||
g['name'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10.w,
|
||||
runSpacing: 10.h,
|
||||
children: [
|
||||
for (final q in qs)
|
||||
GestureDetector(
|
||||
onTap: () => onSelect?.call(q),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 44.w, // 88rpx
|
||||
height: 36.h, // 72rpx
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _itemBg(q['status'] as int),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
'${q['sort']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _itemColor(q['status'] as int),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
padding: EdgeInsets.only(top: 12.h, bottom: 12.h),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ExerciseButton(
|
||||
text: '继续答题',
|
||||
style: ExerciseButtonStyle.outline,
|
||||
onTap: onCancel,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: ExerciseButton(
|
||||
text: '交卷',
|
||||
style: ExerciseButtonStyle.blueGradient,
|
||||
onTap: onSubmit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _itemBg(int status) {
|
||||
switch (status) {
|
||||
case AnswerCardStatus.answered:
|
||||
return const Color(0xFFF0F8FF);
|
||||
case AnswerCardStatus.correct:
|
||||
return const Color(0xFFE8FAF6);
|
||||
case AnswerCardStatus.wrong:
|
||||
return const Color(0xFFFFF0F0);
|
||||
case AnswerCardStatus.planTimeNotEnough:
|
||||
return const Color(0xFFFFF6E8);
|
||||
case AnswerCardStatus.pendingNotes:
|
||||
return const Color(0xFFF4EEFF);
|
||||
default:
|
||||
return const Color(0xFFF5F7FA);
|
||||
}
|
||||
}
|
||||
|
||||
Color _itemColor(int status) {
|
||||
switch (status) {
|
||||
case AnswerCardStatus.answered:
|
||||
return Color(0xFF3D7DF5);
|
||||
case AnswerCardStatus.correct:
|
||||
return ExerciseColors.correct;
|
||||
case AnswerCardStatus.wrong:
|
||||
return const Color(0xFFFF5252);
|
||||
case AnswerCardStatus.planTimeNotEnough:
|
||||
return const Color(0xFFFF9F1A);
|
||||
case AnswerCardStatus.pendingNotes:
|
||||
return const Color(0xFF8B5CF6);
|
||||
default:
|
||||
return const Color(0xFF94A5B8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendItem {
|
||||
const _LegendItem(this.label, this.color);
|
||||
final String label;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
int _num(dynamic v, [int def = 0]) {
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse('${v ?? ''}') ?? def;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/// ============================================================
|
||||
/// 【ObjectiveAnswerView】客观题答案对比卡片
|
||||
/// (1:1 迁移 Vue components/AnswerView.vue 的 type == 'objective' 分支;
|
||||
/// 本页只使用客观题形态)
|
||||
///
|
||||
/// 视觉:
|
||||
/// 218rpx 卡片:上行作答状态图标+文案(10 正确 / 11 错误 / 12 部分正确 /
|
||||
/// 0 未作答),下行白色对比块:"正确答案:X / 你的答案:Y"
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class ObjectiveAnswerView extends StatelessWidget {
|
||||
const ObjectiveAnswerView({
|
||||
super.key,
|
||||
required this.answerStatus,
|
||||
this.userAnswer = '',
|
||||
this.correctAnswer = '',
|
||||
});
|
||||
|
||||
/// 0 未作答 / 10 正确 / 11 错误 / 12 部分正确
|
||||
final dynamic answerStatus;
|
||||
final String userAnswer;
|
||||
final String correctAnswer;
|
||||
|
||||
int get _status => int.tryParse('$answerStatus') ?? 0;
|
||||
|
||||
bool get _isCorrect => _status == 10;
|
||||
|
||||
/// 客观题文字展示清洗(对标 formatOptionText),仅用于判空与纯文本展示
|
||||
String _formatOptionText(dynamic ans) {
|
||||
if (ans == null) return '';
|
||||
|
||||
String str;
|
||||
if (ans is List) {
|
||||
str = ans.join();
|
||||
} else if (ans is Map) {
|
||||
str = ans.toString();
|
||||
if (str == '{}' || str == '[]') return '';
|
||||
} else {
|
||||
str = '$ans';
|
||||
}
|
||||
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty || trimmed == '[]' || trimmed == '{}' ||
|
||||
trimmed == '""' || trimmed == "''") {
|
||||
return '';
|
||||
}
|
||||
|
||||
var result = trimmed.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
result = result.replaceAll(' ', ' ');
|
||||
result = result.replaceAll(RegExp(r'&[a-z]+;'), '');
|
||||
result = result.replaceAll(RegExp(r'\s+'), ' ');
|
||||
result = result.replaceAll(RegExp(r'^(\d+[\.\s、)\s]+|第\d+题)'), '');
|
||||
result = result.replaceAll(',', '');
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
/// 清洗后的答案 HTML(富文本渲染用)
|
||||
String _rendered(String html) => cleanRichText(html);
|
||||
|
||||
bool _hasFormula(String html) =>
|
||||
RegExp(r'math-formula|katex').hasMatch(html);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final correctText = _formatOptionText(correctAnswer);
|
||||
final userText = _formatOptionText(userAnswer);
|
||||
final correctDisplay = correctText.isEmpty ? '无' : correctText;
|
||||
final userDisplay = userText.isEmpty ? '未作答' : userText;
|
||||
|
||||
final statusText = _status == 10
|
||||
? '回答正确'
|
||||
: _status == 11
|
||||
? '回答错误'
|
||||
: _status == 12
|
||||
? '部分正确'
|
||||
: '未作答';
|
||||
|
||||
final accent = _isCorrect ? ExerciseColors.correct : ExerciseColors.error;
|
||||
|
||||
final correctHtml = _rendered(correctAnswer);
|
||||
final userHtml = _rendered(userAnswer);
|
||||
|
||||
return Container(
|
||||
height: 109.h, // 218rpx
|
||||
decoration: BoxDecoration(
|
||||
// CSS 外层:实色 padding-box + 180deg 渐变 border-box
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: _isCorrect
|
||||
? const [Color(0x8000CC96), Color(0x1A00CC96), Color(0x8000CC96)]
|
||||
: const [Color(0x80FF4C4C), Color(0x1AFF4C4C), Color(0x80FF4C4C)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(1.w),
|
||||
decoration: BoxDecoration(
|
||||
color: _isCorrect ? const Color(0xFFEBFFFA) : const Color(0xFFFFF0F0),
|
||||
borderRadius: BorderRadius.circular(11.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 第一行:状态提示
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h),
|
||||
child: Row(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
_isCorrect
|
||||
? '/StaticResource/2026-06-01/correct-icon1780284329572.png'
|
||||
: '/StaticResource/2026-06-01/error-icon1780284292405.png',
|
||||
),
|
||||
width: 16.w,
|
||||
height: 16.w,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 第二行:答案对比
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.fromLTRB(1.w, 0, 1.w, 1.w),
|
||||
padding: EdgeInsets.fromLTRB(14.w, 0, 26.w, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CompareItem(
|
||||
label: '正确答案:',
|
||||
child: _hasFormula(correctHtml)
|
||||
? RichTextContent(
|
||||
html: correctHtml,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF333333),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
correctDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF333333),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _CompareItem(
|
||||
alignEnd: true,
|
||||
label: '你的答案:',
|
||||
child: _hasFormula(userHtml)
|
||||
? RichTextContent(
|
||||
html: userHtml,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _isCorrect
|
||||
? ExerciseColors.correct
|
||||
: (userText.isEmpty
|
||||
? const Color(0xFF999999)
|
||||
: ExerciseColors.error),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
userDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _isCorrect
|
||||
? ExerciseColors.correct
|
||||
: (userText.isEmpty
|
||||
? const Color(0xFF999999)
|
||||
: ExerciseColors.error),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompareItem extends StatelessWidget {
|
||||
const _CompareItem({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.alignEnd = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool alignEnd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.subtitle),
|
||||
),
|
||||
),
|
||||
Flexible(child: child),
|
||||
],
|
||||
);
|
||||
return alignEnd
|
||||
? Align(alignment: Alignment.centerRight, child: row)
|
||||
: Align(alignment: Alignment.centerLeft, child: row);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
/// ============================================================
|
||||
/// 【ConquerPopup】攻克流程弹窗
|
||||
/// (1:1 迁移 Vue components/ConquerPopup/ConquerPopup.vue)
|
||||
///
|
||||
/// 四种类型:
|
||||
/// note 过程笔记提示
|
||||
/// time 最低作答时长提示(确认键在左、取消键在右)
|
||||
/// submit 确认交卷(取消键在左、确认键在右)
|
||||
/// grading 题目批改中(遮罩与按钮都不能关闭;唯一按钮 emit confirm)
|
||||
///
|
||||
/// 事件(与 Vue emit 对齐):
|
||||
/// onConfirm(action) 点确认按钮
|
||||
/// onCancel() 点遮罩/取消;若 confirmAction == 'showAnswerSheet'
|
||||
/// (isConfirm 单按钮或遮罩)会紧跟一次 onConfirm(action)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class ConquerPopup extends StatelessWidget {
|
||||
const ConquerPopup({
|
||||
super.key,
|
||||
required this.show,
|
||||
this.type = 'note',
|
||||
this.title = '',
|
||||
this.subtitle = '',
|
||||
this.confirmText = '确定',
|
||||
this.cancelText = '取消',
|
||||
this.isConfirm = false,
|
||||
this.confirmAction = '',
|
||||
this.onConfirm,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
|
||||
/// note / time / submit / grading
|
||||
final String type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String confirmText;
|
||||
final String cancelText;
|
||||
final bool isConfirm;
|
||||
final String confirmAction;
|
||||
final ValueChanged<String>? onConfirm;
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
static const Map<String, String> _bgMap = {
|
||||
'note': '/StaticResource/2026-04-08/conquer-note-bg.png',
|
||||
'time': '/StaticResource/2026-04-08/conquer-time-bg.png',
|
||||
'submit': '/StaticResource/2026-04-08/conquer-submit-bg.png',
|
||||
'grading': '/StaticResource/2026-04-08/conquer-grading-bg.png',
|
||||
};
|
||||
|
||||
/// grading 遮罩点击:直接拦截不关闭
|
||||
void _handleMaskCancel() {
|
||||
if (type == 'grading') return;
|
||||
_handleCancel();
|
||||
}
|
||||
|
||||
/// 普通取消:关闭 + cancel + showAnswerSheet 时补发 confirm
|
||||
void _handleCancel() {
|
||||
onCancel?.call();
|
||||
if (confirmAction == 'showAnswerSheet') {
|
||||
onConfirm?.call(confirmAction);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConfirm() {
|
||||
onConfirm?.call(confirmAction);
|
||||
}
|
||||
|
||||
/// 标题/副标题可能是 HTML(v-html)
|
||||
Widget _htmlOrText(
|
||||
String html, {
|
||||
required double fontSize,
|
||||
required Color color,
|
||||
FontWeight? fontWeight,
|
||||
double? height,
|
||||
}) {
|
||||
final style = TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: color,
|
||||
fontWeight: fontWeight,
|
||||
height: height,
|
||||
);
|
||||
if (html.contains(RegExp(r'<[a-zA-Z][^>]*>'))) {
|
||||
return RichTextContent(html: html, textStyle: style);
|
||||
}
|
||||
return Text(html, textAlign: TextAlign.center, style: style);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!show) return const SizedBox.shrink();
|
||||
final isPad = isPadDevice(context);
|
||||
final isGrading = type == 'grading';
|
||||
|
||||
return Positioned.fill(
|
||||
child: Stack(
|
||||
children: [
|
||||
// mask rgba(0,0,0,.6)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleMaskCancel,
|
||||
child: const ColoredBox(color: Color(0x99000000)),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: isPad ? 305.w : 278.w, // 556rpx / 610rpx
|
||||
constraints: BoxConstraints(minHeight: 352.5.h), // 705rpx
|
||||
padding: EdgeInsets.only(
|
||||
left: 27.5.w, // 55rpx
|
||||
right: 25.w, // 50rpx
|
||||
bottom: 30.h, // 60rpx
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(_bgMap[type] ?? _bgMap['note']!),
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: isPad ? 170.h : 150.h),
|
||||
child: _htmlOrText(
|
||||
title,
|
||||
fontSize: 18.sp, // 36rpx
|
||||
color: ExerciseColors.title,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 25.h / 18.sp,
|
||||
),
|
||||
),
|
||||
if (isGrading) ...[
|
||||
// 预计 3~5 分钟有结果
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 20.h),
|
||||
height: 44.h, // 88rpx
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F8FF),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ExerciseColors.subtitle,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '预计 '),
|
||||
TextSpan(
|
||||
text: '3~5',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.primary,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: ' 分钟有结果'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 15.h),
|
||||
child: ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 220.w, // 440rpx
|
||||
onTap: _handleConfirm,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (subtitle.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 10.h, left: 10.w, right: 10.w),
|
||||
child: _htmlOrText(
|
||||
subtitle,
|
||||
fontSize: 14.sp, // 28rpx
|
||||
color: ExerciseColors.subtitle,
|
||||
height: 22.h / 14.sp,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 25.h),
|
||||
child: isConfirm
|
||||
? ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: double.infinity,
|
||||
onTap: _handleCancel,
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: type == 'time'
|
||||
? [
|
||||
// time:确认在左(白底)、取消在右(蓝底)
|
||||
ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.secondary,
|
||||
width: 105.w, // 210rpx
|
||||
onTap: _handleConfirm,
|
||||
),
|
||||
ExerciseButton(
|
||||
text: cancelText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 105.w,
|
||||
onTap: _handleCancel,
|
||||
),
|
||||
]
|
||||
: [
|
||||
// note / submit:取消在左、确认在右
|
||||
ExerciseButton(
|
||||
text: cancelText,
|
||||
style: ExerciseButtonStyle.secondary,
|
||||
width: 105.w,
|
||||
onTap: _handleCancel,
|
||||
),
|
||||
ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 105.w,
|
||||
onTap: _handleConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/// ============================================================
|
||||
/// 【ExerciseConfirmBox】通用确认弹窗
|
||||
/// (1:1 迁移 Vue classExercises/ExerciseConfirmBox.vue)
|
||||
///
|
||||
/// 交互点:
|
||||
/// - 点遮罩 / 取消:onCancel(父级负责把 show 置 false)
|
||||
/// - 点确定:onConfirm(不主动关闭,与 Vue 一致由业务决定)
|
||||
/// - showCancel=false 时只显示单满宽确定按钮
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class ExerciseConfirmBox extends StatelessWidget {
|
||||
const ExerciseConfirmBox({
|
||||
super.key,
|
||||
required this.show,
|
||||
this.title = '提示',
|
||||
this.content = '',
|
||||
this.cancelText = '取消',
|
||||
this.confirmText = '确定',
|
||||
this.showCancel = true,
|
||||
this.onCancel,
|
||||
this.onConfirm,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
final String title;
|
||||
final String content;
|
||||
final String cancelText;
|
||||
final String confirmText;
|
||||
final bool showCancel;
|
||||
final VoidCallback? onCancel;
|
||||
final VoidCallback? onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!show) return const SizedBox.shrink();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// mask rgba(0,0,0,.45),点击=cancel
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onCancel,
|
||||
child: const ColoredBox(color: Color(0x73000000)),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 315.w, // 630rpx, max calc(100vw - 80rpx)
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width - 40.w,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 30.h, horizontal: 15.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build('/StaticResource/2026-03-31/header-bac.png'),
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
if (content.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 18.h),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 263.w),
|
||||
child: Text(
|
||||
content,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ExerciseColors.title,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 23.h),
|
||||
child: showCancel
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ExerciseButton(
|
||||
text: cancelText,
|
||||
style: ExerciseButtonStyle.secondary,
|
||||
width: 135.w,
|
||||
onTap: onCancel,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: 135.w,
|
||||
onTap: onConfirm,
|
||||
),
|
||||
],
|
||||
)
|
||||
: ExerciseButton(
|
||||
text: confirmText,
|
||||
style: ExerciseButtonStyle.primary,
|
||||
width: double.infinity,
|
||||
onTap: onConfirm,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/// ============================================================
|
||||
/// 课堂习题模块共享样式
|
||||
///
|
||||
/// 集中放置 classExercises 各 widget 复用的:
|
||||
/// 1. 颜色常量(对标 Vue common.scss 的 $default-color 系列)
|
||||
/// 2. isPadDevice 判定(shortestSide >= 600)
|
||||
/// 3. 渐变描边容器(对标 CSS padding-box/border-box 双层渐变)
|
||||
/// 4. 通用胶囊按钮(对标 default-button1/2/3/4/5/6)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// 与 Vue 端一一对应的色板
|
||||
class ExerciseColors {
|
||||
ExerciseColors._();
|
||||
|
||||
/// 主题蓝 $default-color
|
||||
static const Color primary = Color(0xFF3D7DF5);
|
||||
|
||||
/// $default-color-strong
|
||||
static const Color primaryStrong = Color(0xFF3373FF);
|
||||
|
||||
/// $default-color-mid
|
||||
static const Color primaryMid = Color(0xFF3C84FF);
|
||||
|
||||
/// $default-color-light
|
||||
static const Color primaryLight = Color(0xFF8AD1FD);
|
||||
|
||||
/// 标题深色
|
||||
static const Color title = Color(0xFF1D2B47);
|
||||
|
||||
/// 次级文字
|
||||
static const Color subtitle = Color(0xFF687FAD);
|
||||
|
||||
/// 正确绿
|
||||
static const Color correct = Color(0xFF00CC96);
|
||||
|
||||
/// 错误红
|
||||
static const Color error = Color(0xFFFF4D4D);
|
||||
|
||||
/// 页面/卡片浅底
|
||||
static const Color bg = Color(0xFFF5F8FA);
|
||||
|
||||
/// 选项默认底
|
||||
static const Color tileBg = Color(0xFFFAFBFD);
|
||||
|
||||
/// 浅蓝描边
|
||||
static const Color border = Color(0xFFE3ECFF);
|
||||
}
|
||||
|
||||
/// 对标 Vue utils/util.js isPadDevice
|
||||
bool isPadDevice(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).shortestSide >= 600;
|
||||
|
||||
/// 选项 / 状态卡片的视觉态
|
||||
enum ExerciseTileState {
|
||||
/// 默认:浅底 + 浅蓝描边
|
||||
plain,
|
||||
|
||||
/// 作答中选中:蓝渐变描边
|
||||
active,
|
||||
|
||||
/// 正确:绿渐变描边
|
||||
correct,
|
||||
|
||||
/// 错误:红渐变描边
|
||||
error,
|
||||
}
|
||||
|
||||
/// 渐变描边容器
|
||||
///
|
||||
/// 对标 CSS 写法:
|
||||
/// `background: linear-gradient(...) padding-box,
|
||||
/// linear-gradient(...) border-box; border: 2rpx solid transparent;`
|
||||
/// Flutter 用「外层渐变 + 1.w 内边距 + 内层实色」实现相同观感。
|
||||
class GradientBorderBox extends StatelessWidget {
|
||||
const GradientBorderBox({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.state = ExerciseTileState.plain,
|
||||
this.radius = 12,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.borderWidth,
|
||||
this.fillColor,
|
||||
this.borderGradient,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final ExerciseTileState state;
|
||||
final double radius;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
/// 描边宽度(默认 2rpx = 1.w)
|
||||
final double? borderWidth;
|
||||
|
||||
/// 自定义内层填充色(不传按 state 取)
|
||||
final Color? fillColor;
|
||||
|
||||
/// 自定义描边渐变(不传按 state 取)
|
||||
final Gradient? borderGradient;
|
||||
|
||||
Color get _fill {
|
||||
if (fillColor != null) return fillColor!;
|
||||
switch (state) {
|
||||
case ExerciseTileState.plain:
|
||||
return ExerciseColors.tileBg;
|
||||
case ExerciseTileState.active:
|
||||
return const Color(0xFFF0F8FF);
|
||||
case ExerciseTileState.correct:
|
||||
return const Color(0xFFEBFFFA);
|
||||
case ExerciseTileState.error:
|
||||
return const Color(0xFFFFF0F0);
|
||||
}
|
||||
}
|
||||
|
||||
Gradient? get _gradient {
|
||||
if (borderGradient != null) return borderGradient;
|
||||
switch (state) {
|
||||
case ExerciseTileState.plain:
|
||||
return null;
|
||||
case ExerciseTileState.active:
|
||||
// rgba(101,189,250,.1) → rgba(101,189,250,.5)
|
||||
return const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0x1A65BDFF), Color(0x8065BDFF)],
|
||||
);
|
||||
case ExerciseTileState.correct:
|
||||
return const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0x1A00CC96), Color(0x8000CC96)],
|
||||
);
|
||||
case ExerciseTileState.error:
|
||||
return const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0x1AFF4C4C), Color(0x80FF4C4C)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bw = borderWidth ?? 1.w;
|
||||
final br = BorderRadius.circular(radius.r);
|
||||
final outer = state == ExerciseTileState.plain
|
||||
? BoxDecoration(
|
||||
color: _fill,
|
||||
borderRadius: br,
|
||||
border: Border.all(color: ExerciseColors.border, width: bw),
|
||||
)
|
||||
: BoxDecoration(
|
||||
gradient: _gradient,
|
||||
borderRadius: br,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: margin,
|
||||
decoration: outer,
|
||||
child: Container(
|
||||
margin: state == ExerciseTileState.plain ? null : EdgeInsets.all(bw),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: state == ExerciseTileState.plain ? null : _fill,
|
||||
borderRadius: BorderRadius.circular(
|
||||
state == ExerciseTileState.plain ? radius.r : radius.r - bw,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形选项标号的背景(对标 .options-li-Head 激活态径向渐变)
|
||||
Gradient? headGradientOf(ExerciseTileState state) {
|
||||
switch (state) {
|
||||
case ExerciseTileState.plain:
|
||||
return null;
|
||||
case ExerciseTileState.active:
|
||||
// radial-gradient(110.77% 110% at 50.93% 90%, #74CAFF, transparent), #3D7DF5
|
||||
return const RadialGradient(
|
||||
center: Alignment(0.0186, 0.8),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF74CAFF), ExerciseColors.primary],
|
||||
);
|
||||
case ExerciseTileState.correct:
|
||||
return const RadialGradient(
|
||||
center: Alignment(0.0262, 0.7894),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFF52E3A2), ExerciseColors.correct],
|
||||
);
|
||||
case ExerciseTileState.error:
|
||||
return const RadialGradient(
|
||||
center: Alignment(0.0262, 0.7894),
|
||||
radius: 1.1,
|
||||
colors: [Color(0xFFFF877C), Color(0xFFFF4C4C)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 胶囊按钮样式(对标 default-button1/2/3/4)
|
||||
enum ExerciseButtonStyle {
|
||||
/// default-button1:左→右蓝色渐变,交卷/提交
|
||||
blueGradient,
|
||||
|
||||
/// default-button3:下→上浅蓝渐变,确定/确认
|
||||
primary,
|
||||
|
||||
/// default-button2:白底蓝字带投影,弹窗次级按钮
|
||||
secondary,
|
||||
|
||||
/// default-button4:白底蓝字蓝描边,继续答题
|
||||
outline,
|
||||
}
|
||||
|
||||
/// 通用胶囊按钮(92rpx 高、100rpx 圆角)
|
||||
class ExerciseButton extends StatelessWidget {
|
||||
const ExerciseButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onTap,
|
||||
this.style = ExerciseButtonStyle.primary,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fontSize,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final VoidCallback? onTap;
|
||||
final ExerciseButtonStyle style;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double? fontSize;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final h = height ?? 46.h;
|
||||
final fs = fontSize ?? 16.sp;
|
||||
|
||||
Gradient? gradient;
|
||||
Color? color;
|
||||
Color textColor;
|
||||
Border? border;
|
||||
List<BoxShadow> shadows = const [];
|
||||
|
||||
switch (style) {
|
||||
case ExerciseButtonStyle.blueGradient:
|
||||
// linear-gradient(90deg, #3373FF 0%, #3D7DF5 100%)
|
||||
gradient = const LinearGradient(
|
||||
colors: [ExerciseColors.primaryStrong, ExerciseColors.primary],
|
||||
);
|
||||
textColor = Colors.white;
|
||||
shadows = [
|
||||
BoxShadow(
|
||||
color: const Color(0x4D3D7DF5),
|
||||
offset: Offset(0, 7.5.h),
|
||||
blurRadius: 15.h,
|
||||
),
|
||||
];
|
||||
break;
|
||||
case ExerciseButtonStyle.primary:
|
||||
// linear-gradient(360deg, #8AD1FD -11.22%, #3C84FF 60.2%, #3D7DF5 100%)
|
||||
gradient = const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
ExerciseColors.primaryLight,
|
||||
ExerciseColors.primaryMid,
|
||||
ExerciseColors.primary,
|
||||
],
|
||||
stops: [0, 0.602, 1],
|
||||
);
|
||||
textColor = Colors.white;
|
||||
shadows = [
|
||||
BoxShadow(
|
||||
color: const Color(0x143D7DF5),
|
||||
offset: Offset(0, 7.5.h),
|
||||
blurRadius: 5.65.h,
|
||||
),
|
||||
];
|
||||
break;
|
||||
case ExerciseButtonStyle.secondary:
|
||||
color = const Color(0xFFFDFFFF);
|
||||
textColor = ExerciseColors.primary;
|
||||
shadows = [
|
||||
BoxShadow(
|
||||
color: const Color(0x143D7DF5),
|
||||
offset: Offset(0, 7.5.h),
|
||||
blurRadius: 5.65.h,
|
||||
),
|
||||
];
|
||||
break;
|
||||
case ExerciseButtonStyle.outline:
|
||||
color = Colors.white;
|
||||
textColor = ExerciseColors.primary;
|
||||
border = Border.all(color: ExerciseColors.primary, width: 1.w);
|
||||
break;
|
||||
}
|
||||
|
||||
return Opacity(
|
||||
opacity: enabled ? 1 : 0.5,
|
||||
child: GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: width,
|
||||
height: h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(50.r),
|
||||
border: border,
|
||||
boxShadow: shadows,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: fs,
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/// ============================================================
|
||||
/// 【FeedbackPopup】题目反馈弹层
|
||||
/// (1:1 迁移 Vue components/FeedbackPopup/FeedbackPopup.vue,
|
||||
/// BottomPopup → showModalBottomSheet)
|
||||
///
|
||||
/// 用法(与 Vue defineExpose 对齐):
|
||||
/// `final key = GlobalKey<FeedbackPopupState>();`
|
||||
/// `FeedbackPopup(key: key);`
|
||||
/// `key.currentState!.open(id, questionId);`
|
||||
///
|
||||
/// 校验顺序:反馈内容 → 反馈类型;成功后自动关闭并重置。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
|
||||
import 'package:new_learn/core/components/center_toast.dart';
|
||||
import 'package:new_learn/core/components/upload.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class _FeedbackOption {
|
||||
const _FeedbackOption(this.label, this.value);
|
||||
final String label;
|
||||
final int value;
|
||||
}
|
||||
|
||||
const List<_FeedbackOption> _kOptions = [
|
||||
_FeedbackOption('题干错误', 1),
|
||||
_FeedbackOption(' 解析看不懂', 2),
|
||||
_FeedbackOption('答案错误', 3),
|
||||
_FeedbackOption('选项错误', 4),
|
||||
_FeedbackOption('批阅错误', 5),
|
||||
_FeedbackOption('其他', 0),
|
||||
];
|
||||
|
||||
class FeedbackPopup extends StatefulWidget {
|
||||
const FeedbackPopup({super.key});
|
||||
|
||||
@override
|
||||
State<FeedbackPopup> createState() => FeedbackPopupState();
|
||||
}
|
||||
|
||||
class FeedbackPopupState extends State<FeedbackPopup> {
|
||||
/// 打开反馈弹层
|
||||
void open(dynamic id, dynamic questionId) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _FeedbackSheet(id: id, questionId: questionId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
class _FeedbackSheet extends StatefulWidget {
|
||||
const _FeedbackSheet({required this.id, required this.questionId});
|
||||
|
||||
final dynamic id;
|
||||
final dynamic questionId;
|
||||
|
||||
@override
|
||||
State<_FeedbackSheet> createState() => _FeedbackSheetState();
|
||||
}
|
||||
|
||||
class _FeedbackSheetState extends State<_FeedbackSheet> {
|
||||
final Set<int> _selected = {};
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
List<UploadItem> _imageList = const [];
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_contentController.text.isEmpty) {
|
||||
CenterToast.show(context, '请输入反馈内容');
|
||||
return;
|
||||
}
|
||||
if (_selected.isEmpty) {
|
||||
CenterToast.show(context, '请选择反馈类型');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
// 提交中遮罩(对标 uni.showLoading),压在底部弹层之上
|
||||
unawaited(showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.black38,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
));
|
||||
|
||||
final payload = {
|
||||
'id': widget.id,
|
||||
'questionId': widget.questionId,
|
||||
'feedbackTypes': _selected.toList(),
|
||||
'description': _contentController.text,
|
||||
'images': _imageList.map((e) => e.url).toList(),
|
||||
};
|
||||
|
||||
try {
|
||||
final res = await ClassExercisesApi.instance.feedback(payload);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // 关闭 loading
|
||||
if (res != null && res != false) {
|
||||
CenterToast.show(context, '反馈成功');
|
||||
Navigator.of(context).pop(); // 关闭反馈弹层
|
||||
} else {
|
||||
CenterToast.show(context, '反馈失败,请检查网络');
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // 关闭 loading
|
||||
CenterToast.show(context, '反馈失败,请检查网络');
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(10)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildOptionsGrid(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20.h),
|
||||
child: _buildTextarea(),
|
||||
),
|
||||
Upload(
|
||||
title: '上传图片',
|
||||
maxCount: 5,
|
||||
type: UploadType.feedback,
|
||||
initialImageList: _imageList,
|
||||
onChanged: (list) =>
|
||||
setState(() => _imageList = list),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 30.h),
|
||||
child: ExerciseButton(
|
||||
text: '提交反馈',
|
||||
style: ExerciseButtonStyle.blueGradient,
|
||||
width: double.infinity,
|
||||
enabled: !_submitting,
|
||||
onTap: _submit,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
height: 60.h, // 120rpx
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFE1EEFF), Colors.white],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SizedBox(width: 24.w),
|
||||
Text(
|
||||
'题目反馈',
|
||||
style: TextStyle(fontSize: 16.sp, color: ExerciseColors.title),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Icon(Icons.close, size: 22.w, color: ExerciseColors.title),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionsGrid() {
|
||||
final chipW = (1.sw - 14.w * 2 - 16.w * 2) / 3;
|
||||
return Wrap(
|
||||
spacing: 16.w, // column-gap 32rpx
|
||||
runSpacing: 10.h, // row-gap 20rpx
|
||||
children: [
|
||||
for (final opt in _kOptions)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
if (!_selected.remove(opt.value)) _selected.add(opt.value);
|
||||
}),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: chipW,
|
||||
padding: EdgeInsets.only(top: 11.w, bottom: 13.5.h),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _selected.contains(opt.value)
|
||||
? const Color(0xFF6195F6)
|
||||
: const Color(0xFFF5F8FA),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
opt.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _selected.contains(opt.value)
|
||||
? Colors.white
|
||||
: const Color(0xFF687FAD),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextarea() {
|
||||
return TextField(
|
||||
controller: _contentController,
|
||||
maxLines: 4,
|
||||
minLines: 4,
|
||||
maxLength: 500,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请描述您的反馈...',
|
||||
hintStyle: TextStyle(fontSize: 14.sp, color: const Color(0xFFB3C2E0)),
|
||||
counterStyle: TextStyle(fontSize: 12.sp, color: const Color(0xFFB3C2E0)),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFAFBFD),
|
||||
contentPadding: EdgeInsets.all(12.w),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide: BorderSide(color: ExerciseColors.border, width: 1.w),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
borderSide:
|
||||
BorderSide(color: ExerciseColors.primary, width: 1.w),
|
||||
),
|
||||
),
|
||||
style: TextStyle(fontSize: 14.sp, color: ExerciseColors.title),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/// ============================================================
|
||||
/// 【ObjOptions】客观题选项列表(1:1 迁移 Vue components/objOptions)
|
||||
///
|
||||
/// 交互点(与 Vue 一致):
|
||||
/// - props: options / answerData{correctAnswer,myAnswer} / answer /
|
||||
/// mode('edit'|'view') / questionType
|
||||
/// - edit:点击选项 emit change({...item, userAnswer: hxdb HTML})
|
||||
/// duoxuanti 多选解析已有答案后 toggle,按字母排序
|
||||
/// panduanti 取 optionLabel 或选项纯文本
|
||||
/// - view:不响应点击;正确项绿、选错项红
|
||||
/// - panduanti 标号位渲染本地正确/错误图标
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
enum ObjOptionMode { edit, view }
|
||||
|
||||
/// 对标 Vue answerData: { correctAnswer, myAnswer }
|
||||
class ObjOptionAnswerData {
|
||||
const ObjOptionAnswerData({
|
||||
required this.correctAnswer,
|
||||
required this.myAnswer,
|
||||
});
|
||||
|
||||
final String correctAnswer;
|
||||
final String myAnswer;
|
||||
}
|
||||
|
||||
/// change 事件载荷:{...option, userAnswer: String}
|
||||
typedef ObjOptionChanged = void Function(Map<String, dynamic> payload);
|
||||
|
||||
class ObjOptions extends StatelessWidget {
|
||||
const ObjOptions({
|
||||
super.key,
|
||||
required this.options,
|
||||
this.answerData,
|
||||
this.answer = '',
|
||||
this.mode = ObjOptionMode.view,
|
||||
this.questionType = '',
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
/// [{optionLabel, options}]
|
||||
final List<Map<String, String>> options;
|
||||
|
||||
/// view 模式答案对比数据;edit 模式通常不传
|
||||
final ObjOptionAnswerData? answerData;
|
||||
|
||||
/// edit 模式当前 userAnswer
|
||||
final String answer;
|
||||
final ObjOptionMode mode;
|
||||
|
||||
/// data.questionTypes,如 danxuanti / duoxuanti / panduanti
|
||||
final String questionType;
|
||||
final ObjOptionChanged? onChanged;
|
||||
|
||||
bool get _isPanDuan => questionType == 'panduanti';
|
||||
|
||||
/// 与 correctAnswer 一致的富文本外壳,内容为纯文本
|
||||
String _buildHxdbAnswerHtml(String plain) {
|
||||
if (plain.isEmpty) return '';
|
||||
final text = plain.trim();
|
||||
if (text.isEmpty) return '';
|
||||
return '<div class="hxdb-answer" name="hxdbAnswer">'
|
||||
'<p data-pm-slice="1 1 []">'
|
||||
'<span style="font-family: 宋体;"></span>$text</p></div>';
|
||||
}
|
||||
|
||||
/// 多选:从已有答案解析出选项字母(支持 hxdb HTML 或 A,B,D)
|
||||
List<String> _parseMultiSelectedLabels(
|
||||
String answerStr,
|
||||
List<String> validLabels,
|
||||
) {
|
||||
final plain = stripAnswerPlain(answerStr);
|
||||
if (plain.isEmpty) return const [];
|
||||
final valid = validLabels.where((e) => e.isNotEmpty).toSet();
|
||||
if (plain.contains(',')) {
|
||||
return plain
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where(valid.contains)
|
||||
.toList();
|
||||
}
|
||||
return plain.split('').where(valid.contains).toList();
|
||||
}
|
||||
|
||||
void _onClick(Map<String, String> item) {
|
||||
if (mode != ObjOptionMode.edit) return;
|
||||
final changed = onChanged;
|
||||
if (changed == null) return;
|
||||
|
||||
final validLabels =
|
||||
options.map((o) => o['optionLabel'] ?? '').where((e) => e.isNotEmpty).toList();
|
||||
final label = item['optionLabel'] ?? '';
|
||||
|
||||
if (questionType == 'duoxuanti') {
|
||||
final myAnswer = answerData != null &&
|
||||
answerData!.myAnswer.trim().isNotEmpty
|
||||
? answerData!.myAnswer
|
||||
: answer;
|
||||
final set = _parseMultiSelectedLabels(myAnswer, validLabels).toSet();
|
||||
if (label.isNotEmpty && set.contains(label)) {
|
||||
set.remove(label);
|
||||
} else if (label.isNotEmpty) {
|
||||
set.add(label);
|
||||
}
|
||||
final sorted = (set.toList()..sort()).join();
|
||||
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(sorted)});
|
||||
return;
|
||||
}
|
||||
|
||||
final plain = _isPanDuan
|
||||
? (label.isNotEmpty ? label : stripAnswerPlain(item['options']))
|
||||
: label;
|
||||
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(plain)});
|
||||
}
|
||||
|
||||
/// 选项内容富文本(td 转行内容器并清理头部空白)
|
||||
String _formatOption(String html) {
|
||||
if (html.isEmpty) return '';
|
||||
var res = cleanRichText(html);
|
||||
res = res.replaceAll(
|
||||
RegExp(r'<td\b[^>]*>', caseSensitive: false),
|
||||
'<span>',
|
||||
);
|
||||
res = res.replaceAll(
|
||||
RegExp(r'</td>', caseSensitive: false),
|
||||
'</span>',
|
||||
);
|
||||
res = res.replaceAll(RegExp(r'^(?: |\s)+', caseSensitive: false), '');
|
||||
return res;
|
||||
}
|
||||
|
||||
bool _isTrueOption(Map<String, String> item) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
final opts = item['options'] ?? '';
|
||||
return label == '正确' || label == '✓' || label == 'A' || opts.contains('正确');
|
||||
}
|
||||
|
||||
ExerciseTileState _stateOf(Map<String, String> item) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
if (label.isEmpty) return ExerciseTileState.plain;
|
||||
|
||||
var correctStr = '';
|
||||
var myStr = '';
|
||||
if (answerData != null) {
|
||||
correctStr = answerData!.correctAnswer
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.trim();
|
||||
myStr = answerData!.myAnswer
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.trim();
|
||||
} else {
|
||||
correctStr = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim();
|
||||
if (mode == ObjOptionMode.edit) {
|
||||
// 兼容老代码:没传 answerData 时把 answer 当作选中的高亮
|
||||
myStr = correctStr;
|
||||
}
|
||||
}
|
||||
|
||||
var isItemMine = myStr.contains(label);
|
||||
var isItemCorrect = correctStr.contains(label);
|
||||
|
||||
if (_isPanDuan) {
|
||||
final opts = item['options'] ?? '';
|
||||
if (label == '正确' || opts.contains('正确')) {
|
||||
isItemCorrect = ['正确', '✓', 'T', 'true', '对'].any(correctStr.contains);
|
||||
isItemMine = ['正确', '✓', 'T', 'true', '对'].any(myStr.contains);
|
||||
} else if (label == '错误' || opts.contains('错误') || opts.contains('错')) {
|
||||
isItemCorrect = ['错误', '×', 'F', 'false', '错'].any(correctStr.contains);
|
||||
isItemMine = ['错误', '×', 'F', 'false', '错'].any(myStr.contains);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == ObjOptionMode.edit) {
|
||||
return isItemMine ? ExerciseTileState.active : ExerciseTileState.plain;
|
||||
}
|
||||
if (isItemCorrect) return ExerciseTileState.correct;
|
||||
if (isItemMine && !isItemCorrect) return ExerciseTileState.error;
|
||||
return ExerciseTileState.plain;
|
||||
}
|
||||
|
||||
String _panDuanIcon(Map<String, String> item, ExerciseTileState state) {
|
||||
final isActive = state != ExerciseTileState.plain;
|
||||
if (_isTrueOption(item)) {
|
||||
return isActive
|
||||
? 'assets/img/my/correct-active-icon.png'
|
||||
: 'assets/img/my/correct-inactive-icon.png';
|
||||
}
|
||||
return isActive
|
||||
? 'assets/img/my/wrong-active-icon.png'
|
||||
: 'assets/img/my/wrong-inactive-icon.png';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < options.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: i == options.length - 1 ? 0 : 14.h),
|
||||
child: _buildItem(options[i]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(Map<String, String> item) {
|
||||
final state = _stateOf(item);
|
||||
return GestureDetector(
|
||||
onTap: () => _onClick(item),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: GradientBorderBox(
|
||||
state: state,
|
||||
radius: 12,
|
||||
padding: EdgeInsets.all(12.w), // 24rpx
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildHead(item, state),
|
||||
SizedBox(width: 14.w), // 76 标号 + 28 间距 = 104rpx
|
||||
Expanded(
|
||||
child: RichTextContent(
|
||||
html: _formatOption(item['options'] ?? ''),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.5,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHead(Map<String, String> item, ExerciseTileState state) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
final active = state != ExerciseTileState.plain;
|
||||
return Container(
|
||||
width: 38.w, // 76rpx
|
||||
height: 38.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? null : Colors.white,
|
||||
gradient: headGradientOf(state),
|
||||
border: active
|
||||
? null
|
||||
: Border.all(color: ExerciseColors.border, width: 1.w),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isPanDuan
|
||||
? Image.asset(
|
||||
_panDuanIcon(item, state),
|
||||
width: 20.w, // 40rpx
|
||||
height: 20.w,
|
||||
fit: BoxFit.contain,
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp, // 36rpx
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? Colors.white : ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/// ============================================================
|
||||
/// 【PausePopup】暂停答题弹窗
|
||||
/// (1:1 迁移 Vue components/PausePopup/PausePopup.vue)
|
||||
///
|
||||
/// - 遮罩 rgba(0,0,0,.7),点击任意位置关闭(onClose)
|
||||
/// - 组件独立秒表:每次 show=true 从 0 开始计时,HH:MM:SS(不足 1 小时隐去小时)
|
||||
/// - 文案:共 {totalCount} 道题,还剩 {remainingCount} 道题待完成
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
class PausePopup extends StatefulWidget {
|
||||
const PausePopup({
|
||||
super.key,
|
||||
required this.show,
|
||||
this.totalCount = 20,
|
||||
this.remainingCount = 0,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final bool show;
|
||||
final int totalCount;
|
||||
final int remainingCount;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
State<PausePopup> createState() => _PausePopupState();
|
||||
}
|
||||
|
||||
class _PausePopupState extends State<PausePopup> {
|
||||
int _seconds = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncTimer(widget.show);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PausePopup oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.show != widget.show) {
|
||||
_syncTimer(widget.show);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopTimer();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTimer(bool show) {
|
||||
if (show) {
|
||||
_startTimer();
|
||||
} else {
|
||||
_stopTimer();
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_seconds = 0;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _seconds++);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
String _two(int v) => v < 10 ? '0$v' : '$v';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.show) return const SizedBox.shrink();
|
||||
final isPad = isPadDevice(context);
|
||||
|
||||
final hours = _seconds ~/ 3600;
|
||||
final showHours = hours > 0;
|
||||
final minutes = (_seconds % 3600) ~/ 60;
|
||||
final secs = _seconds % 60;
|
||||
|
||||
final unitW = isPad ? 49.w : 44.w; // 88/98rpx
|
||||
final unitH = isPad ? 45.h : 40.h; // 80/90rpx
|
||||
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onClose,
|
||||
child: ColoredBox(
|
||||
color: const Color(0xB3000000), // rgba(0,0,0,.7)
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Transform.translate(
|
||||
offset: Offset(isPad ? -35.w : 0, 0), // margin-left: -70rpx
|
||||
child: Container(
|
||||
width: isPad ? 315.5.w : 250.w, // 500/631rpx
|
||||
height: isPad ? 299.5.h : 249.h, // 498/599rpx
|
||||
padding: EdgeInsets.only(top: isPad ? 175.h : 137.5.h),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
isPad
|
||||
? '/StaticResource/2026-06-03/conquer-pause-bg-Pad1780466965818.png'
|
||||
: '/StaticResource/2026-04-08/conquer-pause-bg.png',
|
||||
),
|
||||
),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (showHours) ...[
|
||||
_timeUnit(unitW, unitH, _two(hours)),
|
||||
_timeSep(),
|
||||
],
|
||||
_timeUnit(unitW, unitH, _two(minutes)),
|
||||
_timeSep(),
|
||||
_timeUnit(unitW, unitH, _two(secs)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 15.h), // margin-t-30
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp, // 26rpx
|
||||
color: ExerciseColors.subtitle,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '共'),
|
||||
TextSpan(
|
||||
text: '${widget.totalCount}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '道题,还剩'),
|
||||
TextSpan(
|
||||
text: '${widget.remainingCount}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '道题待完成'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 25.h), // margin-t-50
|
||||
Text(
|
||||
'点击任意位置继续',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp, // 28rpx
|
||||
color: const Color(0xFFB4C2E0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeUnit(double w, double h, String text) {
|
||||
return Container(
|
||||
width: w,
|
||||
height: h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F8FF),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x1A1578FF),
|
||||
offset: Offset(0, 2.h),
|
||||
blurRadius: 6.h,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp, // 40rpx
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _timeSep() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10.w), // margin-rl-10 + 视觉间距
|
||||
child: Text(
|
||||
':',
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1578FF),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/// ============================================================
|
||||
/// 【QuestionPlanCountDown】单题建议作答时长倒计时
|
||||
/// (1:1 迁移 Vue classExercises/QuestionPlanCountDown.vue,
|
||||
/// 底层由 uview up-count-down 换为 Flutter Timer.periodic)
|
||||
///
|
||||
/// 对外方法(`GlobalKey<QuestionPlanCountDownState>` 调用):
|
||||
/// start / pause / reset / getRemainTime(毫秒)
|
||||
/// autoStart:initState 后自动开始;到点只触发一次 onFinish(key)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class QuestionPlanCountDown extends StatefulWidget {
|
||||
const QuestionPlanCountDown({
|
||||
super.key,
|
||||
this.questionKey,
|
||||
required this.timeMs,
|
||||
this.format = 'mm:ss',
|
||||
this.onFinish,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final Object? questionKey;
|
||||
|
||||
/// 剩余总时长(毫秒)
|
||||
final int timeMs;
|
||||
|
||||
/// 'mm:ss'(本页另有 HH:mm:ss 的动态格式,按是否含 HH 切换)
|
||||
final String format;
|
||||
final ValueChanged<Object?>? onFinish;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
State<QuestionPlanCountDown> createState() => QuestionPlanCountDownState();
|
||||
}
|
||||
|
||||
class QuestionPlanCountDownState extends State<QuestionPlanCountDown> {
|
||||
Timer? _timer;
|
||||
late int _remainMs;
|
||||
bool _finished = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_remainMs = widget.timeMs;
|
||||
// autoStart
|
||||
start();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant QuestionPlanCountDown oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.timeMs != widget.timeMs) {
|
||||
reset();
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (_timer != null) return;
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||
if (!mounted) return;
|
||||
_remainMs -= 200;
|
||||
if (_remainMs <= 0) {
|
||||
_remainMs = 0;
|
||||
pause();
|
||||
if (!_finished) {
|
||||
_finished = true;
|
||||
widget.onFinish?.call(widget.questionKey);
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
/// 重置回初始时长并暂停(与 uview reset 行为一致,调用方需再 start)
|
||||
void reset() {
|
||||
pause();
|
||||
_remainMs = widget.timeMs;
|
||||
_finished = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// 剩余毫秒
|
||||
int getRemainTime() => _remainMs < 0 ? 0 : _remainMs;
|
||||
|
||||
String _two(int v) => v < 10 ? '0$v' : '$v';
|
||||
|
||||
String get _display {
|
||||
// 展示向上取整:刚开始显示完整秒
|
||||
final seconds = (_remainMs / 1000).ceil().clamp(0, 1 << 31);
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = seconds % 60;
|
||||
if (widget.format.contains('HH')) {
|
||||
return '${_two(h)}:${_two(m)}:${_two(s)}';
|
||||
}
|
||||
return '${_two(m)}:${_two(s)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 字体的 ascent/descent 度量让文字视觉偏下,整体上移 2px 校正
|
||||
return Transform.translate(
|
||||
offset: const Offset(0, -2),
|
||||
child: Text(
|
||||
_display,
|
||||
style: widget.textStyle ??
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user