279 lines
8.9 KiB
Dart
279 lines
8.9 KiB
Dart
/// ============================================================
|
||
/// 【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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|