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,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'^(?:&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,
),
),
);
}
}