更新
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/// ============================================================
|
||||
/// 【ObjOptions】客观题选项列表(1:1 迁移 Vue components/objOptions)
|
||||
///
|
||||
/// 交互点(与 Vue 一致):
|
||||
/// - props: options / answerData{correctAnswer,myAnswer} / answer /
|
||||
/// mode('edit'|'view') / questionType
|
||||
/// - edit:点击选项 emit change({...item, userAnswer: hxdb HTML})
|
||||
/// duoxuanti 多选解析已有答案后 toggle,按字母排序
|
||||
/// panduanti 取 optionLabel 或选项纯文本
|
||||
/// - view:不响应点击;正确项绿、选错项红
|
||||
/// - panduanti 标号位渲染本地正确/错误图标
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
|
||||
import 'exercise_styles.dart';
|
||||
|
||||
enum ObjOptionMode { edit, view }
|
||||
|
||||
/// 对标 Vue answerData: { correctAnswer, myAnswer }
|
||||
class ObjOptionAnswerData {
|
||||
const ObjOptionAnswerData({
|
||||
required this.correctAnswer,
|
||||
required this.myAnswer,
|
||||
});
|
||||
|
||||
final String correctAnswer;
|
||||
final String myAnswer;
|
||||
}
|
||||
|
||||
/// change 事件载荷:{...option, userAnswer: String}
|
||||
typedef ObjOptionChanged = void Function(Map<String, dynamic> payload);
|
||||
|
||||
class ObjOptions extends StatelessWidget {
|
||||
const ObjOptions({
|
||||
super.key,
|
||||
required this.options,
|
||||
this.answerData,
|
||||
this.answer = '',
|
||||
this.mode = ObjOptionMode.view,
|
||||
this.questionType = '',
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
/// [{optionLabel, options}]
|
||||
final List<Map<String, String>> options;
|
||||
|
||||
/// view 模式答案对比数据;edit 模式通常不传
|
||||
final ObjOptionAnswerData? answerData;
|
||||
|
||||
/// edit 模式当前 userAnswer
|
||||
final String answer;
|
||||
final ObjOptionMode mode;
|
||||
|
||||
/// data.questionTypes,如 danxuanti / duoxuanti / panduanti
|
||||
final String questionType;
|
||||
final ObjOptionChanged? onChanged;
|
||||
|
||||
bool get _isPanDuan => questionType == 'panduanti';
|
||||
|
||||
/// 与 correctAnswer 一致的富文本外壳,内容为纯文本
|
||||
String _buildHxdbAnswerHtml(String plain) {
|
||||
if (plain.isEmpty) return '';
|
||||
final text = plain.trim();
|
||||
if (text.isEmpty) return '';
|
||||
return '<div class="hxdb-answer" name="hxdbAnswer">'
|
||||
'<p data-pm-slice="1 1 []">'
|
||||
'<span style="font-family: 宋体;"></span>$text</p></div>';
|
||||
}
|
||||
|
||||
/// 多选:从已有答案解析出选项字母(支持 hxdb HTML 或 A,B,D)
|
||||
List<String> _parseMultiSelectedLabels(
|
||||
String answerStr,
|
||||
List<String> validLabels,
|
||||
) {
|
||||
final plain = stripAnswerPlain(answerStr);
|
||||
if (plain.isEmpty) return const [];
|
||||
final valid = validLabels.where((e) => e.isNotEmpty).toSet();
|
||||
if (plain.contains(',')) {
|
||||
return plain
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where(valid.contains)
|
||||
.toList();
|
||||
}
|
||||
return plain.split('').where(valid.contains).toList();
|
||||
}
|
||||
|
||||
void _onClick(Map<String, String> item) {
|
||||
if (mode != ObjOptionMode.edit) return;
|
||||
final changed = onChanged;
|
||||
if (changed == null) return;
|
||||
|
||||
final validLabels =
|
||||
options.map((o) => o['optionLabel'] ?? '').where((e) => e.isNotEmpty).toList();
|
||||
final label = item['optionLabel'] ?? '';
|
||||
|
||||
if (questionType == 'duoxuanti') {
|
||||
final myAnswer = answerData != null &&
|
||||
answerData!.myAnswer.trim().isNotEmpty
|
||||
? answerData!.myAnswer
|
||||
: answer;
|
||||
final set = _parseMultiSelectedLabels(myAnswer, validLabels).toSet();
|
||||
if (label.isNotEmpty && set.contains(label)) {
|
||||
set.remove(label);
|
||||
} else if (label.isNotEmpty) {
|
||||
set.add(label);
|
||||
}
|
||||
final sorted = (set.toList()..sort()).join();
|
||||
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(sorted)});
|
||||
return;
|
||||
}
|
||||
|
||||
final plain = _isPanDuan
|
||||
? (label.isNotEmpty ? label : stripAnswerPlain(item['options']))
|
||||
: label;
|
||||
changed({...item, 'userAnswer': _buildHxdbAnswerHtml(plain)});
|
||||
}
|
||||
|
||||
/// 选项内容富文本(td 转行内容器并清理头部空白)
|
||||
String _formatOption(String html) {
|
||||
if (html.isEmpty) return '';
|
||||
var res = cleanRichText(html);
|
||||
res = res.replaceAll(
|
||||
RegExp(r'<td\b[^>]*>', caseSensitive: false),
|
||||
'<span>',
|
||||
);
|
||||
res = res.replaceAll(
|
||||
RegExp(r'</td>', caseSensitive: false),
|
||||
'</span>',
|
||||
);
|
||||
res = res.replaceAll(RegExp(r'^(?: |\s)+', caseSensitive: false), '');
|
||||
return res;
|
||||
}
|
||||
|
||||
bool _isTrueOption(Map<String, String> item) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
final opts = item['options'] ?? '';
|
||||
return label == '正确' || label == '✓' || label == 'A' || opts.contains('正确');
|
||||
}
|
||||
|
||||
ExerciseTileState _stateOf(Map<String, String> item) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
if (label.isEmpty) return ExerciseTileState.plain;
|
||||
|
||||
var correctStr = '';
|
||||
var myStr = '';
|
||||
if (answerData != null) {
|
||||
correctStr = answerData!.correctAnswer
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.trim();
|
||||
myStr = answerData!.myAnswer
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.trim();
|
||||
} else {
|
||||
correctStr = answer.replaceAll(RegExp(r'<[^>]+>'), '').trim();
|
||||
if (mode == ObjOptionMode.edit) {
|
||||
// 兼容老代码:没传 answerData 时把 answer 当作选中的高亮
|
||||
myStr = correctStr;
|
||||
}
|
||||
}
|
||||
|
||||
var isItemMine = myStr.contains(label);
|
||||
var isItemCorrect = correctStr.contains(label);
|
||||
|
||||
if (_isPanDuan) {
|
||||
final opts = item['options'] ?? '';
|
||||
if (label == '正确' || opts.contains('正确')) {
|
||||
isItemCorrect = ['正确', '✓', 'T', 'true', '对'].any(correctStr.contains);
|
||||
isItemMine = ['正确', '✓', 'T', 'true', '对'].any(myStr.contains);
|
||||
} else if (label == '错误' || opts.contains('错误') || opts.contains('错')) {
|
||||
isItemCorrect = ['错误', '×', 'F', 'false', '错'].any(correctStr.contains);
|
||||
isItemMine = ['错误', '×', 'F', 'false', '错'].any(myStr.contains);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == ObjOptionMode.edit) {
|
||||
return isItemMine ? ExerciseTileState.active : ExerciseTileState.plain;
|
||||
}
|
||||
if (isItemCorrect) return ExerciseTileState.correct;
|
||||
if (isItemMine && !isItemCorrect) return ExerciseTileState.error;
|
||||
return ExerciseTileState.plain;
|
||||
}
|
||||
|
||||
String _panDuanIcon(Map<String, String> item, ExerciseTileState state) {
|
||||
final isActive = state != ExerciseTileState.plain;
|
||||
if (_isTrueOption(item)) {
|
||||
return isActive
|
||||
? 'assets/img/my/correct-active-icon.png'
|
||||
: 'assets/img/my/correct-inactive-icon.png';
|
||||
}
|
||||
return isActive
|
||||
? 'assets/img/my/wrong-active-icon.png'
|
||||
: 'assets/img/my/wrong-inactive-icon.png';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < options.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: i == options.length - 1 ? 0 : 14.h),
|
||||
child: _buildItem(options[i]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(Map<String, String> item) {
|
||||
final state = _stateOf(item);
|
||||
return GestureDetector(
|
||||
onTap: () => _onClick(item),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: GradientBorderBox(
|
||||
state: state,
|
||||
radius: 12,
|
||||
padding: EdgeInsets.all(12.w), // 24rpx
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildHead(item, state),
|
||||
SizedBox(width: 14.w), // 76 标号 + 28 间距 = 104rpx
|
||||
Expanded(
|
||||
child: RichTextContent(
|
||||
html: _formatOption(item['options'] ?? ''),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.5,
|
||||
color: ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHead(Map<String, String> item, ExerciseTileState state) {
|
||||
final label = item['optionLabel'] ?? '';
|
||||
final active = state != ExerciseTileState.plain;
|
||||
return Container(
|
||||
width: 38.w, // 76rpx
|
||||
height: 38.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? null : Colors.white,
|
||||
gradient: headGradientOf(state),
|
||||
border: active
|
||||
? null
|
||||
: Border.all(color: ExerciseColors.border, width: 1.w),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isPanDuan
|
||||
? Image.asset(
|
||||
_panDuanIcon(item, state),
|
||||
width: 20.w, // 40rpx
|
||||
height: 20.w,
|
||||
fit: BoxFit.contain,
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp, // 36rpx
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? Colors.white : ExerciseColors.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user