This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
@@ -0,0 +1,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'] ?? '';