更新
This commit is contained in:
@@ -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'] ?? '';
|
||||
Reference in New Issue
Block a user