Files
2026-09-28 20:12:34 +08:00

279 lines
8.9 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 【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'^(?:&nbsp;|\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,
),
),
);
}
}