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,303 @@
/// ============================================================
/// 答题卡构建(1:1 移植 Vue utils/examAnswerCard.js)
/// ============================================================
library;
/// 子题带选项、需按“小题答题卡”处理的复合题型
const Set<String> kQuestionTypesWithOptionChildren = {
'yuedulijie',
'yueduxuanzeti',
'qixuanwu',
'wanxingtiankongxuanzeti',
'tingli',
};
/// 仅这些学科按正常逻辑判断 hasOptionChildrenQuestion,其余学科直接 true
const List<int> kSubjectIdsForNormalCheck = [2, 4, 5, 8, 9];
bool _truthy(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v.isNotEmpty && v != '0';
return true;
}
dynamic _or(dynamic a, dynamic b) => _truthy(a) ? a : b;
int _num(dynamic v, [int def = 0]) {
if (v is num) return v.toInt();
final n = int.tryParse('${v ?? ''}');
return n ?? def;
}
/// 答案是否有值('' / null / '[]' 视为未答)
bool hasValue(dynamic value) {
if (value == null) return false;
final str = '$value'.trim();
if (str.isEmpty || str == '[]') return false;
return true;
}
List<dynamic> _metaList(Map<String, dynamic>? question) {
if (question == null) return const [];
final v = question['bkTagMetaList'];
return v is List ? v : const [];
}
/// 填空题:每个空都作答才算已答;非填空题返回 null
bool? isFillBlankFullyAnswered(Map<String, dynamic>? question) {
final metas = _metaList(question);
if (metas.isEmpty) return null;
bool metaHasAnswer(dynamic rawMeta) {
final meta = rawMeta is Map ? rawMeta : const {};
final answer = meta['myAnswer'] ?? meta['userAnswer'];
if (answer is List) return answer.isNotEmpty;
return hasValue(answer);
}
return metas.every(metaHasAnswer);
}
/// 题目是否已答
bool isQuestionAnswered(Map<String, dynamic> question) {
final fillBlankAnswered = isFillBlankFullyAnswered(question);
final answered = (fillBlankAnswered == null || !fillBlankAnswered)
? hasValue(question['userAnswer'])
: fillBlankAnswered;
return answered;
}
/// 读取题目子项(subQuestions 优先,其次 childs)
List<dynamic> getQuestionChildren(Map<String, dynamic>? item) {
if (item == null) return const [];
if (item['subQuestions'] is List && (item['subQuestions'] as List).isNotEmpty) {
return item['subQuestions'] as List;
}
if (item['childs'] is List && (item['childs'] as List).isNotEmpty) {
return item['childs'] as List;
}
return const [];
}
/// 给题目列表补充 hasOptionChildrenQuestion(会就地改写 item 个别字段)
List<Map<String, dynamic>> appendExamQuestionChildOptionStatus(
List<dynamic>? list,
dynamic subjectId,
) {
return (list ?? const []).map((rawItem) {
final item = Map<String, dynamic>.from(rawItem as Map);
final childs = item['childs'] is List ? item['childs'] as List : const [];
// 学科不在白名单:默认按小题答题卡处理
if (!kSubjectIdsForNormalCheck.contains(_num(subjectId, -1))) {
var hasOptionFlag = childs.isNotEmpty;
// 输入型填空题(仅一空)合并到大题
if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
final firstChild = childs.first as Map;
item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
hasOptionFlag = false;
}
// 解答题/问答题有子题:整题按一道题提交,图片作答
if ((item['questionTypes'] == 'jiedati' || item['questionTypes'] == 'wendati') &&
childs.isNotEmpty) {
hasOptionFlag = false;
item['iSFilllankItemUpload'] = 1;
}
return {
...item,
'hasOptionChildrenQuestion': hasOptionFlag,
};
}
final childList = getQuestionChildren(item);
final hasOptionChildren =
kQuestionTypesWithOptionChildren.contains(item['questionTypes']) &&
childList.isNotEmpty &&
childList.any((c) =>
c is Map && c['options'] is List && (c['options'] as List).isNotEmpty);
final isInputFillBlankQuestion = item['questionTypes'] == 'tiankongti' &&
_num(item['iSFilllankItemUpload']) == 0 &&
(item['childs'] is List && (item['childs'] as List).length > 1);
return {
...item,
'hasOptionChildrenQuestion': hasOptionChildren || isInputFillBlankQuestion,
};
}).toList();
}
Map<String, dynamic> _rawId(dynamic sub) => sub is Map && sub['raw'] is Map
? Map<String, dynamic>.from(sub['raw'] as Map)
: const {};
/// 将题目拍平成答题卡题号列表
List<Map<String, dynamic>> buildExamAnswerCardQuestions(
List<dynamic>? questionsList, {
bool isExamAnswerCard = true,
}) {
final list = <Map<String, dynamic>>[];
var displaySort = 1;
(questionsList ?? const []).asMap().forEach((index, rawItem) {
final item = Map<String, dynamic>.from(rawItem as Map);
final subQuestions =
item['subQuestions'] is List ? item['subQuestions'] as List : const [];
if (isExamAnswerCard != true) {
if (item['hasChild'] == true && subQuestions.isNotEmpty) {
subQuestions.asMap().forEach((subIndex, rawSub) {
final sub = Map<String, dynamic>.from(rawSub as Map);
final subRaw = _rawId(sub);
final question = {
...sub,
'questionId': subRaw['id'] ?? '${item['id'] ?? index}-$subIndex',
'parentIndex': index,
'subIndex': subIndex,
'sort': _num(sub['sort']),
'targetSort': _num(sub['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
'correctAnswer': _or(sub['answer'], subRaw['answer']) ?? '',
'answerStatus': _or(sub['answerStatus'], 0),
'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
});
return;
}
final question = {
...item,
'questionId': item['id'] ?? index,
'parentIndex': index,
'subIndex': -1,
'sort': _num(item['sort']),
'targetSort': _num(item['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': item['userAnswer'] ?? '',
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
'answerStatus': item['answerStatus'] ?? 0,
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
return;
}
final children = getQuestionChildren(item);
final collapseAsSingleQuestion = item['hasOptionChildrenQuestion'] == false;
if (collapseAsSingleQuestion) {
final childs = item['childs'] is List ? item['childs'] as List : const [];
final firstChildSort = children.isNotEmpty
? _num((children.first as Map)['sort'])
: 0;
var targetSort = _num(item['sort']);
if (targetSort == 0) targetSort = firstChildSort;
if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
final firstChild = childs.first as Map;
firstChild['questionTypeName'] = item['questionTypeName'] ?? '题目';
item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
}
final question = {
...item,
'questionId': item['id'] ?? index,
'parentIndex': index,
'subIndex': -1,
'sort': displaySort++,
'targetSort': targetSort,
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': item['userAnswer'] ?? '',
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
'answerStatus': item['answerStatus'] ?? 0,
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
return;
}
if (children.isNotEmpty) {
children.asMap().forEach((subIndex, rawSub) {
final sub = Map<String, dynamic>.from(rawSub as Map);
final subRaw = _rawId(sub);
final question = {
...sub,
'questionId': subRaw['id'] ?? sub['id'] ?? '${item['id'] ?? index}-$subIndex',
'parentIndex': index,
'subIndex': subIndex,
'sort': displaySort++,
'targetSort': _num(sub['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
'correctAnswer':
sub['correctAnswer'] ?? sub['answer'] ?? subRaw['answer'] ?? '',
'answerStatus': sub['answerStatus'] ?? 0,
'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
});
return;
}
final question = {
...item,
'questionId': item['id'] ?? index,
'parentIndex': index,
'subIndex': -1,
'sort': displaySort++,
'targetSort': _num(item['sort']),
'questionTypeName': item['questionTypeName'] ?? '题目',
'userAnswer': item['userAnswer'] ?? '',
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
'answerStatus': item['answerStatus'] ?? 0,
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
};
list.add({...question, 'answered': isQuestionAnswered(question)});
});
final filtered = list.where((item) {
final sort = _num(item['sort']);
if (sort <= 0) return false;
if (isExamAnswerCard == true) return true;
return _num(item['targetSort']) > 0 || sort > 0;
}).toList();
filtered.sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
return filtered;
}
/// 按相邻 questionTypeName 分组,供答题卡渲染
List<Map<String, dynamic>> buildExamAnswerCard(
List<dynamic>? questionsList, {
bool isExamAnswerCard = true,
}) {
final sortedQuestions = buildExamAnswerCardQuestions(
questionsList,
isExamAnswerCard: isExamAnswerCard,
);
final result = <Map<String, dynamic>>[];
Map<String, dynamic>? currentGroup;
for (final question in sortedQuestions) {
final typeName = '${question['questionTypeName'] ?? '题目'}';
if (currentGroup == null || currentGroup['name'] != typeName) {
currentGroup = {
'name': typeName,
'questions': <Map<String, dynamic>>[],
};
result.add(currentGroup);
}
(currentGroup['questions'] as List).add({
...question,
'status': question['answerStatus'],
});
}
return result;
}
@@ -0,0 +1,150 @@
/// ============================================================
/// 题干媒体拆分(1:1 移植 Vue utils/extractMediaFromStem.js)
///
/// 从题干 HTML 中拆出内嵌 audio/video,避免富文本直接渲染媒体异常。
/// ============================================================
library;
class StemMediaBlock {
const StemMediaBlock({required this.type, this.content, this.audioUrl, this.videoUrl, this.videoCoverUrl});
/// html / audio / video
final String type;
final String? content;
final String? audioUrl;
final String? videoUrl;
final String? videoCoverUrl;
Map<String, dynamic> toJson() => {
'type': type,
if (content != null) 'content': content,
if (audioUrl != null) 'audioUrl': audioUrl,
if (videoUrl != null) 'videoUrl': videoUrl,
if (videoCoverUrl != null) 'videoCoverUrl': videoCoverUrl,
};
}
class StemMedia {
const StemMedia({
this.content = '',
this.audios = const [],
this.videos = const [],
this.blocks = const [],
});
final String content;
final List<Map<String, dynamic>> audios;
final List<Map<String, dynamic>> videos;
final List<StemMediaBlock> blocks;
}
String _normalizeMediaAttr(String? value) =>
(value ?? '').replaceAll(RegExp(r'''[`'"\s]'''), '').trim();
String? _firstNonEmpty(List<String?> groups) {
for (final g in groups) {
if (g != null && g.isNotEmpty) return g;
}
return null;
}
({String src, String poster}) _extractMediaAttrs(String tagHtml) {
final srcMatch = RegExp(
r'''\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
caseSensitive: false,
).firstMatch(tagHtml);
final posterMatch = RegExp(
r'''\bposter\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
caseSensitive: false,
).firstMatch(tagHtml);
final sourceMatch = RegExp(
r'''<source[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
caseSensitive: false,
).firstMatch(tagHtml);
final src = _normalizeMediaAttr(_firstNonEmpty([
sourceMatch?.group(1),
sourceMatch?.group(2),
sourceMatch?.group(3),
srcMatch?.group(1),
srcMatch?.group(2),
srcMatch?.group(3),
]));
final poster = _normalizeMediaAttr(_firstNonEmpty([
posterMatch?.group(1),
posterMatch?.group(2),
posterMatch?.group(3),
]));
return (src: src, poster: poster);
}
StemMedia extractMediaFromStem(String? html) {
if (html == null || html.isEmpty) {
return const StemMedia();
}
final rawHtml = html;
final mediaTagReg = RegExp(
r'<audio\b[\s\S]*?</audio>|<video\b[\s\S]*?</video>',
caseSensitive: false,
);
final audios = <Map<String, dynamic>>[];
final videos = <Map<String, dynamic>>[];
final blocks = <StemMediaBlock>[];
var lastIndex = 0;
void pushHtmlBlock(String content) {
if (content.trim().isEmpty) return;
blocks.add(StemMediaBlock(type: 'html', content: content));
}
for (final m in mediaTagReg.allMatches(rawHtml)) {
final tagHtml = m.group(0)!;
final offset = m.start;
pushHtmlBlock(rawHtml.substring(lastIndex, offset));
if (RegExp(r'^<audio\b', caseSensitive: false).hasMatch(tagHtml)) {
final attrs = _extractMediaAttrs(tagHtml);
if (attrs.src.isNotEmpty) {
final audioItem = {'audioUrl': attrs.src};
audios.add(audioItem);
blocks.add(StemMediaBlock(type: 'audio', audioUrl: attrs.src));
}
} else {
final attrs = _extractMediaAttrs(tagHtml);
if (attrs.src.isNotEmpty) {
final videoItem = {
'videoUrl': attrs.src,
'videoCoverUrl': attrs.poster,
};
videos.add(videoItem);
blocks.add(StemMediaBlock(
type: 'video',
videoUrl: attrs.src,
videoCoverUrl: attrs.poster,
));
}
}
lastIndex = offset + tagHtml.length;
}
pushHtmlBlock(rawHtml.substring(lastIndex));
var content = rawHtml
.replaceAll(RegExp(r'<audio\b[\s\S]*?</audio>', caseSensitive: false), '')
.replaceAll(RegExp(r'<video\b[\s\S]*?</video>', caseSensitive: false), '');
content = content
.replaceAll(
RegExp(
r'<(div|p)([^>]*)>\s*(?:<br\s*/?>|&nbsp;|\s)*</\1>',
caseSensitive: false,
),
'',
)
.trim();
return StemMedia(content: content, audios: audios, videos: videos, blocks: blocks);
}
+10
View File
@@ -0,0 +1,10 @@
/// ============================================================
/// 公式渲染(对标 Vue utils/formula.js 的 renderLatexInHtml)
///
/// 注意:Vue 端该函数第 960 行 `return content` 是空操作(公式已由
/// 后端转成 SVG,见 RichTextContent 内置 flutter_svg 渲染)。
/// Flutter 侧保持 identity,不引入任何 LaTeX 依赖。
/// ============================================================
library;
String renderLatexInHtml(String? content) => content ?? '';
+139
View File
@@ -0,0 +1,139 @@
/// ============================================================
/// JS 正则兼容层(JRegExp / JMatch)
///
/// Vue 端大量解析脚本依赖 JavaScript 正则语义:
/// - 带 g 标志时 exec() 从 lastIndex 开始,命中后把 lastIndex
/// 推进到本次匹配末尾;失配后 lastIndex 归 0;
/// - replace(regex, fn) 回调参数为 (match, p1..pn, offset, src);
/// - 默认 i(忽略大小写)+ s(dotAll,. 匹配换行)。
///
/// Dart 的 RegExp 语法/语义与 JS 基本一致(支持反向引用 \1、
/// 先行断言 (?=...),不支持后行断言),但没有 lastIndex 游标,
/// 这里做一层薄封装,供移植代码保持原有 while(exec) 写法。
/// ============================================================
library;
/// JS 风格单次匹配结果
class JMatch {
JMatch(this._m, this.source);
final RegExpMatch _m;
/// 原始字符串
final String source;
/// 匹配起始下标(JS match.index)
int get index => _m.start;
/// 匹配结束下标
int get end => _m.end;
/// 整个匹配文本(JS match[0])
String get group0 => _m.group(0)!;
/// 捕获组总数
int get groupCount => _m.groupCount;
/// 取捕获组(JS match[n]),无匹配返回 null
String? call(int n) => _m.group(n);
/// 取捕获组,空串也按空串返回;不存在才返回 null
String? groupOrNull(int n) =>
n <= _m.groupCount ? _m.group(n) : null;
RegExpMatch get raw => _m;
}
/// JS 风格正则(默认 global + ignoreCase + dotAll)
class JRegExp {
JRegExp(
String pattern, {
this.global = true,
bool caseInsensitive = true,
bool dotAll = true,
bool multiLine = false,
}) : _re = RegExp(
pattern,
caseSensitive: !caseInsensitive,
dotAll: dotAll,
multiLine: multiLine,
);
final RegExp _re;
final bool global;
/// JS regex.lastIndex
int lastIndex = 0;
/// JS regex.exec(string)
JMatch? exec(String input) {
if (lastIndex < 0) lastIndex = 0;
if (lastIndex > input.length) {
lastIndex = 0;
return null;
}
RegExpMatch? found;
for (final m in _re.allMatches(input, lastIndex)) {
found = m;
break;
}
if (found == null) {
lastIndex = 0;
return null;
}
final match = JMatch(found, input);
if (global) {
// 零宽匹配时 JS 会让 lastIndex 前进 1 位,避免死循环
lastIndex = found.end == found.start ? found.end + 1 : found.end;
}
return match;
}
/// 重置游标
void reset() => lastIndex = 0;
/// JS String.match(regex):非 global 返回首个匹配,global 返回全量
List<JMatch>? all(String input) {
if (!global) {
final m = _re.firstMatch(input);
return m == null ? null : [JMatch(m, input)];
}
return _re.allMatches(input).map((m) => JMatch(m, input)).toList();
}
}
/// 非全局取首个匹配(对标 string.match(/.../i))
JMatch? jsFirstMatch(String input, String pattern, {bool dotAll = true}) {
final m = RegExp(pattern, dotAll: dotAll).firstMatch(input);
return m == null ? null : JMatch(m, input);
}
/// 对标 JS parseInt(value, radix):允许前导空白、前缀数字,尾部垃圾忽略
int? jsParseInt(dynamic value, [int radix = 10]) {
if (value == null) return null;
var s = '$value'.trim();
if (s.isEmpty) return null;
if (radix == 10) {
final m = RegExp(r'^[+-]?\d+').firstMatch(s);
if (m == null) return null;
return int.tryParse(m.group(0)!);
}
return int.tryParse(s, radix: radix);
}
/// 对标 JS Number(value)(仅在需要时使用)
num? jsNumber(dynamic value) {
if (value == null) return 0;
if (value is num) return value;
final s = '$value'.trim();
if (s.isEmpty) return 0;
return num.tryParse(s);
}
/// 对标 JS String.prototype.replace(regex, '$1') 中最常用的形态:
/// 用捕获组 1 替换每处匹配
String replaceWithGroup1(String input, String pattern) {
return input.replaceAllMapped(RegExp(pattern, dotAll: true), (m) {
return m.group(1) ?? '';
});
}
@@ -0,0 +1,99 @@
/// ============================================================
/// 题型 / 学科常量(对标 Vue utils/constant.js)
/// ============================================================
library;
/// 根据 questionTypeId 和 isObjective 获取对应的题型代码
class QuestionTypeCodeMap {
QuestionTypeCodeMap._();
/// 客观题(isObjective=true 时查这张表)
static const Map<String, String> objective = {
'12352055339057157': 'danxuanti', // 单选题
'12352065806598149': 'duoxuanti', // 多选题
'12352066848751621': 'panduanti', // 判断题
'792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
'805449231827078': 'qixuanwu', // 七选五
'807952051372102': 'qixuanwu', // 七选五
'12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
'12357391078981637': 'yueduxuanzeti', // 阅读选择题
'806897094647878': 'wendati', // 问答题
'12357387196366853': 'tingli', // 听力
'792171174588486': 'yuedulijie', // 阅读理解
'12352067804725253': 'tiankongti', // 填空题
'12352069399805957': 'jiedati', // 解答题
'12352070327861253': 'jisuanti', // 计算题
'12352071266336773': 'yingyongti', // 应用题
'12352072842215429': 'shiyanti', // 实验题
'12357399440457733': 'xiezuo', // 写作
'12361758319509574': 'zhengmingti', // 证明题
};
/// 主观题(isObjective=false 时查这张表)
static const Map<String, String> subjective = {
'12352055339057157': 'danxuanti', // 单选题
'12352065806598149': 'duoxuanti', // 多选题
'12352066848751621': 'panduanti', // 判断题
'792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
'792171174588486': 'yuedulijie', // 阅读理解
'12352067804725253': 'tiankongti', // 填空题
'12352069399805957': 'jiedati', // 解答题
'12352070327861253': 'jisuanti', // 计算题
'12352071266336773': 'yingyongti', // 应用题
'12352072842215429': 'shiyanti', // 实验题
'12357387196366853': 'tingli', // 听力
'12357391078981637': 'yueduxuanzeti', // 阅读选择题
'12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
'12357399440457733': 'xiezuo', // 写作
'12361758319509574': 'zhengmingti', // 证明题
'805449231827078': 'qixuanwu', // 七选五
'807952051372102': 'qixuanwu', // 七选五
'806897094647878': 'wendati', // 问答题
};
/// 通过 Code 获取题型
static const Map<String, String> typeCode = {
'1': 'danxuanti', // 单选题
'2': 'duoxuanti', // 多选题
'3': 'panduanti', // 判断题
'4': 'tiankongti', // 填空题
'5': 'jiedati', // 解答题
'9': 'tingli', // 听力
'qixuanwu': 'qixuanwu', // 七选五
'wangxingtiankong': 'wanxingtiankongxuanzeti', // 完形填空选择题
'wendati': 'wendati', // 问答题
'yueduljie': 'yuedulijie', // 阅读理解
};
}
/// 学科作答方式(type:0=输入框,1=图片作答/手写作答)
class SubjectInfo {
const SubjectInfo(this.id, this.name, this.type);
final int id;
final String name;
final int type;
}
const List<SubjectInfo> kSubjectList = [
SubjectInfo(1, '语文', 0),
SubjectInfo(2, '数学', 1),
SubjectInfo(3, '英语', 0),
SubjectInfo(4, '物理', 1),
SubjectInfo(5, '化学', 1),
SubjectInfo(6, '生物学', 0),
SubjectInfo(7, '政治(道法)', 0),
SubjectInfo(8, '历史', 1),
SubjectInfo(9, '地理', 1),
SubjectInfo(100013, '艺起看世界 ', 0), // 只有客观题,没有主观题
];
/// Vue: getSubjectTypeById —— 找不到返回 ''(load 处 `|| 0` 兜底)
int? getSubjectTypeById(dynamic subjectId) {
final id = int.tryParse('$subjectId');
if (id == null || id == 0) return null;
for (final item in kSubjectList) {
if (item.id == id) return item.type;
}
return null;
}
@@ -0,0 +1,150 @@
/// ============================================================
/// 题目解析总入口(1:1 移植 Vue utils/questionParser.ts)
///
/// parseQuestionData:根据 hasChild/childs 分发给 Standard /
/// NonStandard 解析器,并支持把后端原始 childs 按索引合并回来。
/// ============================================================
library;
import 'js_regex.dart';
import 'question_parser_base.dart';
import 'question_parser_standard.dart';
import 'question_parser_non_standard.dart';
export 'question_parser_base.dart';
export 'question_parser_standard.dart' show parseStandardQuestion, analysisTextOf;
export 'question_parser_non_standard.dart';
/// 对标 Vue formatQuestionText:去掉空格与换行
String formatQuestionText(String? s) {
if (s == null) return '';
return s.replaceAll(RegExp(r'[ \r\n]'), '');
}
/// 去掉 <strong class="hxdb-placeholder">...</strong> 占位盒
String _stripHxdbPlaceholder(String? html) {
if (html == null || html.isEmpty) return '';
var result = html;
result = result.replaceAll(
RegExp(
r"""<([a-z0-9]+)\b[^>]*\bclass\s*=\s*(["'])[^"']*\bhxdb-placeholder\b[^"']*\2[^>]*/>""",
caseSensitive: false,
),
'',
);
final startTagRegex = JRegExp(
r"""<([a-z0-9]+)\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-placeholder\b[^"']*)\3([^>]*)>""",
);
while (true) {
final match = startTagRegex.exec(result);
if (match == null) break;
final fullBlock = findFullElement(result, match);
result = result.replaceFirst(fullBlock, '');
startTagRegex.reset();
}
return result;
}
/// 按索引把原始 childs 合并回解析后的列表({...parsed, ...original})
List<Map<String, dynamic>> _mergeItemListWithOriginalChilds(
List<dynamic>? parsedList,
List<dynamic> originalChilds,
) {
final list = parsedList ?? const [];
final maxLen = list.length > originalChilds.length
? list.length
: originalChilds.length;
if (maxLen == 0) return const [];
return List.generate(maxLen, (index) {
final parsedItem = index < list.length && list[index] is Map
? Map<String, dynamic>.from(list[index] as Map)
: null;
final original = index < originalChilds.length && originalChilds[index] is Map
? Map<String, dynamic>.from(originalChilds[index] as Map)
: null;
if (parsedItem != null && original != null) {
return {...parsedItem, ...original};
}
if (parsedItem != null) return parsedItem;
return {...?original};
});
}
Map<String, dynamic> _mergeParsedWithOriginalChilds(
Map<String, dynamic> parsed,
List<dynamic> originalChilds,
) {
final mergedSubQuestions =
_mergeItemListWithOriginalChilds(parsed['subQuestions'] as List?, originalChilds);
// JS: parsed.childs 非空时用 childs,否则回退 subQuestions
final parsedChildsSource = (parsed['childs'] is List &&
(parsed['childs'] as List).isNotEmpty)
? parsed['childs'] as List
: (parsed['subQuestions'] as List? ?? const []);
final mergedChilds =
_mergeItemListWithOriginalChilds(parsedChildsSource, originalChilds);
final mergedBkTagMetaList =
_mergeItemListWithOriginalChilds(parsed['bkTagMetaList'] as List?, originalChilds);
return {
...parsed,
'subQuestions': mergedSubQuestions,
'childs': mergedChilds,
'bkTagMetaList':
mergedBkTagMetaList.isNotEmpty ? mergedBkTagMetaList : parsed['bkTagMetaList'],
};
}
Map<String, dynamic> _emptyParsed() => {
'stem': '',
'options': const <Map<String, String>>[],
'isSelect': false,
'answer': '',
'analysis': '',
'questionTypes': '',
};
/// 解析单道后端题目
Map<String, dynamic> parseQuestionData(
Map<String, dynamic>? detail, {
bool mergeOriginalChilds = false,
}) {
// 兼容外层包了一层 questions
final questionData = (detail?['questions'] is Map
? detail!['questions'] as Map
: detail)
as Map<String, dynamic>?;
if (questionData == null) return _emptyParsed();
final rawStem = _stripHxdbPlaceholder('${questionData['stem'] ?? ''}');
final rawAnswer = '${questionData['answer'] ?? ''}';
final rawCorrectAnswer = '${questionData['correctAnswer'] ?? ''}';
final rawAnalysisValue = questionData['answerAnalysis'];
final rawAnalysis = rawAnalysisValue is Map
? '${rawAnalysisValue['analysis'] ?? ''}'
: '${rawAnalysisValue ?? ''}';
// 只有空标签的解析置空
final analysis = cleanRichText(rawAnalysis, '').isNotEmpty ? rawAnalysis : '';
final hasChildsArray =
questionData['childs'] is List && (questionData['childs'] as List).isNotEmpty;
final originalChilds =
hasChildsArray ? (questionData['childs'] as List) : const [];
final hasChilds = questionData['hasChild'] == true && hasChildsArray;
final parsed = hasChilds
? parseStandardQuestion(
questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis)
: parseNonStandardQuestion(
questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
if (mergeOriginalChilds && originalChilds.isNotEmpty) {
return _mergeParsedWithOriginalChilds(parsed, originalChilds);
}
return parsed;
}
@@ -0,0 +1,913 @@
/// ============================================================
/// 题目解析基础工具(1:1 移植 Vue utils/questionParserBase.ts)
///
/// 包含:选项提取、平衡闭合标签查找、`<bk>` 标签解析、判断题处理、
/// 题型代码、排序、富文本清洗、数学填空圈号、复合题题干格式化等。
/// ============================================================
library;
import 'dart:convert';
import 'question_constant.dart';
import 'js_regex.dart';
/// 填空占位元数据(对标 Vue BkTagMeta)
typedef BkTagMeta = Map<String, dynamic>;
/// 统一的选项结构
Map<String, String> makeOption(String label, String content) =>
{'optionLabel': label, 'options': content};
// ------------------------------------------------------------
// 选项提取
// ------------------------------------------------------------
/// 从 HTML 字符串中提取选项(hxdb-option-table-td / hxdb-sub-option-table-td)
List<Map<String, String>> extractOptionsFromHtml(String html) {
final options = <Map<String, String>>[];
final tdRegex = JRegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table-td[^"]*"[^>]*>([\s\S]*?)</\1>',
);
const labelSrc = r'^(\s|<[^>]+>)*([A-Z])[\.\、\:\s]\s*(&nbsp;)?';
var index = 0;
JMatch? tdMatch;
while ((tdMatch = tdRegex.exec(html)) != null) {
final tdContent = tdMatch!(2) ?? '';
final labelMatch = RegExp(labelSrc, caseSensitive: false).firstMatch(tdContent);
var optionLabel = labelMatch != null ? labelMatch.group(2)! : '';
if (optionLabel.isEmpty) {
optionLabel = String.fromCharCode(65 + index);
}
// 去掉选项字母与分隔符,保留前面的空白/标签结构
final cleanedContent = tdContent.replaceFirstMapped(
RegExp(labelSrc, caseSensitive: false),
(m) => m.group(1) ?? '',
);
options.add(makeOption(optionLabel, cleanedContent));
index++;
}
return options;
}
/// 寻找平衡的闭合标签(解决嵌套 div/table 匹配问题)
String findFullElement(String html, JMatch startTagMatch) {
final startIdx = startTagMatch.index;
final startTagFull = startTagMatch.group0;
final tagNameMatch = RegExp(r'^<([a-z0-9]+)', caseSensitive: false)
.firstMatch(startTagFull);
if (tagNameMatch == null) return startTagFull;
final tagName = tagNameMatch.group(1)!;
var depth = 1;
var currentPos = startIdx + startTagFull.length;
final tagRegex = JRegExp('<(\\/)?$tagName[^>]*>');
tagRegex.lastIndex = currentPos;
JMatch? tagMatch;
while ((tagMatch = tagRegex.exec(html)) != null) {
if (tagMatch!(1) == '/') {
depth--;
} else {
if (!tagMatch.group0.endsWith('/>')) {
depth++;
}
}
if (depth == 0) {
return html.substring(startIdx, tagMatch.end);
}
}
return startTagFull;
}
/// 剔除 stem 中的 hxdb-sub-option-table 选项表格
String stripOptionTable(String html) {
if (html.isEmpty) return '';
var result = html;
final tableRegex = JRegExp(
r'<table[^>]*class="[^"]*hxdb-sub-option-table[^"]*"[^>]*>',
);
while (true) {
final match = tableRegex.exec(result);
if (match == null) break;
final fullBlock = findFullElement(result, match);
result = result.replaceFirst(fullBlock, '');
tableRegex.reset();
}
return result;
}
/// 纯文本 A. xxx 选项兜底提取
List<Map<String, String>> extractPlainTextOptions(String html) {
final result = <Map<String, String>>[];
final plain = html
.replaceAll(RegExp(r'<[^>]+>'), '\n')
.replaceAll('&nbsp;', ' ')
.replaceAll(RegExp(r'\n{2,}'), '\n')
.trim();
final lines = plain.split('\n').where((l) => l.trim().isNotEmpty);
final optionRegex = RegExp(r'^([A-Z])[\.\、\s]\s*(.+)$');
for (final line in lines) {
final m = optionRegex.firstMatch(line.trim());
if (m != null) {
final label = m.group(1)!;
result.add(makeOption(
label,
'<span class="hxdb-option-label">$label.</span>&nbsp;${m.group(2)!.trim()}',
));
}
}
return result;
}
// ------------------------------------------------------------
// <bk> 填空标签解析
// ------------------------------------------------------------
final RegExp _attrIndexRe = RegExp(
r'''\bindex\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
caseSensitive: false,
);
final RegExp _attrTypeRe = RegExp(
r'''\btype\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
caseSensitive: false,
);
final RegExp _attrDataInputRe = RegExp(
r'''\bdata-input-type\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
caseSensitive: false,
);
String? _attrValue(RegExpMatch? m) =>
m == null ? null : (m.group(2) ?? m.group(3));
/// 解析填空题的 `<bk>` 标签,生成每个空的元数据
List<BkTagMeta> parseBkTagMetaFromStem(
String? stem,
String? correctAnswerHtml,
String? userAnswerJson, [
dynamic iSFilllankItemUpload,
List<Map<String, String>>? options,
]) {
if (stem == null || stem.isEmpty) return const [];
final correctAnswerMap = correctAnswerHtml != null && correctAnswerHtml.isNotEmpty
? parseCorrectAnswerMapFromHtml(correctAnswerHtml)
: <int, String>{};
final userAnswerMap = parseUserAnswerMapFromJson(
userAnswerJson,
jsNumber(iSFilllankItemUpload) == 1,
);
final list = <BkTagMeta>[];
final seenIndexes = <int>{};
final tagRegex = JRegExp(r'<bk\b([^>]*)>', caseInsensitive: true);
JMatch? m;
while ((m = tagRegex.exec(stem)) != null) {
final attrs = m!(1) ?? '';
final indexMatch = _attrIndexRe.firstMatch(attrs);
if (indexMatch == null) continue;
final index = jsNumber(_attrValue(indexMatch))?.toInt() ?? 0;
if (index <= 0) continue;
if (seenIndexes.contains(index)) continue;
seenIndexes.add(index);
final typeMatch = _attrTypeRe.firstMatch(attrs);
final dataInputTypeMatch = _attrDataInputRe.firstMatch(attrs);
final item = <String, dynamic>{
'index': index,
'type': _attrValue(typeMatch) ?? '',
'dataInputType': _attrValue(dataInputTypeMatch) ?? '',
'correctAnswer': correctAnswerMap[index] ?? '',
'options': options ?? const [],
};
final fromUser = userAnswerMap[index];
final isUpload = jsNumber(iSFilllankItemUpload) == 1;
final myAnswer = fromUser?['myAnswer'];
final hasImgArray = myAnswer is List &&
myAnswer.isNotEmpty &&
(myAnswer.first is Map) &&
(myAnswer.first as Map)['url'] != null;
if (isUpload || hasImgArray) {
final imgs = myAnswer is List ? myAnswer : const [];
if (imgs.isNotEmpty) item['myAnswer'] = imgs;
} else {
final my = myAnswer is String ? myAnswer.trim() : '';
if (my.isNotEmpty) item['myAnswer'] = my;
}
if (fromUser?['answerStatus'] is int) {
item['answerStatus'] = fromUser!['answerStatus'];
}
list.add(item);
}
return list;
}
String _normalizeAnswerUrl(dynamic u) =>
'$u'.replaceAll(RegExp(r'[`\s]'), '').trim();
/// 将用户作答 JSON 字符串解析为 {空索引: {myAnswer, answerStatus}}
Map<int, Map<String, dynamic>> parseUserAnswerMapFromJson([
String? userAnswerJson,
bool isUpload = false,
]) {
final map = <int, Map<String, dynamic>>{};
if (userAnswerJson == null || userAnswerJson.isEmpty) return map;
dynamic parsed;
try {
parsed = _jsonDecode(userAnswerJson);
} catch (_) {
return map;
}
if (parsed is! List) return map;
for (final rawItem in parsed) {
if (rawItem is! Map) continue;
final item = Map<String, dynamic>.from(rawItem);
final idx = jsNumber(item['index'] ?? item['Index'])?.toInt() ?? 0;
if (idx <= 0) continue;
final hasImg = item['Url'] != null ||
item['url'] != null ||
item['ThumbnailUrl'] != null ||
item['thumbnailUrl'] != null;
if (isUpload || hasImg) {
if (item.containsKey('myAnswer') || item.containsKey('answerStatus')) {
dynamic myAnswer = item['myAnswer'];
if (myAnswer is String) {
try {
final arr = _jsonDecode(myAnswer);
myAnswer = arr is List ? arr : <dynamic>[];
} catch (_) {
myAnswer = <dynamic>[];
}
}
if (myAnswer is! List) myAnswer = <dynamic>[];
final answerStatus = item['answerStatus'];
final payload = <String, dynamic>{};
if (myAnswer.isNotEmpty) payload['myAnswer'] = myAnswer;
if (answerStatus is int) payload['answerStatus'] = answerStatus;
final merged = Map<String, dynamic>.from(map[idx] ?? const {});
if (payload.containsKey('myAnswer')) merged['myAnswer'] = payload['myAnswer'];
if (payload.containsKey('answerStatus')) {
merged['answerStatus'] = payload['answerStatus'];
}
if (merged.containsKey('myAnswer') || merged.containsKey('answerStatus')) {
map[idx] = merged;
}
continue;
}
final url = _normalizeAnswerUrl(item['Url'] ?? item['url']);
if (url.isEmpty) continue;
final thumbnailUrl = _normalizeAnswerUrl(
item['ThumbnailUrl'] ?? item['thumbnailUrl'] ?? url,
);
final type = item['Type'] ?? item['type'] ?? 0;
final imgItem = <String, dynamic>{
...item,
'index': idx,
'type': type,
'url': url,
'thumbnailUrl': thumbnailUrl,
};
final existing = map[idx];
final list = existing?['myAnswer'] is List
? List<dynamic>.from(existing!['myAnswer'] as List)
: <dynamic>[];
list.add(imgItem);
map[idx] = {
...?existing,
'myAnswer': list,
};
} else {
final myAnswer = item['myAnswer'] is String ? item['myAnswer'] as String : '';
final answerStatus = item['answerStatus'];
final payload = <String, dynamic>{};
if (myAnswer.trim().isNotEmpty) payload['myAnswer'] = myAnswer;
if (answerStatus is int) payload['answerStatus'] = answerStatus;
if (payload.containsKey('myAnswer') || payload.containsKey('answerStatus')) {
map[idx] = payload;
}
}
}
return map;
}
/// 从标准答案 HTML 中按 data-index 切分 hxdb-subquestion 块
Map<int, String> parseCorrectAnswerMapFromHtml(String html) {
final result = <int, String>{};
if (html.isEmpty) return result;
final startTagRegex = JRegExp(
r'''<div\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-subquestion\b[^"']*)\2([^>]*)>''',
);
JMatch? match;
while ((match = startTagRegex.exec(html)) != null) {
final attrs = '${match!(1) ?? ''} ${match(3) ?? ''} ${match(4) ?? ''}';
final indexMatch = RegExp(
r'''\bdata-index\s*=\s*(["'])(\d+)\1''',
caseSensitive: false,
).firstMatch(attrs);
if (indexMatch == null) continue;
final idx = int.tryParse(indexMatch.group(2)!) ?? 0;
if (idx <= 0) continue;
final fullBlock = findFullElement(html, match);
result[idx] = fullBlock;
}
return result;
}
// ------------------------------------------------------------
// 题型 / 排序 / 答案清洗
// ------------------------------------------------------------
/// 判断题识别:命中则向 options 补“正确/错误”两项
bool processPanDuan(Map<String, dynamic>? detail, List<Map<String, String>> options) {
final answer = '${detail?['answer'] ?? ''}';
final typeId = '${detail?['questionTypeId'] ?? ''}';
final isPanduan = answer.contains('data-hxdb-type="panduanti"') ||
typeId == '12352066848751621' ||
typeId == '792156155195462';
if (isPanduan) {
if (options.isEmpty) {
options
..add(makeOption('正确', '正确'))
..add(makeOption('错误', '错误'));
}
return true;
}
return false;
}
/// 题型代码(对标 Vue getQuestionTypeCode)
String getQuestionTypeCode(dynamic typeId, dynamic isObjective, [dynamic typecodeKey]) {
final id = '$typeId';
if (typecodeKey != null &&
'$typecodeKey'.isNotEmpty &&
QuestionTypeCodeMap.typeCode.containsKey('$typecodeKey')) {
return QuestionTypeCodeMap.typeCode['$typecodeKey']!;
}
if (isObjective == true) {
return QuestionTypeCodeMap.objective[id] ?? '';
}
return QuestionTypeCodeMap.subjective[id] ?? '';
}
/// 给题目(含子题)追加从 1 开始的连续 sort
List<Map<String, dynamic>> appendQuestionSort(List<dynamic>? questions) {
if (questions == null) return const [];
var sort = 1;
return questions.map((rawItem) {
final item = rawItem is Map
? Map<String, dynamic>.from(rawItem)
: <String, dynamic>{};
final hasChild = item['hasChild'] == true;
final hasSubQuestions = item['subQuestions'] is List &&
(item['subQuestions'] as List).isNotEmpty;
if (hasChild && hasSubQuestions) {
final subQuestions = (item['subQuestions'] as List).map((rawSub) {
final sub = Map<String, dynamic>.from(rawSub as Map);
sub['sort'] = sort++;
return sub;
}).toList();
return {
...item,
'subQuestions': subQuestions,
};
}
return {
...item,
'sort': sort++,
};
}).toList();
}
/// 去 HTML 标签(对标 Vue stripAnswerPlain)
String stripAnswerPlain(dynamic html) {
if (html == null) return '';
return '$html'.replaceAll(RegExp(r'<[^>]+>'), '').trim();
}
// ------------------------------------------------------------
// 富文本清洗
// ------------------------------------------------------------
final RegExp _htmlAttrNameRe = RegExp(r'^[A-Za-z_:][A-Za-z0-9:._-]*$');
String _normalizeRichTextUrl(String value) =>
value.replaceAll(RegExp(r'[`\s]'), '').trim();
/// 统一处理 HTML 中图片尺寸样式(Flutter 为非 MP 端,尺寸输出 px)
String fixImageStyles(String? html) {
if (html == null || html.isEmpty) return '';
return html.replaceAllMapped(RegExp(r'<img\b([^>]*)>', caseSensitive: false),
(match) {
var newAttrs = match.group(1)!;
var extractedWidth = '';
var extractedHeight = '';
// 提取并移除原有 width/height 属性
newAttrs = newAttrs.replaceAllMapped(
RegExp(
r'''\b(width|height)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))''',
caseSensitive: false,
),
(m) {
final attrName = (m.group(1) ?? '').toLowerCase();
final val = m.group(3) ?? m.group(4) ?? '';
if (attrName == 'width') extractedWidth = val;
if (attrName == 'height') extractedHeight = val;
return '';
},
);
final styleObj = <String, String>{};
if (RegExp(r'\bstyle\s*=\s*', caseSensitive: false).hasMatch(newAttrs)) {
newAttrs = newAttrs.replaceAllMapped(
RegExp(r'''\bstyle\s*=\s*(["'])([\s\S]*?)\1''', caseSensitive: false),
(m) {
final styleValue = m.group(2) ?? '';
for (final item in styleValue.split(';')) {
final idx = item.indexOf(':');
if (idx > 0) {
final key = item.substring(0, idx).trim().toLowerCase();
final value = item.substring(idx + 1).trim();
if (key.isNotEmpty && value.isNotEmpty) styleObj[key] = value;
}
}
return '';
},
);
}
if (styleObj['width'] != null && extractedWidth.isEmpty) {
extractedWidth = styleObj['width']!;
}
if (styleObj['height'] != null && extractedHeight.isEmpty) {
extractedHeight = styleObj['height']!;
}
styleObj.remove('width');
styleObj.remove('height');
styleObj.remove('display');
// 非 MP-WEIXIN:纯数字尺寸输出 px
String processSize(String sizeVal) {
if (sizeVal.isEmpty) return '';
sizeVal = sizeVal.trim().toLowerCase();
if (RegExp(r'(ex|em|rem|%|vw|vh|rpx|auto)$').hasMatch(sizeVal)) {
return sizeVal;
}
final numMatch = RegExp(r'^([0-9.]+)').firstMatch(sizeVal);
if (numMatch != null) {
final num = double.tryParse(numMatch.group(1)!);
if (num != null) {
return '${num}px';
}
}
return sizeVal;
}
final finalWidth = processSize(extractedWidth);
final finalHeight = processSize(extractedHeight);
if (finalWidth.isNotEmpty) styleObj['width'] = finalWidth;
if (finalHeight.isNotEmpty) styleObj['height'] = finalHeight;
styleObj['max-width'] = '100% !important';
if (finalWidth.isEmpty && finalHeight.isEmpty) {
styleObj['display'] = 'block';
styleObj['margin'] = '10rpx auto';
} else {
styleObj['display'] = 'inline-block !important';
styleObj['vertical-align'] = 'middle !important';
styleObj['padding'] = '5px 0 !important';
styleObj['margin'] = '0 2px !important';
}
final styleStr = styleObj.entries
.map((e) => '${e.key}: ${e.value}')
.join('; ');
newAttrs += ' style="$styleStr"';
return '<img$newAttrs>';
});
}
String _sanitizeHtmlAttributeValue(String name, String value) {
var normalized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
if (RegExp(r'^(src|href|poster)$', caseSensitive: false).hasMatch(name)) {
normalized = _normalizeRichTextUrl(normalized);
} else if (name == 'style') {
normalized = normalized.replaceAll('"', '').replaceAll(RegExp(r'\s+'), ' ').trim();
}
return normalized.replaceAll('"', '&quot;');
}
String _sanitizeHtmlTag(String tag) {
if (tag.isEmpty || tag.startsWith('</') || tag.startsWith('<!')) {
return tag;
}
final tagMatch = RegExp(r'^<([A-Za-z0-9-]+)([\s\S]*?)(/?)>$').firstMatch(tag);
if (tagMatch == null) return tag;
final tagName = tagMatch.group(1)!;
final rawAttrs = tagMatch.group(2) ?? '';
final selfClosing = tagMatch.group(3) ?? '';
final attrs = <String>[];
final attrRegex = RegExp(
r'''([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?''',
);
for (final attrMatch in attrRegex.allMatches(rawAttrs)) {
final attrName = (attrMatch.group(1) ?? '').trim();
if (!_htmlAttrNameRe.hasMatch(attrName)) continue;
final hasValue = attrMatch.group(2) != null ||
attrMatch.group(3) != null ||
attrMatch.group(4) != null;
if (!hasValue) {
attrs.add(attrName);
continue;
}
final attrValue = attrMatch.group(2) ?? attrMatch.group(3) ?? attrMatch.group(4) ?? '';
attrs.add('$attrName="${_sanitizeHtmlAttributeValue(attrName, attrValue)}"');
}
return '<$tagName${attrs.isNotEmpty ? ' ${attrs.join(' ')}' : ''}${selfClosing.isNotEmpty ? ' /' : ''}>';
}
/// 修复编辑器产生的破碎 style 属性(font-family 被拆碎等)
String _repairMalformedTagAttributes(String html) {
if (html.isEmpty) return '';
return html.replaceAllMapped(RegExp(r'<[^>]+>'), (m) {
var tag = m.group(0)!;
if (tag.startsWith('</') || tag.startsWith('<!') ||
!RegExp(r'\bstyle\s*=').hasMatch(tag)) {
return tag;
}
final brokenFontFamily = RegExp(
r'''\sstyle=(["'])\s*font-family\s*:\s*\1''',
caseSensitive: false,
).hasMatch(tag);
if (!brokenFontFamily) return tag;
tag = tag.replaceAll(
RegExp(r'''\sstyle=(["'])\s*font-family\s*:\s*\1''', caseSensitive: false),
'',
);
tag = tag.replaceAll(
RegExp(r'''\s+[a-zA-Z][a-zA-Z0-9-]*=""(?=[\s/>])'''),
'',
);
tag = tag.replaceAll(RegExp(r'\s{2,}'), ' ');
return tag;
});
}
/// 将 <audio>/<video> 内 <source src> 提升到父标签
String _hoistMediaSourceToParent(String html) {
if (html.isEmpty) return '';
return html.replaceAllMapped(
RegExp(r'<(audio|video)\b([^>]*)>([\s\S]*?)</\1>', caseSensitive: false),
(m) {
final tagName = m.group(1)!;
final rawAttrs = m.group(2) ?? '';
final innerHtml = m.group(3) ?? '';
final directSrcMatch = RegExp(
r'''\bsrc\s*=\s*(["'])(.*?)\1''',
caseSensitive: false,
).firstMatch(rawAttrs);
final sourceSrcMatch = RegExp(
r'''<source\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1''',
caseSensitive: false,
).firstMatch(innerHtml);
final finalSrc = _normalizeRichTextUrl(
(directSrcMatch?.group(2) ?? sourceSrcMatch?.group(2) ?? ''),
);
if (finalSrc.isEmpty) return m.group(0)!;
var nextAttrs = rawAttrs;
if (directSrcMatch != null) {
nextAttrs = nextAttrs.replaceFirst(directSrcMatch.group(0)!, 'src="$finalSrc"');
} else {
nextAttrs = '$nextAttrs src="$finalSrc"';
}
return '<$tagName$nextAttrs></$tagName>';
},
);
}
/// 富文本进入组件前的轻量清洗
String sanitizeRichTextHtml(String? content) {
if (content == null || content.isEmpty) return '';
var result = fixImageStyles(content);
result = _repairMalformedTagAttributes(result);
result = _hoistMediaSourceToParent(result);
result = result.replaceAllMapped(
RegExp(r'''\sstyle=(["'])([\s\S]*?)\1''', caseSensitive: false),
(m) {
final raw = m.group(2) ?? '';
if (RegExp(r'^\s*font-family\s*:\s*;?\s*$', caseSensitive: false).hasMatch(raw)) {
return '';
}
return m.group(0)!;
},
);
// 移除 KaTeX 隐藏 MathML
result = result.replaceAll(
RegExp(
r'''<span\b[^>]*class=(["'])[^"']*\bkatex-mathml\b[^"']*\1[^>]*>[\s\S]*?</span>''',
caseSensitive: false,
),
'',
);
result = result.replaceAllMapped(
RegExp(
r'''<(audio|video|img|source)\b[^>]*?(?:src|poster)\s*=\s*(["'])(.*?)\2''',
caseSensitive: false,
),
(m) {
final cleanedUrl = _normalizeRichTextUrl(m.group(3) ?? '');
return m.group(0)!.replaceFirst(m.group(3)!, cleanedUrl);
},
);
return result.replaceAllMapped(
RegExp(r'</?[A-Za-z][^>]*>'),
(m) => _sanitizeHtmlTag(m.group(0)!),
);
}
/// 富文本清洗 + 空内容兜底
String cleanRichText(String? content, [String defaultText = '']) {
if (content == null || content.isEmpty) return defaultText;
var result = fixImageStyles(content);
result = sanitizeRichTextHtml(result).replaceAll(
RegExp(r'<br>\s*<br>'),
'<br>',
);
result = result.replaceAllMapped(
RegExp(r'''src=(["'])(.*?)\1'''),
(m) => 'src=${m.group(1)}${(m.group(2) ?? '').replaceAll(RegExp(r'[`\s]'), '')}${m.group(1)}',
);
final plainText = result
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', '')
.trim();
final hasImgOrVideo = RegExp(
r'<(img|video|audio|iframe|embed|object|source)[^>]*>',
caseSensitive: false,
).hasMatch(result);
if (plainText.isEmpty && !hasImgOrVideo) return defaultText;
return result.isEmpty ? defaultText : result;
}
// ------------------------------------------------------------
// 数学填空圈号
// ------------------------------------------------------------
const List<String> _circledNumbers = [
'①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩',
'⑪','⑫','⑬','⑭','⑮','⑯','⑰','⑱','⑲','⑳',
'㉑','㉒','㉓','㉔','㉕','㉖','㉗','㉘','㉙','㉚',
'㉛','㉜','㉝','㉞','㉟','㊱','㊲','㊳','㊴','㊵',
'㊶','㊷','㊸','㊹','㊺','㊻','㊼','㊽','㊾','㊿',
];
/// 将题干数字占位符替换成圈号(数学学科)
String convertBlankToCircled(String? stem) {
if (stem == null || stem.isEmpty) return stem ?? '';
// 1. ___数字___ → ___①___(跳过 URL/文件名场景)
var result = stem.replaceAllMapped(
RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
(m) {
final prevChar = m.start > 0 ? stem[m.start - 1] : '';
final nextChar = m.end < stem.length ? stem[m.end] : '';
if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
return m.group(0)!;
}
final num = int.tryParse(m.group(2)!) ?? 0;
if (num >= 1 && num <= 50) {
return '${m.group(1)}${_circledNumbers[num - 1]}${m.group(3)}';
}
return m.group(0)!;
},
);
// 2. text-decoration:underline 标签内纯数字 → 圈号
result = result.replaceAllMapped(
RegExp(
r'''(<[^>]*style=["'][^"']*text-decoration:\s*underline[^"']*["'][^>]*>)([\s\S]*?)(</[a-zA-Z][^>]*>)''',
caseSensitive: false,
),
(m) {
final openTag = m.group(1)!;
final content = m.group(2)!;
final closeTag = m.group(3)!;
final textOnly = content
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', ' ')
.trim();
if (RegExp(r'^\d+$').hasMatch(textOnly)) {
final num = int.tryParse(textOnly) ?? 0;
if (num >= 1 && num <= 50) {
final newOpenTag = openTag.replaceAll(
RegExp(r'text-decoration:\s*underline', caseSensitive: false),
'text-decoration:underline;text-underline-offset:3px',
);
return '$newOpenTag${content.replaceFirst(RegExp(r'\d+'), _circledNumbers[num - 1])}$closeTag';
}
}
return m.group(0)!;
},
);
return result;
}
// ------------------------------------------------------------
// 复合题题干:<bk> → 可点击 <a href="#subquestion-N">
// ------------------------------------------------------------
/// 格式化复合题题干(subQuestions 为解析后的子题 Map 列表)
String formatCompoundStem(
String? stem,
int activeSubIndex, {
bool disableHighlight = false,
List<Map<String, dynamic>> subQuestions = const [],
}) {
if (stem == null || stem.isEmpty) return '暂无题干';
var formattedStem = stem;
// 非自闭合 <bk ...>...</bk>
formattedStem = formattedStem.replaceAllMapped(
RegExp(r'<bk\b([^>]*)>([\s\S]*?)</bk>', caseSensitive: false),
(m) {
final attrs = m.group(1) ?? '';
final inner = m.group(2) ?? '';
final indexMatch = RegExp(
r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
caseSensitive: false,
).firstMatch(attrs);
if (indexMatch == null) return m.group(0)!;
final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
final hasNumber = RegExp(r'\d').hasMatch(inner);
final isOnlyNumber = RegExp(r'^\s*\d+\s*$').hasMatch(inner);
var content = inner;
if (!isOnlyNumber && !hasNumber) {
content = '___${idx + 1}___';
}
return '<a data-idx="$idx">$content</a>';
},
);
// 自闭合 <bk ... />
formattedStem = formattedStem.replaceAllMapped(
RegExp(r'<bk\b([^>]*?)\s*/\s*>', caseSensitive: false),
(m) {
final attrs = m.group(1) ?? '';
final indexMatch = RegExp(
r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
caseSensitive: false,
).firstMatch(attrs);
if (indexMatch == null) return m.group(0)!;
final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
return '<a data-idx="$idx">___${idx + 1}___</a>';
},
);
// 保护所有 HTML 标签,避免属性/标签内部文本被替换
final tagPlaceholders = <String>[];
var tagCounter = 0;
final protectedStem = formattedStem.replaceAllMapped(
RegExp(r'''<(?:[^>"']|"[^"]*"|'[^']*')*>'''),
(m) {
final token = '__TAG_${tagCounter++}__';
tagPlaceholders.add(m.group(0)!);
return token;
},
);
// 纯文本 ___数字___ → <a data-idx>
var processedStem = protectedStem.replaceAllMapped(
RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
(m) {
final src = m.input;
final prevChar = m.start > 0 ? src[m.start - 1] : '';
final nextChar = m.end < src.length ? src[m.end] : '';
if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
return m.group(0)!;
}
final idx = (int.tryParse(m.group(2)!) ?? 1) - 1;
return '<a data-idx="$idx">${m.group(0)}</a>';
},
);
processedStem = processedStem.replaceAllMapped(
RegExp(r'__TAG_(\d+)__'),
(m) => tagPlaceholders[int.parse(m.group(1)!)],
);
formattedStem = processedStem;
// 注入样式/激活态/跳转链接
formattedStem = formattedStem.replaceAllMapped(
RegExp(r'<a data-idx="(\d+)">([\s\S]*?)</a>', caseSensitive: false),
(m) {
final idxStr = m.group(1)!;
final content = m.group(2) ?? '';
final textOnly = content
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&nbsp;', ' ')
.trim();
final hasImg = RegExp(r'<img', caseSensitive: false).hasMatch(content);
if (textOnly.isEmpty && !hasImg) return content;
final dataIdx = int.tryParse(idxStr) ?? -1;
var isActive = false;
var targetSubIndex = dataIdx;
if (subQuestions.isNotEmpty) {
if (dataIdx >= 0 && dataIdx < subQuestions.length) {
targetSubIndex = dataIdx;
isActive = dataIdx == activeSubIndex;
} else {
final numMatch = RegExp(r'\d+').firstMatch(textOnly);
var matchedIndex = -1;
if (numMatch != null) {
final textNum = int.parse(numMatch.group(0)!);
matchedIndex = subQuestions.indexWhere(
(q) => jsNumber(q['sort'])?.toInt() == textNum);
if (matchedIndex == -1) {
matchedIndex = subQuestions.indexWhere((q) {
final stemMatch = RegExp(
r'''<label[^>]*>.*?(\d+)\.\s*(?:&nbsp;)*</label>|<p>.*?(\d+)\.\s*(?:&nbsp;)*</p>''',
caseSensitive: false,
dotAll: true,
).firstMatch('${q['stem'] ?? ''}');
if (stemMatch != null) {
final n = int.tryParse(stemMatch.group(1) ?? stemMatch.group(2) ?? '');
return n == textNum;
}
return false;
});
}
}
if (matchedIndex != -1) {
targetSubIndex = matchedIndex;
isActive = matchedIndex == activeSubIndex;
} else {
targetSubIndex = -1;
isActive = false;
}
}
} else {
isActive = dataIdx == activeSubIndex;
}
const baseStyle =
'text-decoration: none; color: inherit; cursor: pointer; display: inline-block; line-height: 1; margin: 0 4px; white-space: nowrap; word-break: keep-all; vertical-align: middle;';
var activeStyle = 'border: 1px solid transparent; padding: 0 2px;';
if (isActive && !disableHighlight) {
final shouldHighlight = RegExp(r'\d').hasMatch(textOnly) ||
RegExp(r'_+').hasMatch(textOnly) ||
hasImg;
if (shouldHighlight) {
activeStyle =
'line-height:24rpx;height:24px;border: 1px solid rgba(61, 125, 245, 1); border-radius: 8px; background: rgba(41, 121, 255, 0.1); box-sizing: border-box; padding: 0 4px;';
}
}
return '<a href="#subquestion-$targetSubIndex" id="subquestion-$targetSubIndex" style="$baseStyle $activeStyle">$content</a>';
},
);
return formattedStem;
}
// ------------------------------------------------------------
// 内部工具
// ------------------------------------------------------------
dynamic _jsonDecode(String text) => jsonDecode(text);
@@ -0,0 +1,204 @@
/// ============================================================
/// 非标准题 / 单题解析器(1:1 移植 Vue utils/questionParserNonStandard.ts)
///
/// 处理不带 childs 的独立题目:单选 / 多选 / 判断 / 填空。
/// 七选五与多空填空题会在本地构造 childs 后转交 Standard 解析器。
/// ============================================================
library;
import 'question_parser_base.dart';
import 'question_parser_standard.dart';
import 'formula.dart';
Map<String, dynamic> parseNonStandardQuestion(
Map<String, dynamic> questionData,
String rawStemIn,
String rawAnswer,
String rawCorrectAnswer,
String analysis,
) {
var rawStem = rawStemIn;
final options = <Map<String, String>>[];
var isSelect = questionData['isSelect'] == true;
var isMainPanDuan = false;
// 1) 主判断题
if (processPanDuan(questionData, options)) {
isSelect = true;
isMainPanDuan = true;
}
// 2) 题干选项表
final optionTableRegex = RegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
caseSensitive: false,
);
final match = optionTableRegex.firstMatch(rawStem);
if (match != null) {
isSelect = true;
options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
rawStem = rawStem.replaceFirst(optionTableRegex, '');
}
var finalQuestionTypes = getQuestionTypeCode(
questionData['questionTypeId'],
questionData['isObjective'],
questionData['questionTypeCode'],
);
// 3) 纯文本选项兜底
if (options.isEmpty &&
(finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
final plainOptions = extractPlainTextOptions(rawStem);
if (plainOptions.isNotEmpty) {
options.addAll(plainOptions);
isSelect = true;
}
}
if (isMainPanDuan) {
finalQuestionTypes = 'panduanti';
}
// --- 七选五:按填空元数据构造 childs,转复合题 ---
if (finalQuestionTypes == 'qixuanwu') {
final metas = parseBkTagMetaFromStem(
rawStem,
rawCorrectAnswer,
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
options,
);
if (metas.isNotEmpty) {
final indexes = (questionData['indexes'] as List?) ?? const [];
final generatedChilds = List.generate(metas.length, (index) {
final meta = metas[index];
return <String, dynamic>{
...meta,
'id': '${questionData['id']}_${meta['index']}',
'stem': '',
'answer': meta['correctAnswer'],
'isObjective': true,
'questionTypes': 'qixuanwu_sub',
'options': options,
if (index < indexes.length && indexes[index] is Map)
...Map<String, dynamic>.from(indexes[index] as Map),
'sort': meta['index'],
};
});
final compositeData = <String, dynamic>{
...questionData,
'hasChild': true,
'isObjective': false,
'childs': generatedChilds,
'questionTypes': 'qixuanwu',
};
return parseStandardQuestion(
compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
}
}
// --- 填空题:构造 childs 转复合题 ---
if (finalQuestionTypes == 'tiankongti') {
var metasList = parseBkTagMetaFromStem(
rawStem,
rawCorrectAnswer,
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
);
if (metasList.isEmpty) {
metasList = [
{
'index': 1,
'type': 0,
'dataInputType': 0,
'correctAnswer': rawCorrectAnswer,
'questionTypes': 'tiankongti',
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
}
];
final userAnswerMap = parseUserAnswerMapFromJson(
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'] == 1,
);
final fromUser = userAnswerMap[1];
if (fromUser != null) {
final isUpload = questionData['iSFilllankItemUpload'] == 1;
final myAnswer = fromUser['myAnswer'];
final hasImgArray = myAnswer is List &&
myAnswer.isNotEmpty &&
myAnswer.first is Map &&
(myAnswer.first as Map)['url'] != null;
if (isUpload || hasImgArray) {
metasList[0]['myAnswer'] = myAnswer is List ? myAnswer : const [];
} else {
metasList[0]['myAnswer'] = myAnswer is String ? myAnswer.trim() : '';
}
if (fromUser['answerStatus'] is int) {
metasList[0]['answerStatus'] = fromUser['answerStatus'];
}
}
}
if (metasList.isNotEmpty) {
final indexes = (questionData['indexes'] as List?) ?? const [];
final generatedChilds = List.generate(metasList.length, (index) {
final meta = metasList[index];
return <String, dynamic>{
...meta,
'id': '${questionData['id']}_${meta['index']}',
'questionId': questionData['id'],
'stem': '',
'answer': meta['correctAnswer'],
'isObjective': false,
'questionTypes': 'tiankongti',
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
if (index < indexes.length && indexes[index] is Map)
...Map<String, dynamic>.from(indexes[index] as Map),
'sort': meta['index'],
};
});
final compositeData = <String, dynamic>{
...questionData,
'hasChild': true,
'isObjective': false,
'childs': generatedChilds,
'questionTypes': 'tiankongti',
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
};
return parseStandardQuestion(
compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
}
}
final optionSource = options.isNotEmpty
? options
: ((questionData['options'] as List?)?.whereType<Map>().toList() ?? const []);
return {
...questionData,
'raw': questionData,
'stem': fixImageStyles(renderLatexInHtml(rawStem)),
'options': optionSource.map((rawOpt) {
final opt = Map<String, dynamic>.from(rawOpt);
return {
...opt,
'options': fixImageStyles(renderLatexInHtml('${opt['options'] ?? ''}')),
};
}).toList(),
'isSelect': isSelect,
'questionTypes': finalQuestionTypes,
'answer': rawAnswer,
'analysis': fixImageStyles(renderLatexInHtml(analysis)),
'subQuestions': const [],
if (finalQuestionTypes == 'tiankongti')
'bkTagMetaList': [
{
'index': 1,
'type': 0,
'dataInputType': 0,
'correctAnswer': fixImageStyles(renderLatexInHtml(rawCorrectAnswer)),
}
],
};
}
@@ -0,0 +1,337 @@
/// ============================================================
/// 标准复合题解析器(1:1 移植 Vue utils/questionParserStandard.ts)
///
/// 处理带 childs 的复合题(阅读理解、完形填空、听力等):
/// 提取子题题干 / 选项 / 题型 / 答案,清洗大题干。
/// ============================================================
library;
import 'question_parser_base.dart';
import 'formula.dart';
import 'js_regex.dart';
bool _truthy(dynamic v) {
if (v == null) return false;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) return v.isNotEmpty && v != '0';
return true;
}
/// answerAnalysis 字段可能是字符串,也可能是 {analysis: '...'}
String analysisTextOf(dynamic v) {
if (v == null) return '';
if (v is Map) return '${v['analysis'] ?? ''}';
return '$v';
}
String _latex(dynamic v) => renderLatexInHtml(v == null ? '' : '$v');
Map<String, dynamic> _renderBk(Map<String, dynamic> bk) {
return {
...bk,
'correctAnswer': fixImageStyles(_latex(bk['correctAnswer'])),
};
}
Map<String, dynamic> parseStandardQuestion(
Map<String, dynamic> questionData,
String rawStem,
String rawAnswer,
String rawCorrectAnswer,
String analysis,
) {
final options = <Map<String, String>>[];
var isSelect = _truthy(questionData['isSelect']);
final subQuestions = <Map<String, dynamic>>[];
var newStem = rawStem;
final childsRaw = (questionData['childs'] as List?) ?? const [];
for (var index = 0; index < childsRaw.length; index++) {
final child = Map<String, dynamic>.from(childsRaw[index] as Map);
final subOptions = <Map<String, String>>[];
var childStem = _latex(child['stem']);
var childAnswer = '${child['answer'] ?? ''}';
var childRawAnalysis = _latex(
child['answerAnalysis'] is Map
? (child['answerAnalysis'] as Map)['analysis']
: (child['answerAnalysis'] ?? child['analysis']),
);
// 子题无独立答案时,从大题干答案 rawAnswer 中按 data-index 提取
if (childAnswer.isEmpty && rawAnswer.isNotEmpty) {
final subAnswerRegex = JRegExp(
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*hxdb-subquestion[^"]*")'
'(?=[^>]*data-index="${index + 1}")[^>]*>',
);
final match = subAnswerRegex.exec(rawAnswer);
if (match != null) {
childAnswer = findFullElement(rawAnswer, match);
}
}
final childAnalysis = childRawAnalysis;
// 1) 从大题干中提取属于该子题的内容 / 选项表格
final subquestionRegex = JRegExp(
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")'
'(?=[^>]*data-index="${index + 1}")[^>]*>',
);
JMatch? subMatch;
while ((subMatch = subquestionRegex.exec(newStem)) != null) {
final fullBlock = findFullElement(newStem, subMatch!);
final startTag = subMatch.group0;
final tagNameLen = (subMatch(1) ?? '').length;
final divContent = fullBlock.substring(
startTag.length,
fullBlock.length - (tagNameLen + 3),
);
if (childStem.isEmpty || childStem == '<p></p>') {
childStem = divContent;
}
subOptions.addAll(extractOptionsFromHtml(divContent));
newStem = newStem.replaceFirst(fullBlock, '');
subquestionRegex.reset();
}
// 2) 从子题自身 stem 提取选项
final optionTableRegex = RegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
caseSensitive: false,
);
final childMatch = optionTableRegex.firstMatch(childStem);
if (childMatch != null) {
if (subOptions.isEmpty) {
subOptions.addAll(extractOptionsFromHtml(childMatch.group(2) ?? ''));
}
childStem = childStem.replaceFirst(optionTableRegex, '');
}
if (subOptions.isEmpty &&
RegExp(r'hxdb-sub-option-table', caseSensitive: false).hasMatch(childStem)) {
subOptions.addAll(extractOptionsFromHtml(childStem));
}
// 按 optionLabel 去重
final seenLabels = <String>{};
final deduped = subOptions.where((opt) {
if (opt['optionLabel']!.isEmpty) return true;
if (seenLabels.contains(opt['optionLabel'])) return false;
seenLabels.add(opt['optionLabel']!);
return true;
}).toList();
subOptions
..clear()
..addAll(deduped);
// 3) 判断题
final childIsPanDuan = processPanDuan(
{...child, 'answer': childAnswer},
subOptions,
);
child['questionTypeId'] =
(_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
? child['questionTypeId']
: questionData['questionTypeId'];
var childQuestionTypes = getQuestionTypeCode(
child['questionTypeId'],
child['isObjective'],
child['questionTypeCode'],
);
if (childIsPanDuan) {
childQuestionTypes = 'panduanti';
}
// 4) 组装子题(注意:...child 覆盖前面的显式字段,与 JS 展开顺序一致)
final computedOptions = subOptions.isNotEmpty
? subOptions
.map((opt) => makeOption(opt['optionLabel']!, _latex(opt['options'])))
.toList()
: (child['options'] is List
? (child['options'] as List)
.whereType<Map>()
.map((opt) => makeOption(
'${opt['optionLabel'] ?? ''}',
_latex(opt['options']),
))
.toList()
: <Map<String, String>>[]);
final subQuestionObj = <String, dynamic>{
'index': '${index + 1}.',
'stem': childStem,
'answer': childAnswer,
'analysis': childAnalysis,
'options': computedOptions,
'isSelect': childIsPanDuan || subOptions.isNotEmpty || _truthy(child['isSelect']),
};
subQuestionObj.addAll(child); // ...child
subQuestionObj['questionTypeId'] =
(_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
? child['questionTypeId']
: questionData['questionTypeId'];
subQuestionObj['questionTypeName'] = _truthy(child['questionTypeName'])
? child['questionTypeName']
: questionData['questionTypeName'];
subQuestionObj['questionTypes'] = childQuestionTypes;
subQuestionObj['raw'] = child;
if (childQuestionTypes == 'tiankongti') {
final childBkTagMetaList = parseBkTagMetaFromStem(
childStem,
'${child['correctAnswer'] ?? rawCorrectAnswer}',
child['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
);
if (childBkTagMetaList.isNotEmpty) {
subQuestionObj['bkTagMetaList'] = childBkTagMetaList;
}
}
subQuestions.add(subQuestionObj);
}
// 清洗主干残留的子题块
final cleanupRegex = JRegExp(
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")[^>]*>',
);
while (true) {
final cleanupMatch = cleanupRegex.exec(newStem);
if (cleanupMatch == null) break;
final fullBlock = findFullElement(newStem, cleanupMatch);
newStem = newStem.replaceFirst(fullBlock, '');
cleanupRegex.reset();
}
var finalStem = newStem;
// 大题干自身的选项表(普通单选/多选)
final optionTableRegex = RegExp(
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
caseSensitive: false,
);
final match = optionTableRegex.firstMatch(finalStem);
if (match != null) {
isSelect = true;
options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
finalStem = finalStem.replaceFirst(optionTableRegex, '');
}
var finalQuestionTypes = getQuestionTypeCode(
questionData['questionTypeId'],
questionData['isObjective'],
questionData['questionTypeCode'],
);
if (options.isEmpty &&
(finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
final plainOptions = extractPlainTextOptions(finalStem);
if (plainOptions.isNotEmpty) {
options.addAll(plainOptions);
isSelect = true;
}
}
var bkTagMetaList = <Map<String, dynamic>>[];
if (finalQuestionTypes == 'tiankongti') {
bkTagMetaList = parseBkTagMetaFromStem(
finalStem,
rawCorrectAnswer,
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'],
);
if (bkTagMetaList.isEmpty) {
bkTagMetaList = [
{'index': 1, 'type': 0, 'dataInputType': 0, 'correctAnswer': rawCorrectAnswer}
];
final userAnswerMap = parseUserAnswerMapFromJson(
questionData['userAnswer']?.toString(),
questionData['iSFilllankItemUpload'] == 1,
);
final fromUser = userAnswerMap[1];
if (fromUser != null) {
final isUpload = questionData['iSFilllankItemUpload'] == 1;
final myAnswer = fromUser['myAnswer'];
final hasImgArray = myAnswer is List &&
myAnswer.isNotEmpty &&
myAnswer.first is Map &&
(myAnswer.first as Map)['url'] != null;
if (isUpload || hasImgArray) {
if (myAnswer is List && myAnswer.isNotEmpty) {
bkTagMetaList[0]['myAnswer'] = myAnswer;
}
} else {
final my = myAnswer is String ? myAnswer.trim() : '';
if (my.isNotEmpty) bkTagMetaList[0]['myAnswer'] = my;
}
if (fromUser['answerStatus'] is int) {
bkTagMetaList[0]['answerStatus'] = fromUser['answerStatus'];
}
}
}
}
final renderedStem = fixImageStyles(_latex(finalStem));
final renderedAnalysis = fixImageStyles(_latex(analysis));
final optionSource = options.isNotEmpty
? options
: ((questionData['options'] as List?)?.whereType<Map>().toList() ?? const []);
final renderedOptions = optionSource.map((rawOpt) {
final opt = Map<String, dynamic>.from(rawOpt);
return {
...opt,
'options': fixImageStyles(_latex(opt['options'])),
};
}).toList();
final renderedSubQuestions = subQuestions.map((sub) {
final rendered = <String, dynamic>{...sub};
rendered['stem'] = fixImageStyles(_latex(sub['stem']));
rendered['analysis'] = fixImageStyles(_latex(sub['analysis']));
rendered['answerAnalysis'] = fixImageStyles(_latex(sub['answerAnalysis']));
final subOpts = (sub['options'] as List?) ?? const [];
rendered['options'] = subOpts.whereType<Map>().map((rawOpt) {
final opt = Map<String, dynamic>.from(rawOpt);
return {
...opt,
'options': fixImageStyles(_latex(opt['options'])),
};
}).toList();
final bks = (sub['bkTagMetaList'] as List?) ?? const [];
rendered['bkTagMetaList'] =
bks.whereType<Map>().map((bk) => _renderBk(Map<String, dynamic>.from(bk))).toList();
return rendered;
}).toList();
final renderedBkTagMetaList = bkTagMetaList.map(_renderBk).toList();
return {
...questionData,
'raw': questionData,
'stem': renderedStem,
'options': renderedOptions,
'isSelect': isSelect,
'questionTypes': finalQuestionTypes,
'childs': renderedSubQuestions,
'answer': rawAnswer,
'analysis': renderedAnalysis,
'subQuestions': renderedSubQuestions,
'bkTagMetaList': renderedBkTagMetaList.isNotEmpty
? renderedBkTagMetaList
: (finalQuestionTypes == 'tiankongti'
? [
{
'index': 1,
'type': 0,
'dataInputType': 0,
'correctAnswer': fixImageStyles(_latex(rawCorrectAnswer)),
}
]
: const []),
};
}
+92
View File
@@ -0,0 +1,92 @@
/// ============================================================
/// 题型标签渐变色(对标 Vue utils/tagQuestionTypeColor.js)
/// getTagColor(tag) → 题型名对应渐变;找不到=默认单选题蓝
/// ============================================================
library;
import 'package:flutter/material.dart';
/// 题型渐变描述(起止色 + 角度近似:281.96deg≈右上→左下,126.17deg≈左上→右下)
class TagGradient {
const TagGradient(this.begin, this.end, this.colors);
final Alignment begin;
final Alignment end;
final List<Color> colors;
Gradient get gradient => LinearGradient(
begin: begin,
end: end,
colors: colors,
);
}
const TagGradient _defaultTagGradient = TagGradient(
Alignment(-0.95, -0.2), // 281.96deg
Alignment(0.95, 0.2),
[Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
);
const Map<String, TagGradient> _tagColorMap = {
'单选题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
),
'多选题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF3DB8F5), Color(0xFF5AC8FF)],
),
'判断题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF3FC8A6), Color(0xFF89E9D1)],
),
'填空题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF66D969), Color(0xFF93E594)],
),
'完形填空': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF81B84F), Color(0xFFB1E384)],
),
'阅读理解': TagGradient(
Alignment(-0.44, -0.8), // 126.17deg
Alignment(0.44, 0.8),
[Color(0xFFFFC260), Color(0xFFFF9E45)],
),
'听力理解': TagGradient(
Alignment(-0.44, -0.8),
Alignment(0.44, 0.8),
[Color(0xFFFFA052), Color(0xFFFF6F3A)],
),
'7选5': TagGradient(
Alignment(-0.44, -0.8),
Alignment(0.44, 0.8),
[Color(0xFFFF7A41), Color(0xFFFE5837)],
),
'解答题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFFBA59EB), Color(0xFFCD8AEF)],
),
'作文题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF8845E6), Color(0xFFA87BF5)],
),
'问答题': TagGradient(
Alignment(-0.95, -0.2),
Alignment(0.95, 0.2),
[Color(0xFF6864E4), Color(0xFF8582E9)],
),
};
/// Vue: getTagColor(tag)
TagGradient getTagColor(String? tag) {
if (tag == null || tag.isEmpty) return _defaultTagGradient;
return _tagColorMap[tag] ?? _defaultTagGradient;
}