/// ============================================================ /// 【ObjOptions】客观题选项列表(1:1 迁移 Vue components/objOptions) /// /// 交互点(与 Vue 一致): /// - props: options / answerData{correctAnswer,myAnswer} / answer / /// mode('edit'|'view') / questionType /// - edit:点击选项 emit change({...item, userAnswer: hxdb HTML}) /// duoxuanti 多选解析已有答案后 toggle,按字母排序 /// panduanti 取 optionLabel 或选项纯文本 /// - view:不响应点击;正确项绿、选错项红 /// - panduanti 标号位渲染本地正确/错误图标 /// ============================================================ library; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/components/rich_text_content.dart'; import 'package:new_learn/core/utils/question/question_parser.dart'; import 'exercise_styles.dart'; enum ObjOptionMode { edit, view } /// 对标 Vue answerData: { correctAnswer, myAnswer } class ObjOptionAnswerData { const ObjOptionAnswerData({ required this.correctAnswer, required this.myAnswer, }); final String correctAnswer; final String myAnswer; } /// change 事件载荷:{...option, userAnswer: String} typedef ObjOptionChanged = void Function(Map payload); class ObjOptions extends StatelessWidget { const ObjOptions({ super.key, required this.options, this.answerData, this.answer = '', this.mode = ObjOptionMode.view, this.questionType = '', this.onChanged, }); /// [{optionLabel, options}] final List> options; /// view 模式答案对比数据;edit 模式通常不传 final ObjOptionAnswerData? answerData; /// edit 模式当前 userAnswer final String answer; final ObjOptionMode mode; /// data.questionTypes,如 danxuanti / duoxuanti / panduanti final String questionType; final ObjOptionChanged? onChanged; bool get _isPanDuan => questionType == 'panduanti'; /// 与 correctAnswer 一致的富文本外壳,内容为纯文本 String _buildHxdbAnswerHtml(String plain) { if (plain.isEmpty) return ''; final text = plain.trim(); if (text.isEmpty) return ''; return '
' '

' '$text

'; } /// 多选:从已有答案解析出选项字母(支持 hxdb HTML 或 A,B,D) List _parseMultiSelectedLabels( String answerStr, List validLabels, ) { final plain = stripAnswerPlain(answerStr); if (plain.isEmpty) return const []; final valid = validLabels.where((e) => e.isNotEmpty).toSet(); if (plain.contains(',')) { return plain .split(',') .map((s) => s.trim()) .where(valid.contains) .toList(); } return plain.split('').where(valid.contains).toList(); } void _onClick(Map item) { if (mode != ObjOptionMode.edit) return; final changed = onChanged; if (changed == null) return; final validLabels = options.map((o) => o['optionLabel'] ?? '').where((e) => e.isNotEmpty).toList(); final label = item['optionLabel'] ?? ''; if (questionType == 'duoxuanti') { final myAnswer = answerData != null && answerData!.myAnswer.trim().isNotEmpty ? answerData!.myAnswer : answer; final set = _parseMultiSelectedLabels(myAnswer, validLabels).toSet(); if (label.isNotEmpty && set.contains(label)) { set.remove(label); } else if (label.isNotEmpty) { set.add(label); } final sorted = (set.toList()..sort()).join(); changed({...item, 'userAnswer': _buildHxdbAnswerHtml(sorted)}); return; } final plain = _isPanDuan ? (label.isNotEmpty ? label : stripAnswerPlain(item['options'])) : label; changed({...item, 'userAnswer': _buildHxdbAnswerHtml(plain)}); } /// 选项内容富文本(td 转行内容器并清理头部空白) String _formatOption(String html) { if (html.isEmpty) return ''; var res = cleanRichText(html); res = res.replaceAll( RegExp(r']*>', caseSensitive: false), '', ); res = res.replaceAll( RegExp(r'', caseSensitive: false), '', ); res = res.replaceAll(RegExp(r'^(?: |\s)+', caseSensitive: false), ''); return res; } bool _isTrueOption(Map item) { final label = item['optionLabel'] ?? ''; final opts = item['options'] ?? ''; return label == '正确' || label == '✓' || label == 'A' || opts.contains('正确'); } ExerciseTileState _stateOf(Map item) { final label = item['optionLabel'] ?? ''; if (label.isEmpty) return ExerciseTileState.plain; var correctStr = ''; var myStr = ''; if (answerData != null) { correctStr = answerData!.correctAnswer .replaceAll(RegExp(r'<[^>]+>'), '') .trim(); myStr = answerData!.myAnswer .replaceAll(RegExp(r'<[^>]+>'), '') .trim(); } else { correctStr = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim(); if (mode == ObjOptionMode.edit) { // 兼容老代码:没传 answerData 时把 answer 当作选中的高亮 myStr = correctStr; } } var isItemMine = myStr.contains(label); var isItemCorrect = correctStr.contains(label); if (_isPanDuan) { final opts = item['options'] ?? ''; if (label == '正确' || opts.contains('正确')) { isItemCorrect = ['正确', '✓', 'T', 'true', '对'].any(correctStr.contains); isItemMine = ['正确', '✓', 'T', 'true', '对'].any(myStr.contains); } else if (label == '错误' || opts.contains('错误') || opts.contains('错')) { isItemCorrect = ['错误', '×', 'F', 'false', '错'].any(correctStr.contains); isItemMine = ['错误', '×', 'F', 'false', '错'].any(myStr.contains); } } if (mode == ObjOptionMode.edit) { return isItemMine ? ExerciseTileState.active : ExerciseTileState.plain; } if (isItemCorrect) return ExerciseTileState.correct; if (isItemMine && !isItemCorrect) return ExerciseTileState.error; return ExerciseTileState.plain; } String _panDuanIcon(Map item, ExerciseTileState state) { final isActive = state != ExerciseTileState.plain; if (_isTrueOption(item)) { return isActive ? 'assets/img/my/correct-active-icon.png' : 'assets/img/my/correct-inactive-icon.png'; } return isActive ? 'assets/img/my/wrong-active-icon.png' : 'assets/img/my/wrong-inactive-icon.png'; } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < options.length; i++) Padding( padding: EdgeInsets.only(bottom: i == options.length - 1 ? 0 : 14.h), child: _buildItem(options[i]), ), ], ); } Widget _buildItem(Map item) { final state = _stateOf(item); return GestureDetector( onTap: () => _onClick(item), behavior: HitTestBehavior.opaque, child: GradientBorderBox( state: state, radius: 12, padding: EdgeInsets.all(12.w), // 24rpx child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildHead(item, state), SizedBox(width: 14.w), // 76 标号 + 28 间距 = 104rpx Expanded( child: RichTextContent( html: _formatOption(item['options'] ?? ''), textStyle: TextStyle( fontSize: 15.sp, height: 1.5, color: ExerciseColors.title, ), ), ), ], ), ), ); } Widget _buildHead(Map item, ExerciseTileState state) { final label = item['optionLabel'] ?? ''; final active = state != ExerciseTileState.plain; return Container( width: 38.w, // 76rpx height: 38.w, alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, color: active ? null : Colors.white, gradient: headGradientOf(state), border: active ? null : Border.all(color: ExerciseColors.border, width: 1.w), ), clipBehavior: Clip.antiAlias, child: _isPanDuan ? Image.asset( _panDuanIcon(item, state), width: 20.w, // 40rpx height: 20.w, fit: BoxFit.contain, ) : Text( label, style: TextStyle( fontSize: 18.sp, // 36rpx fontWeight: FontWeight.w600, color: active ? Colors.white : ExerciseColors.title, ), ), ); } }